├── .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 | PerfSSR 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 | ![perfssr](./assets/devtool-sample.gif?raw=true "Title") 10 | 11 | --- 12 | 13 | ## Tech Stack 14 | 15 | ![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) 16 | ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) 17 | ![Next.js](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white) 18 | ![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) 19 | ![NPM](https://img.shields.io/badge/npm-CB3837?style=for-the-badge&logo=npm&logoColor=white) 20 | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) 21 | ![Express](https://img.shields.io/badge/Express.js-000000?style=for-the-badge&logo=express&logoColor=white) 22 | ![GoogleChrome](https://img.shields.io/badge/Google_chrome-4285F4?style=for-the-badge&logo=Google-chrome&logoColor=white) 23 | ![MUI](https://img.shields.io/badge/Material%20UI-007FFF?style=for-the-badge&logo=mui&logoColor=white) 24 | ![Chart.js](https://img.shields.io/badge/Chart.js-FF6384?style=for-the-badge&logo=chartdotjs&logoColor=white) 25 | ![Webpack](https://img.shields.io/badge/webpack-%238DD6F9.svg?style=for-the-badge&logo=webpack&logoColor=black) 26 | ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white) 27 | ![Babel](https://img.shields.io/badge/Babel-F9DC3e?style=for-the-badge&logo=babel&logoColor=black) 28 | ![eslint](https://img.shields.io/badge/eslint-3A33D1?style=for-the-badge&logo=eslint&logoColor=white) 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 | ![fetchrepo](./assets/repo-fetching.png?raw=true "repo fetching") 43 | ![fetchstore](./assets/store-fetching.png?raw=true "store fetching") 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 |
    26 | {repos.map((repo) => ( 27 |
  • 28 | 29 |

    {repo.name}

    30 |

    {repo.description}

    31 |
    32 | 33 | {repo.stargazers_count} 34 | 35 | 36 | {repo.forks_count} 37 | 38 | 39 | {repo.watchers_count} 40 | 41 |
    42 | 43 |
  • 44 | ))} 45 |
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 |
17 | setQuery(e.target.value)} 23 | /> 24 | 27 |
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 |
6 |
7 |
8 | Traversy Media 9 |
10 |
11 | About 12 | Our Team 13 | Code 14 |
15 |
16 |
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 | 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 |
4 |
5 |
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 | 127 |
128 | ) : ( 129 |
130 | 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 | 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 | --------------------------------------------------------------------------------