├── .babelrc
├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── __tests__
├── App.test.js
└── FiberTree.test.js
├── assets
├── devtool-sample.gif
├── repo-fetching.png
└── store-fetching.png
├── backend
├── fiberTree.ts
├── index.ts
└── react-devtools.d.ts
├── examples
└── sample-app
│ ├── .env.example
│ ├── .gitignore
│ ├── app
│ ├── about
│ │ ├── page.jsx
│ │ └── team
│ │ │ └── page.jsx
│ ├── api
│ │ ├── courses
│ │ │ ├── data.json
│ │ │ ├── route.js
│ │ │ └── search
│ │ │ │ └── route.js
│ │ └── hello
│ │ │ └── route.js
│ ├── code
│ │ └── repos
│ │ │ ├── [name]
│ │ │ └── page.jsx
│ │ │ └── page.jsx
│ ├── components
│ │ ├── CourseSearch.jsx
│ │ ├── Courses.jsx
│ │ ├── Header.jsx
│ │ ├── Repo.jsx
│ │ └── RepoDirs.jsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.jsx
│ ├── loading.jsx
│ └── page.jsx
│ ├── instrumentation.ts
│ ├── jsconfig.json
│ ├── next.config.js
│ ├── package.json
│ ├── perfssr-otel.ts
│ └── public
│ ├── next.svg
│ ├── screen.png
│ ├── thirteen.svg
│ └── vercel.svg
├── extension
├── assets
│ ├── perfssr_favicon.png
│ ├── perfssr_icon_128.png
│ ├── perfssr_icon_48.png
│ └── perfssr_logo.png
├── background.ts
├── contentScript.ts
├── devtools.html
├── devtools.ts
├── manifest.json
└── panel.html
├── jest.config.js
├── package-lock.json
├── package.json
├── perfssr-npm-package
├── README.md
├── package-lock.json
├── package.json
├── server.js
└── spanController.js
├── src
├── App.js
├── components
│ ├── NetworkPanel.js
│ ├── benchmarkTime.js
│ ├── clientComponent.js
│ ├── customTooltip.js
│ ├── metric.js
│ ├── metricContainer.js
│ └── serverComponent.js
├── hooks
│ └── webSocketHook.js
├── index.html
├── index.js
└── style.css
├── testing-files
├── FiberNode.js
└── style.mock.js
├── tsconfig.json
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"]
3 | }
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true, // Browser global variables like `window` etc.
4 | commonjs: true, // CommonJS global variables and CommonJS scoping.Allows require, exports and module.
5 | es6: true, // Enable all ECMAScript 6 features except for modules.
6 | jest: true, // Jest global variables like `it` etc.
7 | node: true, // Defines things like process.env when generating through node
8 | },
9 | extends: [
10 | "eslint:recommended",
11 | "plugin:react/recommended",
12 | "plugin:jsx-a11y/recommended",
13 | "plugin:react-hooks/recommended",
14 | "plugin:jest/recommended",
15 | "plugin:testing-library/react",
16 | ],
17 | parser: "@babel/eslint-parser", // Uses babel-eslint transforms.
18 | parserOptions: {
19 | ecmaFeatures: {
20 | jsx: true,
21 | },
22 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
23 | sourceType: "module", // Allows for the use of imports
24 | },
25 | plugins: [
26 | "import", // eslint-plugin-import plugin. https://www.npmjs.com/package/eslint-plugin-import
27 | ],
28 | root: true, // For configuration cascading.
29 | rules: {
30 | "comma-dangle": ["warn", "never"],
31 | "eol-last": "error",
32 | "import/order": [
33 | "warn",
34 | {
35 | alphabetize: {
36 | caseInsensitive: true,
37 | order: "asc",
38 | },
39 | groups: [
40 | "builtin",
41 | "external",
42 | "index",
43 | "sibling",
44 | "parent",
45 | "internal",
46 | ],
47 | },
48 | ],
49 | indent: ["error", 4],
50 | // Indent JSX with 4 spaces
51 | "react/jsx-indent": ["error", 4],
52 |
53 | // Indent props with 4 spaces
54 | "react/jsx-indent-props": ["error", 4],
55 | "jsx-quotes": ["warn", "prefer-double"],
56 |
57 | "max-len": [
58 | "warn",
59 | {
60 | code: 120,
61 | },
62 | ],
63 | "no-console": "warn",
64 | "no-duplicate-imports": "warn",
65 | "no-restricted-imports": [
66 | "error",
67 | {
68 | paths: [
69 | {
70 | message: "Please use import foo from 'lodash-es/foo' instead.",
71 | name: "lodash",
72 | },
73 | {
74 | message:
75 | "Avoid using chain since it is non tree-shakable. Try out flow instead.",
76 | name: "lodash-es/chain",
77 | },
78 | {
79 | importNames: ["chain"],
80 | message:
81 | "Avoid using chain since it is non tree-shakable. Try out flow instead.",
82 | name: "lodash-es",
83 | },
84 | {
85 | message: "Please use import foo from 'lodash-es/foo' instead.",
86 | name: "lodash-es",
87 | },
88 | ],
89 | patterns: ["lodash/**", "lodash/fp/**"],
90 | },
91 | ],
92 | "no-unused-vars": "warn",
93 | "object-curly-spacing": ["warn", "always"],
94 | quotes: ["warn", "double"],
95 | "react/jsx-curly-spacing": [
96 | "warn",
97 | {
98 | allowMultiline: true,
99 | children: {
100 | when: "always",
101 | },
102 | spacing: {
103 | objectLiterals: "always",
104 | },
105 | when: "always",
106 | },
107 | ],
108 | "react/jsx-filename-extension": [
109 | "error",
110 | {
111 | extensions: [".js", ".jsx", ".ts", ".tsx"],
112 | },
113 | ],
114 | "react/jsx-indent": [
115 | "error",
116 | 4,
117 | {
118 | checkAttributes: true,
119 | indentLogicalExpressions: true,
120 | },
121 | ],
122 | "react/jsx-indent-props": ["error", 4],
123 | "react/prop-types": "warn",
124 | semi: "warn",
125 | "sort-imports": [
126 | "warn",
127 | {
128 | ignoreCase: false,
129 | ignoreDeclarationSort: true,
130 | ignoreMemberSort: false,
131 | },
132 | ],
133 | "sort-keys": [
134 | "warn",
135 | "asc",
136 | {
137 | caseSensitive: true,
138 | minKeys: 2,
139 | natural: false,
140 | },
141 | ],
142 | },
143 | settings: {
144 | react: {
145 | version: "detect", // Detect react version
146 | },
147 | },
148 | };
149 |
--------------------------------------------------------------------------------
/.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 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 |
133 | new-sample
134 | .DS_Store
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 OSLabs Beta
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # PerfSSR
6 |
7 | PerfSSR is an open-source Chrome Developer Tool that enhances performance and observability for Next.js applications. It offers real-time performance analytics, providing valuable and comprehensive insights into various aspects of the application.
8 |
9 | 
10 |
11 | ---
12 |
13 | ## Tech Stack
14 |
15 | 
16 | 
17 | 
18 | 
19 | 
20 | 
21 | 
22 | 
23 | 
24 | 
25 | 
26 | 
27 | 
28 | 
29 |
30 | ---
31 |
32 | ## Motivation
33 |
34 | Fetches made server-side get logged in your terminal not the browser. PerfSSR Dev Tool solves this by showing server-side fetch requests in browser alongside the Chrome Network tab.
35 |
36 | Next.js already instruments using [OpenTelemetry](https://nextjs.org/docs/app/building-your-application/optimizing/open-telemetry) for us out of the box so we can just access their built-in spans.
37 |
38 | Credit to [NetPulse](https://github.com/oslabs-beta/NetPulse) for this idea.
39 | Credit to [ReaPer](https://github.com/oslabs-beta/ReaPer) and [Reactime](https://github.com/open-source-labs/reactime) for the idea of accessing render times via React Dev Tool hooks.
40 |
41 |
42 | 
43 | 
44 |
45 |
46 | ---
47 |
48 | ## Setup
49 |
50 | ### Prerequisites
51 |
52 | 1. [Google Chrome](https://www.google.com/chrome/)
53 | 2. Ensure you have [React Dev Tools](https://react.dev/learn/react-developer-tools) installed
54 | 3. In your project directory `npm install perfssr --save-dev`
55 |
56 | and additional OpenTelemtry dependecies
57 |
58 | ```javascript
59 | npm i --save-dev @opentelemetry/exporter-trace-otlp-http @opentelemetry/resources @opentelemetry/sdk-node @opentelemetry/sdk-trace-node @opentelemetry/semantic-conventions
60 | ```
61 |
62 | 4. Install our [PerfSSR Chrome Extension](#chrome-extension-installation)
63 | 5. As of the current Next.js version [13.4.4], [instrumentation](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation) is an experimental hook so it must be included in the `next.config.js` file. Add the following code to your next config object.
64 |
65 | ```javascript
66 | experimental: {
67 | instrumentationHook: true
68 | }
69 | ```
70 |
71 | 6. Create a file in your project root directory called `instrumentation.ts`. This file will be loaded when Next.js dev server runs and sees that instrumentation is enabled. Within this file we need to import a file that we'll be creating in the next step that starts tracing the Next.js application
72 |
73 | ```javascript
74 | export async function register() {
75 | //OpenTelemetry APIs are not compatible with edge runtime
76 | //need to only import when our runtime is nodejs
77 | if (process.env.NEXT_RUNTIME === "nodejs") {
78 | //Import the script that will start tracing the Next.js application
79 | //In our case it is perfssr.ts
80 | //Change it to your own file name if you named it something else
81 | await import("./perfssr");
82 | }
83 | }
84 | ```
85 |
86 | 7. Create another file [.ts or .js] to your project root directory this can be named anything you'd like. We have ours called `perfssr.ts`
87 |
88 | 1. Inside `perfssr.ts` copy and paste this block of code
89 |
90 | ```javascript
91 | import { NodeSDK } from "@opentelemetry/sdk-node";
92 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
93 | import { Resource } from "@opentelemetry/resources";
94 | import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
95 | import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
96 |
97 | const sdk = new NodeSDK({
98 | resource: new Resource({
99 | [SemanticResourceAttributes.SERVICE_NAME]: "next-app",
100 | }),
101 |
102 | spanProcessor: new SimpleSpanProcessor(
103 | new OTLPTraceExporter({
104 | //all traces exported to express server on port 4000
105 | url: `http://localhost:4000`,
106 | })
107 | ),
108 | });
109 |
110 | sdk.start();
111 |
112 |
113 |
114 | 8. Create a `.env` file in the root of your project directory. By default Next.js only creates spans for the API routes, but we want more information than that! To open it up, Next.js looks for a value set in `process.env` Add the line `NEXT_OTEL_VERBOSE=1` to your `.env` file.
115 |
116 | 9. Include another script line to your `package.json` file
117 |
118 | ```javascript
119 | "perfssr": "node ./node_modules/perfssr/server.js & next dev"
120 | ```
121 |
122 | 10. Run PerfSSR by running the command `npm run perfssr` within your terminal.
123 |
124 | ### Chrome Extension Installation
125 |
126 | 1. Clone the PerfSSR repo onto your local machine
127 |
128 | ```
129 | git clone https://github.com/oslabs-beta/perfSSR.git
130 | ```
131 |
132 | 2. Install dependencies and build the PerfSSR application locally
133 |
134 | ```
135 | npm install
136 | npm run build
137 | ```
138 |
139 | 3. Add PerfSSR to your Chrome extensions
140 |
141 | - Navigate to chrome://extensions
142 | - Select Load Unpacked
143 | - Turn on 'Allow access to file URLs' in extension details
144 | - Choose PerfSSR/dist
145 | - Navigate to your application in development mode
146 | - Open up your project in Google Chrome
147 |
148 | 4. Navigate to the PerfSSR panel. Click on the **Start PerfSSR** button will automatically refreshes the page and starts the extraction of performance data of the currently inspected webpage
149 |
150 | - Click on **Regenerate Metrics** will refresh the page to get updated rendering data
151 | - Click on **Clear Network Data** under the Server-side Fetching Summary table will clear all the current requests logged so far
152 |
153 | **Note**: PerfSSR is intended for analyzing and providing performance insights into Next.js applications **in development mode** running on `localhost:3000`
154 |
155 | ## Examples
156 |
157 | To see examples of how to set up your app, we've included a sample app in the `examples` folder.
158 |
159 | ## Contributors
160 |
161 | [James Ye](https://github.com/ye-james) | [Summer Pu](https://github.com/summep) | [Jessica Vo](https://github.com/jessicavo) | [Jonathan Hosea](https://github.com/jhosea92)
162 |
--------------------------------------------------------------------------------
/__tests__/App.test.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 | import React from 'react';
3 | import { render, screen } from '@testing-library/react';
4 | import App from '../src/App.js';
5 | import '../testing-files/style.mock.js';
6 |
7 | jest.mock('../src/components/metricContainer', () => {
8 | return () => Mocked MetricContainer Component
;
9 | });
10 | jest.mock('../src/components/HTTPReqChart', () => {
11 | return () => Mocked HTTPReqChart Component
;
12 | });
13 |
14 | jest.mock('../src/components/serverComponent', () => {
15 | return () => Mocked ServerComponent Component
;
16 | });
17 |
18 | jest.mock('../src/components/clientComponent', () => {
19 | return () => Mocked ClientComponent Component
;
20 | });
21 |
22 | jest.mock('../src/components/benchmarkTime', () => {
23 | return () => Mocked BenchmarkTime Component
;
24 | });
25 |
26 | const chromeMock = {
27 | runtime: {
28 | sendMessage: jest.fn((message, callback) => {
29 | // Provide a dummy response
30 | callback({ type: "MOCK_RESPONSE",
31 | payload: '{ "example": "payload" }',
32 | });
33 | }),
34 | onMessage: {
35 | addListener: jest.fn(),
36 | },
37 | },
38 | devtools: {
39 | inspectedWindow: {
40 | reload: jest.fn(),
41 | },
42 | },
43 | };
44 |
45 | global.chrome = chromeMock;
46 |
47 |
48 | test('renders the App component and intially with the button "Start PerfSSR"', () => {
49 | render( );
50 | const buttonElement = screen.getByRole('button', { name: /Start PerfSSR/i });
51 | expect(buttonElement).toBeInTheDocument();
52 | });
--------------------------------------------------------------------------------
/__tests__/FiberTree.test.js:
--------------------------------------------------------------------------------
1 | const { Tree } = require('../backend/fiberTree.ts');
2 | const FiberNode = require('../testing-files/FiberNode.js');
3 |
4 | const treeInstance = new Tree(FiberNode);
5 |
6 | test('Tree has buildTree method', () => {
7 | expect(treeInstance).toHaveProperty('buildTree');
8 | });
9 |
10 | test('Tree.buildTree is a function', () => {
11 | expect(typeof treeInstance.buildTree).toEqual('function')
12 | });
13 |
14 | test('output tree has a root property at the highest level', () => {
15 | expect(treeInstance).toHaveProperty('root');
16 | });
17 |
18 | test('The first layer of the Tree should have one component with the tag name Host Root ', () => {
19 | expect(treeInstance.root.tagObj.tagName).toEqual('Host Root');
20 | });
21 |
22 |
23 | test('The third layer of the Tree should have two children', () => {
24 | expect(treeInstance.root.children[0].children).toHaveLength(2);
25 | });
26 |
27 | test('The third layer of the Tree\'s first child component should be without name', () => {
28 | expect(treeInstance.root.children[0].children[0].componentName).toEqual('');
29 | });
30 |
31 | test('The third layer of the Tree\'s second child component should be A CSC', () => {
32 | expect(treeInstance.root.children[0].children[1].componentName).toEqual('A CSC');
33 | });
--------------------------------------------------------------------------------
/assets/devtool-sample.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/assets/devtool-sample.gif
--------------------------------------------------------------------------------
/assets/repo-fetching.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/assets/repo-fetching.png
--------------------------------------------------------------------------------
/assets/store-fetching.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/assets/store-fetching.png
--------------------------------------------------------------------------------
/backend/fiberTree.ts:
--------------------------------------------------------------------------------
1 | class TreeNode {
2 | children: any[];
3 | siblings: any[];
4 | _debugHookTypes: null | string | string[];
5 | actualStartTime: number;
6 | componentName: string;
7 | componentProps: any;
8 | key: any;
9 | renderDurationMS: number;
10 | selfBaseDuration: number;
11 | tagObj: {
12 | tag: number;
13 | tagName: string
14 | };
15 |
16 | constructor(fiberNode: any) {
17 | const {
18 | _debugHookTypes,
19 | actualDuration,
20 | actualStartTime,
21 | elementType,
22 | key,
23 | memoizedProps,
24 | selfBaseDuration,
25 | tag,
26 | } = fiberNode;
27 |
28 | //properties to store the found children/siblings
29 | this.children = [];
30 | this.siblings = [];
31 |
32 | this.setTagObj(tag);
33 | this.setComponentName(elementType, tag, memoizedProps);
34 | this.setProps(memoizedProps);
35 | this.setKey(key);
36 | this.setHookTypes(_debugHookTypes);
37 | //render time here includes render time for this fiber node and the time took to render all its children
38 | this.setRenderDurationMS(actualDuration);
39 | this.actualStartTime = actualStartTime;
40 | this.selfBaseDuration = selfBaseDuration;
41 | }
42 |
43 | addChild(newNode: any) {
44 | if (newNode) {
45 | this.children.push(newNode);
46 | }
47 | }
48 |
49 | addSibling(newNode: any) {
50 | if (newNode) this.siblings.push(newNode);
51 | }
52 |
53 | setRenderDurationMS(actualDuration: number) {
54 | this.renderDurationMS = actualDuration;
55 | }
56 |
57 | setProps(memoizedProps: any) {
58 | this.componentProps = memoizedProps;
59 | }
60 |
61 | setKey(key: any) {
62 | //if there is a key defined for the component, grab it
63 | this.key = key;
64 | }
65 |
66 | setHookTypes(_debugHookTypes: null | string | string[]) {
67 | this._debugHookTypes = _debugHookTypes;
68 | }
69 |
70 | setComponentName(elementType: any, tag: any, memoizedProps: any) {
71 | try {
72 | if (elementType && elementType.hasOwnProperty("name")) {
73 | this.componentName =
74 | this.tagObj.tagName === "Host Root" ? "Root" : elementType.name;
75 | } else if (tag === 11 && (memoizedProps.href || memoizedProps.src)) {
76 | //setting component name for next's Link components to the href path
77 | this.componentName = memoizedProps.href ? "link href: " + memoizedProps.href
78 | : "link src: " + memoizedProps.src
79 | } else if (elementType && elementType.hasOwnProperty("_payload")) {
80 | //if component type is react.lazy, then look into its payload to set its name
81 | if (
82 | elementType._payload.hasOwnProperty("value") &&
83 | elementType._payload.value.hasOwnProperty("name")
84 | ) {
85 | this.componentName = this.tagObj.tagName =
86 | elementType._payload.value.name;
87 | } else {
88 | this.componentName = "";
89 | }
90 | }
91 | else {
92 | this.componentName = "";
93 | }
94 | } catch (error) {
95 | console.log("tree.setComponentName error:", error.message);
96 | }
97 | }
98 |
99 | setTagObj(fiberTagNum: number) {
100 | try {
101 | if (fiberTagNum !== undefined && fiberTagNum !== null) {
102 | this.tagObj = {
103 | tag: fiberTagNum,
104 | tagName: getFiberNodeTagName(fiberTagNum),
105 | };
106 | } else {
107 | console.log("tree.setTagObj: fiberTagName is undefined!");
108 | }
109 | } catch (error) {
110 | console.log("tree.setTagObj error:", error.message);
111 | }
112 | }
113 |
114 |
115 | }
116 |
117 | class Tree {
118 | root: any;
119 | constructor(fiberNode: any) {
120 | this.root = null;
121 | this.createTree(fiberNode);
122 | }
123 |
124 | createTree(fiberNode: any) {
125 | function traverse(fiberNode: any, parentTreeNode: any) {
126 | const { tag } = fiberNode;
127 | const tagName = getFiberNodeTagName(tag);
128 | let newNode: any;
129 | if (
130 | //we only want components with these tag names
131 | tagName === "Host Root" ||
132 | tagName === "Host Component" || // components usually converted to html-like name
133 | tagName === "Function Component" ||
134 | tagName === "Forward Ref" //this is to grab next/link components
135 | ) {
136 | newNode = new TreeNode(fiberNode);
137 | if (!parentTreeNode) {
138 | this.root = newNode;
139 | } else {
140 | parentTreeNode.addChild(newNode);
141 | }
142 | }
143 |
144 | if (fiberNode.child) {
145 | traverse(fiberNode.child, newNode ? newNode : parentTreeNode);
146 | }
147 |
148 | if (fiberNode.sibling) {
149 | traverse(fiberNode.sibling, parentTreeNode);
150 | }
151 | }
152 |
153 | traverse.apply(this, [fiberNode, null]);
154 | }
155 | }
156 |
157 | //function to assign tagNames based on React's work tag assignments
158 | //see: https://github.com/facebook/react/blob/a1f97589fd298cd71f97339a230f016139c7382f/packages/react-reconciler/src/ReactWorkTags.js
159 | const getFiberNodeTagName = (tagNum: number) => {
160 | let tagName: string = "";
161 |
162 | switch (tagNum) {
163 | case 0:
164 | tagName = "Function Component";
165 | break;
166 | case 1:
167 | tagName = "Class Component";
168 | break;
169 | case 2:
170 | tagName = "Indeterminate Component";
171 | break;
172 | case 3:
173 | tagName = "Host Root";
174 | break;
175 | case 4:
176 | tagName = "Host Portal";
177 | break;
178 | case 5:
179 | tagName = "Host Component";
180 | break;
181 | case 6:
182 | tagName = "Host Text";
183 | break;
184 | case 7:
185 | tagName = "Fragment";
186 | break;
187 | case 8:
188 | tagName = "Mode";
189 | break;
190 | case 9:
191 | tagName = "Context Consumer";
192 | break;
193 | case 10:
194 | tagName = "Context Provider";
195 | break;
196 | case 11:
197 | tagName = "Forward Ref";
198 | break;
199 | case 12:
200 | tagName = "Profiler";
201 | break;
202 | case 13:
203 | tagName = "Suspense Component";
204 | break;
205 | case 14:
206 | tagName = "Memo Component";
207 | break;
208 | case 15:
209 | tagName = "Simple Memo Component";
210 | break;
211 | case 16:
212 | tagName = "Lazy Component";
213 | break;
214 | case 17:
215 | tagName = "Incomplete Class Component";
216 | break;
217 | case 18:
218 | tagName = "Dehydrated Fragment";
219 | break;
220 | case 19:
221 | tagName = "Suspense List Component";
222 | break;
223 | case 21:
224 | tagName = "Scope Component";
225 | break;
226 | case 22:
227 | tagName = "Offscreen Component";
228 | break;
229 | case 23:
230 | tagName = "Legacy Hidden Component";
231 | break;
232 | case 24:
233 | tagName = "Cache Component";
234 | break;
235 | case 25:
236 | tagName = "Tracing Marker Component";
237 | break;
238 | case 26:
239 | tagName = "Host Hoistable";
240 | break;
241 | case 27:
242 | tagName = "Host Singleton";
243 | break;
244 | default:
245 | console.log(
246 | "Unrecognized tag number",
247 | tagNum
248 | );
249 | }
250 |
251 | return tagName;
252 | };
253 |
254 | export { Tree };
--------------------------------------------------------------------------------
/backend/index.ts:
--------------------------------------------------------------------------------
1 | import { Tree } from './fiberTree';
2 | // __REACT_DEVTOOLS_GLOBAL_HOOK__ and Fiber Tree specific variables, no need to type
3 | let devTool;
4 | let rootNode;
5 | let rdtOnCommitFiberRoot;
6 | let updatedFiberTree: any;
7 |
8 |
9 | //__REACT_DEVTOOLS_GLOBAL_HOOK__ instantiation of React Dev Tools within our app
10 | // can be accessed by using window.__REACT_DEVTOOLS_GLOBAL_HOOK__
11 | devTool = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
12 |
13 | rootNode = devTool.getFiberRoots(1).values().next().value;
14 |
15 | if (!devTool) {
16 | console.log(
17 | "Unable to grab instance of fiber root. Make sure React DevTools is installed."
18 | );
19 | }
20 |
21 |
22 | //patch the original onCommitFiberRoot
23 | //everytime the virtual dom is updated, send it to devtool
24 | const patchOnCommitFiber = function (originalOnCommitFiberRootFn: any, updatedFiberTree: any ) {
25 | // hold on to original function
26 | rdtOnCommitFiberRoot = originalOnCommitFiberRootFn;
27 |
28 | return function (...args: any[]) {
29 | const rdtFiberRootNode = args[1];
30 | updatedFiberTree = new Tree(rdtFiberRootNode);
31 | updatedFiberTree.buildTree(rdtFiberRootNode.current);
32 | if(updatedFiberTree) {
33 | window.postMessage(
34 | {
35 | type: "UPDATED_FIBER",
36 | payload: JSON.stringify(updatedFiberTree, replaceCircularObj()),
37 | },
38 | "*"
39 | );
40 | }
41 |
42 | return originalOnCommitFiberRootFn(...args);
43 | };
44 | };
45 |
46 | //This function replaces the object that causes a circular object
47 | //before it gets stringified
48 | const replaceCircularObj = () => {
49 | const seen = new Map();
50 | return function (key: any, value: any) {
51 | if (typeof value !== "object" || value === null) {
52 | return value;
53 | }
54 | if (seen.has(value)) {
55 | return "";
56 | }
57 | seen.set(value, true);
58 | return value;
59 | };
60 | };
61 |
62 | // listener for everytime onCommitFiber is executed, will intercept it with monkey patching to run additional side effects
63 | devTool.onCommitFiberRoot = patchOnCommitFiber(devTool.onCommitFiberRoot, updatedFiberTree);
64 |
65 | //Build the tree from root fiber node
66 | const newTree =
67 | rootNode && rootNode.current ? new Tree(rootNode.current) : undefined;
68 |
69 |
70 | //Check if the we have an instance of the tree
71 | //send it to the content script which will send it to background.js
72 | if (newTree) {
73 | newTree.createTree(rootNode.current);
74 |
75 | window.postMessage(
76 | {
77 | type: "FIBER_INSTANCE",
78 | payload: JSON.stringify(newTree, replaceCircularObj()),
79 | },
80 | "*"
81 | );
82 | }
--------------------------------------------------------------------------------
/backend/react-devtools.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | __REACT_DEVTOOLS_GLOBAL_HOOK__?: any;
3 | }
--------------------------------------------------------------------------------
/examples/sample-app/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_OTEL_VERBOSE=1
--------------------------------------------------------------------------------
/examples/sample-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
--------------------------------------------------------------------------------
/examples/sample-app/app/about/page.jsx:
--------------------------------------------------------------------------------
1 | export const metadata = {
2 | title: 'About Traversy Media',
3 | };
4 |
5 | const AboutPage = () => {
6 | return (
7 |
8 |
About Traversy Media
9 |
10 | Lorem ipsum dolor sit, amet consectetur adipisicing elit. Suscipit
11 | molestiae ipsam, et aut consequatur ipsum voluptates quasi, quos
12 | recusandae doloribus provident consequuntur amet nobis est voluptate
13 | perferendis quaerat distinctio saepe dolores perspiciatis ex ab nostrum
14 | eaque! Porro perspiciatis possimus, sed a quidem sunt sit doloremque
15 | molestiae maiores blanditiis quasi quod.
16 |
17 |
18 | );
19 | };
20 | export default AboutPage;
21 |
--------------------------------------------------------------------------------
/examples/sample-app/app/about/team/page.jsx:
--------------------------------------------------------------------------------
1 | const TeamPage = () => {
2 | return (
3 |
4 |
Our Team
5 |
6 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Iure tenetur
7 | ducimus laboriosam eaque recusandae tempora enim aut, quae error eum
8 | ipsum reiciendis temporibus dolore delectus ullam impedit cumque id.
9 | Itaque?
10 |
11 |
12 | );
13 | };
14 | export default TeamPage;
15 |
--------------------------------------------------------------------------------
/examples/sample-app/app/api/courses/data.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "title": "React Front To Back",
5 | "description": "Learn Modern React, Including Hooks, Context API, Full Stack MERN & Redux By Building Real Life Projects.",
6 | "link": "https://www.traversymedia.com/Modern-React-Front-To-Back-Course",
7 | "level": "Beginner"
8 | },
9 | {
10 | "id": 2,
11 | "title": "Node.js API Masterclass",
12 | "description": "Build an extensive RESTful API using Node.js, Express, and MongoDB",
13 | "link": "https://www.traversymedia.com/node-js-api-masterclass",
14 | "level": "Intermediate"
15 | },
16 | {
17 | "id": 3,
18 | "title": "Modern JavaScript From The Beginning",
19 | "description": "37 hour course that teaches you all of the fundamentals of modern JavaScript.",
20 | "link": "https://www.traversymedia.com/modern-javascript-2-0",
21 | "level": "All Levels"
22 | },
23 | {
24 | "id": 4,
25 | "title": "Next.js Dev To Deployment",
26 | "description": "Learn Next.js by building a music event website and a web dev blog as a static website",
27 | "link": "https://www.traversymedia.com/next-js-dev-to-deployment",
28 | "level": "Intermediate"
29 | },
30 | {
31 | "id": 5,
32 | "title": "50 Projects in 50 Days",
33 | "description": "Sharpen your skills by building 50 quick, unique & fun mini projects.",
34 | "link": "https://www.traversymedia.com/50-Projects-In-50-Days",
35 | "level": "Beginner"
36 | }
37 | ]
38 |
--------------------------------------------------------------------------------
/examples/sample-app/app/api/courses/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { v4 as uuidv4 } from 'uuid';
3 | import courses from './data.json';
4 |
5 | export async function GET(request) {
6 | return NextResponse.json(courses);
7 | }
8 |
9 | export async function POST(request) {
10 | const { title, description, level, link } = await request.json();
11 |
12 | const newCourse = {
13 | id: uuidv4(),
14 | title,
15 | description,
16 | level,
17 | link,
18 | };
19 |
20 | courses.push(newCourse);
21 |
22 | return NextResponse.json(courses);
23 | }
24 |
--------------------------------------------------------------------------------
/examples/sample-app/app/api/courses/search/route.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import courses from '../data.json';
3 |
4 | export async function GET(request) {
5 | const { searchParams } = new URL(request.url);
6 | const query = searchParams.get('query');
7 | const filteredCourses = courses.filter((course) => {
8 | return course.title.toLowerCase().includes(query.toLowerCase());
9 | });
10 | return NextResponse.json(filteredCourses);
11 | }
12 |
--------------------------------------------------------------------------------
/examples/sample-app/app/api/hello/route.js:
--------------------------------------------------------------------------------
1 | export async function GET(request) {
2 | return new Response('Hello, Next.js!');
3 | }
4 |
--------------------------------------------------------------------------------
/examples/sample-app/app/code/repos/[name]/page.jsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react';
2 | import Link from 'next/link';
3 | import Repo from '@/app/components/Repo';
4 | import RepoDirs from '@/app/components/RepoDirs';
5 |
6 | const RepoPage = ({ params: { name } }) => {
7 | return (
8 |
9 |
10 | Back To Repositories
11 |
12 | Loading repo...
}>
13 |
14 |
15 | Loading directories...}>
16 |
17 |
18 |
19 | );
20 | };
21 | export default RepoPage;
22 |
--------------------------------------------------------------------------------
/examples/sample-app/app/code/repos/page.jsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { FaStar, FaCodeBranch, FaEye } from "react-icons/fa";
3 | async function fetchRepos() {
4 | const response = await fetch(
5 | "https://api.github.com/users/bradtraversy/repos",
6 | {
7 | next: {
8 | revalidate: 60,
9 | },
10 | }
11 | );
12 |
13 | await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second
14 |
15 | const repos = await response.json();
16 | return repos;
17 | }
18 |
19 | const ReposPage = async () => {
20 | const repos = await fetchRepos();
21 |
22 | return (
23 |
24 |
Repositories
25 |
46 |
47 | );
48 | };
49 | export default ReposPage;
50 |
--------------------------------------------------------------------------------
/examples/sample-app/app/components/CourseSearch.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState } from 'react';
3 |
4 | const CourseSearch = ({ getSearchResults }) => {
5 | const [query, setQuery] = useState('');
6 |
7 | const handleSubmit = async (e) => {
8 | e.preventDefault();
9 |
10 | const res = await fetch(`/api/courses/search?query=${query}`);
11 | const courses = await res.json();
12 | getSearchResults(courses);
13 | };
14 |
15 | return (
16 |
28 | );
29 | };
30 | export default CourseSearch;
31 |
--------------------------------------------------------------------------------
/examples/sample-app/app/components/Courses.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | const Courses = ({ courses }) => {
4 | return (
5 |
6 | {courses.map((course) => (
7 |
8 |
{course.title}
9 |
Level: {course.level}
10 |
{course.description}
11 |
12 | Go To Course
13 |
14 |
15 | ))}
16 |
17 | );
18 | };
19 | export default Courses;
20 |
--------------------------------------------------------------------------------
/examples/sample-app/app/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | const Header = () => {
4 | return (
5 |
17 | );
18 | };
19 | export default Header;
20 |
--------------------------------------------------------------------------------
/examples/sample-app/app/components/Repo.jsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { FaStar, FaCodeBranch, FaEye } from "react-icons/fa";
3 |
4 | async function fetchRepo(name) {
5 | const response = await fetch(
6 | `https://api.github.com/repos/bradtraversy/${name}`,
7 | {
8 | next: {
9 | revalidate: 60,
10 | },
11 | }
12 | );
13 | const repo = await response.json();
14 | return repo;
15 | }
16 |
17 | const Repo = async ({ name }) => {
18 | const repo = await fetchRepo(name);
19 |
20 | return (
21 | <>
22 | {repo.name}
23 | {repo.description}
24 |
25 |
26 |
27 | {repo.stargazers_count}
28 |
29 |
30 |
31 | {repo.forks_count}
32 |
33 |
34 |
35 | {repo.watchers_count}
36 |
37 |
38 | >
39 | );
40 | };
41 | export default Repo;
42 |
--------------------------------------------------------------------------------
/examples/sample-app/app/components/RepoDirs.jsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | async function fetchRepoContents(name) {
4 | await new Promise((resolve) => setTimeout(resolve, 3000));
5 |
6 | const response = await fetch(
7 | `https://api.github.com/repos/bradtraversy/${name}/contents`,
8 | {
9 | next: {
10 | revalidate: 60,
11 | },
12 | }
13 | );
14 | const contents = await response.json();
15 | return contents;
16 | }
17 |
18 | const RepoDirs = async ({ name }) => {
19 | const contents = await fetchRepoContents(name);
20 | const dirs = contents.filter((content) => content.type === 'dir');
21 |
22 | return (
23 | <>
24 | Directories
25 |
26 | {dirs.map((dir) => (
27 |
28 | {dir.path}
29 |
30 | ))}
31 |
32 | >
33 | );
34 | };
35 | export default RepoDirs;
36 |
--------------------------------------------------------------------------------
/examples/sample-app/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/examples/sample-app/app/favicon.ico
--------------------------------------------------------------------------------
/examples/sample-app/app/globals.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-color: #0070f3;
3 | }
4 |
5 | html,
6 | body {
7 | margin: 0;
8 | padding: 0;
9 | font-size: 16px;
10 | background-color: #333;
11 | color: #fff;
12 | }
13 |
14 | .container {
15 | max-width: 1100px;
16 | margin: 0 auto;
17 | padding: 0 1rem;
18 | }
19 |
20 | .btn {
21 | display: inline-block;
22 | background-color: var(--primary-color);
23 | color: #fff;
24 | padding: 0.5rem 1rem;
25 | border: none;
26 | border-radius: 4px;
27 | font-size: 1rem;
28 | cursor: pointer;
29 | text-decoration: none;
30 | }
31 |
32 | .btn:hover {
33 | background-color: #0058b7;
34 | color: #fff;
35 | }
36 |
37 | .btn-back {
38 | display: inline-block;
39 | background-color: #f4f4f4;
40 | color: #333;
41 | padding: 0.5rem 1rem;
42 | border: none;
43 | border-radius: 4px;
44 | font-size: 0.8rem;
45 | cursor: pointer;
46 | text-decoration: none;
47 | margin-bottom: 1.3rem;
48 | }
49 |
50 | .header {
51 | display: flex;
52 | justify-content: center;
53 | align-items: center;
54 | background-color: #0047ab;
55 | color: #fff;
56 | padding: 1rem;
57 | border-bottom: 5px solid #fff;
58 | }
59 |
60 | .logo {
61 | text-align: center;
62 | margin-bottom: 0.5rem;
63 | }
64 |
65 | .logo a {
66 | font-size: 1.5rem;
67 | font-weight: bold;
68 | color: #fff;
69 | text-decoration: none;
70 | }
71 |
72 | .links a {
73 | margin: 0 0.5rem 0 0.5rem;
74 | color: #fff;
75 | text-decoration: none;
76 | }
77 |
78 | .links a:hover {
79 | text-decoration: underline;
80 | }
81 |
82 | .search-form {
83 | display: flex;
84 | align-items: center;
85 | }
86 |
87 | .search-input {
88 | flex-grow: 1;
89 | padding: 0.8rem;
90 | border: 0;
91 | border-radius: 4px;
92 | font-size: 1rem;
93 | }
94 |
95 | .search-button {
96 | padding: 0.7rem 1rem;
97 | margin-left: 0.5rem;
98 | background-color: var(--primary-color);
99 | color: #fff;
100 | border: none;
101 | border-radius: 4px;
102 | font-size: 1rem;
103 | cursor: pointer;
104 | }
105 |
106 | .search-button:hover {
107 | background-color: #0058b7;
108 | }
109 |
110 | .search-input:focus,
111 | .search-button:focus {
112 | outline: none;
113 | border-color: var(--primary-color);
114 | box-shadow: 0 0 0 2px rgba(0, 112, 243, 0.2);
115 | }
116 |
117 | .repos-container {
118 | max-width: 800px;
119 | margin: 0 auto;
120 | padding: 2rem 0;
121 | }
122 |
123 | .repo-list {
124 | list-style: none;
125 | margin: 0;
126 | padding: 0;
127 | }
128 |
129 | .repo-list li {
130 | margin: 2rem 0;
131 | }
132 |
133 | .repo-list a {
134 | display: block;
135 | background-color: #fff;
136 | color: #333;
137 | padding: 1rem;
138 | border-radius: 4px;
139 | text-decoration: none;
140 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
141 | transition: all 0.3s ease-in-out;
142 | }
143 |
144 | .repo-list a:hover {
145 | transform: translateY(-5px);
146 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
147 | }
148 |
149 | .repo-list h3 {
150 | margin: 0;
151 | font-size: 1.5rem;
152 | }
153 |
154 | .repo-list p {
155 | margin: 0;
156 | color: #666;
157 | font-size: 1.2rem;
158 | }
159 |
160 | .repo-details {
161 | display: flex;
162 | justify-content: space-between;
163 | margin-top: 1rem;
164 | color: #999;
165 | }
166 |
167 | .repo-details span {
168 | display: flex;
169 | align-items: center;
170 | }
171 |
172 | .repo-details svg {
173 | margin-right: 0.5rem;
174 | }
175 |
176 | .loader {
177 | display: flex;
178 | justify-content: center;
179 | align-items: center;
180 | height: 100vh;
181 | }
182 |
183 | .spinner {
184 | border: 4px solid rgba(0, 0, 0, 0.1);
185 | border-left-color: blue;
186 | border-radius: 50%;
187 | width: 40px;
188 | height: 40px;
189 | animation: spin 1s linear infinite;
190 | }
191 |
192 | .card {
193 | border: 1px solid #ccc;
194 | border-radius: 4px;
195 | padding: 1rem;
196 | margin: 1rem;
197 |
198 | background-color: #fff;
199 | color: #333;
200 | }
201 |
202 | .card h2 {
203 | font-size: 1.5rem;
204 | margin-top: 0;
205 | }
206 |
207 | .card .card-content {
208 | margin-top: 1rem;
209 | }
210 |
211 | .card .card-stats {
212 | display: flex;
213 | margin: 1rem 0;
214 | }
215 |
216 | .card .card-stat {
217 | display: flex;
218 | align-items: center;
219 | margin-right: 1rem;
220 | }
221 |
222 | .card .card-stat svg {
223 | margin-right: 0.5rem;
224 | }
225 |
226 | .card .card-stat span {
227 | font-size: 0.9rem;
228 | }
229 |
230 | .courses .card h2 {
231 | margin-bottom: 0;
232 | }
233 |
234 | @keyframes spin {
235 | to {
236 | transform: rotate(360deg);
237 | }
238 | }
239 |
--------------------------------------------------------------------------------
/examples/sample-app/app/layout.jsx:
--------------------------------------------------------------------------------
1 | import { Poppins } from 'next/font/google';
2 | import Header from './components/Header';
3 | import './globals.css';
4 |
5 | const poppins = Poppins({
6 | weight: ['400', '700'],
7 | subsets: ['latin'],
8 | });
9 |
10 | export const metadata = {
11 | title: 'Traversy Media',
12 | description: 'Web development tutorials and courses',
13 | keywords:
14 | 'web development, web design, javascript, react, node, angular, vue, html, css',
15 | };
16 |
17 | export default function RootLayout({ children }) {
18 | return (
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/examples/sample-app/app/loading.jsx:
--------------------------------------------------------------------------------
1 | const LoadingPage = () => {
2 | return (
3 |
6 | );
7 | };
8 | export default LoadingPage;
9 |
--------------------------------------------------------------------------------
/examples/sample-app/app/page.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState, useEffect } from 'react';
3 | import Link from 'next/link';
4 | import LoadingPage from './loading';
5 | import Courses from './components/Courses';
6 | import CourseSearch from './components/CourseSearch';
7 |
8 | const HomePage = () => {
9 | const [courses, setCourses] = useState([]);
10 | // const [loading, setLoading] = useState(true);
11 |
12 | useEffect(() => {
13 | const fetchCourses = async () => {
14 | const res = await fetch('/api/courses');
15 | const data = await res.json();
16 | setCourses(data);
17 | // setLoading(false);
18 | };
19 |
20 | fetchCourses();
21 | }, []);
22 |
23 | // if (loading) {
24 | // return ;
25 | // }
26 |
27 | return (
28 | <>
29 | Welcome To Traversy Meida
30 | setCourses(results)} />
31 |
32 | >
33 | );
34 | };
35 | export default HomePage;
36 |
--------------------------------------------------------------------------------
/examples/sample-app/instrumentation.ts:
--------------------------------------------------------------------------------
1 | export async function register() {
2 | //instrumentation only works on node runtime not edge
3 | if (process.env.NEXT_RUNTIME === "nodejs") {
4 | await import("./perfssr-otel");
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/examples/sample-app/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/examples/sample-app/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | //to get telemetry data, need to set instrumentationHook to true
5 | instrumentationHook: true,
6 | },
7 | distDir: "build",
8 | };
9 |
10 | module.exports = nextConfig;
11 |
--------------------------------------------------------------------------------
/examples/sample-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next_13_crash",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "perfssr": "node ./node_modules/perfssr/server.js & next dev"
11 | },
12 | "dependencies": {
13 | "@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
14 | "@opentelemetry/instrumentation": "^0.39.1",
15 | "@opentelemetry/instrumentation-http": "^0.39.1",
16 | "@opentelemetry/resources": "^1.13.0",
17 | "@opentelemetry/sdk-node": "^0.39.1",
18 | "@opentelemetry/sdk-trace-node": "^1.13.0",
19 | "@opentelemetry/semantic-conventions": "^1.13.0",
20 | "express": "^4.18.2",
21 | "next": "^13.4.4",
22 | "react": "18.2.0",
23 | "react-dom": "18.2.0",
24 | "react-icons": "^4.8.0",
25 | "uuid": "^9.0.0"
26 | },
27 | "devDependencies": {
28 | "eslint": "^8.41.0"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/examples/sample-app/perfssr-otel.ts:
--------------------------------------------------------------------------------
1 | import { NodeSDK } from "@opentelemetry/sdk-node";
2 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3 | import { Resource } from "@opentelemetry/resources";
4 | import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
5 | import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
6 |
7 | const sdk = new NodeSDK({
8 | //entity producing resource attributes
9 | //next already as otel built in, we are listening to their built-in instrumentation
10 | resource: new Resource({
11 | [SemanticResourceAttributes.SERVICE_NAME]: "next-app",
12 | }),
13 | //bundles all trace data into span objects
14 | spanProcessor: new SimpleSpanProcessor(
15 | new OTLPTraceExporter({
16 | //all traces exported to express server on port 4000
17 | url: `http://localhost:4000`,
18 | })
19 | ),
20 | });
21 |
22 | //start tracing
23 | sdk.start();
24 |
--------------------------------------------------------------------------------
/examples/sample-app/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/sample-app/public/screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/examples/sample-app/public/screen.png
--------------------------------------------------------------------------------
/examples/sample-app/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/sample-app/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/extension/assets/perfssr_favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/extension/assets/perfssr_favicon.png
--------------------------------------------------------------------------------
/extension/assets/perfssr_icon_128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/extension/assets/perfssr_icon_128.png
--------------------------------------------------------------------------------
/extension/assets/perfssr_icon_48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/extension/assets/perfssr_icon_48.png
--------------------------------------------------------------------------------
/extension/assets/perfssr_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/oslabs-beta/PerfSSR/5d1969f31eb577c717dcd92f7b76ea84d2df4aff/extension/assets/perfssr_logo.png
--------------------------------------------------------------------------------
/extension/background.ts:
--------------------------------------------------------------------------------
1 | import { FiberMsg } from './contentScript';
2 |
3 | interface Connection {
4 | postMessage: (msg: any) => void;
5 | }
6 |
7 | const connections : {[tabId: number]: Connection} = {};
8 | let currPort: chrome.runtime.Port | undefined;;
9 | const tabStatus: {[tabId: number]: string}= {};
10 | const responseSender = {};
11 | const messageQueue: FiberMsg[] = [];
12 |
13 |
14 | const sendMessageToDevTool = (msg: {}) => {
15 | if (currPort === undefined) {
16 | return;
17 | }
18 | chrome.runtime.sendMessage({ message: msg });
19 | };
20 |
21 | //this listener will fire on connection with devtool
22 | chrome.runtime.onConnect.addListener((port) => {
23 | currPort = port;
24 | //Listen to messages from dev tool
25 | const devToolsListener = (message: any, port: chrome.runtime.Port) => {
26 | // inject script
27 | chrome.scripting.executeScript({
28 | target: { tabId: message.tabId },
29 | func: injectScript,
30 | args: [chrome.runtime.getURL("/bundles/backend.bundle.js")],
31 | injectImmediately: true,
32 | });
33 |
34 | // check if established initial connection with dev tool
35 | if (message.name === "init" && message.tabId) {
36 | connections[message.tabId] = port;
37 | connections[message.tabId].postMessage("Connected!");
38 | }
39 | };
40 |
41 | // Listen to messages sent from the DevTools page
42 | port.onMessage.addListener(devToolsListener);
43 |
44 | // Disconnect event listener
45 | port.onDisconnect.addListener((port) => {
46 | port.onMessage.removeListener(devToolsListener);
47 |
48 | // on disconnect, will remove reference to dev tool instance
49 | for (const key in connections) {
50 | if (connections[key] === port) {
51 | delete connections[key];
52 | break;
53 | }
54 | }
55 | });
56 | });
57 |
58 | // Listener for messages from contentScript.ts
59 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
60 | //Send fiber instance to App.js of devtool
61 | if (message.type === "UPDATED_FIBER" || message.type === "FIBER_INSTANCE") {
62 | chrome.runtime.sendMessage(message);
63 | // Add the fiber tree to the queue so App.js can retieve the fiber tree message
64 | // after the app / frontend is rendered
65 | // without the queue, fiber tree will be received and lost before the frontend renders
66 | messageQueue.push(message);
67 | }
68 |
69 | if (message.type === "GET_MESSAGE_FROM_QUEUE") {
70 | const message = messageQueue.shift(); // Retrieve the first message from the queue
71 | sendResponse(message); // Send the message back to the component
72 | }
73 |
74 | // send metrics received from contentScript.js to devtools.js
75 | if (message.metricName && message.value) {
76 | chrome.runtime.sendMessage({
77 | metricName: message.metricName,
78 | value: message.value,
79 | });
80 | }
81 | });
82 |
83 | //event listener for on page refresh
84 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
85 | if (changeInfo.status === "complete") {
86 |
87 | //script will be injected on page refresh/load
88 | chrome.scripting.executeScript({
89 | target: { tabId: tabId },
90 | func: injectScript,
91 | args: [chrome.runtime.getURL("/bundles/backend.bundle.js")],
92 | injectImmediately: true,
93 | });
94 |
95 | // Send a message to the contentScript that new performance data is needed
96 | //chrome.tabs.sendMessage(tabId, {message: "TabUpdated"});
97 | }
98 | });
99 |
100 |
101 | //Inject script to grab RDT instance when the app is running
102 | const injectScript = (file: string): void => {
103 | try {
104 | const htmlBody = document.getElementsByTagName("body")[0];
105 | const script = document.createElement("script");
106 | script.setAttribute("type", "text/javascript");
107 | script.setAttribute("src", file);
108 | htmlBody.appendChild(script);
109 | } catch (error) {
110 | console.log("background error:", error.message);
111 | }
112 | };
--------------------------------------------------------------------------------
/extension/contentScript.ts:
--------------------------------------------------------------------------------
1 | export interface FiberMsg {
2 | type: string;
3 | payload: string;
4 | }
5 |
6 | interface MetricMsg {
7 | metricName: string;
8 | value: number;
9 | }
10 |
11 | interface Metric {
12 | name: string;
13 | value: number;
14 | }
15 |
16 | // Send message to background.js
17 | const sendMsgToBackground = (msg: MetricMsg | FiberMsg) => {
18 | chrome.runtime.sendMessage(msg);
19 | };
20 |
21 | //This listener only cares if the window is passing an instance of the fiber tree
22 | window.addEventListener("message", (msg) => {
23 | if (msg.data.type === "FIBER_INSTANCE" || msg.data.type === "UPDATED_FIBER") {
24 | const bgMsg: FiberMsg = {
25 | type: msg.data.type,
26 | payload: msg.data.payload,
27 | };
28 | sendMsgToBackground(bgMsg);
29 | }
30 | });
31 |
32 | // send metrics data to background.js
33 | function sendMetric(metric: Metric): void {
34 | sendMsgToBackground({
35 | metricName: metric.name,
36 | value: metric.value,
37 | });
38 | }
39 |
40 | let po: PerformanceObserver;
41 |
42 | interface LayoutShiftEntry extends PerformanceEntry {
43 | entryType: 'layout-shift';
44 | value: number;
45 | }
46 |
47 | interface FirstInputEntry extends PerformanceEntry {
48 | entryType: 'first-input';
49 | processingStart: number;
50 | }
51 |
52 | function initializePerformanceObserver() {
53 | const po = new PerformanceObserver((entryList) => {
54 | for (const entry of entryList.getEntries()) {
55 | if (
56 | entry.entryType === "paint" &&
57 | entry.name === "first-contentful-paint"
58 | ) {
59 | sendMetric({ name: "FCP", value: entry.startTime });
60 | }
61 | if (entry.entryType === "largest-contentful-paint") {
62 | sendMetric({ name: "LCP", value: entry.startTime });
63 | }
64 | if (entry.entryType === "layout-shift") {
65 | const layoutShiftEntry = entry as LayoutShiftEntry;
66 | sendMetric({ name: "CLS", value: layoutShiftEntry.value });
67 | }
68 | if (entry.entryType === "longtask") {
69 | const tbt = entry.duration - 50; // TBT formula\
70 | sendMetric({ name: "TBT", value: tbt });
71 | }
72 | if (entry.entryType === "first-input") {
73 | const firstInputEntry = entry as FirstInputEntry;
74 | sendMetric({
75 | name: "FID",
76 | value: firstInputEntry.processingStart - entry.startTime,
77 | });
78 | }
79 | }
80 | });
81 |
82 | po.observe({ type: "paint", buffered: true });
83 | po.observe({ type: "largest-contentful-paint", buffered: true });
84 | po.observe({ type: "layout-shift", buffered: true });
85 | po.observe({ type: "longtask", buffered: true });
86 | po.observe({ type: "first-input", buffered: true });
87 | }
88 |
89 | initializePerformanceObserver();
90 |
--------------------------------------------------------------------------------
/extension/devtools.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | PerfSSR!
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/extension/devtools.ts:
--------------------------------------------------------------------------------
1 | chrome.devtools.panels.create(
2 | "PerfSSR",
3 | null, // to add logo later
4 | "panel.html"
5 | );
6 |
7 | // Create a connection to the background service worker
8 | const backgroundPageConnection = chrome.runtime.connect();
9 |
10 | // Relay the tab ID to the background service worker
11 | backgroundPageConnection.postMessage({
12 | name: "init",
13 | tabId: chrome.devtools.inspectedWindow.tabId,
14 | });
--------------------------------------------------------------------------------
/extension/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "PerfSSR",
4 | "version": "1.0.1",
5 |
6 | "description": "A Next.js Performance Analytics Tool",
7 |
8 | "author": "PerfSSR team",
9 | "devtools_page": "devtools.html",
10 | "update_url": "https://path/to/updateInfo.xml",
11 | "version_name": "1.0 beta",
12 | "icons": {
13 | "16": "./assets/perfssr_favicon.png",
14 | "48": "./assets/perfssr_icon_48.png",
15 | "128": "./assets/perfssr_icon_128.png"
16 | },
17 | "background": {
18 | "service_worker": "bundles/background.bundle.js"
19 | },
20 |
21 | "permissions": ["tabs", "activeTab", "webRequest", "scripting"],
22 | "host_permissions": ["http://localhost/*"],
23 |
24 | "content_scripts": [
25 | {
26 | "matches": ["http://localhost:3000/*"],
27 | "js": ["bundles/contentScript.bundle.js"]
28 | }
29 | ],
30 |
31 | "web_accessible_resources": [
32 | {
33 | "resources": ["bundles/backend.bundle.js"],
34 | "matches": [""]
35 | }
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/extension/panel.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
10 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'jest-environment-jsdom',
4 | testMatch: ['**/__tests__/*.test.js'],
5 | transform: {
6 | '^.+\\.jsx?$': 'babel-jest',
7 | },
8 | moduleNameMapper: {
9 | '\\.(css|scss)$': '/testing-files/style.mock.js',
10 | },
11 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "perfssr",
3 | "version": "1.0.0",
4 | "description": "performance metrics for next.js",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "webpack serve --open --hot --env NODE_ENV=development",
8 | "build": "webpack --env NODE_ENV=production",
9 | "test": "jest"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "ISC",
14 | "dependencies": {
15 | "@emotion/react": "^11.11.0",
16 | "@emotion/styled": "^11.11.0",
17 | "@kurkle/color": "^0.3.2",
18 | "@mui/material": "^5.12.3",
19 | "chart.js": "^4.3.0",
20 | "chartjs-plugin-zoom": "^2.0.1",
21 | "css-loader": "^6.7.3",
22 | "react": "^18.2.0",
23 | "react-dom": "^18.2.0",
24 | "recharts": "^2.6.2",
25 | "style-loader": "^3.3.2",
26 | "ts-loader": "^9.4.2"
27 | },
28 | "devDependencies": {
29 | "@babel/core": "^7.21.8",
30 | "@babel/preset-env": "^7.22.2",
31 | "@babel/preset-react": "^7.18.6",
32 | "@babel/preset-typescript": "^7.21.5",
33 | "@testing-library/jest-dom": "^5.16.5",
34 | "@testing-library/react": "^14.0.0",
35 | "@types/chrome": "^0.0.236",
36 | "@types/jest": "^29.5.1",
37 | "@types/react": "^18.2.7",
38 | "babel-jest": "^29.5.0",
39 | "babel-loader": "^9.1.2",
40 | "copy-webpack-plugin": "^11.0.0",
41 | "html-webpack-plugin": "^5.5.1",
42 | "jest": "^29.5.0",
43 | "jest-environment-jsdom": "^29.5.0",
44 | "jsdom": "^22.0.0",
45 | "ts-jest": "^29.1.0",
46 | "webpack": "^5.82.0",
47 | "webpack-cli": "^5.1.0",
48 | "webpack-dev-server": "^4.15.0"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/perfssr-npm-package/README.md:
--------------------------------------------------------------------------------
1 | PerfSSR is a lightweight express server paired with our Chrome Developer Tool to listen to Next.js open-telemetry instrumentation.
2 |
--------------------------------------------------------------------------------
/perfssr-npm-package/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "perfssr",
3 | "version": "1.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "perfssr",
9 | "version": "1.0.0",
10 | "license": "MIT"
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/perfssr-npm-package/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "perfssr",
3 | "version": "1.0.0",
4 | "description": "Performance Analytics Tool for Next.js",
5 | "main": "server.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/oslabs-beta/perfssr.git"
12 | },
13 | "keywords": [
14 | "Nextjs",
15 | "PerfSSR"
16 | ],
17 | "author": "OSLabs Beta",
18 | "license": "MIT",
19 | "bugs": {
20 | "url": "https://github.com/oslabs-beta/perfssr/issues"
21 | },
22 | "homepage": "https://github.com/oslabs-beta/perfssr#readme"
23 | }
24 |
--------------------------------------------------------------------------------
/perfssr-npm-package/server.js:
--------------------------------------------------------------------------------
1 | //express configuration
2 | const express = require("express");
3 | const ws = require("ws");
4 | const app = express();
5 |
6 | app.use(express.json());
7 | app.use(express.urlencoded({ extended: true }));
8 |
9 | let client = null;
10 | // ws instance
11 | const wss = new ws.Server({ noServer: true });
12 |
13 | //controller that handles parsing of span data
14 | const spanController = require("./spanController");
15 |
16 | //at root, middleware will parse spans, set parsed data on res.locals.clientData
17 | app.use("/", spanController.parseTrace, (req, res) => {
18 | if (res.locals.clientData.length > 0 && client !== null) {
19 | client.send(JSON.stringify(res.locals.clientData));
20 | // // what to do after a connection is established
21 | res.sendStatus(200);
22 | }
23 | });
24 |
25 | //server listening on port 4000
26 | const server = app.listen(4000, () => {
27 | console.log(`PerfSSR server listening on port 4000`);
28 | });
29 |
30 | // handle upgrade of the request
31 | server.on("upgrade", function upgrade(request, socket, head) {
32 | try {
33 | // authentication and some other steps will come here
34 | // we can choose whether to upgrade or not
35 |
36 | wss.handleUpgrade(request, socket, head, function done(ws) {
37 | wss.emit("connection", ws, request);
38 | });
39 | } catch (err) {
40 | socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
41 | socket.destroy();
42 | return;
43 | }
44 | });
45 |
46 | wss.on("connection", (ctx) => {
47 | client = ctx;
48 | // print number of active connections
49 | console.log("connected", wss.clients.size);
50 |
51 | // handle close event
52 | ctx.on("close", () => {
53 | console.log("closed", wss.clients.size);
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/perfssr-npm-package/spanController.js:
--------------------------------------------------------------------------------
1 | const spanController = {};
2 |
3 | /**
4 | * parses spans and extracts necessary data that will be sent back to client/devtool
5 | * @param {*} span - Array that contains one span object, containing attributes with tracing information
6 | *
7 | * Data object to send to client/dashboard
8 | * - spanType: type of span either fetch or get route path
9 | * - parentSpanId: id of parent span, shared between Apprender.fetch and next.route
10 | * - spandId: id of span
11 | * - traceId: id of trace
12 | * - parentSpanId: id of parent span, shared between Apprender.fetch and next.route
13 | * - startTime: start time in unix converted to ms
14 | * - endTime: end time in unix converted to ms
15 | * - duration: time it took to run fetch and come back with response in ms
16 | * - url: original fetch url
17 | * - httpMethod: http method used (GET, POST, etc)
18 | */
19 |
20 | const parseFetchSpan = (span, clientArr) => {
21 |
22 | const spanObj = span[0];
23 | const dataObj = {
24 | spanType: "fetch",
25 | parentSpanId: spanObj.parentSpanId,
26 | spanId: spanObj.spanId,
27 | traceId: spanObj.traceId,
28 | startTime: spanObj.startTimeUnixNano / Math.pow(10, 6),
29 | endTime: spanObj.endTimeUnixNano / Math.pow(10, 6),
30 | duration:(spanObj.endTimeUnixNano - spanObj.startTimeUnixNano) / Math.pow(10, 6)
31 | ,
32 | url: spanObj.attributes.find((attr) => attr.key === "http.url")?.value
33 | ?.stringValue,
34 | httpMethod: spanObj.attributes.find((attr) => attr.key === "http.method")
35 | ?.value?.stringValue,
36 | };
37 |
38 | clientArr.push(dataObj);
39 | };
40 |
41 | /**
42 | * parses spans and extracts necessary data that will be sent back to client/devtool
43 | * @param {*} span - Array that contains one span object, containing attributes with tracing information
44 | *
45 | * Data object for routes
46 | * - spanType: type of span either fetch or get route path
47 | * - parentSpanId: id of parent span, shared between Apprender.fetch and next.route, undefined if span type of BaseServer.handleRequest
48 | * - spandId: id of span
49 | * - traceId: id of trace
50 | * - statusCode: http status code; undefined if span type of AppRender.getBodyResult
51 | * - startTime: start time in unix converted to ms
52 | * - endTime: end time in unix converted to ms
53 | * - duration: time it took to run fetch and come back with response in ms
54 | * - url: original fetch url
55 | * - httpMethod: http method used (GET, POST, etc)
56 | */
57 | const parseHandleRequest = (span, clientArr) => {
58 | const spanObj = span[0];
59 | const dataObj = {
60 | spanType: "route",
61 | parentSpanId: spanObj.parentSpanId,
62 | spanId: spanObj.spanId,
63 | traceId: spanObj.traceId,
64 | statusCode: spanObj.attributes.find(
65 | (attr) => attr.key === "http.status_code"
66 | )?.value?.intValue,
67 | startTime: spanObj.startTimeUnixNano / Math.pow(10, 6),
68 | endTime: spanObj.endTimeUnixNano / Math.pow(10, 6),
69 | duration: (spanObj.endTimeUnixNano - spanObj.startTimeUnixNano) / Math.pow(10, 6),
70 | route: spanObj.attributes.find(
71 | (attr) => attr.key === "http.target" || attr.key === "next.route"
72 | )?.value?.stringValue,
73 | };
74 | clientArr.push(dataObj);
75 | };
76 |
77 | /**
78 | *
79 | * @param {*} req - request object
80 | * @param {*} res - response object
81 | * @param {*} next - express next object
82 | * @returns - call to next middleware
83 | */
84 |
85 | spanController.parseTrace = (req, res, next) => {
86 | //grab the incoming spans from the request body
87 | const spans = req.body.resourceSpans[0].scopeSpans[0].spans;
88 |
89 | const spanType = spans[0].attributes.find(
90 | (attr) => attr.key === "next.span_type"
91 | )?.value?.stringValue;
92 | res.locals.clientData = [];
93 |
94 | //handle cases if span type is a route or a fetch
95 | switch (spanType) {
96 | case "AppRender.fetch":
97 | parseFetchSpan(spans, res.locals.clientData);
98 | break;
99 | case "BaseServer.handleRequest":
100 | if (
101 | spans[0].attributes.find(
102 | (attr) =>
103 | attr.key === "http.target" &&
104 | !attr.value.stringValue.includes("favicon")
105 | )
106 | )
107 | parseHandleRequest(spans, res.locals.clientData);
108 | break;
109 | case "AppRender.getBodyResult":
110 | parseHandleRequest(spans, res.locals.clientData);
111 | break;
112 | default:
113 | return next();
114 | }
115 |
116 | return next();
117 | };
118 |
119 | module.exports = spanController;
120 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, {
2 | useRef,
3 | useEffect,
4 | useCallback,
5 | useState,
6 | useMemo,
7 | } from "react";
8 | import useWebSocket from "./hooks/webSocketHook";
9 | import { createTheme, ThemeProvider } from "@mui/material/styles";
10 | import { Box } from "@mui/material";
11 | import MetricContainer from "./components/metricContainer";
12 | import ServerComponent from "./components/serverComponent";
13 | import ClientComponent from "./components/clientComponent";
14 | import BenchmarkTime from "./components/benchmarkTime";
15 | import "./style.css";
16 | import NetworkPanel from './components/NetworkPanel';
17 |
18 | function App() {
19 | const theme = useMemo(() =>
20 | createTheme({
21 | palette: {
22 | primary: {
23 | main: "#881dff",
24 | light: "#d9b6ff",
25 | },
26 | secondary: {
27 | main: "#46b7ff",
28 | light: "#55fffe",
29 | },
30 | background: {
31 | default: "#131219",
32 | paper: "#222233",
33 | },
34 | text: {
35 | primary: "#efecfd",
36 | },
37 | success: {
38 | main: "#47ff82",
39 | },
40 | warning: {
41 | main: "#e9f835",
42 | },
43 | error: {
44 | main: "#ff4b4b",
45 | },
46 | },
47 | })
48 | );
49 |
50 | const [metrics, setMetrics] = useState({});
51 | const [fiberTree, setFiberTree] = useState();
52 | const [messagesList, setMessagesList] = useState([]);
53 | const ws = useWebSocket({ socketUrl: "ws://localhost:4000" });
54 |
55 | useEffect(() => {
56 | const getMessageFromQueue = () => {
57 | // Initial fiber tree will be sent before App.js renders
58 | // so send a message to background.ts once App.js is done rendering to retrieve the fiber tree message
59 | chrome.runtime.sendMessage(
60 | { type: "GET_MESSAGE_FROM_QUEUE" },
61 | (message) => {
62 | if (message) {
63 | // Process the message retrieved from the queue
64 | setFiberTree(JSON.parse(message.payload));
65 | }
66 | }
67 | );
68 | };
69 | getMessageFromQueue();
70 | }, []);
71 |
72 | // receive messages from socket and set the messageList accoridngly
73 | useEffect(() => {
74 | if (ws.data) {
75 | const { message } = ws.data;
76 | setMessagesList((prevMessagesList) => {
77 | if (message.length > 0) return [].concat(message, prevMessagesList);
78 | })
79 | }
80 | }, [ws.data]);
81 |
82 | useEffect(() => {
83 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
84 | // listener to the message sent from background.ts
85 | // if the message is on metrics, set the metrics accordingly
86 | setMetrics((prevMetrics) => {
87 | return {
88 | ...prevMetrics,
89 | [message.metricName]: message.value,
90 | };
91 | });
92 |
93 | // if the message is on fiber tree, set the fiberTree accordingly
94 | setFiberTree((prevFiberTree) => {
95 | if (
96 | message.type === "UPDATED_FIBER" ||
97 | message.type === "FIBER_INSTANCE"
98 | ) {
99 | return JSON.parse(message.payload);
100 | }
101 | return prevFiberTree;
102 | });
103 | });
104 | }, []);
105 |
106 | const handleRefreshClick = () => {
107 | try {
108 | setMessagesList([]);
109 | chrome.devtools.inspectedWindow.reload(); // Refresh the inspected page
110 | } catch (error) {
111 | console.error("Error occurred while refreshing the page:", error);
112 | }
113 | };
114 |
115 | const handleClear = () => {
116 | setMessagesList([]);
117 | }
118 |
119 | let atBegin =
120 | Object.keys(metrics).length === 0 ? (
121 |
122 |
123 |
PerfSSR - Your Next.js Analysitcs Tool
124 |
125 | Start PerfSSR
126 |
127 |
128 | ) : (
129 |
130 |
131 | Regenerate Analytics for Current Page
132 |
133 |
Overall Performance Metrics
134 |
135 |
136 |
137 |
138 |
139 |
Server Side Components Rendering Times
140 |
144 |
Client Side Components Rendering Times
145 |
149 |
Components Rendering Time Benchmarking
150 |
156 |
Server-side Fetching Summary
157 |
158 | Clear network data
159 |
160 |
161 |
162 |
163 |
164 | );
165 |
166 | return {atBegin}
;
167 | }
168 |
169 | export default App;
170 |
--------------------------------------------------------------------------------
/src/components/NetworkPanel.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { styled } from '@mui/material/styles';
3 | import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts';
4 | import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper } from '@mui/material';
5 |
6 | const NetworkPanel = (props) => {
7 | const waterfall = props.chartData ? processData(props.chartData) : null;
8 |
9 | //function to process start/end times for waterfall data
10 | function processData(chartData) {
11 | //span data received from server is from most recent to oldest
12 | //need to reverse our array to display chart data chronologically
13 | chartData.sort((a, b) => a.startTime - b.startTime)
14 |
15 | //get earliest time and latest end time in our chartData array
16 | const minStartTime = Math.min(...chartData.map(item => item.startTime ? item.startTime: Number.POSITIVE_INFINITY));
17 | const maxEndTime = Math.max(...chartData.map(item => item.endTime ? item.endTime: Number.NEGATIVE_INFINITY));
18 |
19 | // find the min and max duration in the dataset
20 | const minDuration = Math.min(...chartData.map(item => item.endTime - item.startTime));
21 | const maxDuration = Math.max(...chartData.map(item => item.endTime - item.startTime));
22 |
23 | // function to normalize a time or duration
24 | const scale = (value, min, max) => (value - min) / (max - min);
25 |
26 | // Generate waterfall bar data for each span object we have
27 | const barData = chartData.map((item, index) => {
28 | const scaledStartTime = scale(item.startTime, minStartTime, maxEndTime);
29 | const scaledDuration = scale(item.endTime - item.startTime, minDuration, maxDuration);
30 | return {
31 | ...item,
32 | index: index,
33 | pv: scaledStartTime, //scaledStartTime, // pv is the floating part (transparent)
34 | uv: scaledDuration, //scaledEndTime - scaledStartTime // uv is the part of the graph we want to show
35 | }})
36 |
37 | //need maxEndTime to get the upperbound limit for waterfall chart
38 | const maxScaledEndTime = Math.max(...barData.map(item => item.uv + item.pv));
39 |
40 | const parent = barData.filter(item => !item.parentSpanId);
41 | // if no parent span present in the span array, just display all the span directly
42 | let toggle = true;
43 | if (parent.length !== 0) toggle = false;
44 |
45 | const StyledTableRow = styled(TableRow)(({ theme }) => ({
46 | '&:nth-of-type(odd)': {
47 | backgroundColor: '#353554',
48 | },
49 | // hide last border
50 | '&:last-child td, &:last-child th': {
51 | border: 0,
52 | },
53 | }));
54 |
55 | const renderRow = (data, i, indent) => {
56 | const children = barData.filter(child => data.spanId === child.parentSpanId);
57 | let padding = indent * 20;
58 |
59 | return (
60 |
61 |
62 | {data.route ? data.route : data.url}
63 | {data.httpMethod ? data.httpMethod : ""}
64 | {data.statusCode}
65 | {data.duration.toFixed(2)}
66 |
67 |
74 |
75 |
76 |
77 |
78 | |
79 |
80 |
81 |
82 |
83 | {children.map((child, j) => renderRow(child, j, indent+1))}
84 |
85 | )
86 | }
87 |
88 | return (
89 |
90 |
91 |
92 |
93 | Endpoint / URL
94 | Method
95 | Status
96 | Duration (ms)
97 | Waterfall
98 |
99 |
100 |
101 | {toggle ? barData.map((data, i) => renderRow(data, i)) : parent.map((data, i) => renderRow(data, i, 1))}
102 |
103 |
104 |
105 | );
106 | }
107 |
108 | return (
109 |
110 | {waterfall}
111 |
112 | )
113 | };
114 |
115 | export default NetworkPanel;
--------------------------------------------------------------------------------
/src/components/benchmarkTime.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from "react";
2 | import Chart from 'chart.js/auto'
3 |
4 | const BenchmarkTime = (props) => {
5 |
6 | const chartRef = useRef(null);
7 | const [chart, setChart] = useState(null);
8 | const [toggle, setToggle] = useState(false);
9 |
10 | const inputData = [];
11 | const notThese = [
12 | "InnerLayoutRouter", "RedirectBoundary", "NotFoundBoundary",
13 | "LoadingBoundary", "ErrorBoundary", "HotReload", "Router",
14 | "ServerRoot", "RSCComponent", "Root", "ThrottledComponent",
15 | "AppRouter", "OuterLayoutRouter", "RenderFromTemplateContext",
16 | "HistoryUpdater", "AppRouterAnnouncer", "ScrollAndFocusHandler"
17 | ];
18 |
19 | const convertTreeToChart = (tree) => {
20 |
21 | //function to perform breadth first search on fiber tree to get nodes
22 | const bfs = (...tree) => {
23 | const q = [...tree];
24 |
25 | while (q.length > 0) {
26 | const node = q.shift();
27 | // Only keep nodes that are funtion components and with a component name
28 | if ((node.tagObj.tag === 0 || node.tagObj.tag === 11) && node.componentName !== "" && !notThese.includes(node.componentName)) {
29 | if (node.renderDurationMS === 0)
30 | inputData.push({componentName: node.componentName,
31 | time: Number(node.selfBaseDuration * 100)
32 | })
33 | else inputData.push({componentName: node.componentName,
34 | time: Number(node.renderDurationMS * 100)
35 | })
36 | }
37 | if (node.children.length > 0) q.push(...node.children);
38 | }
39 | }
40 |
41 | bfs(tree);
42 | let avg = inputData.reduce((acc, curr) => acc + curr.time, 0) / inputData.length;
43 |
44 | const processBenchmarking = (t) => {
45 | let result;
46 | result = t < avg ? (avg/t) : -(t/avg)
47 | return result.toFixed(2);
48 | }
49 |
50 | inputData.forEach(e => {
51 | e.time = Number(processBenchmarking(e.time))
52 | });
53 |
54 | }
55 |
56 | useEffect(() => {
57 |
58 | if (props.chartData)
59 | convertTreeToChart(...props.chartData.root.children);
60 |
61 | if (inputData.length === 0) setToggle(false);
62 | else setToggle(true);
63 |
64 | if (!chartRef.current) {
65 | return;
66 | }
67 |
68 | // If there's an existing chart, destroy it before creating a new one
69 | if (chart) {
70 | chart.destroy();
71 | }
72 |
73 | const ctx = chartRef.current.getContext("2d");
74 |
75 | //configuration of data into readable chart data for component
76 | const newChart = new Chart(ctx, {
77 | type: 'bar',
78 | data: {
79 | labels: inputData.map((row) => row.componentName),
80 | datasets: [
81 | {
82 | label: props.label,
83 | data: inputData.map((row) => row.time),
84 | },
85 | ],
86 | },
87 | options: {
88 | indexAxis: 'y',
89 | // Elements options apply to all of the options unless overridden in a dataset
90 | // In this case, we are setting the border of each horizontal bar to be 2px wide
91 | elements: {
92 | bar: {
93 | borderWidth: 2,
94 | }
95 | },
96 | responsive: true,
97 | plugins: {
98 | legend: {
99 | position: 'top',
100 | },
101 | title: {
102 | display: false,
103 | }
104 | }
105 | },
106 | });
107 | setChart(newChart);
108 |
109 | // Clean up the previous chart instance when the component unmounts
110 | return () => {
111 | if (chart) {
112 | chart.destroy();
113 | }
114 | };
115 |
116 | }, [props.chartData]);
117 |
118 | const display = toggle
119 | ? ( )
120 | : (No valid components available
)
121 |
122 | return (
123 |
124 | {display}
125 |
126 | );
127 | };
128 |
129 | export default BenchmarkTime;
--------------------------------------------------------------------------------
/src/components/clientComponent.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from "react";
2 | import Chart from 'chart.js/auto';
3 | import zoomPlugin from 'chartjs-plugin-zoom';
4 | Chart.register(zoomPlugin);
5 | import "../style.css";
6 |
7 | const ClientComponent = (props) => {
8 |
9 | const chartRef = useRef(null);
10 | const [chart, setChart] = useState(null);
11 | const [toggle, setToggle] = useState(false);
12 |
13 | const inputData = [];
14 | // excluding built-in Next.js components
15 | const notThese = [
16 | "InnerLayoutRouter", "RedirectBoundary", "NotFoundBoundary",
17 | "LoadingBoundary", "ErrorBoundary", "HotReload", "Router",
18 | "ServerRoot", "RSCComponent", "Root", "ThrottledComponent",
19 | "OuterLayoutRouter", "RenderFromTemplateContext",
20 | "HistoryUpdater", "AppRouterAnnouncer", "ScrollAndFocusHandler"
21 | ];
22 |
23 | //function will take in a fiber tree and parse through all the nodes to generate an input array for charts
24 | const convertTreeToChart = (tree) => {
25 |
26 | //using breadth first search to get all the nodes from fiber tree
27 | const bfs = (...tree) => {
28 | const q = [...tree];
29 |
30 | while (q.length > 0) {
31 | const node = q.shift();
32 | // Only keep nodes that are funtion components and with a component name
33 | // client components also use Hooks
34 | if ((node.tagObj.tag === 0 || node.tagObj.tag === 11) && node.componentName !== "" && !notThese.includes(node.componentName) && node._debugHookTypes !== null) {
35 | if (node.renderDurationMS === 0)
36 | inputData.push({componentName: node.componentName,
37 | time: Number((node.selfBaseDuration * 100))
38 | })
39 | else inputData.push({componentName: node.componentName,
40 | time: Number((node.renderDurationMS * 100))
41 | })
42 | }
43 | if (node.children.length > 0) q.push(...node.children);
44 | }
45 | }
46 |
47 | bfs(tree);
48 | }
49 |
50 | useEffect(() => {
51 |
52 | if (props.chartData)
53 | convertTreeToChart(...props.chartData.root.children);
54 |
55 | //if we receive no valid components from fiber tree, then set toggle to false
56 | if (inputData.length === 0) setToggle(false);
57 | else setToggle(true);
58 |
59 | if (!chartRef.current) {
60 | return;
61 | }
62 |
63 | // If there's an existing chart, destroy it before creating a new one
64 | if (chart) {
65 | chart.destroy();
66 | }
67 |
68 | const scales = {
69 | x: {
70 | type: 'category'
71 | },
72 | y: {
73 | type: 'linear'
74 | },
75 | };
76 |
77 | const ctx = chartRef.current.getContext("2d");
78 |
79 | //define new chart
80 | const newChart = new Chart(ctx, {
81 | type: "bar",
82 | data: {
83 | labels: inputData.map((row) => row.componentName),
84 | datasets: [
85 | {
86 | label: props.label,
87 | data: inputData.map((row) => row.time),
88 | backgroundColor: '#ff6384',
89 | },
90 | ],
91 | },
92 | options: {
93 | scales: scales,
94 | plugins: {
95 | zoom: {
96 | pan: {
97 | enabled: true,
98 | mode: 'x',
99 | threshold: 5,
100 | },
101 | zoom: {
102 | wheel: {
103 | enabled: true
104 | },
105 | pinch: {
106 | enabled: true
107 | },
108 | mode: 'x',
109 | },
110 | }
111 | },
112 | }
113 | });
114 |
115 | setChart(newChart);
116 |
117 | // Clean up the previous chart instance when the component unmounts
118 | return () => {
119 | if (chart) {
120 | chart.destroy();
121 | }
122 | };
123 | }, [props.chartData]);
124 |
125 | //display charts conditionally depending if we have valid client components
126 | const display = toggle
127 | ? ( )
128 | : (No valid client-side components available
)
129 |
130 |
131 | return (
132 |
133 | {display}
134 |
135 | );
136 | };
137 |
138 | export default ClientComponent;
--------------------------------------------------------------------------------
/src/components/customTooltip.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { styled } from '@mui/material/styles';
3 | import Tooltip, { tooltipClasses } from '@mui/material/Tooltip';
4 |
5 | const CustomTooltip = styled(({ className, ...props }) => (
6 |
7 | ))(({ theme }) => ({
8 | [`& .${tooltipClasses.tooltip}`]: {
9 | backgroundColor: theme.palette.background.paper,
10 | color: theme.palette.text.primary,
11 | boxShadow: theme.shadows[1],
12 | fontSize: 11,
13 | },
14 | [`& .${tooltipClasses.arrow}`]: {
15 | color: theme.palette.background.paper,
16 | },
17 | }));
18 |
19 | export default CustomTooltip;
--------------------------------------------------------------------------------
/src/components/metric.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { CircularProgress, Typography, Box } from "@mui/material";
3 | import CustomTooltip from "./customTooltip";
4 | import '../style.css';
5 |
6 | const Metric = ({ name, value, handleClick, size, isActive, description }) => {
7 | const color = value >= 80 ? "success" : value >= 60 ? "warning" : "error";
8 |
9 | return (
10 |
16 | handleClick(name)}
19 | sx={{
20 | position: "relative",
21 | display: "inline-flex",
22 | flexDirection: "column",
23 | alignItems: "center",
24 | justifyContent: "center",
25 | cursor: "pointer",
26 | }}
27 | >
28 |
42 |
49 |
64 | {`${Math.round(value)}`}
65 |
66 |
67 |
74 |
75 | {name}
76 |
77 |
78 |
79 |
80 | )
81 | }
82 |
83 | Metric.defaultProps = {
84 | size: 70,
85 | isActive: false,
86 | description: "",
87 | };
88 |
89 | export default Metric;
--------------------------------------------------------------------------------
/src/components/metricContainer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Metric from "./metric";
3 | import { Box, Paper } from "@mui/material";
4 | import '../style.css';
5 |
6 | const MetricContainer = ({metrics}) => {
7 |
8 | // to process FCP data
9 | let FCP_score = 0;
10 | if (metrics.FCP < 500) FCP_score = Math.round(90 + ((500 - metrics.FCP) / (500 - 50)) * (10 - 0.01));
11 | else if (metrics.FCP < 1800) FCP_score = Math.round(90 - ((metrics.FCP - 500) / (1800 - 500)) * (90 - 80));
12 | else if (metrics.FCP < 3000) FCP_score = Math.round(80 - ((metrics.FCP - 1800) / (3000 - 1800)) * (80 - 60));
13 | else FCP_score = Math.round(60 * Math.exp(-0.003 * (metrics.LCP - 3000)));
14 |
15 | // to process LCP data
16 | let LCP_score = 0;
17 | if (metrics.LCP < 1000) LCP_score = Math.round(90 + ((1000 - metrics.LCP) / (1000 - 80)) * (10 - 0.01));
18 | else if (metrics.LCP < 2500) LCP_score = Math.round(90 - ((metrics.LCP - 1000) / (2500 - 1000)) * (90 - 80));
19 | else if (metrics.LCP < 4000) LCP_score = Math.round(80 - ((metrics.LCP - 2500) / (4000 - 2500)) * (80 - 60));
20 | else LCP_score = Math.round(60 * Math.exp(-0.005 * (metrics.LCP - 4000)));
21 |
22 | // to process CLS data
23 | let CLS_score = 0;
24 | if (metrics.CLS < 0.05) CLS_score = Math.round(90 + ((0.05 - metrics.CLS) / (0.05 - 0)) * (10 - 0.01));
25 | else if (metrics.CLS < 0.1) CLS_score = Math.round(90 - ((metrics.CLS - 0.05) / (0.1 - 0.05)) * (90 - 80));
26 | else if (metrics.CLS < 0.25) CLS_score = Math.round(80 - ((metrics.CLS - 0.1) / (0.25 - 0.1)) * (80 - 60));
27 | else CLS_score = Math.round(60 * Math.exp(-0.003 * (metrics.CLS - 0.25)));
28 | const clsShow = isNaN(metrics.CLS) ? 0 : CLS_score;
29 | const clsValue = isNaN(metrics.CLS) ? 0 : metrics.CLS.toFixed(2);
30 |
31 | // to process TBT data
32 | let TBT_score = 0;
33 | if (metrics.TBT < 100) TBT_score = Math.round(90 + ((100 - metrics.TBT) / (100 - 0)) * (10 - 0.01));
34 | else if (metrics.TBT < 200) TBT_score = Math.round(90 - ((metrics.TBT - 100) / (200 - 100)) * (90 - 80));
35 | else if (metrics.TBT < 600) TBT_score = Math.round(80 - ((metrics.TBT - 200) / (600 - 200)) * (80 - 60));
36 | else TBT_score = Math.round(60 * Math.exp(-0.003 * (metrics.TBT - 600)));
37 |
38 | // to process FID data
39 | let FID_score = 0;
40 | if (metrics.FID < 50) FID_score = Math.round(90 + ((50 - metrics.FID) / (50 - 0)) * (10 - 0.01));
41 | else if (metrics.FID < 100) FID_score = Math.round(90 - ((metrics.FID - 50) / (100 - 50)) * (90 - 80));
42 | else if (metrics.FID < 300) FID_score = Math.round(80 - ((metrics.FID - 100) / (300 - 100)) * (80 - 60));
43 | else FID_score = Math.round(60 * Math.exp(-0.003 * (metrics.FID - 300)));
44 | const fidShow = isNaN(metrics.FID) ? 0 : FID_score;
45 | const fidValue = isNaN(metrics.FID) ? 0 : metrics.FID;
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
58 |
64 |
70 |
77 |
84 |
85 |
86 |
87 |
88 | )
89 | }
90 |
91 | export default MetricContainer
--------------------------------------------------------------------------------
/src/components/serverComponent.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from "react";
2 | import Chart from 'chart.js/auto';
3 | import "../style.css";
4 | import zoomPlugin from 'chartjs-plugin-zoom';
5 | Chart.register(zoomPlugin);
6 |
7 | const ServerComponent = (props) => {
8 |
9 | const chartRef = useRef(null);
10 | const [chart, setChart] = useState(null);
11 | const [toggle, setToggle] = useState(false);
12 |
13 | const inputData = [];
14 | // excluding built-in Next.js components
15 | const notThese = [
16 | "InnerLayoutRouter", "RedirectBoundary", "NotFoundBoundary",
17 | "LoadingBoundary", "ErrorBoundary", "HotReload", "Router",
18 | "ServerRoot", "RSCComponent", "Root", "ThrottledComponent",
19 | "AppRouter"
20 | ];
21 |
22 | //function will take in a fiber tree and parse through all the nodes to generate an input array of components for charts
23 | const convertTreeToChart = (tree) => {
24 |
25 | //using breadth first search to get all the nodes from fiber tree
26 | const bfs = (...tree) => {
27 | const q = [...tree];
28 |
29 | while (q.length > 0) {
30 | const node = q.shift();
31 | // Only keep nodes that are funtion components and with a component name
32 | if ((node.tagObj.tag === 0 || node.tagObj.tag === 11) && node.componentName !== "" && !notThese.includes(node.componentName) && node._debugHookTypes === null) {
33 | if (node.renderDurationMS === 0)
34 | inputData.push({componentName: node.componentName,
35 | time: Number((node.selfBaseDuration * 100))
36 | })
37 | else inputData.push({componentName: node.componentName,
38 | time: Number((node.renderDurationMS * 100))
39 | })
40 | }
41 | if (node.children.length > 0) q.push(...node.children);
42 | }
43 | }
44 |
45 | bfs(tree);
46 | }
47 |
48 | useEffect(() => {
49 |
50 | if (props.chartData)
51 | convertTreeToChart(...props.chartData.root.children);
52 |
53 | if (inputData.length === 0) setToggle(false);
54 | else setToggle(true);
55 |
56 | if (!chartRef.current) {
57 | return;
58 | }
59 |
60 | // If there's an existing chart, destroy it before creating a new one
61 | if (chart) {
62 | chart.destroy();
63 | }
64 |
65 | const scales = {
66 | x: {
67 | type: 'category'
68 | },
69 | y: {
70 | type: 'linear'
71 | },
72 | };
73 |
74 | const ctx = chartRef.current.getContext("2d");
75 | const newChart = new Chart(ctx, {
76 | type: "bar",
77 | data: {
78 | labels: inputData.map((row) => row.componentName),
79 | datasets: [
80 | {
81 | label: props.label,
82 | data: inputData.map((row) => row.time),
83 | },
84 | ],
85 | },
86 | options: {
87 | scales: scales,
88 | plugins: {
89 | zoom: {
90 | pan: {
91 | enabled: true,
92 | mode: 'x',
93 | threshold: 5,
94 | },
95 | zoom: {
96 | wheel: {
97 | enabled: true
98 | },
99 | pinch: {
100 | enabled: true
101 | },
102 | mode: 'x',
103 | },
104 | }
105 | },
106 | }
107 | });
108 | setChart(newChart);
109 |
110 | // Clean up the previous chart instance when the component unmounts
111 | return () => {
112 | if (chart) {
113 | chart.destroy();
114 | }
115 | };
116 | }, [props.chartData]);
117 |
118 |
119 | const display = toggle
120 | ? ( )
121 | : (No valid server-side components available
)
122 |
123 | return (
124 |
125 | {display}
126 |
127 | );
128 | };
129 |
130 | export default ServerComponent;
--------------------------------------------------------------------------------
/src/hooks/webSocketHook.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | function useWebSocket({
4 | socketUrl,
5 | retry: defaultRetry = 3,
6 | retryInterval = 1500,
7 | }) {
8 | const [data, setData] = useState();
9 | // send function
10 | const [send, setSend] = useState(() => () => undefined);
11 | // state of our connection
12 | const [retry, setRetry] = useState(defaultRetry);
13 | // retry counter
14 | const [readyState, setReadyState] = useState(false);
15 |
16 | useEffect(() => {
17 | const ws = new WebSocket(socketUrl);
18 | ws.onopen = () => {
19 | console.log("Connected to socket");
20 | setReadyState(true);
21 |
22 | // function to send messages
23 | setSend(() => {
24 | return (data) => {
25 | try {
26 | const d = JSON.stringify(data);
27 | ws.send(d);
28 | return true;
29 | } catch (err) {
30 | return false;
31 | }
32 | };
33 | });
34 |
35 | // receive messages
36 | ws.onmessage = (event) => {
37 | const msg = formatMessage(event.data);
38 | setData({ message: msg, timestamp: getTimestamp() });
39 | };
40 | };
41 |
42 | // on close we should update connection state
43 | // and retry connection
44 | ws.onclose = () => {
45 | setReadyState(false);
46 | // retry logic
47 | if (retry > 0) {
48 | setTimeout(() => {
49 | setRetry((retry) => retry - 1);
50 | }, retryInterval);
51 | }
52 | };
53 | // terminate connection on unmount
54 | return () => {
55 | ws.close();
56 | };
57 | // retry dependency here triggers the connection attempt
58 | }, [retry]);
59 |
60 | return { send, data, readyState };
61 | }
62 |
63 | // small utilities that we need
64 | // handle json messages
65 | const formatMessage = (data) => {
66 | try {
67 | const parsed = JSON.parse(data);
68 | return parsed;
69 | } catch (err) {
70 | return data;
71 | }
72 | };
73 |
74 | // get epoch timestamp
75 | const getTimestamp = () => {
76 | return new Date().getTime();
77 | };
78 |
79 | export default useWebSocket;
80 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | PerfSSR
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from "react-dom/client";
3 | import App from './App.js';
4 |
5 | const root = ReactDOM.createRoot(document.getElementById('root'));
6 | root.render( );
7 | // ReactDOM.render( , document.getElementById('root'))
--------------------------------------------------------------------------------
/src/style.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Poppins:400,500,600,700&display=swap');
2 | html,body{
3 | display: grid;
4 | height: 100%;
5 | place-items: center;
6 | background: #131219;
7 | }
8 | .title{
9 | font-size: 20px;
10 | color: whitesmoke;
11 | font-family: 'Poppins',sans-serif;
12 | position: relative;
13 | margin-bottom: 40px;
14 | }
15 | /* Refresh Button */
16 | .container{
17 | text-align: center;
18 | }
19 | .refresh-button{
20 | font-family: 'Poppins',sans-serif;
21 | position: relative;
22 | height: 65px;
23 | width: 400px;
24 | margin: auto;
25 | margin-bottom: 40px;
26 | font-size: 15px;
27 | font-weight: 500;
28 | letter-spacing: 1px;
29 | border-radius: 5px;
30 | border: 1px solid transparent;
31 | outline: none;
32 | cursor: pointer;
33 | background: #0d0d0d;
34 | overflow: hidden;
35 | transition: 0.6s;
36 | color: #206592;
37 | border-color: #206592;
38 | }
39 | .refresh-button:before, refresh-button:after{
40 | position: absolute;
41 | content: '';
42 | left: 0;
43 | top: 0;
44 | height: 100%;
45 | filter: blur(30px);
46 | opacity: 0.4;
47 | transition: 0.6s;
48 | }
49 | .refresh-button:before{
50 | width: 60px;
51 | background: rgba(255,255,255,0.6);
52 | transform: translateX(-130px) skewX(-45deg);
53 | }
54 | .refresh-button:after{
55 | width: 30px;
56 | background: rgba(255,255,255,0.6);
57 | transform: translateX(-130px) skewX(-45deg);
58 | }
59 | .refresh-button:hover:before,
60 | .refresh-button:hover:after{
61 | opacity: 0.6;
62 | transform: translateX(320px) skewX(-45deg);
63 | }
64 | .refresh-button:hover{
65 | color: #f2f2f2;
66 | background: #206592;
67 | }
68 |
69 |
70 | /* Regenerate Button */
71 | .reg-button{
72 | font-family: 'Poppins',sans-serif;
73 | position: relative;
74 | height: 40px;
75 | width: 300px;
76 | margin: auto;
77 | font-size: 10px;
78 | font-weight: 500;
79 | letter-spacing: 1px;
80 | border-radius: 5px;
81 | border: 1px solid transparent;
82 | outline: none;
83 | cursor: pointer;
84 | background: #0d0d0d;
85 | overflow: hidden;
86 | transition: 0.6s;
87 | color: #ce5c0c;
88 | border-color: #ce5c0c;
89 | }
90 | .reg-button:before, refresh-button:after{
91 | position: absolute;
92 | content: '';
93 | left: 0;
94 | top: 0;
95 | height: 100%;
96 | filter: blur(30px);
97 | opacity: 0.4;
98 | transition: 0.6s;
99 | }
100 | .reg-button:before{
101 | width: 60px;
102 | background: rgba(255,255,255,0.6);
103 | transform: translateX(-130px) skewX(-45deg);
104 | }
105 | .reg-button:after{
106 | width: 30px;
107 | background: rgba(255,255,255,0.6);
108 | transform: translateX(-130px) skewX(-45deg);
109 | }
110 | .reg-button:hover:before,
111 | .reg-button:hover:after{
112 | opacity: 0.6;
113 | transform: translateX(320px) skewX(-45deg);
114 | }
115 | .reg-button:hover{
116 | color: #f2f2f2;
117 | background: #ce5c0c;
118 | }
119 |
120 | /* Overall Performance Metrics */
121 | #mainBox {
122 | display: flex;
123 | flex-direction: column;
124 | align-items: center;
125 | margin-top: 15px;
126 | position: relative;
127 | }
128 |
129 | #metric-container {
130 | display: flex;
131 | flex-direction: column;
132 | align-items: center;
133 | }
134 |
135 | .metric-container-inner {
136 | padding: 10px;
137 | min-width: 530px;
138 | }
139 |
140 | .metric {
141 | transition: all 0.2s ease-in-out;
142 | }
143 |
144 | .metric:hover {
145 | transform: scale(1.05);
146 | }
147 |
148 | #metricCircle {
149 | border-radius: 50%;
150 | }
151 |
152 |
153 | .chart-title{
154 | font-size: 15px;
155 | color: whitesmoke;
156 | font-family: 'Poppins',sans-serif;
157 | position: relative;
158 | text-align: center;
159 | margin-top: 40px;
160 | }
161 |
162 | .toggle-text{
163 | font-size: 10px;
164 | color: whitesmoke;
165 | font-family: 'Poppins',sans-serif;
166 | position: relative;
167 | text-align: center;
168 | margin-bottom: 40px;
169 | }
170 |
171 | .panel {
172 | font-size: 15px;
173 | color: whitesmoke;
174 | font-family: 'Poppins',sans-serif;
175 | }
176 |
177 | /* Size of charts */
178 | .chart-dimension {
179 | margin: auto;
180 | width: 80%;
181 | }
182 |
183 | /* Clear Network Button */
184 | .clear-button{
185 | font-family: 'Poppins',sans-serif;
186 | position: relative;
187 | height: 25px;
188 | width: 200px;
189 | margin: auto auto 10px auto;
190 | font-size: 12px;
191 | font-weight: 500;
192 | letter-spacing: 1px;
193 | border-radius: 5px;
194 | border: 1px solid transparent;
195 | outline: none;
196 | cursor: pointer;
197 | background: #0d0d0d;
198 | overflow: hidden;
199 | transition: 0.6s;
200 | color: #206592;
201 | border-color: #206592;
202 | }
203 | .clear-button:before, refresh-button:after{
204 | position: absolute;
205 | content: '';
206 | left: 0;
207 | top: 0;
208 | height: 100%;
209 | filter: blur(30px);
210 | opacity: 0.4;
211 | transition: 0.6s;
212 | }
213 | .clear-button:before{
214 | width: 60px;
215 | background: rgba(255,255,255,0.6);
216 | transform: translateX(-130px) skewX(-45deg);
217 | }
218 | .clear-button:after{
219 | width: 30px;
220 | background: rgba(255,255,255,0.6);
221 | transform: translateX(-130px) skewX(-45deg);
222 | }
223 | .clear-button:hover:before,
224 | .clear-button:hover:after{
225 | opacity: 0.6;
226 | transform: translateX(320px) skewX(-45deg);
227 | }
228 | .clear-button:hover{
229 | color: #f2f2f2;
230 | background: #206592;
231 | }
--------------------------------------------------------------------------------
/testing-files/FiberNode.js:
--------------------------------------------------------------------------------
1 | // a server-side component
2 | const subNode5 = {
3 | actualDuration: 0.39999985694885254,
4 | actualStartTime: 2888.99000999046326,
5 | alternate: null,
6 | child: null,
7 | childLanes: 0,
8 | deletions: null,
9 | dependencies: null,
10 | elementType: {$$typeof: 'Symbol(react.lazy)',
11 | _payload: {
12 | reason: null,
13 | status: "fullfilled",
14 | value: {
15 | length: 0,
16 | name: "A SSC"
17 | }
18 | }},
19 | flags: 0,
20 | index: 0,
21 | key: null,
22 | lanes: 0,
23 | memoizedProps: null,
24 | memoizedState: null,
25 | mode: 3,
26 | pendingProps: null,
27 | ref: null,
28 | refCleanup: null,
29 | return: null,
30 | selfBaseDuration: 0.09999990463256836,
31 | sibling: null,
32 | stateNode: null,
33 | subtreeFlags: 11536897,
34 | tag: 0,
35 | treeBaseDuration: 5.699999809265137,
36 | type: null,
37 | updateQueue: null,
38 | _debugHookTypes: null,
39 | _debugNeedsRemount: false,
40 | _debugOwner: null,
41 | _debugSource: null,
42 | }
43 |
44 | // a client-side component
45 | const subNode4 = {
46 | actualDuration: 0.6099999046325684,
47 | actualStartTime: 2888.49000999046326,
48 | alternate: null,
49 | child: null,
50 | childLanes: 0,
51 | deletions: null,
52 | dependencies: null,
53 | elementType: {length: 1,
54 | name: "A CSC",
55 | },
56 | flags: 8388608,
57 | index: 0,
58 | key: null,
59 | lanes: 0,
60 | memoizedProps: {},
61 | memoizedState: {baseQueue: null,
62 | baseState: "",
63 | memoizedState: ""},
64 | mode: 3,
65 | pendingProps: null,
66 | ref: null,
67 | refCleanup: null,
68 | return: null,
69 | selfBaseDuration: 0.09999990463256836,
70 | sibling: null,
71 | stateNode: null,
72 | subtreeFlags: 14682117,
73 | tag: 0,
74 | treeBaseDuration: 5.699999809265137,
75 | type: null,
76 | updateQueue: null,
77 | _debugHookTypes: ['useState', 'useState', 'useEffect'],
78 | _debugNeedsRemount: false,
79 | _debugOwner: null,
80 | _debugSource: null,
81 | }
82 |
83 | // an h1 element
84 | const subNode3 = {
85 | actualDuration: 0.5999999046325684,
86 | actualStartTime: 2887.99000999046326,
87 | alternate: null,
88 | child: subNode5,
89 | childLanes: 0,
90 | deletions: null,
91 | dependencies: null,
92 | elementType: "h1",
93 | flags: 0,
94 | index: 0,
95 | key: null,
96 | lanes: 0,
97 | memoizedProps: {children: 'Welcome To My Application'},
98 | memoizedState: null,
99 | mode: 3,
100 | pendingProps: {children: 'Welcome To My Application'},
101 | ref: null,
102 | refCleanup: null,
103 | return: null,
104 | selfBaseDuration: 0.09999990463256836,
105 | sibling: subNode4,
106 | stateNode: null,
107 | subtreeFlags: 0,
108 | tag: 5,
109 | treeBaseDuration: 5.699999809265137,
110 | type: null,
111 | updateQueue: null,
112 | _debugHookTypes: null,
113 | _debugNeedsRemount: false,
114 | _debugOwner: null,
115 | _debugSource: null,
116 | }
117 |
118 | const subNode2 = {
119 | actualDuration: 0.5999999046325684,
120 | actualStartTime: 2887.8999999046326,
121 | alternate: null,
122 | child: subNode3,
123 | childLanes: 0,
124 | deletions: null,
125 | dependencies: null,
126 | elementType: {$$typeof: 'Symbol(react.provider)',
127 | Consumer: "",
128 | Provider: "",
129 | disPlayName: "HeadManagerContext",
130 | _currentRenderer: null},
131 | flags: 0,
132 | index: 0,
133 | key: null,
134 | lanes: 0,
135 | memoizedProps: null,
136 | memoizedState: null,
137 | mode: 3,
138 | pendingProps: null,
139 | ref: null,
140 | refCleanup: null,
141 | return: null,
142 | selfBaseDuration: 0.09999990463256836,
143 | sibling: null,
144 | stateNode: null,
145 | subtreeFlags: 14682117,
146 | tag: 10,
147 | treeBaseDuration: 5.699999809265137,
148 | type: null,
149 | updateQueue: null,
150 | _debugHookTypes: null,
151 | _debugNeedsRemount: false,
152 | _debugOwner: null,
153 | _debugSource: null,
154 | }
155 |
156 | const subNode = {
157 | actualDuration: 0.5999999046325684,
158 | actualStartTime: 2887.6999999046326,
159 | alternate: null,
160 | child: subNode2,
161 | childLanes: 0,
162 | deletions: null,
163 | dependencies: null,
164 | elementType: {name: 'App'},
165 | flags: 0,
166 | index: 0,
167 | key: null,
168 | lanes: 0,
169 | memoizedProps: null,
170 | memoizedState: null,
171 | mode: 3,
172 | pendingProps: null,
173 | ref: null,
174 | refCleanup: null,
175 | return: null,
176 | selfBaseDuration: 0.09999990463256836,
177 | sibling: null,
178 | stateNode: null,
179 | subtreeFlags: 14682117,
180 | tag: 0,
181 | treeBaseDuration: 5.699999809265137,
182 | type: null,
183 | updateQueue: null,
184 | _debugHookTypes: null,
185 | _debugNeedsRemount: false,
186 | _debugOwner: null,
187 | _debugSource: null,
188 | }
189 |
190 |
191 | const fiberNode = {
192 | actualDuration: 0.5999999046325684,
193 | actualStartTime: 2887.5999999046326,
194 | alternate: null,
195 | child: subNode,
196 | childLanes: 0,
197 | deletions: null,
198 | dependencies: null,
199 | elementType: null,
200 | flags: 0,
201 | index: 0,
202 | key: null,
203 | lanes: 0,
204 | memoizedProps: null,
205 | memoizedState: null,
206 | mode: 3,
207 | pendingProps: null,
208 | ref: null,
209 | refCleanup: null,
210 | return: null,
211 | selfBaseDuration: 0.09999990463256836,
212 | sibling: null,
213 | stateNode: null,
214 | subtreeFlags: 14682117,
215 | tag: 3,
216 | treeBaseDuration: 5.699999809265137,
217 | type: null,
218 | updateQueue: null,
219 | _debugHookTypes: null,
220 | _debugNeedsRemount: false,
221 | _debugOwner: null,
222 | _debugSource: null,
223 | };
224 |
225 | module.exports = fiberNode;
--------------------------------------------------------------------------------
/testing-files/style.mock.js:
--------------------------------------------------------------------------------
1 | module.exports = {};
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "noImplicitAny": true,
5 | "module": "Commonjs",
6 | "target": "es6",
7 | "jsx": "react",
8 | "allowJs": true,
9 | "moduleResolution": "node",
10 | "esModuleInterop": true,
11 | },
12 | "include": ["src/**/*", "extension/*", "backend/*"],
13 | "exclude": ["node_modules", "**/*.spec.ts"]
14 | }
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const HTMLWebpackPlugin = require("html-webpack-plugin");
3 | const CopyPlugin = require("copy-webpack-plugin");
4 |
5 | module.exports = (env) => {
6 | return {
7 | entry: {
8 | index: path.resolve(__dirname, "./src/index.js"),
9 | backend: "./backend/index.ts",
10 | contentScript: "./extension/contentScript.ts",
11 | background: "./extension/background.ts",
12 | devtools: "./extension/devtools.ts",
13 | },
14 | output: {
15 | path: path.join(__dirname, "./dist/bundles"),
16 | publicPath: "/",
17 | filename: "[name].bundle.js",
18 | },
19 |
20 | mode: env.NODE_ENV,
21 |
22 | plugins: [
23 | new HTMLWebpackPlugin({
24 | template: "./src/index.html",
25 | }),
26 | new CopyPlugin({
27 | patterns: [
28 | {
29 | from: path.resolve(__dirname, "./extension"),
30 | to: path.resolve(__dirname, "./dist"),
31 | },
32 | ],
33 | }),
34 | ],
35 |
36 | module: {
37 | rules: [
38 | {
39 | test: /.(js|jsx)$/,
40 | exclude: /node_modules/,
41 | use: {
42 | loader: "babel-loader",
43 | options: {
44 | presets: ["@babel/preset-env", "@babel/preset-react"],
45 | },
46 | },
47 | },
48 | {
49 | test: /.(css|scss)$/,
50 | exclude: /node_modules/,
51 | use: ["style-loader", "css-loader"],
52 | },
53 | {
54 | test: /\.tsx?$/,
55 | use: 'ts-loader',
56 | exclude: /node_modules/,
57 | },
58 | ],
59 | },
60 | resolve: {
61 | // Enable importing JS / JSX files without specifying their extension
62 | extensions: ['.jsx', '.js', '.tsx', '.ts'],
63 | },
64 | };
65 | };
66 |
--------------------------------------------------------------------------------