├── .changeset
├── README.md
└── config.json
├── .eslintrc.cjs
├── .github
├── actions
│ ├── build-test
│ │ └── action.yml
│ └── setup
│ │ └── action.yml
└── workflows
│ ├── pull-request.yml
│ └── release.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── docs
├── customizing.md
└── images
│ ├── envy-custom-system.png
│ ├── envy-example.png
│ ├── envy-hero.png
│ ├── envy-request-component.png
│ ├── envy-response-component.png
│ ├── envy-system-icon.png
│ └── envy-trace-row-data.png
├── examples
├── apollo-client
│ ├── .postcssrc
│ ├── package.json
│ ├── src
│ │ ├── App.tsx
│ │ ├── components
│ │ │ ├── CatFact.tsx
│ │ │ ├── Cocktail.tsx
│ │ │ ├── Sanity.tsx
│ │ │ └── Xkcd.tsx
│ │ ├── index.css
│ │ ├── index.html
│ │ ├── index.js
│ │ ├── queries
│ │ │ └── index.ts
│ │ └── viewer
│ │ │ ├── systems
│ │ │ ├── CatFacts.tsx
│ │ │ └── CocktailDb.tsx
│ │ │ ├── viewer.html
│ │ │ └── viewer.js
│ ├── tailwind.config.js
│ └── tsconfig.json
├── apollo
│ ├── package.json
│ └── src
│ │ ├── index.ts
│ │ ├── schema
│ │ ├── catFacts
│ │ │ ├── resolvers.ts
│ │ │ └── schema.ts
│ │ ├── cocktails
│ │ │ ├── resolvers.ts
│ │ │ └── schema.ts
│ │ ├── sanity
│ │ │ ├── queries.ts
│ │ │ ├── resolvers.ts
│ │ │ ├── schema.ts
│ │ │ └── transformers.ts
│ │ └── xkcd
│ │ │ ├── resolvers.ts
│ │ │ └── schema.ts
│ │ └── utils
│ │ └── sanityClient.ts
├── express-client
│ ├── .eslintrc.cjs
│ ├── README.md
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.js
│ ├── src
│ │ ├── App.tsx
│ │ ├── components
│ │ │ ├── CatFact.tsx
│ │ │ ├── Cocktail.tsx
│ │ │ └── RandomeDogImage.tsx
│ │ ├── index.css
│ │ ├── main.jsx
│ │ └── utils
│ │ │ ├── query.ts
│ │ │ └── types.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ └── vite.config.js
├── express
│ ├── app.ts
│ ├── package.json
│ └── tsconfig.json
└── next
│ ├── .eslintrc.json
│ ├── README.md
│ ├── app
│ ├── _components
│ │ ├── CatFact.tsx
│ │ ├── Cocktail.tsx
│ │ ├── Dogo.tsx
│ │ └── index.ts
│ ├── api
│ │ └── swapi
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── layout.tsx
│ ├── next13app
│ │ ├── call-api-route
│ │ │ ├── ApiRouteExample.tsx
│ │ │ └── page.tsx
│ │ └── page.tsx
│ └── page.tsx
│ ├── globals.css
│ ├── next.config.js
│ ├── package.json
│ ├── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ └── next13pages
│ │ └── index.tsx
│ ├── postcss.config.js
│ ├── shared
│ ├── CatFactContainer.tsx
│ ├── CatFactWidget.tsx
│ ├── CocktailContainer.tsx
│ ├── CocktailWidget.tsx
│ ├── DogContainer.tsx
│ ├── DogWidget.tsx
│ └── index.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ └── utils
│ ├── query.ts
│ └── types.ts
├── jest.config.ts
├── package.json
├── packages
├── apollo
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
├── core
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── jest.config.ts
│ ├── package.json
│ ├── src
│ │ ├── consts.ts
│ │ ├── event.ts
│ │ ├── fetch.test.ts
│ │ ├── fetch.ts
│ │ ├── fetchTypes.ts
│ │ ├── graphql.ts
│ │ ├── http.ts
│ │ ├── index.ts
│ │ ├── json.test.ts
│ │ ├── json.ts
│ │ ├── log.ts
│ │ ├── middleware
│ │ │ ├── graphql.test.ts
│ │ │ ├── graphql.ts
│ │ │ ├── index.ts
│ │ │ ├── meta.test.ts
│ │ │ ├── meta.ts
│ │ │ ├── sanity.test.ts
│ │ │ └── sanity.ts
│ │ ├── nanoid.ts
│ │ ├── options.ts
│ │ ├── payload.ts
│ │ ├── plugin.ts
│ │ ├── sanity.ts
│ │ ├── time.ts
│ │ ├── url.test.ts
│ │ ├── url.ts
│ │ └── websocket.ts
│ └── tsconfig.json
├── nextjs
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── globals.d.ts
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── loaders
│ │ │ ├── config.loader.ts
│ │ │ ├── page.loader.ts
│ │ │ ├── server.loader.ts
│ │ │ └── web.loader.ts
│ │ ├── route.ts
│ │ ├── tracing.ts
│ │ └── webpack.ts
│ └── tsconfig.json
├── node
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── fetch.ts
│ │ ├── http.ts
│ │ ├── id.ts
│ │ ├── index.ts
│ │ ├── log.ts
│ │ ├── test.ts
│ │ ├── tracing.ts
│ │ └── utils
│ │ │ ├── time.ts
│ │ │ └── wrap.ts
│ └── tsconfig.json
├── web
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── fetch.ts
│ │ ├── id.ts
│ │ ├── index.ts
│ │ ├── log.ts
│ │ ├── performance.ts
│ │ └── tracing.ts
│ └── tsconfig.json
└── webui
│ ├── .eslintrc.cjs
│ ├── .parcelrc
│ ├── .postcssrc
│ ├── .storybook
│ ├── addons
│ │ └── preset.ts
│ ├── main.ts
│ └── preview.ts
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── jest.config.ts
│ ├── package.json
│ ├── pkg
│ └── demoResolver.js
│ ├── src
│ ├── App.test.tsx
│ ├── App.tsx
│ ├── collector
│ │ ├── CollectorClient.test.ts
│ │ ├── CollectorClient.ts
│ │ └── __storybook__mocks__
│ │ │ └── CollectorClient.ts
│ ├── components
│ │ ├── Authorization.stories.tsx
│ │ ├── Authorization.test.tsx
│ │ ├── Authorization.tsx
│ │ ├── Badge.stories.ts
│ │ ├── Badge.test.tsx
│ │ ├── Badge.tsx
│ │ ├── Button.stories.ts
│ │ ├── Button.test.tsx
│ │ ├── Button.tsx
│ │ ├── Code.stories.ts
│ │ ├── Code.test.tsx
│ │ ├── Code.tsx
│ │ ├── CodeDisplay.test.tsx
│ │ ├── CodeDisplay.tsx
│ │ ├── DarkModeToggle.stories.tsx
│ │ ├── DarkModeToggle.test.tsx
│ │ ├── DarkModeToggle.tsx
│ │ ├── DateTime.stories.tsx
│ │ ├── DateTime.test.tsx
│ │ ├── DateTime.tsx
│ │ ├── Fields.stories.tsx
│ │ ├── Fields.test.tsx
│ │ ├── Fields.tsx
│ │ ├── Input.stories.ts
│ │ ├── Input.test.tsx
│ │ ├── Input.tsx
│ │ ├── KeyValueList.stories.ts
│ │ ├── KeyValueList.test.tsx
│ │ ├── KeyValueList.tsx
│ │ ├── Loading.stories.ts
│ │ ├── Loading.test.tsx
│ │ ├── Loading.tsx
│ │ ├── Menu.stories.ts
│ │ ├── Menu.test.tsx
│ │ ├── Menu.tsx
│ │ ├── MonacoEditor.tsx
│ │ ├── SearchInput.stories.ts
│ │ ├── SearchInput.test.tsx
│ │ ├── SearchInput.tsx
│ │ ├── Section.stories.ts
│ │ ├── Section.test.tsx
│ │ ├── Section.tsx
│ │ ├── ToggleSwitch.stories.ts
│ │ ├── ToggleSwitch.test.tsx
│ │ ├── ToggleSwitch.tsx
│ │ ├── index.ts
│ │ └── ui
│ │ │ ├── CopyAsCurlButton.stories.tsx
│ │ │ ├── CopyAsCurlButton.test.tsx
│ │ │ ├── CopyAsCurlButton.tsx
│ │ │ ├── DebugToolbar.stories.tsx
│ │ │ ├── DebugToolbar.test.tsx
│ │ │ ├── DebugToolbar.tsx
│ │ │ ├── FiltersAndActions.stories.tsx
│ │ │ ├── FiltersAndActions.test.tsx
│ │ │ ├── FiltersAndActions.tsx
│ │ │ ├── Header.stories.tsx
│ │ │ ├── Header.test.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── Logo.tsx
│ │ │ ├── MainDisplay.stories.tsx
│ │ │ ├── MainDisplay.test.tsx
│ │ │ ├── MainDisplay.tsx
│ │ │ ├── QueryParams.stories.tsx
│ │ │ ├── QueryParams.test.tsx
│ │ │ ├── QueryParams.tsx
│ │ │ ├── RequestHeaders.stories.tsx
│ │ │ ├── RequestHeaders.test.tsx
│ │ │ ├── RequestHeaders.tsx
│ │ │ ├── ResponseHeaders.stories.tsx
│ │ │ ├── ResponseHeaders.test.tsx
│ │ │ ├── ResponseHeaders.tsx
│ │ │ ├── SourceAndSystemFilter.stories.tsx
│ │ │ ├── SourceAndSystemFilter.test.tsx
│ │ │ ├── SourceAndSystemFilter.tsx
│ │ │ ├── Tabs.stories.tsx
│ │ │ ├── Tabs.test.tsx
│ │ │ ├── Tabs.tsx
│ │ │ ├── TimingsDiagram.stories.tsx
│ │ │ ├── TimingsDiagram.test.tsx
│ │ │ ├── TimingsDiagram.tsx
│ │ │ ├── TraceDetail.stories.tsx
│ │ │ ├── TraceDetail.test.tsx
│ │ │ ├── TraceDetail.tsx
│ │ │ ├── TraceList.stories.tsx
│ │ │ ├── TraceList.test.tsx
│ │ │ ├── TraceList.tsx
│ │ │ ├── TraceListHeader.test.tsx
│ │ │ ├── TraceListHeader.tsx
│ │ │ ├── TraceListPlaceholder.test.tsx
│ │ │ ├── TraceListPlaceholder.tsx
│ │ │ ├── TraceListRow.test.tsx
│ │ │ ├── TraceListRow.tsx
│ │ │ ├── TraceListRowCell.test.tsx
│ │ │ ├── TraceListRowCell.tsx
│ │ │ ├── TraceRequestData.stories.tsx
│ │ │ ├── TraceRequestData.test.tsx
│ │ │ └── TraceRequestData.tsx
│ ├── context
│ │ ├── ApplicationContext.test.tsx
│ │ └── ApplicationContext.tsx
│ ├── favicon.png
│ ├── hooks
│ │ ├── useApplication.test.ts
│ │ ├── useApplication.ts
│ │ ├── useClickAway.test.tsx
│ │ ├── useClickAway.ts
│ │ ├── useFeatureFlags.ts
│ │ ├── useKeyboardShortcut.test.ts
│ │ ├── useKeyboardShortcut.ts
│ │ ├── usePlatform.test.ts
│ │ └── usePlatform.ts
│ ├── index.html
│ ├── index.js
│ ├── integration.tsx
│ ├── scripts
│ │ ├── buildIntegration.cjs
│ │ ├── start.cjs
│ │ ├── startCollector.cjs
│ │ ├── startViewer.cjs
│ │ └── startViewerDev.cjs
│ ├── styles
│ │ ├── allotment.css
│ │ └── base.css
│ ├── systems
│ │ ├── Default.test.tsx
│ │ ├── Default.tsx
│ │ ├── GraphQL.test.tsx
│ │ ├── GraphQL.tsx
│ │ ├── Sanity.test.tsx
│ │ ├── Sanity.tsx
│ │ ├── index.tsx
│ │ ├── registration.test.ts
│ │ ├── registration.ts
│ │ └── systems.test.tsx
│ ├── testing
│ │ ├── mockSystems.ts
│ │ ├── mockTraces
│ │ │ ├── gql.ts
│ │ │ ├── index.ts
│ │ │ ├── large-gql.ts
│ │ │ ├── rest.ts
│ │ │ ├── sanity.ts
│ │ │ ├── util.ts
│ │ │ └── xml.ts
│ │ ├── mockUseApplication.ts
│ │ ├── mockUsePlatform.ts
│ │ ├── setupJest.ts
│ │ ├── setupJestAfterEnv.ts
│ │ └── setupJestGlobal.ts
│ ├── types
│ │ ├── graphql-prettier.d.ts
│ │ └── index.ts
│ └── utils
│ │ ├── index.ts
│ │ ├── styles.test.ts
│ │ ├── styles.ts
│ │ ├── utils.test.ts
│ │ └── utils.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.types.json
│ └── vite.config.ts
├── scripts
└── test-webui-npm.sh
├── tsconfig.json
├── turbo.json
└── yarn.lock
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
10 | ## Releases
11 |
12 | Releases are automatically managed by the CI, and changesets are added to each PR before merge.
13 |
14 | ### Adding a changeset
15 |
16 | The following command will create a changeset in your Pull Request
17 |
18 | ```
19 | yarn changeset
20 | ```
21 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [["@envyjs/apollo", "@envyjs/core", "@envyjs/nextjs", "@envyjs/node", "@envyjs/web", "@envyjs/webui"]],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": [
11 | "@envyjs/example-apollo",
12 | "@envyjs/example-apollo-client",
13 | "@envyjs/example-express",
14 | "@envyjs/example-express-client",
15 | "@envyjs/example-next"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/.github/actions/build-test/action.yml:
--------------------------------------------------------------------------------
1 | name: Build Lint Test
2 |
3 | runs:
4 | using: "composite"
5 | steps:
6 | - name: Build
7 | run: yarn build --cache-dir=".turbo"
8 | shell: bash
9 | env:
10 | NODE_ENV: production
11 |
12 | - name: Lint
13 | run: yarn lint --cache-dir=".turbo"
14 | shell: bash
15 |
16 | - name: Test
17 | run: yarn test --cache-dir=".turbo"
18 | shell: bash
19 |
--------------------------------------------------------------------------------
/.github/actions/setup/action.yml:
--------------------------------------------------------------------------------
1 | name: Setup
2 | description: Setup Build Step
3 |
4 | runs:
5 | using: "composite"
6 | steps:
7 | - name: Setup Node
8 | uses: actions/setup-node@v4
9 | with:
10 | node-version-file: '.nvmrc'
11 | cache: 'yarn'
12 |
13 | - name: Install dependencies
14 | shell: bash
15 | run: yarn install --frozen-lockfile
16 |
17 | - name: Turbo Cache
18 | uses: actions/cache@v4
19 | with:
20 | path: .turbo
21 | key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
22 | restore-keys: |
23 | turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
24 | turbo-${{ github.job }}-${{ github.ref_name }}-
25 | turbo-${{ github.job }}-
26 |
--------------------------------------------------------------------------------
/.github/workflows/pull-request.yml:
--------------------------------------------------------------------------------
1 | name: Pull Request
2 | on:
3 | pull_request:
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - uses: ./.github/actions/setup
11 | - uses: ./.github/actions/build-test
12 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | concurrency: ${{ github.workflow }}-${{ github.ref }}
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: ./.github/actions/setup
15 | - uses: ./.github/actions/build-test
16 |
17 | publish:
18 | runs-on: ubuntu-latest
19 | needs: [build]
20 | permissions:
21 | contents: write
22 | id-token: write
23 | issues: write
24 | repository-projects: write
25 | deployments: write
26 | packages: write
27 | pull-requests: write
28 |
29 | steps:
30 | - uses: actions/checkout@v4
31 | - uses: ./.github/actions/setup
32 |
33 | - name: Build
34 | run: yarn build --cache-dir=".turbo"
35 | env:
36 | NODE_ENV: production
37 |
38 | - uses: changesets/action@v1
39 | with:
40 | version: yarn run changeset version
41 | publish: yarn run changeset publish
42 | env:
43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
45 | NPM_CONFIG_PROVENANCE: true
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 |
13 | # Coverage directory used by tools like istanbul
14 | coverage
15 | *.lcov
16 | .nyc_output
17 |
18 | # Dependency directories
19 | node_modules/
20 | jspm_packages/
21 |
22 | # TypeScript cache
23 | *.tsbuildinfo
24 |
25 | # Optional npm cache directory
26 | .npm
27 |
28 | # Optional eslint cache
29 | .eslintcache
30 |
31 | # Optional stylelint cache
32 | .stylelintcache
33 |
34 | # Output of 'npm pack'
35 | *.tgz
36 |
37 | # Yarn Integrity file
38 | .yarn-integrity
39 |
40 | # build output
41 | .turbo
42 | dist
43 | bin
44 | .vscode
45 | .parcel-cache
46 |
47 | # yarn v2
48 | .yarn/cache
49 | .yarn/unplugged
50 | .yarn/build-state.yml
51 | .yarn/install-state.gz
52 | .pnp.*
53 |
54 | # examples/next.js
55 | **/.next/
56 | **/next-env.d.ts
57 |
58 | # other
59 | .DS_Store
60 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v18
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # ignore artifacts
2 | dist
3 | .turbo
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "printWidth": 120,
4 | "quoteProps": "consistent",
5 | "semi": true,
6 | "singleQuote": true,
7 | "tabWidth": 2,
8 | "useTabs": false
9 | }
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Formidable
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docs/images/envy-custom-system.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/docs/images/envy-custom-system.png
--------------------------------------------------------------------------------
/docs/images/envy-example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/docs/images/envy-example.png
--------------------------------------------------------------------------------
/docs/images/envy-hero.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/docs/images/envy-hero.png
--------------------------------------------------------------------------------
/docs/images/envy-request-component.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/docs/images/envy-request-component.png
--------------------------------------------------------------------------------
/docs/images/envy-response-component.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/docs/images/envy-response-component.png
--------------------------------------------------------------------------------
/docs/images/envy-system-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/docs/images/envy-system-icon.png
--------------------------------------------------------------------------------
/docs/images/envy-trace-row-data.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/docs/images/envy-trace-row-data.png
--------------------------------------------------------------------------------
/examples/apollo-client/.postcssrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": {
3 | "tailwindcss": {}
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/examples/apollo-client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/example-apollo-client",
3 | "version": "1.0.0",
4 | "description": "Example website connected to apollo server application using Envy",
5 | "license": "MIT",
6 | "private": true,
7 | "scripts": {
8 | "start": "yarn start:web",
9 | "start:custom-viewer": "concurrently \"yarn start:envy\" \"yarn start:web\" \"yarn start:viewer\"",
10 | "start:web": "parcel ./src/index.html --port 4001 --no-cache",
11 | "start:envy": "npx @envyjs/webui --no-ui",
12 | "start:viewer": "parcel ./src/viewer/viewer.html --port 4002 --no-cache"
13 | },
14 | "dependencies": {
15 | "@envyjs/web": "*",
16 | "@envyjs/webui": "*",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "urql": "^4.0.5"
20 | },
21 | "devDependencies": {
22 | "@types/react": "^18.2.21",
23 | "@types/react-dom": "^18.2.7",
24 | "parcel": "^2.9.3",
25 | "postcss": "^8.4.29",
26 | "tailwindcss": "^3.3.3",
27 | "typescript": "^5.2.2"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Client, Provider, cacheExchange, fetchExchange } from 'urql';
2 |
3 | import CatFact from './components/CatFact';
4 | import Cocktail from './components/Cocktail';
5 | import Sanity from './components/Sanity';
6 | import Xkcd from './components/Xkcd';
7 |
8 | const client = new Client({
9 | url: 'http://localhost:4000/graphql',
10 | exchanges: [cacheExchange, fetchExchange],
11 | });
12 |
13 | export function App() {
14 | return (
15 |
16 |
17 | Envy - Example website with Apollo GraphQL server
18 |
19 | This website will make calls to the example apollo GraphQL server, which will send request telemetry over
20 | websockets for Envy to display in one of the Network Viewer UIs.
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/components/CatFact.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'urql';
2 |
3 | import { RANDOM_CAT_FACT_QUERY } from '../queries';
4 |
5 | export default function CatFact() {
6 | const [result, refresh] = useQuery({
7 | query: RANDOM_CAT_FACT_QUERY,
8 | requestPolicy: 'network-only',
9 | });
10 |
11 | return (
12 |
13 |
Random cat fact:
14 | {result.fetching ? (
15 |
Loading cat fact...
16 | ) : (
17 | (() => {
18 | const randomFact = result.data?.randomCatFact;
19 | if (!randomFact) return null;
20 |
21 | return (
22 | <>
23 |
{randomFact.fact}
24 | >
25 | );
26 | })()
27 | )}
28 |
29 | refresh()}>Refresh
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/components/Cocktail.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'urql';
2 |
3 | import { RANDOM_COCKTAIL_QUERY } from '../queries';
4 |
5 | export default function Cocktail() {
6 | const [result, refresh] = useQuery({
7 | query: RANDOM_COCKTAIL_QUERY,
8 | requestPolicy: 'network-only',
9 | });
10 |
11 | return (
12 |
13 |
Random cocktail:
14 | {result.fetching ? (
15 |
Loading cocktail...
16 | ) : (
17 | (() => {
18 | const cocktail = result.data?.randomCocktail;
19 | if (!cocktail) return null;
20 |
21 | return (
22 |
23 |
{cocktail.name}
24 |
25 |
26 |
27 |
Ingredients
28 |
29 | {cocktail.ingredients.map((x: string, idx: number) => (
30 | {x}
31 | ))}
32 |
33 |
34 |
35 |
36 |
Instructions
37 |
{cocktail.instructions}
38 |
39 |
40 | );
41 | })()
42 | )}
43 |
44 | refresh()}>Refresh
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/components/Sanity.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'urql';
2 |
3 | import { CATEGORIES_WITH_PRODUCTS_QUERY } from '../queries';
4 |
5 | export default function Sanity() {
6 | const [result, refresh] = useQuery({
7 | query: CATEGORIES_WITH_PRODUCTS_QUERY,
8 | requestPolicy: 'network-only',
9 | });
10 |
11 | return (
12 |
13 |
Formidable Boulangerie products:
14 | {result.fetching ? (
15 |
Loading categories and products...
16 | ) : (
17 | (() => {
18 | const categoriesWithProducts = result.data?.categoriesWithProducts;
19 | if (!categoriesWithProducts) return null;
20 |
21 | return (
22 |
23 | {categoriesWithProducts.map((category: any) => (
24 |
25 |
{category.name}
26 |
27 | {category.products.map((product: any) => {
28 | return product.variants.map((variant: any) => (
29 |
30 |
{variant.name}
31 |
${variant.price.toFixed(2)}
32 |
33 | ));
34 | })}
35 |
36 |
37 | ))}
38 |
39 | );
40 | })()
41 | )}
42 |
43 | refresh()}>Refresh
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/components/Xkcd.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'urql';
2 |
3 | import { LATEST_XKCD_QUERY } from '../queries';
4 |
5 | export default function Xkcd() {
6 | const [result, refresh] = useQuery({
7 | query: LATEST_XKCD_QUERY,
8 | requestPolicy: 'network-only',
9 | });
10 |
11 | return (
12 |
13 |
Latest xkcd:
14 | {result.fetching ? (
15 |
Loading comic...
16 | ) : (
17 | (() => {
18 | const comic = result.data?.latestXkcd;
19 | if (!comic) return null;
20 |
21 | return (
22 |
23 |
{comic.name}
24 |
25 |
26 | );
27 | })()
28 | )}
29 |
30 | refresh()}>Refresh
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | h1,
6 | h2,
7 | h3,
8 | h4 {
9 | @apply font-bold;
10 | }
11 |
12 | h1 {
13 | @apply text-3xl mb-8;
14 | }
15 |
16 | h2 {
17 | @apply text-2xl mb-4;
18 | }
19 |
20 | h3 {
21 | @apply text-xl mb-4;
22 | }
23 |
24 | h4 {
25 | @apply text-lg mt-4 mb-2;
26 | }
27 |
28 | ul {
29 | @apply list-disc ml-8;
30 | }
31 |
32 | button {
33 | @apply block bg-orange-500 text-white px-8 py-2 rounded mt-auto;
34 | }
35 |
36 | .content {
37 | @apply space-y-4;
38 | @apply xl:flex xl:flex-row xl:space-x-4 xl:space-y-0;
39 | }
40 |
41 | .thingy {
42 | @apply flex flex-col bg-slate-50 shadow-md p-4 rounded-lg xl:flex-1;
43 | }
44 |
45 | .button-container {
46 | @apply mt-auto pt-4;
47 | }
48 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Envy - Example website (Apollo)(
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/index.js:
--------------------------------------------------------------------------------
1 | import { enableTracing } from '@envyjs/web';
2 | enableTracing({ serviceName: 'examples/apollo-client' });
3 |
4 | import { createRoot } from 'react-dom/client';
5 |
6 | import { App } from './App';
7 |
8 | const container = document.getElementById('app');
9 | const root = createRoot(container);
10 | root.render( );
11 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/queries/index.ts:
--------------------------------------------------------------------------------
1 | import { gql } from 'urql';
2 |
3 | export const RANDOM_CAT_FACT_QUERY = gql`
4 | query RandomCatFact {
5 | randomCatFact {
6 | id
7 | fact
8 | }
9 | }
10 | `;
11 |
12 | export const RANDOM_COCKTAIL_QUERY = gql`
13 | query RandomCocktail {
14 | randomCocktail {
15 | id
16 | name
17 | ingredients
18 | instructions
19 | imageUrl
20 | }
21 | }
22 | `;
23 |
24 | export const LATEST_XKCD_QUERY = gql`
25 | query LatestXkcd {
26 | latestXkcd {
27 | id
28 | title
29 | imageUrl
30 | }
31 | }
32 | `;
33 |
34 | export const CATEGORIES_WITH_PRODUCTS_QUERY = gql`
35 | query CategoriesWithProducts {
36 | categoriesWithProducts {
37 | id
38 | name
39 | description
40 | products {
41 | id
42 | name
43 | description
44 | categoryIds
45 | variants {
46 | id
47 | name
48 | price
49 | }
50 | }
51 | }
52 | }
53 | `;
54 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/viewer/systems/CatFacts.tsx:
--------------------------------------------------------------------------------
1 | import { System, Trace } from '@envyjs/webui';
2 |
3 | export default class CatFactsSystem implements System {
4 | name = 'Cat Facts API';
5 |
6 | isMatch(trace: Trace) {
7 | return trace.http?.host === 'cat-fact.herokuapp.com';
8 | }
9 |
10 | getIconUri() {
11 | return 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pjxzdmcgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IkJsYWNrX2NhdCI+PHBhdGggZD0iTTM3MC4wOTE4LDUyLjE0NmMtNS4xMzEyLTUuMTAzNS0xMy40NS00LjczLTE4LjU2MzguMzg1Ni0yMi44NzQ4LDIyLjg1NzYtNDIuNDkzOSw1MC4zNTYzLTU3LjkwNDcsODEuMThhMjYyLjg1NDQsMjYyLjg1NDQsMCwwLDAtNzUuMzM2MywwLDMxMi45NTEyLDMxMi45NTEyLDAsMCwwLTU3LjgyMzUtODEuMTYwN2MtNS4xMS01LjEyMzgtMTMuNDMyNi01LjUwNTEtMTguNTY4MS0uNDAyN0M4My4zOTI2LDExMC4yODE0LDQ2LDE5OC4zMzc4LDQ2LDI5Ny4yNDg1YzAsOTEuODc1LDkzLjk3NzEsMTY2LjI1LDIxMCwxNjYuMjUsMTE1LjkzNzUsMCwyMTAtNzQuMzc1LDIxMC0xNjYuMjVDNDY2LDE5OC4zMzY4LDQyOC41MjYyLDExMC4yNzgxLDM3MC4wOTE4LDUyLjE0NlpNMTQ2LjYyNSwzMzAuODQ5M2MtMjQuMzI3NC00LjcyNTQtNDQuOTc2Mi0yMi4zMTI5LTU2Ljg3NS00Ni43MjU4LDExLjg5ODgtMjQuNDEyOCwzMi41NDc2LTQyLDU2Ljg3NS00Ni43MjQ3Wm0yNi4yNSwwVjIzNy4zOTg4YzI0LjIzNzcsNC43MjQzLDQ0Ljk3NjIsMjIuMzExOSw1Ni44NzUsNDYuNzI0N0MyMTcuODUxMiwzMDguNTM2NCwxOTcuMTEyNywzMjYuMTIzOSwxNzIuODc1LDMzMC44NDkzWm0xNjYuMjUsMGMtMjQuMzI3NC00LjcyNTQtNDQuOTc2Mi0yMi4zMTI5LTU2Ljg3NS00Ni43MjU4LDExLjg5ODgtMjQuNDEyOCwzMi41NDc2LTQyLDU2Ljg3NS00Ni43MjQ3Wm0yNi4yNSwwVjIzNy4zOTg4YzI0LjIzNzcsNC43MjQzLDQ0Ljk3NjIsMjIuMzExOSw1Ni44NzUsNDYuNzI0N0M0MTAuMzUxMiwzMDguNTM2NCwzODkuNjEyNywzMjYuMTIzOSwzNjUuMzc1LDMzMC44NDkzWiIvPjwvZz48L3N2Zz4=';
12 | }
13 |
14 | getTraceRowData() {
15 | return {
16 | data: 'Cat fact',
17 | };
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/viewer/viewer.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Envy - Custom viewer
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/apollo-client/src/viewer/viewer.js:
--------------------------------------------------------------------------------
1 | import EnvyViewer from '@envyjs/webui';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import CatFactsSystem from './systems/CatFacts';
5 | import CocktailDbSystem from './systems/CocktailDb';
6 |
7 | const container = document.getElementById('root');
8 | const root = createRoot(container);
9 |
10 | root.render( );
11 |
--------------------------------------------------------------------------------
/examples/apollo-client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ['./src/**/*.{html,tsx}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/examples/apollo-client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": false,
5 | "jsx": "react-jsx",
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ES2022",
8 | "moduleResolution": "node",
9 | "outDir": "dist",
10 | "strict": true,
11 | "target": "ES2022"
12 | },
13 | "include": ["src/**/*", "index.js"]
14 | }
15 |
--------------------------------------------------------------------------------
/examples/apollo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/example-apollo",
3 | "version": "1.0.0",
4 | "description": "Example Apollo server application using Envy",
5 | "license": "MIT",
6 | "private": true,
7 | "scripts": {
8 | "start": "ts-node ./src/index.ts"
9 | },
10 | "dependencies": {
11 | "@apollo/server": "^4.9.3",
12 | "@graphql-tools/utils": "^10.0.6",
13 | "@sanity/client": "^4.0.1",
14 | "cors": "2.8.5",
15 | "graphql": "^16.8.0",
16 | "groqd": "^0.15.9",
17 | "node-fetch": "2"
18 | },
19 | "devDependencies": {
20 | "@envyjs/node": "*",
21 | "@envyjs/webui": "*",
22 | "@types/cors": "2.8.14",
23 | "ts-node": "^10.9.1",
24 | "typescript": "^5.2.2"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/catFacts/resolvers.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 |
3 | export default {
4 | Query: {
5 | async randomCatFact() {
6 | const url = 'https://cat-fact.herokuapp.com/facts';
7 | const resp = await fetch(url);
8 | const json = await resp.json();
9 |
10 | const allFacts = json.map((x: any) => ({
11 | id: x._id,
12 | fact: x.text,
13 | }));
14 |
15 | const randomIdx = Math.floor(Math.random() * allFacts.length);
16 | const randomFact = allFacts[randomIdx];
17 |
18 | return randomFact;
19 | },
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/catFacts/schema.ts:
--------------------------------------------------------------------------------
1 | export default `#graphql
2 |
3 | type CatFact {
4 | id: ID!
5 | fact: String!
6 | }
7 |
8 | extend type Query {
9 | randomCatFact: CatFact!
10 | }
11 | `;
12 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/cocktails/resolvers.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 |
3 | export default {
4 | Query: {
5 | async randomCocktail() {
6 | const url = 'https://www.thecocktaildb.com/api/json/v1/1/random.php';
7 | const resp = await fetch(url);
8 | const json = await resp.json();
9 | const item = json.drinks[0];
10 |
11 | const ingredients = [];
12 | for (let i = 1; i <= 15; i += 1) {
13 | const ingredient = item[`strIngredient${i}`];
14 | if (!!ingredient) {
15 | ingredients.push(ingredient);
16 | }
17 | }
18 |
19 | const cocktail = {
20 | id: item.idDrink,
21 | name: item.strDrink,
22 | ingredients,
23 | instructions: item.strInstructions,
24 | imageUrl: item.strDrinkThumb,
25 | };
26 |
27 | return cocktail;
28 | },
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/cocktails/schema.ts:
--------------------------------------------------------------------------------
1 | export default `#graphql
2 |
3 | type Cocktail {
4 | id: ID!
5 | name: String!
6 | ingredients: [String!]!
7 | instructions: String!
8 | imageUrl: String
9 | }
10 |
11 | extend type Query {
12 | randomCocktail: Cocktail!
13 | }
14 | `;
15 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/sanity/queries.ts:
--------------------------------------------------------------------------------
1 | import { q } from 'groqd';
2 |
3 | import Sanity from '../../utils/sanityClient';
4 |
5 | const categorySelection = {
6 | _id: q.string(),
7 | name: q.string(),
8 | description: q.string(),
9 | };
10 |
11 | export function getAllCategories() {
12 | const client = new Sanity();
13 | return client.runQuery(q('*').filterByType('category').grab$(categorySelection));
14 | }
15 |
16 | const productsSelection = {
17 | _id: q.string(),
18 | name: q.string(),
19 | description: q.contentBlocks(),
20 | categories: q('categories').filter().deref().grab$({ _id: q.string() }),
21 | variants: q('variants').filter().deref().grab$({
22 | _id: q.string(),
23 | name: q.string(),
24 | price: q.number(),
25 | }),
26 | };
27 |
28 | export function getAllProducts() {
29 | const client = new Sanity();
30 | return client.runQuery(q('*').filterByType('product').grab$(productsSelection));
31 | }
32 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/sanity/resolvers.ts:
--------------------------------------------------------------------------------
1 | import { getAllCategories, getAllProducts } from './queries';
2 | import { transformCategoryData, transformProductData } from './transformers';
3 |
4 | export default {
5 | Query: {
6 | async allCategories() {
7 | const categories = await getAllCategories();
8 | return categories.map(transformCategoryData);
9 | },
10 |
11 | async allProducts() {
12 | const products = await getAllProducts();
13 | return products.map(transformProductData);
14 | },
15 |
16 | async categoriesWithProducts() {
17 | const categories = await this.allCategories();
18 | const products = await this.allProducts();
19 |
20 | return categories.map((category: any) => ({
21 | ...category,
22 | products: products.filter((product: any) => product.categoryIds?.includes(category.id)),
23 | }));
24 | },
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/sanity/schema.ts:
--------------------------------------------------------------------------------
1 | export default `#graphql
2 |
3 | type Category {
4 | id: ID!
5 | name: String!
6 | description: String
7 | }
8 |
9 | type CategoryWithProduct {
10 | id: ID!
11 | name: String!
12 | description: String
13 | products: [Product!]!
14 | }
15 |
16 | type Product {
17 | id: ID!
18 | name: String!
19 | description: String
20 | categoryIds: [String!]!
21 | variants: [ProductVariant!]!
22 | }
23 |
24 | type ProductVariant {
25 | id: ID!
26 | name: String!
27 | price: Float!
28 | }
29 |
30 | extend type Query {
31 | allCategories: [Category!]!
32 | allProducts: [Product!]!
33 | categoriesWithProducts: [CategoryWithProduct!]!
34 | }
35 | `;
36 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/sanity/transformers.ts:
--------------------------------------------------------------------------------
1 | export function transformCategoryData(sanityData: any) {
2 | return {
3 | id: sanityData._id,
4 | name: sanityData.name,
5 | description: sanityData.description,
6 | };
7 | }
8 |
9 | export function transformProductData(sanityData: any) {
10 | return {
11 | id: sanityData._id,
12 | name: sanityData.name,
13 | description: sanityData.description?.[0]?.children?.join('\n\n') ?? '',
14 | categoryIds: sanityData.categories?.map((x: any) => x._id) ?? [],
15 | variants:
16 | sanityData.variants?.map((v: any) => ({
17 | id: v._id,
18 | name: v.name,
19 | price: v.price,
20 | })) ?? [],
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/xkcd/resolvers.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch';
2 |
3 | export default {
4 | Query: {
5 | async latestXkcd() {
6 | const url = 'https://xkcd.com/info.0.json';
7 | const resp = await fetch(url);
8 | const json = await resp.json();
9 | const item = json;
10 |
11 | const xkcd = {
12 | id: Buffer.from(item.img).toString('base64'),
13 | title: item.title,
14 | imageUrl: item.img,
15 | };
16 |
17 | return xkcd;
18 | },
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/examples/apollo/src/schema/xkcd/schema.ts:
--------------------------------------------------------------------------------
1 | export default `#graphql
2 |
3 | type XkdcComic {
4 | id: ID!
5 | title: String!
6 | imageUrl: String!
7 | }
8 |
9 | extend type Query {
10 | latestXkcd: XkdcComic!
11 | }
12 | `;
13 |
--------------------------------------------------------------------------------
/examples/apollo/src/utils/sanityClient.ts:
--------------------------------------------------------------------------------
1 | import sanityClient, { SanityClient } from '@sanity/client';
2 | import { BaseQuery, makeSafeQueryRunner } from 'groqd';
3 |
4 | export default class Sanity {
5 | private client: SanityClient;
6 |
7 | constructor() {
8 | this.client = sanityClient({
9 | apiVersion: '2021-10-21',
10 | projectId: '5bsv02jj', // "Formidable Boulangerie" Sanity project
11 | dataset: 'production',
12 | useCdn: true,
13 | useProjectHostname: true,
14 | });
15 | }
16 |
17 | async runQuery(query: BaseQuery, params?: Record) {
18 | const runSafeQuery = makeSafeQueryRunner((query, params) => {
19 | return this.client.fetch(query, params);
20 | });
21 |
22 | return runSafeQuery(query, params);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/examples/express-client/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:react/recommended',
7 | 'plugin:react/jsx-runtime',
8 | 'plugin:react-hooks/recommended',
9 | ],
10 | ignorePatterns: ['dist', '.eslintrc.cjs'],
11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
12 | settings: { react: { version: '18.2' } },
13 | plugins: ['react-refresh'],
14 | rules: {
15 | 'react-refresh/only-export-components': [
16 | 'warn',
17 | { allowConstantExport: true },
18 | ],
19 | },
20 | }
21 |
--------------------------------------------------------------------------------
/examples/express-client/README.md:
--------------------------------------------------------------------------------
1 | # React + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
--------------------------------------------------------------------------------
/examples/express-client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Envy + Express
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/examples/express-client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/example-express-client",
3 | "version": "1.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build"
9 | },
10 | "dependencies": {
11 | "react": "^18.2.0",
12 | "react-dom": "^18.2.0"
13 | },
14 | "devDependencies": {
15 | "@types/react": "^18.2.15",
16 | "@types/react-dom": "^18.2.7",
17 | "@vitejs/plugin-react": "^4.0.3",
18 | "autoprefixer": "^10.4.15",
19 | "postcss": "^8.4.29",
20 | "tailwindcss": "^3.3.3",
21 | "typescript": "^5.2.2",
22 | "vite": "^4.4.5"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/examples/express-client/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/examples/express-client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import CatFact from './components/CatFact';
2 | import Cocktail from './components/Cocktail';
3 | import RandomDogImage from './components/RandomeDogImage';
4 |
5 | function App() {
6 | return (
7 |
8 | Envy - Example website with Express server
9 |
10 | This website will make calls to the example express server, which will send request telemetry over websockets
11 | for Envy to display in one of the Network Viewer UIs.
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
23 | export default App;
24 |
--------------------------------------------------------------------------------
/examples/express-client/src/components/CatFact.tsx:
--------------------------------------------------------------------------------
1 | import { fetchCatFact } from '../utils/query';
2 | import { useCallback, useEffect, useState } from 'react';
3 | import { CatFact as CatFactType } from '../utils/types';
4 |
5 | export default function CatFact() {
6 | const [catFact, setCatFact] = useState();
7 |
8 | const onRefresh = useCallback(() => fetchCatFact().then(setCatFact), []);
9 |
10 | useEffect(() => {
11 | onRefresh();
12 | }, []);
13 |
14 | return (
15 |
16 |
Random cat fact:
17 | {catFact &&
{catFact.text}
}
18 |
19 | Refresh
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/examples/express-client/src/components/Cocktail.tsx:
--------------------------------------------------------------------------------
1 | import { fetchRandomCocktail } from '../utils/query';
2 | import { Cocktail as CocktailType } from '../utils/types';
3 | import { useCallback, useEffect, useState } from 'react';
4 |
5 | export default function Cocktail() {
6 | const [cocktail, setCocktail] = useState();
7 | const onRefresh = useCallback(() => fetchRandomCocktail().then(setCocktail), []);
8 |
9 | useEffect(() => {
10 | onRefresh();
11 | }, []);
12 |
13 | return (
14 |
15 |
Random cocktail:
16 | {cocktail && (
17 |
18 |
{cocktail.name}
19 |
20 |
21 |
22 |
Ingredients
23 |
24 | {cocktail.ingredients.map((x: string, idx: number) => (
25 | {x}
26 | ))}
27 |
28 |
29 |
30 |
31 |
Instructions
32 |
{cocktail.instructions}
33 |
34 |
35 | )}
36 |
37 | Refresh
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/examples/express-client/src/components/RandomeDogImage.tsx:
--------------------------------------------------------------------------------
1 | import { fetchRandomDog } from '../utils/query';
2 | import { Dog as DogType } from '../utils/types';
3 | import { useCallback, useEffect, useState } from 'react';
4 |
5 | export default function RandomDogImage() {
6 | const [dog, setDog] = useState();
7 | const onRefresh = useCallback(() => fetchRandomDog().then(setDog), []);
8 |
9 | useEffect(() => {
10 | onRefresh();
11 | }, []);
12 |
13 | return (
14 |
15 |
Ramdom Dog Image:
16 |
17 |
18 |
19 |
20 | Refresh
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/examples/express-client/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | h1,
6 | h2,
7 | h3,
8 | h4 {
9 | @apply font-bold;
10 | }
11 |
12 | h1 {
13 | @apply text-3xl mb-8;
14 | }
15 |
16 | h2 {
17 | @apply text-2xl mb-4;
18 | }
19 |
20 | h3 {
21 | @apply text-xl mb-4;
22 | }
23 |
24 | h4 {
25 | @apply text-lg mt-4 mb-2;
26 | }
27 |
28 | ul {
29 | @apply list-disc ml-8;
30 | }
31 |
32 | button {
33 | @apply block bg-orange-500 text-white px-8 py-2 rounded mt-auto;
34 | }
35 |
36 | .content {
37 | @apply space-y-4;
38 | @apply xl:flex xl:flex-row xl:space-x-4 xl:space-y-0;
39 | }
40 |
41 | .thingy {
42 | @apply flex flex-col bg-slate-50 shadow-md p-4 rounded-lg xl:flex-1;
43 | }
44 |
45 | .button-container {
46 | @apply mt-auto pt-4;
47 | }
48 |
--------------------------------------------------------------------------------
/examples/express-client/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import App from './App';
4 | import './index.css';
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render( );
7 |
--------------------------------------------------------------------------------
/examples/express-client/src/utils/query.ts:
--------------------------------------------------------------------------------
1 | import { CatFact, Cocktail, Dog } from './types';
2 |
3 | export async function fetchCatFact(): Promise {
4 | const res = await fetch('/api/cat-fact');
5 | const { data } = await res.json();
6 | return data;
7 | }
8 |
9 | export async function fetchRandomCocktail(): Promise {
10 | const res = await fetch('/api/cocktail');
11 | const { data } = await res.json();
12 | return data;
13 | }
14 |
15 | export async function fetchRandomDog(): Promise {
16 | const res = await fetch('/api/dog');
17 | const { data } = await res.json();
18 | return data;
19 | }
20 |
--------------------------------------------------------------------------------
/examples/express-client/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | export type CatFact = { _id: string; text: string };
2 | export type Cocktail = {
3 | id: string;
4 | name: string;
5 | ingredients: string[];
6 | instructions: string[];
7 | imageUrl: string;
8 | };
9 | export type Dog = {
10 | imageUrl: string;
11 | };
12 |
--------------------------------------------------------------------------------
/examples/express-client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./index.html",
5 | "./src/**/*.{js,ts,jsx,tsx}",
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
13 |
--------------------------------------------------------------------------------
/examples/express-client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": false,
5 | "jsx": "react-jsx",
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ES2022",
8 | "moduleResolution": "node",
9 | "outDir": "dist",
10 | "strict": true,
11 | "target": "ES2022"
12 | },
13 | "include": ["src/**/*", "index.js"]
14 | }
15 |
--------------------------------------------------------------------------------
/examples/express-client/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | port: '4001',
9 | proxy: {
10 | '/api': {
11 | target: 'http://localhost:4000/',
12 | changeOrigin: true,
13 | }
14 | }
15 | }
16 | })
17 |
--------------------------------------------------------------------------------
/examples/express/app.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/order
2 | import { enableTracing } from '@envyjs/node';
3 | enableTracing({ serviceName: 'examples/express' });
4 | import express, { NextFunction, Response } from 'express';
5 | import fetch from 'node-fetch';
6 |
7 | const app = express();
8 | const port = 4000;
9 |
10 | app.use((_, response: Response, next: NextFunction) => {
11 | response.setHeader('Timing-Allow-Origin', '*');
12 | next();
13 | });
14 |
15 | app.get('/api/cat-fact', async (_, response) => {
16 | const res = await fetch('https://cat-fact.herokuapp.com/facts');
17 | const data = await res.json();
18 | const allFacts = data.map((fact: { _id: string; text: string }) => ({
19 | id: fact._id,
20 | text: fact.text,
21 | }));
22 | const randomIdx = Math.floor(Math.random() * allFacts.length);
23 | return response.send({ data: allFacts[randomIdx] });
24 | });
25 |
26 | app.get('/api/cocktail', async (_, response) => {
27 | const url = 'https://www.thecocktaildb.com/api/json/v1/1/random.php';
28 | const resp = await fetch(url);
29 | const json = await resp.json();
30 | const item = json.drinks[0];
31 |
32 | const ingredients = [];
33 | for (let i = 1; i <= 15; i += 1) {
34 | const ingredient = item[`strIngredient${i}`];
35 | if (ingredient) {
36 | ingredients.push(ingredient);
37 | }
38 | }
39 |
40 | return response.send({
41 | data: {
42 | id: item.idDrink,
43 | name: item.strDrink,
44 | ingredients,
45 | instructions: item.strInstructions,
46 | imageUrl: item.strDrinkThumb,
47 | },
48 | });
49 | });
50 |
51 | app.get('/api/dog', async (_, response) => {
52 | const url = 'https://dog.ceo/api/breeds/image/random';
53 | const resp = await fetch(url);
54 | const item = await resp.json();
55 |
56 | return response.send({
57 | data: {
58 | imageUrl: item.message,
59 | },
60 | });
61 | });
62 |
63 | // eslint-disable-next-line no-console
64 | app.listen(port, () => console.log(`Express Sever running on http://localhost:${port}`));
65 |
--------------------------------------------------------------------------------
/examples/express/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/example-express",
3 | "version": "1.0.0",
4 | "private": true,
5 | "scripts": {
6 | "start": "ts-node ./app.ts"
7 | },
8 | "license": "MIT",
9 | "dependencies": {
10 | "express": "^4.18.2",
11 | "@envyjs/node": "*"
12 | },
13 | "devDependencies": {
14 | "@types/express": "^4.17.17",
15 | "ts-node": "^10.9.1",
16 | "typescript": "^5.2.2"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/examples/express/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist",
4 | "target": "es2016",
5 | "module": "commonjs",
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "skipLibCheck": true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/examples/next/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": ["@typescript-eslint"],
5 | "extends": [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/eslint-recommended",
8 | "plugin:@typescript-eslint/recommended"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/examples/next/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | ```
14 |
15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
16 |
17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
18 |
19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/examples/next/app/_components/CatFact.tsx:
--------------------------------------------------------------------------------
1 | import { CatFactWidget } from '@/shared';
2 | import { fetchCatFact } from '@/utils/query';
3 |
4 | export async function CatFact() {
5 | const fact = await fetchCatFact();
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/examples/next/app/_components/Cocktail.tsx:
--------------------------------------------------------------------------------
1 | import { CocktailWidget } from '@/shared';
2 | import { fetchRandomCocktail } from '@/utils/query';
3 |
4 | export async function Cocktail() {
5 | const cocktail = await fetchRandomCocktail();
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/examples/next/app/_components/Dogo.tsx:
--------------------------------------------------------------------------------
1 | import { DogWidget } from '@/shared';
2 | import { fetchRandomDog } from '@/utils/query';
3 |
4 | export async function Dogo() {
5 | const comic = await fetchRandomDog();
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/examples/next/app/_components/index.ts:
--------------------------------------------------------------------------------
1 | export { CatFact } from './CatFact';
2 | export { Cocktail } from './Cocktail';
3 | export { Dogo } from './Dogo';
4 |
--------------------------------------------------------------------------------
/examples/next/app/api/swapi/route.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { NextResponse } from 'next/server';
3 |
4 | const SWAPI_URL = 'https://swapi.py4e.com/api/people/1';
5 |
6 | export async function GET() {
7 | // Look - just one call to the SWAPI endpoint!
8 | const { data } = await axios.get(SWAPI_URL);
9 | return NextResponse.json(data);
10 | }
11 |
--------------------------------------------------------------------------------
/examples/next/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/examples/next/app/favicon.ico
--------------------------------------------------------------------------------
/examples/next/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import '../globals.css';
2 | import type { Metadata } from 'next';
3 | import { Inter } from 'next/font/google';
4 |
5 | const inter = Inter({ subsets: ['latin'] });
6 |
7 | export const metadata: Metadata = {
8 | title: 'Create Next App',
9 | description: 'Generated by create next app',
10 | };
11 |
12 | export default function RootLayout({ children }: { children: React.ReactNode }) {
13 | return (
14 |
15 | {children}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/examples/next/app/next13app/call-api-route/ApiRouteExample.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | const API_ROUTE_URL = 'http://localhost:3000/api/swapi';
4 | const SWAPI_URL = 'https://swapi.dev/api/people/1';
5 |
6 | export default function ApiRouteExample() {
7 | async function makeSwapiCall() {
8 | await fetch(API_ROUTE_URL);
9 | }
10 |
11 | const swapiUrl = {SWAPI_URL}
;
12 | const apiRouteUrl = (
13 | {API_ROUTE_URL}
14 | );
15 |
16 | const button = (
17 |
18 | Make SWAPI call
19 |
20 | );
21 |
22 | return (
23 |
24 |
25 |
Calling an API route which makes an upstream request
26 |
Click the button to make a call to an API route from this page.
27 |
28 | This will call {apiRouteUrl}, where that API route will call {swapiUrl}
29 |
30 |
31 | {button}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/examples/next/app/next13app/call-api-route/page.tsx:
--------------------------------------------------------------------------------
1 | import ApiRouteExample from './ApiRouteExample';
2 |
3 | export default function Home() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/examples/next/app/next13app/page.tsx:
--------------------------------------------------------------------------------
1 | import { CatFact, Cocktail, Dogo } from '@/app/_components';
2 | import { CatFactContainer, CocktailContainer, DogContainer } from '@/shared';
3 |
4 | export default function Home() {
5 | return (
6 |
7 |
8 |
Envy - Example website with Next JS
9 |
10 | This website will make calls to a few endpoints using server render components, which will send request
11 | telemetry over websockets for Envy to display in one of the Network Viewer UIs.
12 |
13 |
14 |
15 | Server Components
16 |
17 |
18 |
19 |
20 |
21 | Client Components
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/examples/next/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | export default function Home() {
4 | return (
5 |
6 |
7 |
Envy - Example website with Next JS
8 |
9 | This website will make calls to a few endpoints using server render components, which will send request
10 | telemetry over websockets for Envy to display in one of the Network Viewer UIs.
11 |
12 |
13 |
14 |
15 |
16 |
21 | App Router
22 |
23 | Example
24 |
25 |
26 |
27 |
32 | Pages Router
33 |
34 | Example
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/examples/next/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | h1,
6 | h2,
7 | h3,
8 | h4 {
9 | @apply font-bold;
10 | }
11 |
12 | h1 {
13 | @apply text-3xl mb-8;
14 | }
15 |
16 | h2 {
17 | @apply text-2xl mb-4;
18 | }
19 |
20 | h3 {
21 | @apply text-xl mb-4;
22 | }
23 |
24 | h4 {
25 | @apply text-lg mt-4 mb-2;
26 | }
27 |
28 | ul {
29 | @apply list-disc ml-8;
30 | }
31 |
32 | button {
33 | @apply block bg-orange-500 text-white px-8 py-2 rounded mt-auto;
34 | }
35 |
36 | .content {
37 | @apply space-y-4;
38 | @apply xl:flex xl:flex-row xl:space-x-4 xl:space-y-0;
39 | }
40 |
41 | .thingy {
42 | @apply flex flex-col bg-slate-50 shadow-md p-4 rounded-lg xl:flex-1;
43 | }
44 |
45 | .button-container {
46 | @apply mt-auto pt-4;
47 | }
48 |
--------------------------------------------------------------------------------
/examples/next/next.config.js:
--------------------------------------------------------------------------------
1 | const { withEnvy } = require('@envyjs/nextjs');
2 |
3 | /** @type {import('next').NextConfig} */
4 | const nextConfig = {};
5 |
6 | const envyConfig = {
7 | serviceName: 'next-app',
8 | };
9 |
10 | module.exports = withEnvy(nextConfig, envyConfig);
11 |
--------------------------------------------------------------------------------
/examples/next/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/example-next",
3 | "private": true,
4 | "version": "1.0.0",
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start -p 4000",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@types/react": "18.2.21",
13 | "@types/react-dom": "18.2.7",
14 | "autoprefixer": "10.4.15",
15 | "axios": "1.6.5",
16 | "eslint": "8.49.0",
17 | "eslint-config-next": "13.4.19",
18 | "next": "13.4.19",
19 | "postcss": "8.4.29",
20 | "react": "18.2.0",
21 | "react-dom": "18.2.0",
22 | "tailwindcss": "3.3.3",
23 | "typescript": "5.2.2"
24 | },
25 | "devDependencies": {
26 | "@envyjs/nextjs": "*"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/examples/next/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import type { AppProps } from 'next/app';
2 |
3 | import '../globals.css';
4 |
5 | export default function MyApp({ Component, pageProps }: AppProps) {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/examples/next/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/examples/next/pages/next13pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { fetchCatFact, fetchRandomCocktail, fetchRandomDog } from '@/utils/query';
2 | import { CatFact, Cocktail, Dog } from '@/utils/types';
3 | import { CatFactContainer, CocktailContainer, DogContainer } from '@/shared';
4 |
5 | type Props = { fact: CatFact; cocktail: Cocktail; dog: Dog };
6 |
7 | export default function Home({ fact, cocktail, dog }: Props) {
8 | return (
9 |
10 |
11 |
Envy - Example website with Next JS
12 |
13 | This website will make calls to a few endpoints using server render components, which will send request
14 | telemetry over websockets for Envy to display in one of the Network Viewer UIs.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
27 | export async function getStaticProps() {
28 | const fact = await fetchCatFact();
29 | const dog = await fetchRandomDog();
30 | const cocktail = await fetchRandomCocktail();
31 |
32 | return {
33 | props: {
34 | fact,
35 | cocktail,
36 | dog,
37 | },
38 | };
39 | }
40 |
--------------------------------------------------------------------------------
/examples/next/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/examples/next/shared/CatFactContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CatFactWidget } from '@/shared';
4 | import { fetchCatFact } from '@/utils/query';
5 | import { CatFact } from '@/utils/types';
6 | import { useCallback, useEffect, useState } from 'react';
7 |
8 | type Props = {
9 | initialCatFact?: CatFact;
10 | };
11 |
12 | export function CatFactContainer({ initialCatFact }: Props) {
13 | const [catFact, setCatFact] = useState(initialCatFact);
14 |
15 | const onRefresh = useCallback(() => fetchCatFact().then(setCatFact), []);
16 |
17 | useEffect(() => {
18 | if (!initialCatFact) {
19 | onRefresh();
20 | }
21 | }, []);
22 |
23 | return (
24 |
25 |
26 | Refresh
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/examples/next/shared/CatFactWidget.tsx:
--------------------------------------------------------------------------------
1 | import { CatFact } from '@/utils/types';
2 |
3 | type Props = {
4 | fact?: CatFact;
5 | children?: React.ReactNode;
6 | };
7 |
8 | export function CatFactWidget({ fact, children }: Props) {
9 | return (
10 |
11 |
Random cat fact:
12 |
{fact?.text}
13 | {children}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/examples/next/shared/CocktailContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CocktailWidget } from '@/shared';
4 | import { fetchRandomCocktail } from '@/utils/query';
5 | import { Cocktail } from '@/utils/types';
6 | import { useCallback, useEffect, useState } from 'react';
7 |
8 | type Props = {
9 | initialCocktail?: Cocktail;
10 | };
11 |
12 | export function CocktailContainer({ initialCocktail }: Props) {
13 | const [cocktail, setCocktail] = useState(initialCocktail);
14 |
15 | const onRefresh = useCallback(() => fetchRandomCocktail().then(setCocktail), []);
16 |
17 | useEffect(() => {
18 | if (!initialCocktail) {
19 | onRefresh();
20 | }
21 | }, []);
22 |
23 | return (
24 |
25 |
26 | Refresh
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/examples/next/shared/CocktailWidget.tsx:
--------------------------------------------------------------------------------
1 | import { Cocktail } from '@/utils/types';
2 |
3 | type Props = {
4 | cocktail?: Cocktail;
5 | children?: React.ReactNode;
6 | };
7 |
8 | export function CocktailWidget({ cocktail, children }: Props) {
9 | return (
10 |
11 |
Random cocktail:
12 | {cocktail && (
13 |
14 |
{cocktail.name}
15 |
16 |
17 |
18 |
Ingredients
19 |
20 | {cocktail.ingredients.map((x: string, idx: number) => (
21 | {x}
22 | ))}
23 |
24 |
25 |
26 |
27 |
Instructions
28 |
{cocktail.instructions}
29 |
30 |
31 | )}
32 | {children}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/examples/next/shared/DogContainer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { DogWidget } from '@/shared';
4 | import { fetchRandomDog } from '@/utils/query';
5 | import { Dog } from '@/utils/types';
6 | import { useCallback, useEffect, useState } from 'react';
7 |
8 | type Props = {
9 | initialDog?: Dog;
10 | };
11 |
12 | export function DogContainer({ initialDog }: Props) {
13 | const [dog, setDog] = useState(initialDog);
14 |
15 | const onRefresh = useCallback(() => fetchRandomDog().then(setDog), []);
16 |
17 | useEffect(() => {
18 | if (!initialDog) {
19 | onRefresh();
20 | }
21 | }, []);
22 |
23 | return (
24 |
25 |
26 | Refresh
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/examples/next/shared/DogWidget.tsx:
--------------------------------------------------------------------------------
1 | import { Dog } from '@/utils/types';
2 |
3 | type Props = {
4 | dogo?: Dog;
5 | children?: React.ReactNode;
6 | };
7 |
8 | export function DogWidget({ dogo, children }: Props) {
9 | return (
10 |
11 |
Random Dog Image:
12 |
13 |
14 |
15 | {children}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/examples/next/shared/index.ts:
--------------------------------------------------------------------------------
1 | export * from './CatFactWidget';
2 | export * from './CocktailWidget';
3 | export * from './DogWidget';
4 | export * from './CatFactContainer';
5 | export * from './CocktailContainer';
6 | export * from './DogContainer';
7 |
--------------------------------------------------------------------------------
/examples/next/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config: Config = {
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic':
14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
15 | },
16 | },
17 | },
18 | plugins: [],
19 | }
20 | export default config
21 |
--------------------------------------------------------------------------------
/examples/next/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "baseUrl": ".",
22 | "paths": {
23 | "@/*": ["./*"],
24 | "@/components": ["app/_components"]
25 | }
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/examples/next/utils/query.ts:
--------------------------------------------------------------------------------
1 | import { CatFact, Cocktail, Dog } from './types';
2 |
3 | export async function fetchCatFact(): Promise {
4 | const res = await fetch('https://catfact.ninja/fact');
5 | const data = await res.json();
6 | return {
7 | text: data.fact,
8 | };
9 | }
10 |
11 | export async function fetchRandomCocktail(): Promise {
12 | const url = 'https://www.thecocktaildb.com/api/json/v1/1/random.php';
13 | const resp = await fetch(url);
14 | const json = await resp.json();
15 | const item = json.drinks[0];
16 |
17 | const ingredients = [];
18 | for (let i = 1; i <= 15; i += 1) {
19 | const ingredient = item[`strIngredient${i}`];
20 | if (ingredient) {
21 | ingredients.push(ingredient);
22 | }
23 | }
24 |
25 | return {
26 | id: item.idDrink,
27 | name: item.strDrink,
28 | ingredients,
29 | instructions: item.strInstructions,
30 | imageUrl: item.strDrinkThumb,
31 | };
32 | }
33 |
34 | export async function fetchRandomDog(): Promise {
35 | const url = 'https://dog.ceo/api/breeds/image/random';
36 | const resp = await fetch(url);
37 | const item = await resp.json();
38 |
39 | return {
40 | imageUrl: item.message,
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/examples/next/utils/types.ts:
--------------------------------------------------------------------------------
1 | export type CatFact = { text: string };
2 | export type Cocktail = {
3 | id: string;
4 | name: string;
5 | ingredients: string[];
6 | instructions: string[];
7 | imageUrl: string;
8 | };
9 | export type Dog = {
10 | imageUrl: string;
11 | };
12 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import type { JestConfigWithTsJest } from 'ts-jest';
2 |
3 | const jestConfig: JestConfigWithTsJest = {
4 | preset: 'ts-jest',
5 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/src/**/?(*.)+(spec|test).[jt]s?(x)'],
6 | };
7 |
8 | export default jestConfig;
9 |
--------------------------------------------------------------------------------
/packages/apollo/README.md:
--------------------------------------------------------------------------------
1 | # Envy
2 |
3 | This package is part of the Envy Developer Toolset. Please refer to the main [README](https://github.com/FormidableLabs/envy#readme) for usage.
4 |
--------------------------------------------------------------------------------
/packages/apollo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/apollo",
3 | "version": "0.10.1",
4 | "description": "Node.js Network & Telemetry Viewer",
5 | "main": "dist/index.js",
6 | "author": {
7 | "name": "Formidable",
8 | "url": "https://formidable.com"
9 | },
10 | "homepage": "https://github.com/formidablelabs/envy",
11 | "keywords": [
12 | "react",
13 | "nextjs",
14 | "graphql",
15 | "typescript",
16 | "nodejs",
17 | "telemetry",
18 | "tracing"
19 | ],
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/FormidableLabs/envy.git",
23 | "directory": "packages/apollo"
24 | },
25 | "license": "MIT",
26 | "publishConfig": {
27 | "provenance": true
28 | },
29 | "files": [
30 | "dist",
31 | "README.md"
32 | ],
33 | "scripts": {
34 | "prebuild": "rimraf dist",
35 | "build": "tsc"
36 | },
37 | "dependencies": {
38 | "@envyjs/node": "0.10.1"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/apollo/src/index.ts:
--------------------------------------------------------------------------------
1 | export const one = 1;
2 |
--------------------------------------------------------------------------------
/packages/apollo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": ["src/**/*"],
4 | "exclude": ["**/*.spec.ts"],
5 | "compilerOptions": {
6 | "outDir": "dist"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/core/README.md:
--------------------------------------------------------------------------------
1 | # Envy
2 |
3 | This package is part of the Envy Developer Toolset. Please refer to the main [README](https://github.com/FormidableLabs/envy#readme) for usage.
4 |
--------------------------------------------------------------------------------
/packages/core/jest.config.ts:
--------------------------------------------------------------------------------
1 | import rootConfig from '../../jest.config';
2 |
3 | /** @type {import('ts-jest').JestConfigWithTsJest} */
4 | export default {
5 | ...rootConfig,
6 | };
7 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/core",
3 | "version": "0.10.1",
4 | "description": "Node.js Network & Telemetry Viewer",
5 | "main": "dist/index.js",
6 | "author": {
7 | "name": "Formidable",
8 | "url": "https://formidable.com"
9 | },
10 | "homepage": "https://github.com/formidablelabs/envy",
11 | "keywords": [
12 | "react",
13 | "nextjs",
14 | "graphql",
15 | "typescript",
16 | "nodejs",
17 | "telemetry",
18 | "tracing"
19 | ],
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/FormidableLabs/envy.git",
23 | "directory": "packages/core"
24 | },
25 | "license": "MIT",
26 | "publishConfig": {
27 | "provenance": true
28 | },
29 | "files": [
30 | "dist",
31 | "README.md"
32 | ],
33 | "scripts": {
34 | "prebuild": "rimraf dist",
35 | "build": "tsc",
36 | "test": "jest"
37 | },
38 | "dependencies": {
39 | "isomorphic-ws": "^5.0.0"
40 | },
41 | "devDependencies": {
42 | "@types/ws": "8.5.7",
43 | "ws": "8.14.2"
44 | },
45 | "optionalDependencies": {
46 | "ws": "8.14.2"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/core/src/consts.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_WEB_SOCKET_PORT = 9999;
2 |
--------------------------------------------------------------------------------
/packages/core/src/event.ts:
--------------------------------------------------------------------------------
1 | import { GraphqlRequest } from './graphql';
2 | import { HttpRequest } from './http';
3 | import { SanityRequest } from './sanity';
4 |
5 | /**
6 | * An event that can be emitted through the websocket
7 | * @private This is an internal type and should not be used by consumers
8 | */
9 | export interface Event {
10 | /**
11 | * A unique identifier for this span
12 | */
13 | id: string;
14 |
15 | /**
16 | * UNIX Epoch time in seconds since 00:00:00 UTC on 1 January 1970
17 | */
18 | timestamp: number;
19 |
20 | /**
21 | * A unique identifier used for grouping
22 | * multiple events
23 | */
24 | parentId?: string;
25 |
26 | /**
27 | * Optional service name identifier
28 | */
29 | serviceName?: string;
30 |
31 | /**
32 | * Graphql request data
33 | */
34 | graphql?: GraphqlRequest;
35 |
36 | /**
37 | * Http request data
38 | */
39 | http?: HttpRequest;
40 |
41 | /**
42 | * Sanity request data
43 | */
44 | sanity?: SanityRequest;
45 | }
46 |
--------------------------------------------------------------------------------
/packages/core/src/fetchTypes.ts:
--------------------------------------------------------------------------------
1 | // These types are subsets of the libDom,
2 | // for cross-compatibility with Node 17+ and browser
3 | interface Headers {
4 | entries(): IterableIterator<[string, string]>;
5 | }
6 |
7 | export type HeadersInit = [string, string][] | Record | Headers;
8 |
9 | interface Request {
10 | readonly headers: Headers;
11 | readonly method: string;
12 | readonly url: string;
13 | }
14 |
15 | export type RequestInfo = Request | string;
16 |
17 | type BodyInit = ReadableStream | XMLHttpRequestBodyInit;
18 |
19 | export interface RequestInit {
20 | body?: BodyInit | null;
21 | headers?: HeadersInit;
22 | method?: string;
23 | }
24 |
25 | export interface Response {
26 | readonly headers: Headers;
27 | readonly status: number;
28 | readonly statusText: string;
29 | readonly type: ResponseType;
30 | text: () => Promise;
31 | }
32 |
--------------------------------------------------------------------------------
/packages/core/src/graphql.ts:
--------------------------------------------------------------------------------
1 | export type GraphqlOperationType = 'Query' | 'Mutation';
2 |
3 | /**
4 | * A Graphql Request
5 | */
6 | export interface GraphqlRequest {
7 | /**
8 | * The full request query
9 | */
10 | query: string;
11 |
12 | /**
13 | * The parsed operation name
14 | */
15 | operationName?: string;
16 |
17 | /**
18 | * The operation type
19 | */
20 | operationType: GraphqlOperationType;
21 |
22 | /**
23 | * The query variables
24 | */
25 | variables?: Record;
26 | }
27 |
--------------------------------------------------------------------------------
/packages/core/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './consts';
2 | export * from './event';
3 | export * from './fetch';
4 | export * from './graphql';
5 | export * from './http';
6 | export * from './json';
7 | export * from './middleware';
8 | export * from './nanoid';
9 | export * from './options';
10 | export * from './payload';
11 | export * from './plugin';
12 | export * from './sanity';
13 | export * from './time';
14 | export * from './websocket';
15 |
--------------------------------------------------------------------------------
/packages/core/src/json.test.ts:
--------------------------------------------------------------------------------
1 | import { safeParseJson } from './json';
2 |
3 | describe('safeParseJson', () => {
4 | it('should return parsed JSON if input is valid', () => {
5 | const result = safeParseJson('{"foo":"bar"}');
6 | expect(result).toEqual({ value: { foo: 'bar' } });
7 | });
8 |
9 | it('should return null if input is invalid', () => {
10 | const result = safeParseJson('{"foo"');
11 | expect(result).toEqual({ error: new SyntaxError('Unexpected end of JSON input') });
12 | });
13 |
14 | it('should return null if input is undefined', () => {
15 | const result = safeParseJson(undefined);
16 | expect(result).toEqual({});
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/packages/core/src/json.ts:
--------------------------------------------------------------------------------
1 | type SafeParseJsonResult = {
2 | value?: T;
3 | error?: any;
4 | };
5 |
6 | export function safeParseJson(data: string | null | undefined): SafeParseJsonResult {
7 | if (!data) return {};
8 | try {
9 | const value = JSON.parse(data) as T;
10 | return { value };
11 | } catch (error) {
12 | return { error };
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/packages/core/src/log.ts:
--------------------------------------------------------------------------------
1 | // TODO: would be good to find a way to have a universal logger
2 | // for envy, perhaps via window|globals
3 | export interface Log {
4 | info: (msg: string, ...args: unknown[]) => void;
5 | warn: (msg: string, ...args: unknown[]) => void;
6 | error: (msg: string, ...args: unknown[]) => void;
7 | debug: (msg: string, ...args: unknown[]) => void;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/graphql.ts:
--------------------------------------------------------------------------------
1 | import { GraphqlRequest, safeParseJson } from '..';
2 |
3 | import { Middleware } from '.';
4 |
5 | export const Graphql: Middleware = event => {
6 | if (event.http) {
7 | const httpRequest = event.http;
8 |
9 | if (httpRequest.method === 'GET') {
10 | const url = new URL(httpRequest.url);
11 |
12 | event.graphql = mapGraphqlData({
13 | operationName: url.searchParams.get('operationName'),
14 | query: url.searchParams.get('query'),
15 | variables: safeParseJson(url.searchParams.get('variables')).value,
16 | });
17 | }
18 |
19 | if (
20 | httpRequest.method === 'POST' &&
21 | httpRequest.requestBody &&
22 | httpRequest.requestHeaders['content-type'] === 'application/json'
23 | ) {
24 | const json = safeParseJson(httpRequest.requestBody);
25 | if (json.value) {
26 | event.graphql = mapGraphqlData(json.value);
27 | }
28 | }
29 | }
30 | return event;
31 | };
32 |
33 | function mapGraphqlData({
34 | operationName,
35 | query,
36 | variables,
37 | }: {
38 | operationName?: string | null;
39 | query?: string | null;
40 | variables?: Record;
41 | }): GraphqlRequest | undefined {
42 | const matcher = /^(mutation|query)?(\s*)?([a-zA-Z]*)?(\s*)?(\(.*\))?(\s*)?{/g;
43 | const match = query && matcher.exec(query.trim());
44 | if (match) {
45 | return {
46 | operationType: (match[1] || 'query') as GraphqlRequest['operationType'],
47 | operationName: operationName || undefined,
48 | query,
49 | variables: variables || undefined,
50 | };
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/index.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '../event';
2 | import { Options } from '../options';
3 |
4 | export type Middleware = (event: Event, options: Options) => Event;
5 |
6 | export { Sanity } from './sanity';
7 | export { Meta } from './meta';
8 | export { Graphql } from './graphql';
9 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/meta.test.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '../event';
2 |
3 | import { Meta } from './meta';
4 |
5 | describe('meta', () => {
6 | it('should add the service name', () => {
7 | const event = {} as Event;
8 | const output = Meta(event, { serviceName: 'test-name' });
9 | expect(output.serviceName).toBe('test-name');
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/meta.ts:
--------------------------------------------------------------------------------
1 | import { Middleware } from '.';
2 |
3 | export const Meta: Middleware = (event, options) => {
4 | event.serviceName = options.serviceName;
5 | return event;
6 | };
7 |
--------------------------------------------------------------------------------
/packages/core/src/middleware/sanity.ts:
--------------------------------------------------------------------------------
1 | import { safeParseJson } from '../json';
2 |
3 | import { Middleware } from '.';
4 |
5 | const HOST = '.sanity.io';
6 |
7 | export const Sanity: Middleware = event => {
8 | if (event.http) {
9 | const httpRequest = event.http;
10 |
11 | if (httpRequest.host.endsWith(HOST)) {
12 | let query: string | null = null;
13 | switch (httpRequest.method) {
14 | case 'GET': {
15 | const [, qs] = (httpRequest.path ?? '').split('?');
16 | if (qs) {
17 | query = decodeURIComponent(qs).replace('query=', '');
18 | }
19 | break;
20 | }
21 | case 'POST': {
22 | if (httpRequest.requestBody && httpRequest.requestHeaders['content-type'] === 'application/json') {
23 | const json = safeParseJson(httpRequest.requestBody);
24 | query = json.value?.query;
25 | }
26 |
27 | break;
28 | }
29 | }
30 | const queryType = query ? /_type\s*==\s*['"](.*?)['"]/m.exec(query)?.[1] ?? null : null;
31 |
32 | event.sanity = {
33 | query: query || undefined,
34 | queryType: queryType || undefined,
35 | };
36 | }
37 | }
38 | return event;
39 | };
40 |
--------------------------------------------------------------------------------
/packages/core/src/nanoid.ts:
--------------------------------------------------------------------------------
1 | export type CryptoModule = {
2 | getRandomValues(array: T): T;
3 | };
4 |
5 | // cross compatible nanoid implementation from
6 | // https://github.com/ai/nanoid
7 | export const nanoid =
8 | (crypto: CryptoModule) =>
9 | (t = 21) =>
10 | crypto
11 | .getRandomValues(new Uint8Array(t))
12 | .reduce(
13 | (t: any, e: any) =>
14 | (t += (e &= 63) < 36 ? e.toString(36) : e < 62 ? (e - 26).toString(36).toUpperCase() : e < 63 ? '_' : '-'),
15 | '',
16 | );
17 |
--------------------------------------------------------------------------------
/packages/core/src/options.ts:
--------------------------------------------------------------------------------
1 | import { HttpRequest } from '.';
2 |
3 | export type FilterableHttpRequest = Pick;
4 |
5 | export interface Options {
6 | /**
7 | * A unique identifier for the application
8 | */
9 | serviceName: string;
10 |
11 | /**
12 | * Set to true to enable debugging of exported messages
13 | * @default false
14 | */
15 | debug?: boolean;
16 |
17 | /**
18 | * Define a function to filter http requests
19 | * @default undefined
20 | */
21 | filter?: (request: FilterableHttpRequest) => boolean;
22 | }
23 |
--------------------------------------------------------------------------------
/packages/core/src/payload.ts:
--------------------------------------------------------------------------------
1 | import { Event } from './event';
2 |
3 | // how the 'connections' data will arrive from the collector
4 | // [serviceName: string, isActive: boolean][]
5 | export type ConnectionStatusData = [string, boolean][];
6 |
7 | export type ConnectionStatusPayload = {
8 | type: 'connections';
9 | data: ConnectionStatusData;
10 | };
11 |
12 | export type TracePayload = {
13 | type: 'trace';
14 | data: Event;
15 | };
16 |
17 | export type WebSocketPayload = ConnectionStatusPayload | TracePayload;
18 |
--------------------------------------------------------------------------------
/packages/core/src/plugin.ts:
--------------------------------------------------------------------------------
1 | import { Event } from './event';
2 | import { Options } from './options';
3 |
4 | export interface Exporter {
5 | send: (data: Event) => void;
6 | }
7 |
8 | export type Plugin = (options: Options, exporter: Exporter) => void;
9 |
--------------------------------------------------------------------------------
/packages/core/src/sanity.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A Sanity Request
3 | */
4 | export interface SanityRequest {
5 | /**
6 | * The full request query
7 | */
8 | query?: string | null;
9 |
10 | /**
11 | * The sanity type used in the query
12 | */
13 | queryType?: string | null;
14 | }
15 |
--------------------------------------------------------------------------------
/packages/core/src/time.ts:
--------------------------------------------------------------------------------
1 | const DEFAULT_RETRY_DELAY = 250;
2 | const DEFAULT_RETRY_FACTOR = 2; // how sharply the retry delay rises
3 | const DEFAULT_RETRY_FLATTEN = 6; // how slowly the retry delay rises
4 | const DEFAULT_RETRY_MAX_ATTEMPTS = 30;
5 |
6 | /**
7 | * Tracks expoonential time delay for retries
8 | */
9 | export class Retry {
10 | private retryDelay = DEFAULT_RETRY_DELAY;
11 | private retryAttempts = 0;
12 |
13 | /**
14 | * The number of attempts
15 | */
16 | get attempts() {
17 | return this.retryAttempts;
18 | }
19 |
20 | /**
21 | * The current delay in ms
22 | */
23 | get delay() {
24 | return this.retryDelay;
25 | }
26 |
27 | /**
28 | * Returns true if the max attempts has not been exceeded
29 | */
30 | get shouldRetry() {
31 | return this.retryAttempts >= DEFAULT_RETRY_MAX_ATTEMPTS;
32 | }
33 |
34 | /**
35 | * Increments the attempts and returns a new delay in ms
36 | * @returns the delay in ms
37 | */
38 | getNextDelay() {
39 | const jitter = Math.random() * 1000;
40 | const exp = Math.pow(this.retryAttempts++ / DEFAULT_RETRY_FLATTEN, DEFAULT_RETRY_FACTOR) * 1000;
41 | this.retryDelay = jitter + exp;
42 | return this.retryDelay;
43 | }
44 |
45 | /**
46 | * Reset the attempts and delay to defaults
47 | */
48 | reset() {
49 | this.retryDelay = DEFAULT_RETRY_DELAY;
50 | this.retryAttempts = 0;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/packages/core/src/url.test.ts:
--------------------------------------------------------------------------------
1 | import { tryParseURL } from './url';
2 |
3 | describe('url', () => {
4 | const cases = [
5 | ['/relative', undefined, undefined],
6 | ['/relative', 'localhost:3001', undefined],
7 | ['/relative', 'http://localhost:3001', 'http://localhost:3001/relative'],
8 | ['http://localhost:3001/path', undefined, 'http://localhost:3001/path'],
9 | [new URL('http://localhost:3001/another'), undefined, 'http://localhost:3001/another'],
10 | ];
11 |
12 | it.each(cases)('should tryparse', (url: any, base: any, expected: any) => {
13 | expect(tryParseURL(url, base)?.href).toEqual(expected);
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/packages/core/src/url.ts:
--------------------------------------------------------------------------------
1 | export function tryParseURL(url: string | URL, base?: string): URL | undefined {
2 | // implement cross platform `URL.canParse` since this function
3 | // only exists in the DOM and not in Node
4 | try {
5 | return new URL(url, base);
6 | } catch {}
7 | }
8 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": ["src/**/*"],
4 | "exclude": ["**/*.test.ts"],
5 | "compilerOptions": {
6 | "outDir": "dist"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/nextjs/README.md:
--------------------------------------------------------------------------------
1 | # Envy
2 |
3 | This package is part of the Envy Developer Toolset. Please refer to the main [README](https://github.com/FormidableLabs/envy#readme) for usage.
4 |
--------------------------------------------------------------------------------
/packages/nextjs/globals.d.ts:
--------------------------------------------------------------------------------
1 | import { Options } from '@envyjs/core';
2 | import { NextjsTracingOptions } from '@envyjs/nextjs';
3 |
4 | declare global {
5 | interface Window {
6 | envy: Options;
7 | }
8 |
9 | // eslint-disable-next-line no-var
10 | var envy: NextjsTracingOptions;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/nextjs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/nextjs",
3 | "version": "0.10.1",
4 | "description": "Node.js Network & Telemetry Viewer",
5 | "main": "dist/index.js",
6 | "author": {
7 | "name": "Formidable",
8 | "url": "https://formidable.com"
9 | },
10 | "homepage": "https://github.com/formidablelabs/envy",
11 | "keywords": [
12 | "react",
13 | "nextjs",
14 | "graphql",
15 | "typescript",
16 | "nodejs",
17 | "telemetry",
18 | "tracing"
19 | ],
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/FormidableLabs/envy.git",
23 | "directory": "packages/nextjs"
24 | },
25 | "license": "MIT",
26 | "publishConfig": {
27 | "provenance": true
28 | },
29 | "files": [
30 | "dist",
31 | "README.md"
32 | ],
33 | "scripts": {
34 | "prebuild": "rimraf dist",
35 | "build": "tsc"
36 | },
37 | "dependencies": {
38 | "@envyjs/node": "0.10.1",
39 | "@envyjs/web": "0.10.1"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/nextjs/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tracing';
2 | export * from './webpack';
3 |
--------------------------------------------------------------------------------
/packages/nextjs/src/loaders/config.loader.ts:
--------------------------------------------------------------------------------
1 | export default function envyWebpackLoader(this: any, source: string) {
2 | const options = this.getOptions();
3 |
4 | let injectedCode = 'var _envGlobalObject = typeof window === "undefined" ? global : window;\n';
5 | injectedCode += `_envGlobalObject.envy=${JSON.stringify(options)};\n`;
6 | return `${injectedCode}\n${source}`;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/nextjs/src/loaders/page.loader.ts:
--------------------------------------------------------------------------------
1 | export default function envyPageLoader(this: any, source: string) {
2 | const options = this.getOptions();
3 | const injectedCode = `
4 | const { enableTracing } = require('@envyjs/nextjs');
5 | enableTracing(${JSON.stringify(options)});
6 | `;
7 | return `${injectedCode}\n${source}`;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/nextjs/src/loaders/server.loader.ts:
--------------------------------------------------------------------------------
1 | import { enableTracing } from '../tracing';
2 |
3 | // global.envy is injected by webpack
4 | const options = global.envy;
5 | enableTracing(options);
6 |
--------------------------------------------------------------------------------
/packages/nextjs/src/loaders/web.loader.ts:
--------------------------------------------------------------------------------
1 | import { enableTracing } from '@envyjs/web';
2 |
3 | // window.envy is injected by webpack
4 | const options = window.envy;
5 | enableTracing(options);
6 |
--------------------------------------------------------------------------------
/packages/nextjs/src/route.ts:
--------------------------------------------------------------------------------
1 | import { Plugin } from '@envyjs/core';
2 |
3 | export const Routes: Plugin = () => {
4 | // intentionally blank
5 | };
6 |
--------------------------------------------------------------------------------
/packages/nextjs/src/tracing.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_WEB_SOCKET_PORT } from '@envyjs/core';
2 | import { TracingOptions, enableTracing as nodeTracing } from '@envyjs/node';
3 |
4 | import { Routes } from './route';
5 |
6 | type GlobalWithFlag = { __nextjsTracingInitialized: boolean };
7 | const globalWithFlag: GlobalWithFlag = global as unknown as GlobalWithFlag;
8 |
9 | // eslint-disable-next-line @typescript-eslint/ban-types
10 | export type NextjsTracingOptions = TracingOptions & {};
11 |
12 | export function enableTracing(options: NextjsTracingOptions) {
13 | const nextjsOptions: NextjsTracingOptions = {
14 | ...options,
15 | };
16 |
17 | // Exclude the following traces
18 | // 127.0.0.1:9999 websocket upgrade protocol
19 | // (ip address):\d{5} nextjs websocket
20 | const matcher = new RegExp(`(127\\.0\\.0\\.1|localhost|\\[::1\\]):(${DEFAULT_WEB_SOCKET_PORT}|\\d{5})`);
21 |
22 | nextjsOptions.filter = request => {
23 | if (matcher.test(request.url)) return false;
24 |
25 | if (options.filter) {
26 | return options.filter(request);
27 | }
28 |
29 | return true;
30 | };
31 |
32 | if (!globalWithFlag.__nextjsTracingInitialized) {
33 | globalWithFlag.__nextjsTracingInitialized = true;
34 | return nodeTracing({
35 | ...nextjsOptions,
36 | plugins: [...(options.plugins || []), Routes],
37 | });
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/nextjs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": ["globals.d.ts", "src/**/*"],
4 | "exclude": ["**/*.spec.ts"],
5 | "compilerOptions": {
6 | "outDir": "dist"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/node/README.md:
--------------------------------------------------------------------------------
1 | # Envy
2 |
3 | This package is part of the Envy Developer Toolset. Please refer to the main [README](https://github.com/FormidableLabs/envy#readme) for usage.
4 |
--------------------------------------------------------------------------------
/packages/node/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/node",
3 | "version": "0.10.1",
4 | "description": "Node.js Network & Telemetry Viewer",
5 | "main": "dist/index.js",
6 | "author": {
7 | "name": "Formidable",
8 | "url": "https://formidable.com"
9 | },
10 | "homepage": "https://github.com/formidablelabs/envy",
11 | "keywords": [
12 | "react",
13 | "nextjs",
14 | "graphql",
15 | "typescript",
16 | "nodejs",
17 | "telemetry",
18 | "tracing"
19 | ],
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/FormidableLabs/envy.git",
23 | "directory": "packages/node"
24 | },
25 | "license": "MIT",
26 | "publishConfig": {
27 | "provenance": true
28 | },
29 | "files": [
30 | "dist",
31 | "README.md"
32 | ],
33 | "scripts": {
34 | "prebuild": "rimraf dist",
35 | "build": "tsc",
36 | "test:live": "ts-node ./src/test.ts"
37 | },
38 | "dependencies": {
39 | "@envyjs/core": "0.10.1",
40 | "chalk": "^4.1.2",
41 | "shimmer": "^1.2.1",
42 | "ws": "8.14.2"
43 | },
44 | "devDependencies": {
45 | "@types/node-fetch": "^2.6.4",
46 | "@types/shimmer": "^1.0.2",
47 | "node-fetch": "^2.7.0",
48 | "ts-node": "^10.9.1"
49 | },
50 | "optionalDependencies": {
51 | "bufferutil": "4.0.7",
52 | "utf-8-validate": "6.0.3"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/packages/node/src/fetch.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Plugin,
3 | getEventFromAbortedFetchRequest,
4 | getEventFromFetchRequest,
5 | getEventFromFetchResponse,
6 | } from '@envyjs/core';
7 |
8 | import { generateId } from './id';
9 |
10 | export const Fetch: Plugin = (_options, exporter) => {
11 | const { fetch: originalFetch } = global;
12 | global.fetch = async (...args) => {
13 | const id = generateId();
14 | const startTs = performance.now();
15 | let response: Response | undefined = undefined;
16 |
17 | // export the initial request data
18 | const reqEvent = getEventFromFetchRequest(id, ...args);
19 | exporter.send(reqEvent);
20 |
21 | // if the args contain a signal, listen for abort events
22 | const signal = args[1]?.signal;
23 | if (signal) {
24 | signal.addEventListener('abort', () => {
25 | // if the request has already been resolved, we don't need to do anything
26 | if (response) return;
27 |
28 | // export the aborted request data
29 | const duration = performance.now() - startTs;
30 | const abortEvent = getEventFromAbortedFetchRequest(reqEvent, duration);
31 | exporter.send(abortEvent);
32 | });
33 | }
34 |
35 | // execute the actual request
36 | response = await originalFetch(...args);
37 | const responseClone = response.clone();
38 | const resEvent = await getEventFromFetchResponse(reqEvent, responseClone);
39 |
40 | resEvent.http!.duration = performance.now() - startTs;
41 |
42 | // export the final request data which now includes response
43 | exporter.send(resEvent);
44 |
45 | return response;
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/packages/node/src/id.ts:
--------------------------------------------------------------------------------
1 | import { webcrypto } from 'node:crypto';
2 |
3 | import { nanoid } from '@envyjs/core';
4 |
5 | export const generateId = nanoid(webcrypto as any);
6 |
--------------------------------------------------------------------------------
/packages/node/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tracing';
2 |
--------------------------------------------------------------------------------
/packages/node/src/log.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import chalk from 'chalk';
3 |
4 | const { name } = require('../package.json');
5 |
6 | export default {
7 | info: (msg: string, ...args: unknown[]) => console.log(chalk.green(`✅ ${name}`, msg), ...args),
8 | warn: (msg: string, ...args: unknown[]) => console.log(chalk.yellow(`🚸 ${name}`, msg), ...args),
9 | error: (msg: string, ...args: unknown[]) => console.log(chalk.red(`❌ ${name}`, msg), ...args),
10 | debug: (msg: string, ...args: unknown[]) => console.log(chalk.cyan(`🔧 ${name}`, msg), ...args),
11 | };
12 |
--------------------------------------------------------------------------------
/packages/node/src/test.ts:
--------------------------------------------------------------------------------
1 | // HACK: convert to jest test that captures the output
2 | // from console and verifies it against expected values
3 |
4 | /* eslint-disable import/order */
5 | import log from './log';
6 | import { enableTracing } from './tracing';
7 |
8 | // must happen first in order to wrap http/https
9 | enableTracing({
10 | debug: true,
11 | serviceName: 'unicorns',
12 | });
13 |
14 | import fetch from 'node-fetch';
15 |
16 | // test against node-fetch
17 | fetch('https://api.quotable.io/quotes/random', {
18 | headers: {
19 | Authorization: 'Bearer 12345',
20 | },
21 | })
22 | .then(response => response.json())
23 | .then((quote: Array<{ content: string }>) => {
24 | log.info('Quote Fetched:', quote[0].content);
25 | });
26 |
27 | // gzip test
28 | fetch('https://xkcd.com/info.0.json')
29 | .then(response => response.json())
30 | .then(item => {
31 | log.info('xkcd', {
32 | id: Buffer.from(item.img).toString('base64'),
33 | title: item.title,
34 | imageUrl: item.img,
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/packages/node/src/tracing.ts:
--------------------------------------------------------------------------------
1 | import { Exporter, Graphql, Meta, Middleware, Options, Plugin, Sanity, WebSocketClient } from '@envyjs/core';
2 |
3 | import { Fetch } from './fetch';
4 | import { Http } from './http';
5 | import log from './log';
6 |
7 | export interface TracingOptions extends Options {
8 | plugins?: Plugin[];
9 | port?: number;
10 | }
11 |
12 | export function enableTracing(options: TracingOptions) {
13 | if (options.debug) {
14 | log.info('debug mode');
15 | }
16 |
17 | // custom websocket client
18 | const wsClient = WebSocketClient({
19 | ...options,
20 | clientName: 'node',
21 | log,
22 | });
23 |
24 | // middleware transforms event data
25 | const middleware: Middleware[] = [Meta, Sanity, Graphql];
26 |
27 | // apply the middleware and send with the websocket
28 | const exporter: Exporter = {
29 | send(message) {
30 | const result = middleware.reduce((prev, t) => t(prev, options), message);
31 | if (result) {
32 | if (result.http && options.filter && options.filter(result.http) === false) {
33 | return;
34 | }
35 | wsClient.send(result);
36 | }
37 | },
38 | };
39 |
40 | // initialize all plugins
41 | [Http, Fetch, ...(options.plugins || [])].forEach(fn => fn(options, exporter));
42 | }
43 |
--------------------------------------------------------------------------------
/packages/node/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | // HAR timing Data adapted from
2 | // https://github.com/exogen/node-fetch-har/blob/master/index.js
3 |
4 | import { HttpRequest } from '@envyjs/core';
5 |
6 | export type HRTime = [number, number];
7 |
8 | export type Timestamps = {
9 | firstByte?: HRTime;
10 | start: HRTime;
11 | socket?: HRTime;
12 | lookup?: HRTime;
13 | connect?: HRTime;
14 | received?: HRTime;
15 | secureConnect?: HRTime;
16 | sent?: HRTime;
17 | };
18 |
19 | export function calculateTiming(time: Timestamps): HttpRequest['timings'] {
20 | // For backwards compatibility with HAR 1.1, the `connect` timing
21 | // includes `ssl` instead of being mutually exclusive.
22 | const legacyConnnect = time.secureConnect || time.connect;
23 |
24 | const blocked = getDuration(time.start, time.socket);
25 | const dns = getDuration(time.socket, time.lookup);
26 | const connect = getDuration(time.lookup, legacyConnnect);
27 |
28 | let ssl = -1;
29 | if (time.secureConnect) {
30 | ssl = getDuration(time.connect, time.secureConnect);
31 | }
32 |
33 | const send = getDuration(legacyConnnect, time.sent);
34 | const wait = Math.max(getDuration(time.sent, time.firstByte), 0);
35 | const receive = getDuration(time.firstByte, time.received);
36 |
37 | return {
38 | blocked,
39 | dns,
40 | connect,
41 | send,
42 | wait,
43 | receive,
44 | ssl,
45 | };
46 | }
47 |
48 | export function getDuration(a: HRTime | undefined, b: HRTime | undefined): number {
49 | if (!(a && b)) return 0;
50 | const seconds = b[0] - a[0];
51 | const nanoseconds = b[1] - a[1];
52 | return seconds * 1000 + nanoseconds / 1e6;
53 | }
54 |
--------------------------------------------------------------------------------
/packages/node/src/utils/wrap.ts:
--------------------------------------------------------------------------------
1 | import { types as utilTypes } from 'util';
2 |
3 | import { wrap as _wrap } from 'shimmer';
4 |
5 | // ESM handling of wrapping
6 | export const wrap: typeof _wrap = (moduleExports, name, wrapper) => {
7 | if (!utilTypes.isProxy(moduleExports)) {
8 | return _wrap(moduleExports, name, wrapper);
9 | } else {
10 | const wrapped = _wrap(Object.assign({}, moduleExports), name, wrapper);
11 |
12 | return Object.defineProperty(moduleExports, name, {
13 | value: wrapped,
14 | });
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/packages/node/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": ["src/**/*"],
4 | "exclude": ["**/*.spec.ts"],
5 | "compilerOptions": {
6 | "outDir": "dist"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/web/README.md:
--------------------------------------------------------------------------------
1 | # Envy
2 |
3 | This package is part of the Envy Developer Toolset. Please refer to the main [README](https://github.com/FormidableLabs/envy#readme) for usage.
4 |
--------------------------------------------------------------------------------
/packages/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@envyjs/web",
3 | "version": "0.10.1",
4 | "description": "Node.js Network & Telemetry Viewer",
5 | "main": "dist/index.js",
6 | "author": {
7 | "name": "Formidable",
8 | "url": "https://formidable.com"
9 | },
10 | "homepage": "https://github.com/formidablelabs/envy",
11 | "keywords": [
12 | "react",
13 | "nextjs",
14 | "graphql",
15 | "typescript",
16 | "nodejs",
17 | "telemetry",
18 | "tracing"
19 | ],
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/FormidableLabs/envy.git",
23 | "directory": "packages/web"
24 | },
25 | "license": "MIT",
26 | "publishConfig": {
27 | "provenance": true
28 | },
29 | "files": [
30 | "dist",
31 | "README.md"
32 | ],
33 | "scripts": {
34 | "prebuild": "rimraf dist",
35 | "build": "tsc"
36 | },
37 | "dependencies": {
38 | "@envyjs/core": "0.10.1"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/packages/web/src/id.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from '@envyjs/core';
2 |
3 | export const generateId = nanoid(crypto);
4 |
--------------------------------------------------------------------------------
/packages/web/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tracing';
2 |
--------------------------------------------------------------------------------
/packages/web/src/log.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | const { name } = require('../package.json');
4 |
5 | export default {
6 | info: (msg: string, ...args: unknown[]) => console.log(`✅ %c${name} ${msg}`, 'color: green', ...args),
7 | warn: (msg: string, ...args: unknown[]) => console.log(`🚸 %c${name} ${msg}`, 'color: yellow', ...args),
8 | error: (msg: string, ...args: unknown[]) => console.log(`❌ %c${name} ${msg}`, 'color: red', ...args),
9 | debug: (msg: string, ...args: unknown[]) => console.log(`🔧 %c$${name} ${msg}`, 'color: cyan', ...args),
10 | };
11 |
--------------------------------------------------------------------------------
/packages/web/src/performance.ts:
--------------------------------------------------------------------------------
1 | import { HttpRequest } from '@envyjs/core';
2 |
3 | /**
4 | * Calculate timing data from performance API data
5 | * @see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming
6 | */
7 | export function calculateTiming(time: PerformanceResourceTiming | undefined): {
8 | duration?: number;
9 | timings?: HttpRequest['timings'];
10 | } {
11 | // Some data is not available if the resource is a cross-origin request
12 | // and no Timing-Allow-Origin HTTP response header is used
13 | //
14 | // Check if we have this data available, and if we do, we can
15 | // improve the timing data with it
16 | if (!time?.requestStart) return {};
17 |
18 | const timings: HttpRequest['timings'] = {
19 | blocked: time.fetchStart - time.startTime,
20 | dns: time.domainLookupEnd - time.domainLookupStart,
21 | connect: time.connectEnd - time.connectStart,
22 | send: 0, // there is no data available for this in native fetch
23 | wait: time.responseStart - time.requestStart,
24 | receive: time.responseEnd - time.responseStart,
25 | ssl: -1,
26 | };
27 |
28 | if (time.secureConnectionStart) {
29 | timings.ssl = time.connectEnd - time.secureConnectionStart;
30 | }
31 |
32 | return {
33 | duration: time.duration,
34 | timings,
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/packages/web/src/tracing.ts:
--------------------------------------------------------------------------------
1 | import { Exporter, Graphql, Meta, Middleware, Options, Plugin, Sanity, WebSocketClient } from '@envyjs/core';
2 |
3 | import { Fetch } from './fetch';
4 | import log from './log';
5 |
6 | export interface TracingOptions extends Options {
7 | plugins?: Plugin[];
8 | port?: number;
9 | }
10 |
11 | export function enableTracing(options: TracingOptions): void {
12 | if (typeof window === 'undefined') {
13 | log.error('Attempted to use @envyjs/web in a non-browser environment');
14 | }
15 |
16 | if (options.debug) log.info('Starting in debug mode');
17 |
18 | // custom websocket client
19 | const ws = WebSocketClient({
20 | ...options,
21 | clientName: 'web',
22 | log,
23 | });
24 |
25 | // middleware transforms event data
26 | const middleware: Middleware[] = [Meta, Sanity, Graphql];
27 |
28 | // apply the middleware and send with the websocket
29 | const exporter: Exporter = {
30 | send(message) {
31 | const result = middleware.reduce((prev, t) => t(prev, options), message);
32 | if (result.http && options.filter && options.filter(result.http) === false) {
33 | return;
34 | }
35 | ws.send(result);
36 | },
37 | };
38 |
39 | // initialize all plugins
40 | [Fetch, ...(options.plugins || [])].forEach(fn => fn(options, exporter));
41 | }
42 |
--------------------------------------------------------------------------------
/packages/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "include": ["src/**/*"],
4 | "exclude": ["**/*.spec.ts"],
5 | "compilerOptions": {
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "outDir": "dist"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/webui/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const rootConfig = require('../../.eslintrc.cjs');
4 |
5 | module.exports = {
6 | ...rootConfig,
7 | extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended', ...rootConfig.extends],
8 | settings: {
9 | 'react': { version: 'detect' },
10 | 'import/parsers': {
11 | '@typescript-eslint/parser': ['.ts', '.tsx'],
12 | },
13 | 'import/resolver': {
14 | typescript: {
15 | project: 'packages/webui/tsconfig.json',
16 | },
17 | alias: [['@', path.resolve(__dirname, './src')]],
18 | },
19 | },
20 | rules: {
21 | 'react/button-has-type': 'off',
22 | 'react-hooks/exhaustive-deps': 'error',
23 | 'react/jsx-key': ['error', { checkFragmentShorthand: true, warnOnDuplicates: true }],
24 | 'react/prop-types': 'off',
25 | 'react/react-in-jsx-scope': 'off',
26 | ...rootConfig.rules,
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/packages/webui/.parcelrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@parcel/config-default",
3 | "resolvers": ["./pkg/demoResolver.js", "..."]
4 | }
5 |
--------------------------------------------------------------------------------
/packages/webui/.postcssrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": {
3 | "tailwindcss": {}
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/packages/webui/.storybook/addons/preset.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs';
3 |
4 | const MOCKS_DIRECTORY = "__storybook__mocks__";
5 |
6 | // based on https://github.com/gebeto/storybook-addon-manual-mocks
7 | // modified to work with a different directory to avoid
8 | // conflicts with jest mocks
9 | export async function viteFinal(config) {
10 | const { mergeConfig } = await import("vite");
11 | function parcelMocksPlugin() {
12 | return {
13 | name: "mocks-plugin",
14 | load(_importPath) {
15 | const importPath = _importPath.replace(/\0/g, "");
16 | const basePath = path.parse(importPath);
17 | const mockPath = path.join(
18 | basePath.dir,
19 | MOCKS_DIRECTORY,
20 | basePath.base
21 | );
22 | const isReplacementPathExists = fs.existsSync(mockPath);
23 | if (isReplacementPathExists) {
24 | return fs.readFileSync(mockPath, { encoding: "utf8" });
25 | }
26 | },
27 | };
28 | }
29 |
30 | return mergeConfig(config, {
31 | plugins: [parcelMocksPlugin()],
32 | });
33 | }
34 |
--------------------------------------------------------------------------------
/packages/webui/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/react-vite';
2 | import { viteFinal } from './addons/preset';
3 |
4 | const config: StorybookConfig = {
5 | stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
6 | addons: [
7 | '@storybook/addon-essentials',
8 | '@storybook/addon-styling',
9 | ],
10 | framework: {
11 | name: '@storybook/react-vite',
12 | options: {},
13 | },
14 | docs: {
15 | autodocs: 'tag',
16 | },
17 | viteFinal
18 | };
19 | export default config;
20 |
--------------------------------------------------------------------------------
/packages/webui/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import '../src/styles/base.css';
2 |
3 | import { withThemeByClassName } from '@storybook/addon-styling';
4 |
5 | export const decorators = [
6 | withThemeByClassName({
7 | themes: {
8 | light: 'light',
9 | dark: 'dark',
10 | },
11 | defaultTheme: 'light',
12 | }),
13 | ];
14 |
--------------------------------------------------------------------------------
/packages/webui/README.md:
--------------------------------------------------------------------------------
1 | # Envy
2 |
3 | This package is part of the Envy Developer Toolset. Please refer to the main [README](https://github.com/FormidableLabs/envy#readme) for usage.
4 |
--------------------------------------------------------------------------------
/packages/webui/jest.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/src/**/?(*.)+(spec|test).[jt]s?(x)'],
3 | testEnvironment: 'jsdom',
4 | setupFiles: ['/src/testing/setupJest.ts'],
5 | setupFilesAfterEnv: ['/src/testing/setupJestAfterEnv.ts'],
6 | globalSetup: '/src/testing/setupJestGlobal.ts',
7 | moduleNameMapper: {
8 | '^@/(.*)$': '/src/$1',
9 |
10 | // support css imports in react components
11 | '.+\\.(css|styl|less|sass|scss)$': 'identity-obj-proxy',
12 | },
13 | collectCoverageFrom: [
14 | './src/**/*.{ts,tsx}',
15 | '!./src/testing/**/*.{ts,tsx}',
16 | '!./src/**/*.stories.{ts,tsx}',
17 | '!./src/**/__storybook__mocks__/*.ts',
18 | '!**/*.d.ts',
19 | '!**/node_modules/**',
20 | '!**/dist/**',
21 | '!**/bin/**',
22 | '!**/coverage/**',
23 | ],
24 | transform: {
25 | // default ts-jest preset
26 | '^.+\\.tsx?$': 'ts-jest',
27 |
28 | // support for mjs files
29 | '^.+\\.mjs?$': [
30 | 'babel-jest',
31 | {
32 | presets: ['@babel/preset-env'],
33 | targets: {
34 | node: 'current',
35 | },
36 | },
37 | ],
38 | },
39 | transformIgnorePatterns: ['/node_modules/(?!allotment).+\\.mjs$'],
40 | };
41 |
--------------------------------------------------------------------------------
/packages/webui/pkg/demoResolver.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | import { Resolver } from '@parcel/plugin';
4 |
5 | const isProduction = process.env.NODE_ENV === 'production';
6 | const isDemo = process.env.DEMO === 'true';
7 |
8 | const productionCode = `
9 | const mockTraces = [];
10 | export default mockTraces;
11 | export function generateLotsOfMockTraces() {
12 | return [];
13 | }
14 | export function mockTraceCollection() {
15 | return new Map();
16 | }
17 | `;
18 |
19 | export default new Resolver({
20 | async resolve({ specifier }) {
21 | // remove mock traces in production unless its a demo
22 | if (isProduction && !isDemo) {
23 | if (specifier === '@/testing/mockTraces') {
24 | return {
25 | filePath: path.resolve('dummy.ts'),
26 | code: productionCode,
27 | };
28 | }
29 | }
30 | return null;
31 | },
32 | });
33 |
--------------------------------------------------------------------------------
/packages/webui/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import App from './App';
4 |
5 | describe('App', () => {
6 | afterEach(() => {
7 | cleanup();
8 | });
9 |
10 | it('should render without error', () => {
11 | render( );
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/packages/webui/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Header from '@/components/ui/Header';
2 | import MainDisplay from '@/components/ui/MainDisplay';
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | export default function App() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/webui/src/collector/__storybook__mocks__/CollectorClient.ts:
--------------------------------------------------------------------------------
1 | import { ConnectionStatusData, DEFAULT_WEB_SOCKET_PORT, Event } from '@envyjs/core';
2 |
3 | import { mockTraceCollection } from '@/testing/mockTraces';
4 | import { Traces } from '@/types';
5 |
6 | export const MockCollectorData: {
7 | connected: boolean;
8 | connecting: boolean;
9 | connections: ConnectionStatusData;
10 | traces: Traces;
11 | } = {
12 | connected: true,
13 | connecting: false,
14 | connections: [
15 | ['node-client', true],
16 | ['web-client', true],
17 | ['ts-client', false],
18 | ],
19 | traces: mockTraceCollection(),
20 | };
21 |
22 | type WebSocketClientOptions = {
23 | port?: number;
24 | changeHandler?: () => void;
25 | };
26 |
27 | export default class CollectorClient {
28 | private readonly _port: number;
29 | private _changeHandler?: (newTraceId?: string) => void;
30 |
31 | constructor({ port, changeHandler }: WebSocketClientOptions) {
32 | this._port = port ?? DEFAULT_WEB_SOCKET_PORT;
33 | this._changeHandler = changeHandler;
34 | }
35 |
36 | get port() {
37 | return '8080';
38 | }
39 |
40 | get traces() {
41 | return MockCollectorData.traces;
42 | }
43 |
44 | get connected() {
45 | return MockCollectorData.connected;
46 | }
47 |
48 | get connecting() {
49 | return MockCollectorData.connecting;
50 | }
51 |
52 | get connections() {
53 | return MockCollectorData.connections;
54 | }
55 |
56 | private _signalChange(newTraceId?: string) {
57 | this._changeHandler?.(newTraceId);
58 | }
59 |
60 | start() {
61 | // intentionally left blank
62 | }
63 |
64 | addEvent(event: Event) {
65 | const trace = { ...event };
66 | const isNewTrace = !MockCollectorData.traces.has(trace.id);
67 | MockCollectorData.traces.set(trace.id, trace);
68 | this._signalChange(isNewTrace ? trace.id : undefined);
69 | }
70 |
71 | clearTraces() {
72 | MockCollectorData.traces.clear();
73 | this._signalChange();
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Authorization.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Authorization from './Authorization';
4 |
5 | const meta = {
6 | title: 'Components/Authorization',
7 | component: Authorization,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | decorators: [
12 | Story => (
13 |
14 |
15 |
16 | ),
17 | ],
18 | } satisfies Meta;
19 |
20 | export default meta;
21 | type Story = StoryObj;
22 |
23 | export const Standard: Story = {
24 | args: {
25 | value:
26 | 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.vqb33-7FqzFWPNlr0ElW1v2RjJRZBel3CdDHBWD7y_o',
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Badge.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Badge from './Badge';
4 |
5 | const meta = {
6 | title: 'Components/Badge',
7 | component: Badge,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | children: 'Label',
19 | className: 'bg-gray-100',
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Badge.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import Badge from './Badge';
4 |
5 | describe('badge', () => {
6 | afterEach(() => {
7 | cleanup();
8 | });
9 |
10 | it('should not overwrite base css classes', () => {
11 | const { container } = render(Label );
12 | expect(container.firstChild).toHaveClass('lkj');
13 | expect(container.firstChild).toHaveClass('inline-flex');
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Badge.tsx:
--------------------------------------------------------------------------------
1 | import { HTMLAttributes } from 'react';
2 |
3 | import { tw } from '@/utils';
4 |
5 | type BadgeProps = HTMLAttributes;
6 |
7 | export default function Badge({ className, children }: BadgeProps) {
8 | return (
9 |
15 | {children}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Button.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Trash } from 'lucide-react';
3 |
4 | import Button from './Button';
5 |
6 | const meta = {
7 | title: 'Components/Button',
8 | component: Button,
9 | parameters: {
10 | layout: 'centered',
11 | },
12 | argTypes: {
13 | selected: {
14 | control: { type: 'boolean' },
15 | },
16 | size: {
17 | options: ['small', 'standard'],
18 | control: { type: 'select' },
19 | },
20 | border: {
21 | options: ['standard', 'none'],
22 | control: { type: 'select' },
23 | },
24 | },
25 | } satisfies Meta;
26 |
27 | export default meta;
28 | type Story = StoryObj;
29 |
30 | export const TextOnly: Story = {
31 | args: {
32 | children: 'Standard Button',
33 | size: 'standard',
34 | border: 'standard',
35 | },
36 | };
37 |
38 | export const IconOnly: Story = {
39 | args: {
40 | Icon: Trash,
41 | },
42 | };
43 |
44 | export const IconButton: Story = {
45 | args: {
46 | children: 'Trash Can',
47 | Icon: Trash,
48 | },
49 | };
50 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Code.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Code from './Code';
4 |
5 | const meta = {
6 | title: 'Components/Code',
7 | component: Code,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | children: `import React from 'react';
19 | import { render } from 'react-dom';
20 |
21 | function SomeComponent({ children }: { children: React.ReactNode }) {
22 | return Some Component
;
23 | }`,
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Code.tsx:
--------------------------------------------------------------------------------
1 | import { prettyFormat, tw } from '@/utils';
2 |
3 | type CodeProps = Omit, 'children'> & {
4 | inline?: boolean;
5 | prettify?: boolean;
6 | children?: React.ReactNode | Record | undefined;
7 | };
8 |
9 | export default function Code({ inline = false, prettify = true, className, children, ...props }: CodeProps) {
10 | let content: string;
11 | if (typeof children === 'object') content = JSON.stringify(children, null, 2);
12 | else content = children?.toString() ?? '';
13 |
14 | if (inline)
15 | return (
16 |
17 | {content}
18 |
19 | );
20 |
21 | const finalContent = content && prettify ? prettyFormat(content) : content;
22 | const lines = finalContent.split('\n');
23 |
24 | return (
25 |
26 |
27 | {lines.map((x, idx) => (
28 | {x}
29 | ))}
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/packages/webui/src/components/CodeDisplay.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render, waitFor } from '@testing-library/react';
2 |
3 | import CodeDisplay from './CodeDisplay';
4 |
5 | jest.mock(
6 | './MonacoEditor',
7 | () =>
8 | function MockEditor({ value, language }: any) {
9 | return {value}
;
10 | },
11 | );
12 |
13 | describe('CodeDisplay', () => {
14 | afterEach(() => {
15 | cleanup();
16 | });
17 |
18 | it('should parse application/json', async () => {
19 | const data = { foo: 'bar' };
20 | const { getByTestId } = render( );
21 |
22 | const reactJson = await waitFor(() => getByTestId('lang-json'));
23 | expect(reactJson).toHaveTextContent('{ "foo": "bar" }');
24 | });
25 |
26 | it('should parse application/graphql-response+json as json', async () => {
27 | const data = { foo: 'bar' };
28 | const { getByTestId } = render(
29 | ,
30 | );
31 |
32 | const reactJson = await waitFor(() => getByTestId('lang-json'));
33 | expect(reactJson).toHaveTextContent('{ "foo": "bar" }');
34 | });
35 |
36 | it('should parse application/json with charset', async () => {
37 | const data = { foo: 'bar' };
38 | const { getByTestId } = render(
39 | ,
40 | );
41 |
42 | const reactJson = await waitFor(() => getByTestId('lang-json'));
43 | expect(reactJson).toHaveTextContent('{ "foo": "bar" }');
44 | });
45 |
46 | it('should use txt language when contentType is undefined', async () => {
47 | const data = { foo: 'bar' };
48 | const { getByTestId } = render( );
49 |
50 | const reactJson = await waitFor(() => getByTestId('lang-txt'));
51 | expect(reactJson).toHaveTextContent('{"foo":"bar"}');
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/packages/webui/src/components/CodeDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { safeParseJson } from '@envyjs/core';
2 | import formatXml from 'xml-formatter';
3 |
4 | import { tw } from '@/utils';
5 |
6 | import Editor, { EditorHeight, MonacoEditorProps } from './MonacoEditor';
7 |
8 | type CodeDisplayProps = {
9 | contentType?: string | string[] | null;
10 | data: string | null | undefined;
11 | editorHeight?: EditorHeight;
12 | className?: string;
13 | };
14 |
15 | const languageMap: Record = {
16 | 'application/json': 'json',
17 | 'application/graphql-response+json': 'json',
18 | 'application/xml': 'xml',
19 | };
20 |
21 | export default function CodeDisplay({ data, contentType, editorHeight, className }: CodeDisplayProps) {
22 | if (!data) {
23 | return;
24 | }
25 |
26 | // content types can be an array or a string value
27 | // each value in the array or string can be a content type with a charset
28 | // example: [content-type: application/json; charset=utf-8]
29 | let resolvedContentType = Array.isArray(contentType) ? contentType[0] : contentType;
30 | resolvedContentType = resolvedContentType && resolvedContentType.split(';')[0];
31 | const lang = resolvedContentType ? languageMap[resolvedContentType as string] : 'txt';
32 |
33 | let value = data;
34 | if (lang === 'json') {
35 | const parseResult = safeParseJson(data);
36 | if (parseResult.value) {
37 | value = JSON.stringify(parseResult.value, null, 2);
38 | }
39 | } else if (lang === 'xml') {
40 | value = formatXml(data, {
41 | indentation: ' ',
42 | lineSeparator: '\n',
43 | collapseContent: true,
44 | whiteSpaceAtEndOfSelfclosingTag: true,
45 | });
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/packages/webui/src/components/DarkModeToggle.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import DarkModeToggle from './DarkModeToggle';
4 |
5 | const meta = {
6 | title: 'Components/DarkModeToggle',
7 | component: DarkModeToggle,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | argTypes: {},
12 | } satisfies Meta;
13 |
14 | export default meta;
15 | type Story = StoryObj;
16 |
17 | export const Standard: Story = {
18 | args: {},
19 | };
20 |
--------------------------------------------------------------------------------
/packages/webui/src/components/DarkModeToggle.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, cleanup, render } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 |
4 | import DarkModeToggle from './DarkModeToggle';
5 |
6 | function isDarkModeOnRoot() {
7 | return document.documentElement.classList.contains('dark');
8 | }
9 |
10 | describe('DarkModeToggle', () => {
11 | afterEach(() => {
12 | cleanup();
13 | });
14 |
15 | it('should render without error', () => {
16 | render( );
17 | });
18 |
19 | it('should toggle the class on the root element', async () => {
20 | const isDarkMode = isDarkModeOnRoot();
21 |
22 | const { getByRole } = render( );
23 | const toggle = getByRole('toggle');
24 |
25 | // toggle once
26 | await act(async () => {
27 | await userEvent.click(toggle);
28 | });
29 |
30 | expect(isDarkModeOnRoot()).toEqual(!isDarkMode);
31 |
32 | // toggle again
33 | await act(async () => {
34 | await userEvent.click(toggle);
35 | });
36 |
37 | expect(isDarkModeOnRoot()).toEqual(isDarkMode);
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/packages/webui/src/components/DarkModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { MoonStar, SunMedium } from 'lucide-react';
2 | import { useState } from 'react';
3 |
4 | import Button from './Button';
5 |
6 | export default function DarkModeToggle() {
7 | const initialTheme = localStorage.theme === 'dark';
8 | const [useDarkMode, setUseDarkMode] = useState(initialTheme);
9 |
10 | const handleCheckboxChange = () => {
11 | const shouldSetDarkMode = !useDarkMode;
12 |
13 | if (shouldSetDarkMode) {
14 | document.documentElement.classList.add('dark');
15 | localStorage.theme = 'dark';
16 | } else {
17 | document.documentElement.classList.remove('dark');
18 | localStorage.theme = 'light';
19 | }
20 |
21 | setUseDarkMode(shouldSetDarkMode);
22 | };
23 |
24 | const Icon = useDarkMode ? MoonStar : SunMedium;
25 |
26 | return ;
27 | }
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/DateTime.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import DateTime from './DateTime';
4 |
5 | const meta = {
6 | title: 'Components/DateTime',
7 | component: DateTime,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | argTypes: {},
12 | } satisfies Meta;
13 |
14 | export default meta;
15 | type Story = StoryObj;
16 |
17 | export const Standard: Story = {
18 | args: {
19 | time: 1629999999999,
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/packages/webui/src/components/DateTime.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import DateTime from './DateTime';
4 |
5 | describe('DateTime', () => {
6 | const time = 1695198462902; // 2023-09-20 08:27:42 UTC
7 |
8 | afterEach(() => {
9 | cleanup();
10 | });
11 |
12 | it('should render without error', () => {
13 | render( );
14 | });
15 |
16 | it('should render nothing if no time is provided', () => {
17 | const { container } = render( );
18 |
19 | expect(container).toBeEmptyDOMElement();
20 | });
21 |
22 | it('should render the time in the expected format', () => {
23 | const { container } = render( );
24 |
25 | expect(container).toHaveTextContent('2023-09-20 @ 08:27:42');
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/DateTime.tsx:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 |
3 | export type DateTimeProps = React.HTMLAttributes & {
4 | time: number | undefined;
5 | };
6 |
7 | export default function DateTime({ time }: DateTimeProps) {
8 | if (time === undefined) return null;
9 |
10 | const formattedTime = dayjs(time).format('YYYY-MM-DD @ hh:mm:ss');
11 |
12 | return <>{formattedTime}>;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Fields.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Fields, { Field } from './Fields';
4 |
5 | const meta = {
6 | title: 'Components/Fields',
7 | component: Fields,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | render: () => (
18 |
19 | Bar
20 | Shm
21 | Lep
22 |
23 | ),
24 | args: {
25 | children: [],
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Fields.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import Fields, { Field } from './Fields';
4 |
5 | describe('Fields', () => {
6 | afterEach(() => {
7 | cleanup();
8 | });
9 |
10 | it('should render without error', () => {
11 | render(
12 |
13 | Bar
14 | ,
15 | );
16 | });
17 |
18 | it('should render child fields', () => {
19 | const { getByTestId } = render(
20 |
21 | Bar
22 | Qux
23 | ,
24 | );
25 |
26 | const fields = getByTestId('fields');
27 | expect(fields.firstChild?.childNodes).toHaveLength(2);
28 | });
29 |
30 | it('should render field label and content', () => {
31 | const { getByTestId } = render(
32 |
33 | Bar
34 | Qux
35 | ,
36 | );
37 |
38 | const fields = getByTestId('fields');
39 | const field1 = fields.firstChild?.childNodes.item(0);
40 | const field2 = fields.firstChild?.childNodes.item(1);
41 |
42 | expect(field1).toHaveTextContent('Foo');
43 | expect(field1).toHaveTextContent('Bar');
44 |
45 | expect(field2).toHaveTextContent('Baz');
46 | expect(field2).toHaveTextContent('Qux');
47 | });
48 |
49 | it('should not render any field items without children', () => {
50 | const { getByTestId } = render(
51 |
52 |
53 | ,
54 | );
55 |
56 | const fields = getByTestId('fields');
57 | expect(fields.firstChild).toBeEmptyDOMElement();
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Fields.tsx:
--------------------------------------------------------------------------------
1 | import { tw } from '@/utils';
2 |
3 | type FieldProps = React.HTMLAttributes & {
4 | label: string;
5 | };
6 |
7 | type FieldsProps = Omit, 'children'> & {
8 | children: React.ReactNode | React.ReactNode[];
9 | };
10 |
11 | export default function Fields({ className, children, ...props }: FieldsProps) {
12 | return (
13 |
16 | );
17 | }
18 |
19 | export function Field({ label, className, children, ...props }: FieldProps) {
20 | if (!children) return null;
21 | return (
22 |
23 | {label}
24 | {children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Input.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Input from './Input';
4 |
5 | const meta = {
6 | title: 'Components/Input',
7 | component: Input,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | className: 'w-96',
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/packages/webui/src/components/KeyValueList.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import KeyValueList from './KeyValueList';
4 |
5 | const meta = {
6 | title: 'Components/KeyValueList',
7 | component: KeyValueList,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | values: [
19 | ['Key 1', 'Value 1'],
20 | ['Key 2', 'Value 2'],
21 | ],
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/packages/webui/src/components/KeyValueList.tsx:
--------------------------------------------------------------------------------
1 | export type KeyValuePair = [string, React.ReactNode];
2 |
3 | type KeyValueList = {
4 | values: KeyValuePair[];
5 | };
6 |
7 | export default function KeyValueList({ values }: KeyValueList) {
8 | if (!values.length) return null;
9 |
10 | return (
11 |
12 |
13 | {values.map(([k, v]) => {
14 | let value: React.ReactNode = v;
15 | switch (typeof v) {
16 | case 'string': {
17 | value = decodeURIComponent(v);
18 | break;
19 | }
20 | case 'number':
21 | case 'boolean': {
22 | value = v.toString();
23 | break;
24 | }
25 | }
26 | return (
27 |
28 | {k}
29 | {value}
30 |
31 | );
32 | })}
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Loading.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Loading from './Loading';
4 |
5 | const meta = {
6 | title: 'Components/Loading',
7 | component: Loading,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | size: 8,
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Loading.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import Loading from './Loading';
4 |
5 | describe('Loading', () => {
6 | afterEach(() => {
7 | cleanup();
8 | });
9 |
10 | it('should render without error', () => {
11 | render( );
12 | });
13 |
14 | it.each([
15 | [2, ['w-2', 'h-2']],
16 | [3, ['w-3', 'h-3']],
17 | [4, ['w-4', 'h-4']],
18 | [8, ['w-8', 'h-8']],
19 | [32, ['w-32', 'h-32']],
20 | ])('should render correct classes for size `%s`', (size, classNames) => {
21 | const { getByTestId } = render( );
22 | const loading = getByTestId('loading');
23 |
24 | for (const className of classNames) {
25 | expect(loading).toHaveClass(className);
26 | }
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { tw } from '@/utils';
2 |
3 | type LoadingProps = React.HTMLAttributes & {
4 | size?: 2 | 3 | 4 | 8 | 32;
5 | };
6 |
7 | export default function Loading({ size = 2, className, ...props }: LoadingProps) {
8 | return (
9 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Menu.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Menu from './Menu';
4 |
5 | const meta = {
6 | title: 'Components/Menu',
7 | component: Menu,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | label: 'Some Label',
19 | items: [
20 | {
21 | label: 'Item 1',
22 | callback: () => {
23 | // eslint-disable-next-line no-console
24 | console.log('Item 1 clicked');
25 | },
26 | },
27 | {
28 | label: 'Item 2',
29 | callback: () => {
30 | // eslint-disable-next-line no-console
31 | console.log('Item 2 clicked');
32 | },
33 | },
34 | ],
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/packages/webui/src/components/SearchInput.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import SearchInput from './SearchInput';
4 |
5 | const meta = {
6 | title: 'Components/SearchInput',
7 | component: SearchInput,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | className: 'w-96',
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/packages/webui/src/components/SearchInput.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import { setUsePlatformData } from '@/testing/mockUsePlatform';
4 |
5 | import SearchInput from './SearchInput';
6 |
7 | jest.mock('lucide-react', () => ({
8 | Search: function MockSearch() {
9 | return <>Mock Search component>;
10 | },
11 | }));
12 |
13 | jest.mock('@/hooks/usePlatform');
14 |
15 | describe('SearchInput', () => {
16 | beforeEach(() => {
17 | setUsePlatformData('mac');
18 | });
19 |
20 | afterEach(() => {
21 | cleanup();
22 | });
23 |
24 | it('should render without error', () => {
25 | render( );
26 | });
27 |
28 | it('should render an input with the role "searchbox"', () => {
29 | const { getByRole } = render( );
30 |
31 | const input = getByRole('searchbox');
32 | expect(input).toBeInTheDocument();
33 | });
34 |
35 | it('should display search icon in text box', () => {
36 | const { getByRole } = render( );
37 |
38 | const input = getByRole('searchbox');
39 | expect(input.previousSibling).toHaveTextContent('Mock Search component');
40 | });
41 |
42 | it('should display focus key if suppied', () => {
43 | const { getByRole } = render( );
44 |
45 | const input = getByRole('searchbox');
46 | expect(input.nextSibling).toHaveTextContent('⌘K');
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/packages/webui/src/components/SearchInput.tsx:
--------------------------------------------------------------------------------
1 | import { Search } from 'lucide-react';
2 | import { Ref, forwardRef } from 'react';
3 |
4 | import Input, { InputProps } from './Input';
5 |
6 | type SearchInputProps = Omit;
7 |
8 | function SearchInput({ className, ...props }: SearchInputProps, ref: Ref) {
9 | return (
10 |
11 |
12 | Search
13 |
14 |
24 |
25 | );
26 | }
27 |
28 | export default forwardRef(SearchInput);
29 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Section.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Section from './Section';
4 |
5 | const meta = {
6 | title: 'Components/Section',
7 | component: Section,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | className: 'w-96',
19 | title: 'Section Title',
20 | children: 'Contents',
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/packages/webui/src/components/Section.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronDown, ChevronUp } from 'lucide-react';
2 | import { useState } from 'react';
3 |
4 | import { tw } from '@/utils';
5 |
6 | type SectionProps = React.HTMLAttributes & {
7 | title?: string;
8 | collapsible?: boolean;
9 | };
10 |
11 | export default function Section({ title, collapsible = true, className, children, ...props }: SectionProps) {
12 | const [expanded, setExpanded] = useState(true);
13 | const Icon = expanded ? ChevronDown : ChevronUp;
14 | return (
15 | <>
16 | {title && (
17 | {
26 | if (collapsible) setExpanded(x => !x);
27 | }}
28 | {...props}
29 | >
30 |
{title}
31 | {collapsible &&
}
32 |
33 | )}
34 | {children && expanded && (
35 |
36 | {children}
37 |
38 | )}
39 | >
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ToggleSwitch.stories.ts:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import Toggle from './ToggleSwitch';
4 |
5 | const meta = {
6 | title: 'Components/Toggle Switch',
7 | component: Toggle,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | argTypes: {},
12 | } satisfies Meta;
13 |
14 | export default meta;
15 | type Story = StoryObj;
16 |
17 | export const Standard: Story = {
18 | args: {
19 | label: 'Autoscroll',
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ToggleSwitch.tsx:
--------------------------------------------------------------------------------
1 | import { CheckSquare, Square } from 'lucide-react';
2 | import { useEffect, useState } from 'react';
3 |
4 | import Button, { ButtonProps } from './Button';
5 |
6 | type ToggleSwitchProps = Omit & {
7 | label?: string;
8 | checked?: boolean;
9 | onChange?: (checked: boolean) => void;
10 | };
11 |
12 | export default function ToggleSwitch({ label, checked, onChange, ...props }: ToggleSwitchProps) {
13 | const [isChecked, setIsChecked] = useState(checked ?? false);
14 |
15 | useEffect(() => {
16 | setIsChecked(checked ?? false);
17 | }, [checked]);
18 |
19 | const onToggleChanged = () => {
20 | setIsChecked(!isChecked);
21 | if (onChange) onChange(!isChecked);
22 | };
23 |
24 | const Icon = isChecked ? CheckSquare : Square;
25 |
26 | return (
27 | <>
28 |
29 |
30 | {label}
31 |
32 |
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/packages/webui/src/components/index.ts:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | export { default as Authorization } from './Authorization';
4 | export { default as Badge } from './Badge';
5 | export { default as Button } from './Button';
6 | export { default as Code } from './Code';
7 | export { default as CodeDisplay } from './CodeDisplay';
8 | export { default as DateTime } from './DateTime';
9 | export { default as Fields, Field } from './Fields';
10 | export { default as Input } from './Input';
11 | export { default as KeyValueList } from './KeyValueList';
12 | export { default as Loading } from './Loading';
13 | export { default as Menu } from './Menu';
14 | export { default as SearchInput } from './SearchInput';
15 | export { default as Section } from './Section';
16 | export { default as ToggleSwitch } from './ToggleSwitch';
17 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/CopyAsCurlButton.stories.tsx:
--------------------------------------------------------------------------------
1 | import { HttpRequestState } from '@envyjs/core';
2 | import type { Meta, StoryObj } from '@storybook/react';
3 |
4 | import CopyAsCurlButton from './CopyAsCurlButton';
5 |
6 | const meta = {
7 | title: 'UI/CopyAsCurlButton',
8 | component: CopyAsCurlButton,
9 | parameters: {
10 | layout: 'centered',
11 | },
12 | } satisfies Meta;
13 |
14 | export default meta;
15 | type Story = StoryObj;
16 |
17 | export const Standard: Story = {
18 | args: {
19 | trace: {
20 | id: 'http-1',
21 | timestamp: 1616239022,
22 | http: {
23 | host: 'httpbin.org',
24 | method: 'GET',
25 | port: 443,
26 | path: '/api/v1/?trace=123&name=test',
27 | requestHeaders: {
28 | 'content-type': 'application/json',
29 | 'x-custom-header': 'custom value',
30 | },
31 | state: HttpRequestState.Received,
32 | url: 'https://httpbin.org/api/v1/?trace=123&name=test',
33 | },
34 | },
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/CopyAsCurlButton.tsx:
--------------------------------------------------------------------------------
1 | import { safeParseJson } from '@envyjs/core';
2 | import { CurlGenerator } from 'curl-generator';
3 | import { ClipboardCopy } from 'lucide-react';
4 | import { toast } from 'react-hot-toast';
5 |
6 | import { Button } from '@/components';
7 | import { Trace } from '@/types';
8 | import { cloneHeaders, flatMapHeaders } from '@/utils';
9 |
10 | type CopyAsCurlButtonProps = {
11 | trace: Trace;
12 | };
13 |
14 | export default function CopyAsCurlButton({ trace, ...props }: CopyAsCurlButtonProps) {
15 | async function copyAsCurl() {
16 | const headers = flatMapHeaders(cloneHeaders(trace.http!.requestHeaders, true));
17 | const body = safeParseJson(trace.http!.requestBody).value ?? null;
18 |
19 | const curlSnippet = CurlGenerator({
20 | method: trace.http!.method as any,
21 | url: trace.http!.url,
22 | headers,
23 | body,
24 | });
25 |
26 | await navigator.clipboard.writeText(curlSnippet);
27 |
28 | toast.success('cURL snippet written to clipboard', {
29 | position: 'top-right',
30 | });
31 | }
32 |
33 | return await copyAsCurl()} />;
34 | }
35 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/DebugToolbar.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import DebugToolbar from './DebugToolbar';
4 |
5 | const meta = {
6 | title: 'UI/DebugToolbar',
7 | component: DebugToolbar,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | } satisfies Meta;
12 |
13 | export default meta;
14 | type Story = StoryObj;
15 |
16 | export const Standard: Story = {
17 | args: {
18 | children: `import React from 'react';
19 | import { render } from 'react-dom';
20 |
21 | function SomeComponent({ children }: { children: React.ReactNode }) {
22 | return Some Component
;
23 | }`,
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/DebugToolbar.tsx:
--------------------------------------------------------------------------------
1 | import { Bug } from 'lucide-react';
2 |
3 | import { Menu } from '@/components';
4 | import useApplication from '@/hooks/useApplication';
5 | import mockData, { generateLotsOfMockTraces } from '@/testing/mockTraces';
6 |
7 | import { MenuItem } from '../Menu';
8 |
9 | export default function DebugToolbar() {
10 | const { collector, traces } = useApplication();
11 |
12 | const debugOptions: MenuItem[] = [
13 | {
14 | label: 'Mock data',
15 | description: 'Inject mock traces',
16 | callback: (e: React.MouseEvent) => {
17 | const data = e.shiftKey ? generateLotsOfMockTraces() : mockData;
18 | for (const trace of data) {
19 | collector?.addEvent(trace);
20 | }
21 | },
22 | },
23 | {
24 | label: 'Print traces',
25 | description: 'Output traces to the console',
26 | callback: () => {
27 | // eslint-disable-next-line no-console
28 | console.log('Traces:', [...traces.values()]);
29 | },
30 | },
31 | ];
32 |
33 | return ;
34 | }
35 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/FiltersAndActions.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import FiltersAndActions from './FiltersAndActions';
6 |
7 | const meta = {
8 | title: 'UI/FiltersAndActions',
9 | component: FiltersAndActions,
10 | parameters: {
11 | layout: 'centered',
12 | },
13 | decorators: [
14 | Story => (
15 |
16 |
17 |
18 | ),
19 | ],
20 | } satisfies Meta;
21 |
22 | export default meta;
23 | type Story = StoryObj;
24 |
25 | export const Standard: Story = {
26 | args: {},
27 | };
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/FiltersAndActions.tsx:
--------------------------------------------------------------------------------
1 | import { SearchInput } from '@/components';
2 | import useApplication from '@/hooks/useApplication';
3 |
4 | export default function FiltersAndActions() {
5 | const { setFilters } = useApplication();
6 |
7 | function handleSearchTermChange(value: string) {
8 | setFilters(curr => ({
9 | ...curr,
10 | searchTerm: value,
11 | }));
12 | }
13 |
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/Header.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import Header from './Header';
6 |
7 | const meta = {
8 | title: 'UI/Header',
9 | component: Header,
10 | parameters: {
11 | layout: 'centered',
12 | },
13 | decorators: [
14 | Story => (
15 |
16 |
17 |
18 | ),
19 | ],
20 | } satisfies Meta;
21 |
22 | export default meta;
23 | type Story = StoryObj;
24 |
25 | export const Standard: Story = {
26 | args: {},
27 | };
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/Header.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import Header from './Header';
4 |
5 | jest.mock(
6 | '@/components/ui/DebugToolbar',
7 | () =>
8 | function MockDebugToolbar() {
9 | return Mock DebugToolbar component
;
10 | },
11 | );
12 | jest.mock(
13 | '@/components/ui/FiltersAndActions',
14 | () =>
15 | function MockFiltersAndActions() {
16 | return Mock FiltersAndActions component
;
17 | },
18 | );
19 | jest.mock(
20 | '@/components/ui/SourceAndSystemFilter',
21 | () =>
22 | function SourceAndSystemFilter() {
23 | return Mock Source and Systems component
;
24 | },
25 | );
26 |
27 | describe('Header', () => {
28 | const originalProcessEnv = process.env;
29 |
30 | afterEach(() => {
31 | process.env = originalProcessEnv;
32 | cleanup();
33 | });
34 |
35 | it('should render without error', () => {
36 | render();
37 | });
38 |
39 | it('should render the filters and actions component', () => {
40 | const { getByTestId } = render();
41 |
42 | const filtersAndActions = getByTestId('mock-filters-and-actions');
43 | expect(filtersAndActions).toBeVisible();
44 | });
45 |
46 | it('should not render the debug toolbar if not in development mode', () => {
47 | process.env.NODE_ENV = 'production';
48 |
49 | const { queryByTestId } = render();
50 |
51 | const debugToolbar = queryByTestId('mock-debug-toolbar');
52 | expect(debugToolbar).not.toBeInTheDocument();
53 | });
54 |
55 | it('should render the debug toolbar if in development mode', () => {
56 | process.env.NODE_ENV = 'development';
57 |
58 | const { queryByTestId } = render();
59 |
60 | const debugToolbar = queryByTestId('mock-debug-toolbar');
61 | expect(debugToolbar).toBeVisible();
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/Header.tsx:
--------------------------------------------------------------------------------
1 | import useFeatureFlags from '@/hooks/useFeatureFlags';
2 |
3 | import DarkModeToggle from '../DarkModeToggle';
4 |
5 | import DebugToolbar from './DebugToolbar';
6 | import FiltersAndActions from './FiltersAndActions';
7 | import Logo from './Logo';
8 | import SourceAndSystemFilter from './SourceAndSystemFilter';
9 |
10 | export default function Header() {
11 | const { enableThemeSwitcher } = useFeatureFlags();
12 | const isDebugMode = process.env.NODE_ENV !== 'production';
13 |
14 | return (
15 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/MainDisplay.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import MainDisplay from './MainDisplay';
6 |
7 | const meta = {
8 | title: 'UI/MainDisplay',
9 | component: MainDisplay,
10 | parameters: {
11 | layout: 'centered',
12 | },
13 | decorators: [
14 | Story => (
15 |
16 |
17 |
18 | ),
19 | ],
20 | } satisfies Meta;
21 |
22 | export default meta;
23 | type Story = StoryObj;
24 |
25 | export const Standard: Story = {
26 | args: {},
27 | };
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/MainDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { Allotment } from 'allotment';
2 | import { Toaster } from 'react-hot-toast';
3 |
4 | import TraceDetail from '@/components/ui/TraceDetail';
5 | import TraceList from '@/components/ui/TraceList';
6 | import useApplication from '@/hooks/useApplication';
7 |
8 | export default function MainDisplay() {
9 | const { selectedTraceId } = useApplication();
10 |
11 | return (
12 |
13 |
14 |
15 |
16 |
17 | {selectedTraceId && (
18 |
19 |
20 |
21 | )}
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/QueryParams.stories.tsx:
--------------------------------------------------------------------------------
1 | import { HttpRequestState } from '@envyjs/core';
2 | import type { Meta, StoryObj } from '@storybook/react';
3 |
4 | import QueryParams from './QueryParams';
5 |
6 | const meta = {
7 | title: 'UI/QueryParams',
8 | component: QueryParams,
9 | parameters: {
10 | layout: 'centered',
11 | },
12 | } satisfies Meta;
13 |
14 | export default meta;
15 | type Story = StoryObj;
16 |
17 | export const Standard: Story = {
18 | args: {
19 | trace: {
20 | id: 'http-1',
21 | timestamp: 1616239022,
22 | http: {
23 | host: 'httpbin.org',
24 | method: 'GET',
25 | port: 443,
26 | path: '/api/v1/?trace=123&name=test',
27 | requestHeaders: {},
28 | state: HttpRequestState.Received,
29 | url: 'https://httpbin.org/api/v1/?trace=123&name=test',
30 | },
31 | },
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/QueryParams.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import { Trace } from '@/types';
4 |
5 | import QueryParams from './QueryParams';
6 |
7 | const mockKeyValueListComponent = jest.fn();
8 |
9 | jest.mock(
10 | '@/components/KeyValueList',
11 | () =>
12 | function MockKeyValueList(props: any) {
13 | // call the spy, since we want to verify props passed in without caring
14 | // much about the presentation for this unit test
15 | mockKeyValueListComponent(props);
16 | return <>>;
17 | },
18 | );
19 |
20 | describe('QueryParams', () => {
21 | afterEach(() => {
22 | jest.resetAllMocks();
23 | cleanup();
24 | });
25 |
26 | it('should render without error', () => {
27 | const trace = {} as Trace;
28 | render( );
29 | });
30 |
31 | it('should pass trace query params as `values`', () => {
32 | const trace = {
33 | http: {
34 | path: '/page?foo=bar&baz=qux',
35 | },
36 | } as Trace;
37 | render( );
38 |
39 | expect(mockKeyValueListComponent).lastCalledWith(
40 | expect.objectContaining({
41 | values: [
42 | ['foo', 'bar'],
43 | ['baz', 'qux'],
44 | ],
45 | }),
46 | );
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/QueryParams.tsx:
--------------------------------------------------------------------------------
1 | import KeyValueList from '@/components/KeyValueList';
2 | import Section from '@/components/Section';
3 | import { Trace } from '@/types';
4 | import { pathAndQuery } from '@/utils';
5 |
6 | export default function QueryParams({ trace }: { trace: Trace }) {
7 | const [, qs] = pathAndQuery(trace);
8 | if (!qs) return null;
9 |
10 | const urlSearchParams = new URLSearchParams(qs);
11 | const queryParams: [string, string][] = [];
12 | urlSearchParams.forEach((value, key) => {
13 | queryParams.push([key, value]);
14 | });
15 |
16 | return (
17 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/RequestHeaders.stories.tsx:
--------------------------------------------------------------------------------
1 | import { HttpRequestState } from '@envyjs/core';
2 | import type { Meta, StoryObj } from '@storybook/react';
3 |
4 | import RequestHeaders from './RequestHeaders';
5 |
6 | const meta = {
7 | title: 'UI/RequestHeaders',
8 | component: RequestHeaders,
9 | parameters: {
10 | layout: 'centered',
11 | },
12 | } satisfies Meta;
13 |
14 | export default meta;
15 | type Story = StoryObj;
16 |
17 | export const Standard: Story = {
18 | args: {
19 | trace: {
20 | id: 'http-1',
21 | timestamp: 1616239022,
22 | http: {
23 | host: 'httpbin.org',
24 | method: 'GET',
25 | port: 443,
26 | path: '/api/v1/?trace=123&name=test',
27 | requestHeaders: {
28 | 'content-type': 'application/json',
29 | 'x-custom-header': 'custom value',
30 | 'x-custom-header-2': 'custom value 2',
31 | 'authorization':
32 | 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.vqb33-7FqzFWPNlr0ElW1v2RjJRZBel3CdDHBWD7y_o',
33 | },
34 | state: HttpRequestState.Received,
35 | url: 'https://httpbin.org/api/v1/?trace=123&name=test',
36 | },
37 | },
38 | },
39 | };
40 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/RequestHeaders.tsx:
--------------------------------------------------------------------------------
1 | import Authorization from '@/components/Authorization';
2 | import KeyValueList from '@/components/KeyValueList';
3 | import { Trace } from '@/types';
4 | import { cloneHeaders } from '@/utils';
5 |
6 | export default function RequestHeaders({ trace }: { trace: Trace }) {
7 | const requestHeaders = trace.http?.requestHeaders;
8 | if (!requestHeaders || Object.keys(requestHeaders).length === 0) return null;
9 |
10 | const headers = cloneHeaders(requestHeaders) as Record;
11 | if (headers.authorization) headers.authorization = ;
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/ResponseHeaders.stories.tsx:
--------------------------------------------------------------------------------
1 | import { HttpRequestState } from '@envyjs/core';
2 | import type { Meta, StoryObj } from '@storybook/react';
3 |
4 | import ResponseHeaders from './ResponseHeaders';
5 |
6 | const meta = {
7 | title: 'UI/ResponseHeaders',
8 | component: ResponseHeaders,
9 | parameters: {
10 | layout: 'centered',
11 | },
12 | } satisfies Meta;
13 |
14 | export default meta;
15 | type Story = StoryObj;
16 |
17 | export const Standard: Story = {
18 | args: {
19 | trace: {
20 | id: 'http-1',
21 | timestamp: 1616239022,
22 | http: {
23 | host: 'httpbin.org',
24 | method: 'GET',
25 | port: 443,
26 | path: '/api/v1/?trace=123&name=test',
27 | requestHeaders: {},
28 | responseHeaders: {
29 | 'content-type': 'application/json',
30 | 'x-custom-header': 'custom value',
31 | 'x-custom-header-2': 'custom value 2',
32 | },
33 | state: HttpRequestState.Received,
34 | url: 'https://httpbin.org/api/v1/?trace=123&name=test',
35 | },
36 | },
37 | },
38 | };
39 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/ResponseHeaders.tsx:
--------------------------------------------------------------------------------
1 | import Authorization from '@/components/Authorization';
2 | import KeyValueList from '@/components/KeyValueList';
3 | import { Trace } from '@/types';
4 | import { cloneHeaders } from '@/utils';
5 |
6 | export default function ResponseHeaders({ trace }: { trace: Trace }) {
7 | const responseHeaders = trace.http?.responseHeaders;
8 | if (!responseHeaders || Object.keys(responseHeaders).length === 0) return null;
9 |
10 | const headers = cloneHeaders(responseHeaders) as Record;
11 | if (headers.authorization) headers.authorization = ;
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/SourceAndSystemFilter.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import SourceAndSystemFilter from './SourceAndSystemFilter';
6 |
7 | const meta = {
8 | title: 'UI/SourceAndSystemFilter',
9 | component: SourceAndSystemFilter,
10 | parameters: {
11 | layout: 'centered',
12 | },
13 | decorators: [
14 | Story => (
15 |
16 |
17 |
18 | ),
19 | ],
20 | } satisfies Meta;
21 |
22 | export default meta;
23 | type Story = StoryObj;
24 |
25 | export const Standard: Story = {
26 | args: {
27 | className: 'w-72',
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/Tabs.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import { TabContent, TabList, TabListItem } from './Tabs';
6 |
7 | const meta = {
8 | title: 'UI/Tabs',
9 | component: TabList,
10 | parameters: {
11 | layout: 'centered',
12 | },
13 | decorators: [
14 | Story => (
15 |
16 |
17 |
18 | ),
19 | ],
20 | } satisfies Meta;
21 |
22 | export default meta;
23 | type Story = StoryObj;
24 |
25 | export const Standard: Story = {
26 | render: () => (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Foo content
36 | Bar content
37 | Baz content
38 | Qux content
39 |
40 |
41 | ),
42 | args: {
43 | children: [],
44 | },
45 | };
46 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import useApplication from '@/hooks/useApplication';
2 | import { tw } from '@/utils';
3 |
4 | export function TabList({
5 | children,
6 | ...props
7 | }: { children: React.ReactNode } & React.HTMLAttributes) {
8 | return (
9 |
12 | );
13 | }
14 |
15 | export function TabListItem({
16 | id,
17 | title,
18 | disabled = false,
19 | ...props
20 | }: { id: string; title: string; disabled?: boolean } & React.HTMLAttributes) {
21 | const { selectedTab, setSelectedTab } = useApplication();
22 |
23 | const href = disabled ? undefined : `#${id}`;
24 |
25 | const allowInteractive = !(disabled || selectedTab === id);
26 |
27 | const className = tw(
28 | 'inline-block px-3 py-2 rounded-[0.25rem] font-bold uppercase text-xs',
29 | 'text-manatee-800',
30 | allowInteractive && 'hover:bg-apple-200 hover:text-apple-900',
31 | allowInteractive && 'active:bg-apple-500 active:text-apple-950',
32 | disabled && 'text-manatee-300 cursor-not-allowed',
33 | selectedTab === id && 'bg-apple-400 text-apple-950',
34 | );
35 |
36 | return (
37 |
38 | {
45 | if (disabled) {
46 | e.preventDefault();
47 | return;
48 | }
49 | setSelectedTab(id);
50 | }}
51 | >
52 | {title}
53 |
54 |
55 | );
56 | }
57 |
58 | export function TabContent({ id, children }: { id: string; children: React.ReactNode }) {
59 | const { selectedTab } = useApplication();
60 | return {children}
;
61 | }
62 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TimingsDiagram.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import TimingsDiagram from './TimingsDiagram';
4 |
5 | const meta = {
6 | title: 'UI/TimingsDiagram',
7 | component: TimingsDiagram,
8 | parameters: {
9 | layout: 'centered',
10 | },
11 | decorators: [
12 | Story => (
13 |
14 |
15 |
16 | ),
17 | ],
18 | } satisfies Meta;
19 |
20 | export default meta;
21 | type Story = StoryObj;
22 |
23 | export const Standard: Story = {
24 | args: {
25 | timings: {
26 | blocked: 200,
27 | connect: 120,
28 | dns: 50,
29 | receive: 300,
30 | send: 200,
31 | ssl: 100,
32 | wait: 200,
33 | },
34 | },
35 | };
36 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceDetail.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import TraceDetail from './TraceDetail';
6 |
7 | const meta = {
8 | title: 'UI/TraceDetail',
9 | component: TraceDetail,
10 | parameters: {
11 | layout: 'centered',
12 | },
13 | decorators: [
14 | Story => (
15 |
16 |
17 |
18 | ),
19 | ],
20 | } satisfies Meta;
21 |
22 | export default meta;
23 | type Story = StoryObj;
24 |
25 | export const Standard: Story = {
26 | args: {
27 | contentType: 'application/json',
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceList.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import TraceList from './TraceList';
6 |
7 | const meta = {
8 | title: 'UI/TraceList',
9 | component: TraceList,
10 | parameters: {
11 | layout: 'centered',
12 | },
13 | decorators: [
14 | Story => (
15 |
16 |
17 |
18 | ),
19 | ],
20 | } satisfies Meta;
21 |
22 | export default meta;
23 | type Story = StoryObj;
24 |
25 | export const Connecting: Story = {
26 | args: {},
27 | };
28 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceListHeader.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import TraceListHeader from './TraceListHeader';
4 |
5 | describe('TraceListHeader', () => {
6 | afterEach(() => {
7 | cleanup();
8 | });
9 |
10 | it('should not overwrite base css classes', () => {
11 | const { container } = render( );
12 | expect(container.firstChild).toHaveClass('table-cell');
13 | expect(container.firstChild).toHaveClass('lkj');
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceListHeader.tsx:
--------------------------------------------------------------------------------
1 | import { tw } from '@/utils';
2 |
3 | type TraceListHeaderProps = React.HtmlHTMLAttributes;
4 |
5 | export default function TraceListHeader({ className, children, ...props }: TraceListHeaderProps) {
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceListPlaceholder.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import { setUseApplicationData } from '@/testing/mockUseApplication';
4 |
5 | import TraceListPlaceholder from './TraceListPlaceholder';
6 |
7 | describe('TraceListPlaceholder', () => {
8 | afterEach(() => {
9 | cleanup();
10 | });
11 |
12 | describe('when there are no traces', () => {
13 | const scenarios = [
14 | { status: 'connecting', useApplicationState: { connecting: true, connected: false }, message: 'Connecting...' },
15 | {
16 | status: 'connected',
17 | useApplicationState: { connecting: false, connected: true },
18 | message: 'Listening for traces...',
19 | },
20 | {
21 | status: 'failed to connect',
22 | useApplicationState: { connecting: false, connected: false },
23 | message: 'Unable to connect',
24 | },
25 | ];
26 |
27 | it.each(scenarios)('shoud rennder $status message when in $status state', ({ useApplicationState, message }) => {
28 | setUseApplicationData({
29 | ...useApplicationState,
30 | });
31 |
32 | const { getByTestId } = render( );
33 | const noTracesMessage = getByTestId('trace-list-placeholder');
34 |
35 | expect(noTracesMessage).toBeVisible();
36 | expect(noTracesMessage).toHaveTextContent(message);
37 | });
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceListPlaceholder.tsx:
--------------------------------------------------------------------------------
1 | import { Wifi, Zap, ZapOff } from 'lucide-react';
2 |
3 | import useApplication from '@/hooks/useApplication';
4 |
5 | export default function TraceListPlaceholder() {
6 | const { connected, connecting } = useApplication();
7 |
8 | const [Icon, message] = connected
9 | ? [Wifi, `Listening for traces...`]
10 | : connecting
11 | ? [Zap, 'Connecting...']
12 | : [ZapOff, 'Unable to connect'];
13 |
14 | return (
15 |
19 | {message}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceListRow.tsx:
--------------------------------------------------------------------------------
1 | import { HttpRequestState } from '@envyjs/core';
2 |
3 | import { Badge, Loading } from '@/components';
4 | import useApplication from '@/hooks/useApplication';
5 | import { ListDataComponent } from '@/systems';
6 | import { Trace } from '@/types';
7 | import { tw } from '@/utils';
8 | import { badgeStyle } from '@/utils/styles';
9 |
10 | import TraceListRowCell from './TraceListRowCell';
11 |
12 | export default function TraceListRow({ trace }: { trace: Trace }) {
13 | const { selectedTraceId, setSelectedTrace } = useApplication();
14 |
15 | return (
16 | setSelectedTrace(trace.id)}
20 | className={tw(
21 | 'table-row h-11 hover:bg-apple-200 hover:cursor-pointer hover:text-apple-900 text-manatee-800',
22 | trace.http?.state === HttpRequestState.Sent && 'text-manatee-500',
23 | trace.id === selectedTraceId ? 'bg-manatee-400 text-manatee-950' : 'even:bg-manatee-200',
24 | )}
25 | >
26 |
27 |
28 | {trace.http?.method.toUpperCase()} {getResponseStatus(trace)}
29 |
30 |
31 |
32 |
33 |
34 |
35 | {formatRequestDuration(trace)}
36 |
37 |
38 | );
39 | }
40 |
41 | function getResponseStatus(trace: Trace) {
42 | const { statusCode, state } = trace.http || {};
43 | if (state === HttpRequestState.Aborted) return 'Aborted';
44 | return statusCode;
45 | }
46 |
47 | function formatRequestDuration(trace: Trace) {
48 | return trace.http?.duration ? `${(trace.http.duration / 1000).toFixed(2)}s` : ;
49 | }
50 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceListRowCell.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render } from '@testing-library/react';
2 |
3 | import TraceListRowCell from './TraceListRowCell';
4 |
5 | describe('TraceListRowCell', () => {
6 | afterEach(() => {
7 | cleanup();
8 | });
9 |
10 | it('should not overwrite base css classes', () => {
11 | const { container } = render( );
12 | expect(container.firstChild).toHaveClass('table-cell');
13 | expect(container.firstChild).toHaveClass('lkj');
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceListRowCell.tsx:
--------------------------------------------------------------------------------
1 | import { tw } from '@/utils';
2 |
3 | type TraceListRowCellProps = React.HtmlHTMLAttributes;
4 |
5 | export default function TraceListRowCell({ className, children, ...props }: TraceListRowCellProps) {
6 | return (
7 |
14 | {children}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceRequestData.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 |
3 | import DefaultSystem from '@/systems/Default';
4 | import GraphqlSystem from '@/systems/GraphQL';
5 | import SanitySystem from '@/systems/Sanity';
6 |
7 | import TraceRequestData from './TraceRequestData';
8 |
9 | const meta = {
10 | title: 'UI/TraceRequestData',
11 | component: TraceRequestData,
12 | parameters: {
13 | layout: 'centered',
14 | },
15 | decorators: [
16 | Story => (
17 |
18 |
19 |
20 | ),
21 | ],
22 | } satisfies Meta;
23 |
24 | export default meta;
25 | type Story = StoryObj;
26 |
27 | export const RestRequest: Story = {
28 | args: {
29 | systemName: new DefaultSystem().name,
30 | iconPath: new DefaultSystem().getIconUri(),
31 | path: '/api/v1/trace/1234?name=test&options=br|hk|desc',
32 | data: 'name=test&options=br|hk|desc',
33 | hostName: 'https://api.myservices.com',
34 | },
35 | };
36 |
37 | export const GraphqlRequest: Story = {
38 | args: {
39 | systemName: new GraphqlSystem().name,
40 | iconPath: new GraphqlSystem().getIconUri(),
41 | path: '/graphql',
42 | data: 'Query',
43 | hostName: 'https://api.myservices.com',
44 | },
45 | };
46 |
47 | export const SanityRequest: Story = {
48 | args: {
49 | systemName: new SanitySystem().name,
50 | iconPath: new SanitySystem().getIconUri(),
51 | path: '/api/v1/trace/1234',
52 | data: 'type: Product',
53 | hostName: 'https://api.myservices.com',
54 | },
55 | };
56 |
--------------------------------------------------------------------------------
/packages/webui/src/components/ui/TraceRequestData.tsx:
--------------------------------------------------------------------------------
1 | import useApplication from '@/hooks/useApplication';
2 |
3 | export type TraceRequestDataProps = {
4 | systemName: string;
5 | iconPath: string;
6 | hostName?: string;
7 | path: string;
8 | data?: string;
9 | };
10 |
11 | export default function TraceRequestData({ systemName, iconPath, hostName, path, data }: TraceRequestDataProps) {
12 | const { selectedTraceId } = useApplication();
13 | const pathValue = !!selectedTraceId ? `.../${path.split('/').splice(-1, 1).join('/')}` : path;
14 |
15 | return (
16 | <>
17 |
18 |
19 | {hostName && (
20 |
21 | {hostName}
22 |
23 | )}
24 | {pathValue}
25 |
26 | {data && (
27 |
28 | {data}
29 |
30 | )}
31 | >
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/packages/webui/src/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FormidableLabs/envy/ea1bb24cbfece740093868bf120c170b122c380a/packages/webui/src/favicon.png
--------------------------------------------------------------------------------
/packages/webui/src/hooks/useApplication.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react';
2 |
3 | import ApplicationContextProvider from '@/context/ApplicationContext';
4 |
5 | import useApplication from './useApplication';
6 |
7 | describe('useApplication', () => {
8 | it('should expose ApplicationContext from ApplicationContextProvider', () => {
9 | const { result } = renderHook(() => useApplication(), { wrapper: ApplicationContextProvider });
10 |
11 | const context = result.current;
12 | [
13 | 'collector',
14 | 'port',
15 | 'connecting',
16 | 'connected',
17 | 'traces',
18 | 'connections',
19 | 'selectedTraceId',
20 | 'setSelectedTrace',
21 | 'getSelectedTrace',
22 | 'clearSelectedTrace',
23 | 'filters',
24 | 'setFilters',
25 | 'clearTraces',
26 | ].forEach(propName => expect(context).toHaveProperty(propName));
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/packages/webui/src/hooks/useApplication.ts:
--------------------------------------------------------------------------------
1 | import { ConnectionStatusData } from '@envyjs/core';
2 | import { Dispatch, SetStateAction, createContext, useContext } from 'react';
3 |
4 | import CollectorClient from '@/collector/CollectorClient';
5 | import { Trace, Traces } from '@/types';
6 |
7 | export type Filters = {
8 | sources: string[];
9 | systems: string[];
10 | searchTerm: string;
11 | };
12 |
13 | export type ApplicationContextData = {
14 | collector: CollectorClient | undefined;
15 | port: number;
16 | connecting: boolean;
17 | connected: boolean;
18 | connections: ConnectionStatusData;
19 | traces: Traces;
20 | selectedTraceId?: string;
21 | newestTraceId?: string;
22 | getSelectedTrace: () => Trace | undefined;
23 | setSelectedTrace: (id: string) => void;
24 | clearSelectedTrace: () => void;
25 | filters: Filters;
26 | setFilters: Dispatch>;
27 | clearTraces: () => void;
28 | selectedTab: string;
29 | setSelectedTab: Dispatch>;
30 | };
31 |
32 | export const ApplicationContext = createContext({} as ApplicationContextData);
33 |
34 | export default function useApplication() {
35 | return useContext(ApplicationContext);
36 | }
37 |
--------------------------------------------------------------------------------
/packages/webui/src/hooks/useClickAway.ts:
--------------------------------------------------------------------------------
1 | import { RefObject, useCallback, useEffect } from 'react';
2 |
3 | export default function useClickAway(ref: RefObject, callback: () => void) {
4 | const handler = useCallback(
5 | (e: React.MouseEvent) => {
6 | if (ref?.current && !ref?.current?.contains(e.target as Node)) {
7 | callback?.();
8 | }
9 | },
10 | [ref, callback],
11 | );
12 |
13 | useEffect(() => {
14 | document.addEventListener('click', handler);
15 | return () => {
16 | document.removeEventListener('click', handler);
17 | };
18 | }, [handler]);
19 | }
20 |
--------------------------------------------------------------------------------
/packages/webui/src/hooks/useFeatureFlags.ts:
--------------------------------------------------------------------------------
1 | export default function useFeatureFlags() {
2 | return {
3 | enableThemeSwitcher: false,
4 | };
5 | }
6 |
--------------------------------------------------------------------------------
/packages/webui/src/hooks/useKeyboardShortcut.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect } from 'react';
2 |
3 | export type Shortcut = {
4 | condition?: boolean;
5 | predicate: (e: KeyboardEvent) => boolean;
6 | callback: (e?: KeyboardEvent) => void;
7 | };
8 |
9 | export default function useKeyboardShortcut(shortcuts: Shortcut[]) {
10 | const handler = useCallback(
11 | (e: KeyboardEvent) => {
12 | shortcuts.forEach(x => {
13 | if (x.predicate(e)) {
14 | e.preventDefault();
15 | x.callback(e);
16 | }
17 | });
18 | },
19 | [shortcuts],
20 | );
21 |
22 | useEffect(() => {
23 | if (shortcuts.filter(x => x.condition !== false).length === 0) return;
24 |
25 | document.addEventListener('keydown', handler);
26 | return () => {
27 | document.removeEventListener('keydown', handler);
28 | };
29 | }, [shortcuts, handler]);
30 | }
31 |
--------------------------------------------------------------------------------
/packages/webui/src/hooks/usePlatform.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react';
2 |
3 | import usePlatform from './usePlatform';
4 |
5 | describe('usePlatform', () => {
6 | const setUserAgent = (ua: string) => {
7 | Object.defineProperty(window.navigator, 'userAgent', {
8 | value: ua,
9 | writable: true,
10 | });
11 | };
12 |
13 | const originalUserAgent = window.navigator.userAgent;
14 |
15 | afterEach(() => {
16 | setUserAgent(originalUserAgent);
17 | });
18 |
19 | describe('when user agent contains Macintosh', () => {
20 | beforeEach(() => {
21 | setUserAgent('This is a Macintosh user agent');
22 | });
23 |
24 | it('should return expected object', () => {
25 | const { result } = renderHook(() => usePlatform());
26 |
27 | expect(result.current).toEqual({
28 | isMac: true,
29 | isWindows: false,
30 | specialKey: '⌘',
31 | });
32 | });
33 | });
34 |
35 | describe('when user agent contains Windows', () => {
36 | beforeEach(() => {
37 | setUserAgent('This is a Windows user agent');
38 | });
39 |
40 | it('should return expected object', () => {
41 | const { result } = renderHook(() => usePlatform());
42 |
43 | expect(result.current).toEqual({
44 | isMac: false,
45 | isWindows: true,
46 | specialKey: 'CTRL+',
47 | });
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/packages/webui/src/hooks/usePlatform.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | export default function usePlatform() {
4 | const ua = navigator.userAgent;
5 | const isMac = useRef(/Macintosh/i.test(ua));
6 | const isWindows = useRef(/Windows/i.test(ua));
7 |
8 | const specialKey = useRef(isMac.current === true ? '⌘' : 'CTRL+');
9 |
10 | return {
11 | isMac: isMac.current,
12 | isWindows: isWindows.current,
13 | specialKey: specialKey.current,
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/packages/webui/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
22 | Envy
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/packages/webui/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 |
4 | import App from './App';
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/packages/webui/src/integration.tsx:
--------------------------------------------------------------------------------
1 | /* istanbul ignore file */
2 |
3 | /* eslint-disable import/no-unresolved */
4 | // @ts-expect-error
5 | import css from 'inline:../dist/viewer.css';
6 |
7 | import App from './App';
8 | import { registerSystem } from './systems/registration';
9 | import { System } from './types';
10 |
11 | export type { System, TraceRowData, Trace } from './types';
12 | export * from './components';
13 |
14 | export type EnvyViewerProps = {
15 | systems?: System[];
16 | };
17 |
18 | export default function EnvyViewer({ systems }: EnvyViewerProps) {
19 | for (const system of systems ?? []) {
20 | registerSystem(system);
21 | }
22 |
23 | return (
24 | <>
25 |
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/packages/webui/src/scripts/buildIntegration.cjs:
--------------------------------------------------------------------------------
1 | const { build } = require('esbuild');
2 | const inlineImportPlugin = require('esbuild-plugin-inline-import');
3 |
4 | const { dependencies } = require('../../package.json');
5 |
6 | const integrationFile = 'src/integration.tsx';
7 |
8 | const shared = {
9 | bundle: true,
10 | entryPoints: [integrationFile],
11 | external: Object.keys(dependencies),
12 | logLevel: 'info',
13 | plugins: [inlineImportPlugin()],
14 | minify: true,
15 | sourcemap: true,
16 | };
17 |
18 | build({
19 | ...shared,
20 | format: 'esm',
21 | outfile: './dist/integration.esm.js',
22 | target: ['es2022', 'node16'],
23 | });
24 |
25 | build({
26 | ...shared,
27 | format: 'cjs',
28 | outfile: './dist/integration.cjs.js',
29 | target: ['es2022', 'node16'],
30 | });
31 |
--------------------------------------------------------------------------------
/packages/webui/src/scripts/start.cjs:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 |
3 | const argv = require('yargs-parser')(process.argv.slice(2));
4 | const devMode = argv.dev ?? false;
5 |
6 | // arguments prefixed with --no- are treated as negations
7 | const collector = argv.collector ?? true;
8 | const ui = argv.ui ?? true;
9 |
10 | if (collector === true) {
11 | require('./startCollector.cjs');
12 | }
13 |
14 | if (ui === true) {
15 | require(devMode ? './startViewerDev.cjs' : './startViewer.cjs');
16 | }
17 |
--------------------------------------------------------------------------------
/packages/webui/src/scripts/startViewer.cjs:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 |
3 | const http = require('http');
4 | const path = require('path');
5 |
6 | const chalk = require('chalk');
7 | const handler = require('serve-handler');
8 | const argv = require('yargs-parser')(process.argv.slice(2));
9 |
10 | const port = argv.viewerPort || 9998;
11 |
12 | const root = path.resolve(__dirname, '..', 'dist');
13 |
14 | const server = http.createServer((request, response) => {
15 | return handler(request, response, {
16 | public: root,
17 | });
18 | });
19 |
20 | server.listen(port, () => {
21 | // eslint-disable-next-line no-console
22 | console.log(chalk.cyan(`🚀 Envy web viewer started on http://localhost:${port}`));
23 | });
24 |
--------------------------------------------------------------------------------
/packages/webui/src/scripts/startViewerDev.cjs:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 |
3 | const { Parcel } = require('@parcel/core');
4 | const chalk = require('chalk');
5 | const argv = require('yargs-parser')(process.argv.slice(2));
6 | const port = argv.viewerPort || 9998;
7 |
8 | let bundler = new Parcel({
9 | defaultConfig: '@parcel/config-default',
10 | entries: 'src/index.html',
11 | serveOptions: {
12 | port,
13 | },
14 | hmrOptions: {
15 | port: 9997,
16 | },
17 | });
18 |
19 | bundler.watch().then(() => {
20 | // eslint-disable-next-line no-console
21 | console.log(chalk.cyan(`🚀 Envy web viewer started (dev mode) on http://localhost:${port}`));
22 | });
23 |
--------------------------------------------------------------------------------
/packages/webui/src/systems/Default.tsx:
--------------------------------------------------------------------------------
1 | import { System, TraceContext } from '@/types';
2 |
3 | export default class DefaultSystem implements System {
4 | name = 'Default';
5 |
6 | isMatch() {
7 | return true;
8 | }
9 |
10 | getData() {
11 | return null;
12 | }
13 |
14 | getIconUri() {
15 | return 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MjAiCmhlaWdodD0iNDIwIiBzdHJva2U9IiMyMjIyMjIiIGZpbGw9Im5vbmUiPgo8cGF0aCBzdHJva2Utd2lkdGg9IjI2IgpkPSJNMjA5LDE1YTE5NSwxOTUgMCAxLDAgMiwweiIvPgo8cGF0aCBzdHJva2Utd2lkdGg9IjE4IgpkPSJtMjEwLDE1djM5MG0xOTUtMTk1SDE1TTU5LDkwYTI2MCwyNjAgMCAwLDAgMzAyLDAgbTAsMjQwIGEyNjAsMjYwIDAgMCwwLTMwMiwwTTE5NSwyMGEyNTAsMjUwIDAgMCwwIDAsMzgyIG0zMCwwIGEyNTAsMjUwIDAgMCwwIDAtMzgyIi8+Cjwvc3ZnPg==';
16 | }
17 |
18 | getSearchKeywords() {
19 | return [];
20 | }
21 |
22 | getTraceRowData() {
23 | return null;
24 | }
25 |
26 | getRequestDetailComponent() {
27 | return null;
28 | }
29 |
30 | getRequestBody({ trace }: TraceContext) {
31 | // no transform; just return the response body
32 | return trace.http?.requestBody;
33 | }
34 |
35 | getResponseDetailComponent() {
36 | return null;
37 | }
38 |
39 | getResponseBody({ trace }: TraceContext) {
40 | // no transform; just return the response body
41 | return trace.http?.responseBody;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/webui/src/systems/registration.test.ts:
--------------------------------------------------------------------------------
1 | import { System } from '@/types';
2 |
3 | import { getDefaultSystem, getRegisteredSystems, registerSystem } from './registration';
4 |
5 | describe('Registration', () => {
6 | it('should return systems collection for `getRegisteredSystems`', () => {
7 | const result = getRegisteredSystems();
8 | expect(result).toBeInstanceOf(Array);
9 | });
10 |
11 | it('should return a the default system `getDefaultSystem`', () => {
12 | const result = getDefaultSystem();
13 | expect(result.name).toEqual('Default');
14 | });
15 |
16 | it('should add a new system to the end of the systems list when calling `registerSystem`', () => {
17 | const systemsBefore = getRegisteredSystems();
18 | const numSystemsBefore = systemsBefore.length;
19 |
20 | const newSystem = new (class implements System {
21 | name = 'New system';
22 | isMatch() {
23 | return true;
24 | }
25 | })();
26 |
27 | registerSystem(newSystem);
28 |
29 | const systemsAfter = getRegisteredSystems();
30 | const numSystemsAfter = systemsAfter.length;
31 |
32 | expect(numSystemsAfter).toEqual(numSystemsBefore + 1);
33 | expect(systemsAfter[systemsAfter.length - 1]).toBe(newSystem);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/packages/webui/src/systems/registration.ts:
--------------------------------------------------------------------------------
1 | import { System } from '@/types';
2 |
3 | import DefaultSystem from './Default';
4 | import GraphQLSystem from './GraphQL';
5 | import SanitySystem from './Sanity';
6 |
7 | const defaultSystem = new DefaultSystem();
8 |
9 | const systems: System[] = [
10 | // TODO: provide a way to register custom systems here, before `defaultSystem`
11 | new GraphQLSystem(),
12 | new SanitySystem(),
13 | ];
14 |
15 | export function getDefaultSystem() {
16 | return defaultSystem;
17 | }
18 |
19 | export function getRegisteredSystems(): System[] {
20 | return systems;
21 | }
22 |
23 | export function registerSystem(system: System) {
24 | systems.push(system);
25 | }
26 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/mockTraces/index.ts:
--------------------------------------------------------------------------------
1 | import { Trace } from '@/types';
2 |
3 | import gql from './gql';
4 | import largeGql from './large-gql';
5 | import rest from './rest';
6 | import sanity from './sanity';
7 | import xml from './xml';
8 |
9 | const withSequentialIds = (traces: Trace[]) =>
10 | traces.map((trace, idx) => ({
11 | ...trace,
12 | id: (idx + 1).toString(),
13 | }));
14 |
15 | // given that mock traces are split into separate files, we need to be able to have the ids for each trace sequential
16 | // so that certain tests which expect a certain sequence of IDs will pass
17 | const mockTraces: Trace[] = withSequentialIds([...gql, ...largeGql, ...sanity, ...rest, ...xml]);
18 |
19 | export default mockTraces;
20 |
21 | // useful for testing performance with lots of traces in the list. Hold shift whilst choosing the "Inject Mock Traces"
22 | // option in the debug dropdown menu. Going forward, we should increase the multiplier in order to really stress test
23 | // the UI with large volumes of traces
24 | export function generateLotsOfMockTraces(): Trace[] {
25 | const multiplier = 100; // number of times to repeat mock traces
26 | return withSequentialIds(new Array(multiplier).fill(mockTraces).flat());
27 | }
28 |
29 | export function mockTraceCollection(): Map {
30 | return mockTraces.reduce((acc, curr) => {
31 | acc.set(curr.id, curr);
32 | return acc;
33 | }, new Map());
34 | }
35 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/mockTraces/sanity.ts:
--------------------------------------------------------------------------------
1 | import { Event, HttpRequestState } from '@envyjs/core';
2 |
3 | import { elapseTime, requestData } from './util';
4 |
5 | // Sanity request (data)
6 | const sanityEvent: Event = {
7 | id: 'TBC',
8 | parentId: undefined,
9 | serviceName: 'gql',
10 | timestamp: elapseTime(0.9),
11 | http: {
12 | ...requestData(
13 | 'GET',
14 | '5bsv02jj.apicdn.sanity.io',
15 | 443,
16 | '/v2021-10-21/data/query/production?query=*%5B_type%20%3D%3D%20%27product%27%5D%7B_id%2C%20name%2C%20description%2C%20%22categories%22%3A%20categories%5B%5D-%3E%7B_id%7D%2C%20%22variants%22%3A%20variants%5B%5D-%3E%7B_id%2C%20name%2C%20price%7D%7D',
17 | ),
18 | state: HttpRequestState.Received,
19 | requestHeaders: {
20 | 'accept': 'application/json',
21 | 'User-Agent': ['node-fetch/1.0 (+https://github.com/bitinn/node-fetch)'],
22 | 'accept-encoding': 'br, gzip, deflate',
23 | },
24 | requestBody: undefined,
25 | // ---------
26 | httpVersion: '1.1',
27 | statusCode: 200,
28 | statusMessage: 'OK',
29 | responseHeaders: {
30 | 'content-type': 'application/json; charset=utf-8',
31 | 'content-length': '351',
32 | 'date': 'Thu, 17 Mar 2022 19:51:00 GMT',
33 | 'vary': 'Origin',
34 | 'connection': 'close',
35 | },
36 | responseBody: JSON.stringify({
37 | awesomeFeature: true,
38 | crappyFeature: false,
39 | }),
40 | duration: 15,
41 | timings: {
42 | blocked: 1,
43 | dns: 1,
44 | connect: 5,
45 | ssl: 2,
46 | send: 3,
47 | wait: 2,
48 | receive: 3,
49 | },
50 | },
51 | sanity: {
52 | query: `*[_type == 'product']{_id, name, description, "categories": categories[]->{_id}, "variants": variants[]->{_id, name, price}}`,
53 | queryType: 'product',
54 | },
55 | };
56 |
57 | export default [sanityEvent];
58 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/mockTraces/util.ts:
--------------------------------------------------------------------------------
1 | import { HttpRequest, HttpRequestState } from '@envyjs/core';
2 |
3 | let now = Date.now();
4 |
5 | export function elapseTime(seconds: number): number {
6 | now += seconds * 1000;
7 | return now;
8 | }
9 |
10 | export function requestData(
11 | method: HttpRequest['method'],
12 | host: HttpRequest['host'],
13 | port: HttpRequest['port'],
14 | path: HttpRequest['path'],
15 | ): Pick {
16 | const protocol = port === 433 ? 'https://' : 'http://';
17 | const hostString = port === 80 || port === 443 ? `${host}` : `${host}:${port.toString()}`;
18 |
19 | return {
20 | state: HttpRequestState.Sent,
21 | method,
22 | host,
23 | port,
24 | path,
25 | url: `${protocol}${hostString}${path}`,
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/mockTraces/xml.ts:
--------------------------------------------------------------------------------
1 | import { Event, HttpRequestState } from '@envyjs/core';
2 |
3 | import { elapseTime, requestData } from './util';
4 |
5 | // XML response
6 | const xmlEvent: Event = {
7 | id: 'TBC',
8 | parentId: undefined,
9 | serviceName: 'gql',
10 | timestamp: elapseTime(3.14),
11 | http: {
12 | ...requestData('GET', 'hits.webstats.com', 433, '/?apikey=c82e66bd-4d5b-4bb7-b439-896936c94eb2'),
13 | state: HttpRequestState.Received,
14 | requestHeaders: {
15 | 'accept': 'application/json',
16 | 'User-Agent': ['node-fetch/1.0 (+https://github.com/bitinn/node-fetch)'],
17 | 'accept-encoding': 'br, gzip, deflate',
18 | },
19 | requestBody: undefined,
20 | // ---------
21 | httpVersion: '1.1',
22 | statusCode: 200,
23 | statusMessage: 'OK',
24 | responseHeaders: {
25 | 'content-type': 'application/xml; charset=utf-8',
26 | 'content-length': '55',
27 | 'date': 'Thu, 17 Mar 2022 19:51:01 GMT',
28 | 'vary': 'Origin',
29 | 'connection': 'close',
30 | },
31 | responseBody: '10 15 ',
32 | duration: 200,
33 | timings: {
34 | blocked: 10,
35 | dns: 20,
36 | connect: 100,
37 | ssl: 70,
38 | send: 30,
39 | wait: 30,
40 | receive: 10,
41 | },
42 | },
43 | };
44 |
45 | export default [xmlEvent];
46 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/mockUseApplication.ts:
--------------------------------------------------------------------------------
1 | import useApplication, { ApplicationContextData } from '@/hooks/useApplication';
2 |
3 | jest.mock('@/hooks/useApplication');
4 |
5 | const defaults: ApplicationContextData = {
6 | collector: undefined,
7 | port: 9999,
8 | connecting: true,
9 | connected: false,
10 | traces: new Map(),
11 | connections: [],
12 | getSelectedTrace: () => void 0,
13 | setSelectedTrace: () => void 0,
14 | clearSelectedTrace: () => void 0,
15 | filters: {
16 | sources: [],
17 | systems: [],
18 | searchTerm: '',
19 | },
20 | setFilters: () => void 0,
21 | clearTraces: () => void 0,
22 | selectedTab: 'default',
23 | setSelectedTab: () => void 0,
24 | };
25 |
26 | beforeEach(() => {
27 | jest.mocked(useApplication).mockReturnValue(defaults);
28 | });
29 |
30 | export function setUseApplicationData(data: Partial) {
31 | jest.mocked(useApplication).mockReturnValue({
32 | ...defaults,
33 | ...data,
34 | });
35 | }
36 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/mockUsePlatform.ts:
--------------------------------------------------------------------------------
1 | import usePlatform from '@/hooks/usePlatform';
2 |
3 | jest.mock('@/hooks/usePlatform');
4 |
5 | export function setUsePlatformData(platform: 'mac' | 'windows') {
6 | switch (platform) {
7 | case 'mac':
8 | jest.mocked(usePlatform).mockReturnValue({
9 | isMac: true,
10 | isWindows: false,
11 | specialKey: '⌘',
12 | });
13 | break;
14 | case 'windows':
15 | jest.mocked(usePlatform).mockReturnValue({
16 | isMac: false,
17 | isWindows: true,
18 | specialKey: 'CTRL+',
19 | });
20 | break;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/setupJest.ts:
--------------------------------------------------------------------------------
1 | import { configure } from '@testing-library/react';
2 |
3 | configure({ testIdAttribute: 'data-test-id' });
4 |
5 | // JSDom does not implement scrollTo, so we just need to mock it here
6 | Element.prototype.scrollTo = () => void 0;
7 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/setupJestAfterEnv.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
3 | // The "allotment" package uses ResizeObserver, which is not available in JSDOM.
4 | window.ResizeObserver =
5 | window.ResizeObserver ||
6 | jest.fn().mockImplementation(() => ({
7 | disconnect: jest.fn(),
8 | observe: jest.fn(),
9 | unobserve: jest.fn(),
10 | }));
11 |
--------------------------------------------------------------------------------
/packages/webui/src/testing/setupJestGlobal.ts:
--------------------------------------------------------------------------------
1 | module.exports = () => {
2 | // forces the current timezone to be UTC
3 | process.env.TZ = 'UTC';
4 | };
5 |
--------------------------------------------------------------------------------
/packages/webui/src/types/graphql-prettier.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'graphql-prettier' {
2 | function fn(source: string, noDuplicates?: boolean): string;
3 | export = fn;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/webui/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { Event } from '@envyjs/core';
2 |
3 | export type Trace = Event;
4 | export type Traces = Map;
5 |
6 | export type TraceRowData = {
7 | data?: string;
8 | };
9 |
10 | export type TraceContext = {
11 | trace: Trace;
12 | data: T;
13 | };
14 |
15 | export interface System {
16 | name: string;
17 | isMatch(trace: Trace): boolean;
18 | getData?(trace: Trace): T;
19 | getIconUri?(): string | null;
20 | getSearchKeywords?(context: TraceContext): string[];
21 | getTraceRowData?(context: TraceContext): TraceRowData | null;
22 | getRequestDetailComponent?(context: TraceContext): React.ReactNode;
23 | getRequestBody?(context: TraceContext): string | undefined | null;
24 | getResponseDetailComponent?(context: TraceContext): React.ReactNode;
25 | getResponseBody?(context: TraceContext): string | undefined | null;
26 | }
27 |
--------------------------------------------------------------------------------
/packages/webui/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './utils';
2 |
--------------------------------------------------------------------------------
/packages/webui/src/utils/styles.test.ts:
--------------------------------------------------------------------------------
1 | import { HttpRequestState } from '@envyjs/core';
2 |
3 | import { badgeStyle } from './styles';
4 |
5 | describe('badgeStyle', () => {
6 | const scenarios = [
7 | { statusCode: 500, bgColor: 'badge-500' },
8 | { statusCode: 404, bgColor: 'badge-400' },
9 | { statusCode: 300, bgColor: 'badge-300' },
10 | { statusCode: 200, bgColor: 'badge-200' },
11 | ];
12 |
13 | it.each(scenarios)('should have $bgColor for HTTP $statusCode responses', ({ statusCode, bgColor }) => {
14 | const trace = {
15 | http: {
16 | state: HttpRequestState.Received,
17 | statusCode,
18 | },
19 | } as any;
20 |
21 | const style = badgeStyle(trace);
22 | expect(style).toEqual(bgColor);
23 | });
24 |
25 | it('should have badge-abort for aborted HTTP requests', () => {
26 | const trace = {
27 | http: {
28 | state: HttpRequestState.Aborted,
29 | },
30 | } as any;
31 |
32 | const style = badgeStyle(trace);
33 | expect(style).toEqual('badge-abort');
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/packages/webui/src/utils/styles.ts:
--------------------------------------------------------------------------------
1 | import { HttpRequestState } from '@envyjs/core';
2 |
3 | import { Trace } from '@/types';
4 |
5 | export function badgeStyle(trace: Trace) {
6 | const { statusCode, state } = trace.http || {};
7 |
8 | if (state === HttpRequestState.Aborted) return 'badge-abort';
9 |
10 | if (statusCode) {
11 | if (statusCode >= 500) return 'badge-500';
12 | else if (statusCode >= 400) return 'badge-400';
13 | else if (statusCode >= 300) return 'badge-300';
14 | else if (statusCode >= 200) return 'badge-200';
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/webui/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | import defaultTheme from 'tailwindcss/defaultTheme';
3 |
4 | export default {
5 | darkMode: ['class'],
6 | content: ['./src/index.html', './src/**/*.tsx'],
7 | theme: {
8 | extend: {
9 | colors: {
10 | apple: {
11 | 50: '#F3FBF2',
12 | 100: '#E4F8E0',
13 | 200: '#CAEFC3',
14 | 300: '#9DE093',
15 | 400: '#6CC95F',
16 | 500: '#48AE39',
17 | 600: '#378F2A',
18 | 700: '#2D7124',
19 | 800: '#275A21',
20 | 900: '#214A1D',
21 | 950: '#0D280B',
22 | },
23 | manatee: {
24 | 50: '#F5F7F8',
25 | 100: '#EEEFF1',
26 | 200: '#DFE1E6',
27 | 300: '#CBCFD6',
28 | 400: '#B5B9C4',
29 | 500: '#A1A5B3',
30 | 600: '#8D90A1',
31 | 700: '#787B8A',
32 | 800: '#626471',
33 | 900: '#52535D',
34 | 950: '#303136',
35 | },
36 | shark: {
37 | 50: '#E8EBEC',
38 | 100: '#D8DCDF',
39 | 200: '#B6BFC3',
40 | 300: '#94A3A7',
41 | 400: '#73878C',
42 | 500: '#57686A',
43 | 600: '#3C4849',
44 | 700: '#202727',
45 | 800: '#191F1E',
46 | 900: '#121616',
47 | 950: '#0F1212',
48 | },
49 | },
50 | fontFamily: {
51 | sans: ['Roboto', ...defaultTheme.fontFamily.sans],
52 | mono: ['Roboto Mono', ...defaultTheme.fontFamily.mono],
53 | },
54 | fontSize: {
55 | '2xs': ['0.625rem', '0.875rem'],
56 | },
57 | },
58 | },
59 | variants: {
60 | extend: {
61 | display: ['group-hover'],
62 | },
63 | },
64 | plugins: [require('@tailwindcss/forms')],
65 | };
66 |
--------------------------------------------------------------------------------
/packages/webui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": false,
5 | "jsx": "react-jsx",
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ES2022",
8 | "moduleResolution": "node",
9 | "outDir": "dist",
10 | "paths": {
11 | "@/*": ["./src/*"]
12 | },
13 | "strict": true,
14 | "target": "ES2022",
15 | "types": ["node", "jest", "@testing-library/jest-dom"]
16 | },
17 | "include": ["src/**/*", "index.js"]
18 | }
19 |
--------------------------------------------------------------------------------
/packages/webui/tsconfig.types.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "downlevelIteration": true,
5 | "emitDeclarationOnly": true,
6 | "esModuleInterop": true,
7 | "module": "ES2022",
8 | "moduleResolution": "node",
9 | "jsx": "react-jsx",
10 | "outDir": "dist",
11 | "paths": {
12 | "@/*": ["./src/*"]
13 | }
14 | },
15 | "include": ["src/integration.tsx", "src/types/css.d.ts"]
16 | }
17 |
--------------------------------------------------------------------------------
/packages/webui/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | import react from '@vitejs/plugin-react';
4 | import { defineConfig } from 'vite';
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | optimizeDeps: {
9 | include: ['@envyjs/core'],
10 | },
11 | plugins: [react()],
12 | resolve: {
13 | alias: {
14 | '@': path.resolve(__dirname, './src'),
15 | },
16 | },
17 | });
18 |
--------------------------------------------------------------------------------
/scripts/test-webui-npm.sh:
--------------------------------------------------------------------------------
1 | #! /bin/bash -e
2 |
3 | # This script will build and install the webui package
4 | # into your global npm cache to test the dist
5 | # Run this script from the repository root
6 |
7 | yarn install
8 | yarn build --force
9 |
10 | cd packages/webui
11 |
12 | NAME="$(npm pack)"
13 | npm install -g "./$NAME"
14 | npx @envyjs/webui
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node16/tsconfig.json",
3 | "compilerOptions": {
4 | "allowSyntheticDefaultImports": true,
5 | "declaration": true,
6 | "esModuleInterop": true,
7 | "preserveConstEnums": true,
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": ["dist/**"]
7 | },
8 | "lint": {
9 | "dependsOn": ["^build"]
10 | },
11 | "test": {
12 | "dependsOn": ["^build"]
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------