From b61d9e99ab205aec8689add32e4b3cf92d326207 Mon Sep 17 00:00:00 2001 From: easy1090 Date: Mon, 27 Apr 2026 11:52:45 +0800 Subject: [PATCH] feat: support runtime profilings --- .gitignore | 2 + examples/rsbuild-minimal/rsbuild.config.ts | 13 +- examples/rsbuild-minimal/src/App.css | 132 ++++- examples/rsbuild-minimal/src/App.tsx | 197 ++++++- examples/rsbuild-minimal/src/mock/shop.ts | 60 +++ packages/client/package.json | 30 +- packages/client/rsbuild.config.ts | 18 +- packages/client/src/router.tsx | 5 + .../src/components/Alerts/bundle-alert.tsx | 58 +- .../src/components/Layout/menus.tsx | 15 + .../src/pages/RuntimePerf/constants.ts | 5 + .../src/pages/RuntimePerf/index.module.scss | 67 +++ .../src/pages/RuntimePerf/index.tsx | 509 ++++++++++++++++++ packages/components/src/pages/index.ts | 1 + packages/components/src/utils/request.test.ts | 25 + packages/components/src/utils/routes.ts | 4 + .../core/src/inner-plugins/plugins/index.ts | 1 + .../inner-plugins/plugins/runtimeVitals.ts | 62 +++ .../plugins/runtimeVitalsSnippet.ts | 129 +++++ .../core/src/inner-plugins/utils/config.ts | 2 + packages/rspack-plugin/src/plugin.ts | 14 +- packages/sdk/src/sdk/sdk/index.ts | 185 +++---- packages/sdk/src/sdk/server/apis/index.ts | 3 +- packages/sdk/src/sdk/server/apis/runtime.ts | 54 ++ packages/types/src/client.ts | 1 + packages/types/src/manifest.ts | 7 +- packages/types/src/plugin/plugin.ts | 33 +- packages/types/src/sdk/index.ts | 1 + packages/types/src/sdk/instance.ts | 8 +- packages/types/src/sdk/result.ts | 8 +- packages/types/src/sdk/runtime.ts | 61 +++ packages/types/src/sdk/server/apis/index.ts | 16 +- packages/utils/src/common/data/index.ts | 33 +- packages/utils/tests/common/data.test.ts | 55 ++ packages/webpack-plugin/src/plugin.ts | 12 +- pnpm-lock.yaml | 22 +- 36 files changed, 1603 insertions(+), 245 deletions(-) create mode 100644 examples/rsbuild-minimal/src/mock/shop.ts create mode 100644 packages/components/src/pages/RuntimePerf/constants.ts create mode 100644 packages/components/src/pages/RuntimePerf/index.module.scss create mode 100644 packages/components/src/pages/RuntimePerf/index.tsx create mode 100644 packages/core/src/inner-plugins/plugins/runtimeVitals.ts create mode 100644 packages/core/src/inner-plugins/plugins/runtimeVitalsSnippet.ts create mode 100644 packages/sdk/src/sdk/server/apis/runtime.ts create mode 100644 packages/types/src/sdk/runtime.ts diff --git a/.gitignore b/.gitignore index 453aaa913..88720ef75 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ log/ .cursor/ e2e/test-results/ .rsdoctor/ + +.worktrees/ diff --git a/examples/rsbuild-minimal/rsbuild.config.ts b/examples/rsbuild-minimal/rsbuild.config.ts index 7b4de88ed..e1f624700 100644 --- a/examples/rsbuild-minimal/rsbuild.config.ts +++ b/examples/rsbuild-minimal/rsbuild.config.ts @@ -17,12 +17,12 @@ export default defineConfig({ chain.plugin('Rsdoctor').use(RsdoctorRspackPlugin, [ { disableClientServer: !process.env.ENABLE_CLIENT_SERVER, - features: ['resolver', 'bundle', 'plugins', 'loader'], + features: ['resolver', 'bundle', 'plugins', 'loader', 'runtime'], output: { - mode: 'brief', - options: { - type: ['json', 'html'], - }, + // mode: 'brief', + // options: { + // type: ['json', 'html'], + // }, reportCodeType: { noCode: true, }, @@ -50,6 +50,9 @@ export default defineConfig({ ]); }, }, + server: { + port: 1111, + }, output: { minify: false, filenameHash: false, diff --git a/examples/rsbuild-minimal/src/App.css b/examples/rsbuild-minimal/src/App.css index 164c0a6aa..f8ba63129 100644 --- a/examples/rsbuild-minimal/src/App.css +++ b/examples/rsbuild-minimal/src/App.css @@ -5,22 +5,130 @@ body { background-image: linear-gradient(to bottom, #020917, #101725); } -.content { - display: flex; +.shell { min-height: 100vh; - line-height: 1.1; - text-align: center; + padding: 24px; + box-sizing: border-box; +} + +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.top-nav h1 { + margin: 0; + font-size: 22px; +} + +.top-nav nav { + display: flex; + gap: 12px; +} + +.top-nav a, +.actions a { + color: #8ac5ff; + text-decoration: none; +} + +.top-nav a:hover, +.actions a:hover { + text-decoration: underline; +} + +.page { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 20px; +} + +.page h2 { + margin-top: 0; + margin-bottom: 8px; +} + +.hint { + margin-top: 0; + color: rgba(255, 255, 255, 0.75); +} + +.grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); +} + +.card, +.detail, +.order-item { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 14px; +} + +.card h3, +.detail h3 { + margin-top: 0; +} + +.meta, +.actions, +.order-right { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.actions { + margin-top: 12px; +} + +.list { + display: flex; flex-direction: column; - justify-content: center; + gap: 10px; } -.content h1 { - font-size: 3.6rem; - font-weight: 700; +.order-item p { + margin: 6px 0 0; + color: rgba(255, 255, 255, 0.75); +} + +.status { + font-size: 12px; + border-radius: 999px; + padding: 2px 8px; + font-weight: 600; +} + +.status-pending { + color: #ffe08a; + background: rgba(255, 224, 138, 0.18); } -.content p { - font-size: 1.2rem; - font-weight: 400; - opacity: 0.5; +.status-paid { + color: #9af0b8; + background: rgba(154, 240, 184, 0.18); +} + +.status-shipped { + color: #8ac5ff; + background: rgba(138, 197, 255, 0.18); +} + +.primary-btn { + border: 0; + color: #041229; + font-weight: 700; + cursor: pointer; + border-radius: 8px; + padding: 10px 14px; + background: #8ac5ff; } diff --git a/examples/rsbuild-minimal/src/App.tsx b/examples/rsbuild-minimal/src/App.tsx index e8bf01734..9b7b4acd3 100644 --- a/examples/rsbuild-minimal/src/App.tsx +++ b/examples/rsbuild-minimal/src/App.tsx @@ -1,6 +1,36 @@ import './App.css'; import './semver'; import './semver7'; +import { useEffect, useMemo, useState } from 'react'; +import { mockOrders, mockProducts, Order } from './mock/shop'; + +type RouteState = + | { name: 'products' } + | { name: 'product'; id: string } + | { name: 'order' } + | { name: 'checkout' }; + +function parseHashRoute(hash: string): RouteState { + const normalized = hash.replace(/^#/, '') || '/products'; + const segments = normalized.split('/').filter(Boolean); + + if (segments[0] === 'product' && segments[1]) { + return { name: 'product', id: segments[1] }; + } + if (segments[0] === 'order') { + return { name: 'order' }; + } + if (segments[0] === 'checkout') { + return { name: 'checkout' }; + } + return { name: 'products' }; +} + +function StatusTag({ status }: { status: Order['status'] }) { + return ( + {status.toUpperCase()} + ); +} const App = () => { // Dynamically import shared.ts to make it an async chunk @@ -9,14 +39,167 @@ const App = () => { console.log('Shared module loaded as async chunk'); }); + const [route, setRoute] = useState( + parseHashRoute(window.location.hash), + ); + const [checkoutProductId, setCheckoutProductId] = useState( + mockProducts[0].id, + ); + + useEffect(() => { + if (!window.location.hash) { + window.location.hash = '/products'; + } + const onHashChange = () => { + setRoute(parseHashRoute(window.location.hash)); + }; + window.addEventListener('hashchange', onHashChange); + return () => { + window.removeEventListener('hashchange', onHashChange); + }; + }, []); + + const checkoutProduct = useMemo( + () => + mockProducts.find((item) => item.id === checkoutProductId) ?? + mockProducts[0], + [checkoutProductId], + ); + + const renderPage = () => { + if (route.name === 'products') { + return ( +
+

商品页

+

Demo mock 商品列表(点击查看详情或直接下单)

+
+ {mockProducts.map((item) => ( + + ))} +
+
+ ); + } + + if (route.name === 'product') { + const product = mockProducts.find((item) => item.id === route.id); + if (!product) { + return ( +
+

详情页

+

未找到商品:{route.id}

+ 返回商品页 +
+ ); + } + return ( +
+

详情页

+ +
+ ); + } + + if (route.name === 'order') { + return ( +
+

订单页

+

Demo mock 订单列表

+
+ {mockOrders.map((order) => ( +
+
+ {order.id} +

+ {order.itemCount} 件商品 | 创建时间: {order.createdAt} +

+
+
+ + ¥{order.total} +
+
+ ))} +
+
+ ); + } + + return ( +
+

下单页

+

Demo mock 结算信息

+
+

{checkoutProduct.name}

+
    +
  • 商品 ID: {checkoutProduct.id}
  • +
  • 单价: ¥{checkoutProduct.price}
  • +
  • 数量: 1
  • +
  • 合计: ¥{checkoutProduct.price}
  • +
+ +
+
+ ); + }; + return ( - <> -
-

Rsbuild with React

-

Start building amazing things with Rsbuild.

-
- - +
+
+

Rsbuild Minimal Mall Demo

+ +
+ {renderPage()} +
); }; diff --git a/examples/rsbuild-minimal/src/mock/shop.ts b/examples/rsbuild-minimal/src/mock/shop.ts new file mode 100644 index 000000000..942d1f5c2 --- /dev/null +++ b/examples/rsbuild-minimal/src/mock/shop.ts @@ -0,0 +1,60 @@ +export interface Product { + id: string; + name: string; + category: string; + price: number; + stock: number; + desc: string; +} + +export interface Order { + id: string; + status: 'pending' | 'paid' | 'shipped'; + total: number; + itemCount: number; + createdAt: string; +} + +export const mockProducts: Product[] = [ + { + id: 'sku-1001', + name: 'Air Runner', + category: 'Shoes', + price: 499, + stock: 23, + desc: 'Lightweight running shoes for daily training.', + }, + { + id: 'sku-1002', + name: 'Urban Backpack', + category: 'Bags', + price: 299, + stock: 52, + desc: 'Commuter backpack with 15-inch laptop compartment.', + }, + { + id: 'sku-1003', + name: 'Cloud Hoodie', + category: 'Apparel', + price: 199, + stock: 17, + desc: 'Soft fleece hoodie for all-season comfort.', + }, +]; + +export const mockOrders: Order[] = [ + { + id: 'ord-20260401-001', + status: 'paid', + total: 998, + itemCount: 2, + createdAt: '2026-04-01 10:20', + }, + { + id: 'ord-20260329-016', + status: 'shipped', + total: 299, + itemCount: 1, + createdAt: '2026-03-29 13:47', + }, +]; diff --git a/packages/client/package.json b/packages/client/package.json index 9e0c13b14..84baf3bfe 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@rsdoctor/client", - "version": "1.5.9", + "version": "1.5.2", "main": "dist/index.html", "repository": { "type": "git", @@ -15,30 +15,30 @@ "dev": "rsbuild dev", "start": "NODE_ENV=development rsbuild build -w", "build": "rsbuild build", - "build:analysis": "ENABLE_DEVTOOLS_PLUGIN=true DEVTOOLS_DEV=true rsbuild build", + "build:analysis": "ENABLE_DEVTOOLS_PLUGIN=true DEVTOOLS_DEV=true rsbuild dev", "preview": "rsbuild preview", "inspect": "rsbuild inspect" }, "devDependencies": { - "@rsbuild/core": "^1.7.5", + "@rsbuild/core": "^1.7.3", "@rsbuild/plugin-node-polyfill": "^1.4.4", - "@rsbuild/plugin-react": "^1.4.6", - "@rsbuild/plugin-sass": "^1.5.1", - "@rsbuild/plugin-type-check": "^1.3.4", + "@rsbuild/plugin-react": "^1.4.5", + "@rsbuild/plugin-sass": "^1.5.0", + "@rsbuild/plugin-type-check": "^1.3.3", "@rsdoctor/components": "workspace:*", "@rsdoctor/types": "workspace:*", - "@types/node": "catalog:", - "@types/react": "catalog:", - "@types/react-dom": "catalog:", - "antd": "catalog:", + "@types/node": "^22.8.1", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "antd": "5.19.1", "normalize.css": "8.0.1", - "react": "catalog:", - "react-dom": "catalog:", + "react": "18.3.1", + "react-dom": "18.3.1", "react-error-boundary": "^4.1.2", - "react-router-dom": "catalog:", - "sirv": "catalog:", + "react-router-dom": "6.30.3", + "sirv": "3.0.2", "source-map-loader": "^5.0.0", - "typescript": "catalog:" + "typescript": "^5.9.2" }, "publishConfig": { "access": "public", diff --git a/packages/client/rsbuild.config.ts b/packages/client/rsbuild.config.ts index ca7f356ca..c30239cf3 100644 --- a/packages/client/rsbuild.config.ts +++ b/packages/client/rsbuild.config.ts @@ -32,14 +32,7 @@ export default defineConfig(({ env }) => { pluginReact(), pluginNodePolyfill(), pluginSass(), - pluginTypeCheck({ - enable: IS_PRODUCTION, - tsCheckerOptions: { - typescript: { - mode: 'readonly', - }, - }, - }), + pluginTypeCheck({ enable: IS_PRODUCTION }), ], source: { @@ -127,7 +120,7 @@ export default defineConfig(({ env }) => { vender: { chunks: 'all', name: 'vender', - test: /node_modules\/(acorn|lodash|i18next|socket.io-*|remark-*)/, + test: /node_modules\/(acorn|lodash|i18next|socket.io-*|axios|remark-*)/, maxSize: 1000000, minSize: 200000, }, @@ -174,6 +167,13 @@ export default defineConfig(({ env }) => { chainConfig.plugin('rsdoctor').use(RsdoctorRspackPlugin, [ { disableClientServer: !ENABLE_CLIENT_SERVER, + features: ['runtime', 'bundle', 'loader', 'plugins'], + output: { + mode: 'brief', + options: { + type: ['json'], + }, + }, port: 9988, linter: { rules: { diff --git a/packages/client/src/router.tsx b/packages/client/src/router.tsx index 3af70cca2..2dcf9e700 100644 --- a/packages/client/src/router.tsx +++ b/packages/client/src/router.tsx @@ -11,6 +11,7 @@ import { TreeShaking, BundleDiff, Uploader, + RuntimePerf, } from '@rsdoctor/components/pages'; export default function Router(): React.ReactElement { @@ -43,6 +44,10 @@ export default function Router(): React.ReactElement { path: TreeShaking.route, element: , }, + { + path: RuntimePerf.route, + element: , + }, ].filter((e) => Boolean(e)) as { path: string; element: JSX.Element }[]; return ( diff --git a/packages/components/src/components/Alerts/bundle-alert.tsx b/packages/components/src/components/Alerts/bundle-alert.tsx index 0d268fd34..275e5f35c 100644 --- a/packages/components/src/components/Alerts/bundle-alert.tsx +++ b/packages/components/src/components/Alerts/bundle-alert.tsx @@ -15,9 +15,6 @@ import styles from './bundle-alert.module.scss'; import { CSSProperties, useState } from 'react'; import { CrossChunksAlertCollapse } from './collapse-cross-chunks'; import { ModuleMixedChunksAlertCollapse } from './collapse-module-mixed-chunks'; -import { SideEffectsOnlyImportsAlertCollapse } from './collapse-side-effects-only-imports'; -import { CjsRequireAlertCollapse } from './collapse-cjs-require'; -import { EsmResolvedToCjsAlertCollapse } from './collapse-esm-cjs'; interface BundleAlertProps { title: string; @@ -34,19 +31,7 @@ export const BundleAlert: React.FC = ({ dataSource, extraData, }) => { - const firstKeyWithData = - [ - 'E1001', - 'E1002', - 'E1003', - 'E1004', - 'E1005', - 'E1006', - 'E1007', - 'E1008', - 'E1009', - ].find((code) => dataSource.some((d) => d.code === code)) ?? 'E1001'; - const [activeKey, setActiveKey] = useState(firstKeyWithData); + const [activeKey, setActiveKey] = useState('E1001'); const tabData: Array<{ key: string; label: string; @@ -82,32 +67,13 @@ export const BundleAlert: React.FC = ({ label: 'Module Mixed Chunks', data: [], }, - { - key: 'E1007', - label: 'Tree Shaking Side Effects Only', - data: [], - }, - { - key: 'E1008', - label: 'CJS Require Cannot Tree-Shake', - data: [], - }, - { - key: 'E1009', - label: 'ESM Import Resolved to CJS', - data: [], - }, ]; - + console.log('datasource:::', dataSource); dataSource.forEach((data) => { const target = tabData.find((td) => td.key === data.code)?.data; target?.push(data); }); - tabData.sort( - (a, b) => (b.data.length > 0 ? 1 : 0) - (a.data.length > 0 ? 1 : 0), - ); - const tabItems = tabData.map((td) => { const tagStyle = activeKey === td.key @@ -179,24 +145,6 @@ export const BundleAlert: React.FC = ({ /> ); break; - case 'E1007': - children = ( - - ); - break; - case 'E1008': - children = ( - - ); - break; - case 'E1009': - children = ( - - ); - break; default: children = null; break; @@ -261,7 +209,7 @@ export const BundleAlert: React.FC = ({ onChange={setActiveKey} tabBarGutter={10} type="card" - defaultActiveKey={tabData[0]?.key ?? 'E1001'} + defaultActiveKey="E1001" items={tabItems} /> )} diff --git a/packages/components/src/components/Layout/menus.tsx b/packages/components/src/components/Layout/menus.tsx index fbead6bd6..df6952743 100644 --- a/packages/components/src/components/Layout/menus.tsx +++ b/packages/components/src/components/Layout/menus.tsx @@ -4,6 +4,7 @@ import { FundFilled, ApiFilled, NodeIndexOutlined, + DashboardOutlined, } from '@ant-design/icons'; import { Manifest, SDK } from '@rsdoctor/types'; import { Menu, MenuProps } from 'antd'; @@ -15,6 +16,7 @@ import { useI18n, hasBundle, hasCompile, + hasRuntime, getEnableRoutesFromUrlQuery, } from '../../utils'; import { withServerAPI } from '../Manifest'; @@ -30,6 +32,7 @@ import { PluginsAnalyze, ModuleResolve, LoaderTimeline, + RuntimePerf, } from 'src/pages'; import { CompileName } from './constants'; @@ -143,6 +146,18 @@ const MenusBase: React.FC<{ }); } + if (hasRuntime(enableRoutes)) { + items.push({ + label: t(RuntimePerf.name), + key: RuntimePerf.route, + icon: , + children: [], + onTitleClick(e) { + navigate(e.key); + }, + }); + } + const MenuComponent = ( = ({ + vitals, +}) => { + if (!vitals || vitals.length === 0) { + return ( + + + + ); + } + + // Deduplicate by name, keep latest + const latestVitals = new Map(); + for (const v of vitals) { + const existing = latestVitals.get(v.name); + if (!existing || v.timestamp > existing.timestamp) { + latestVitals.set(v.name, v); + } + } + + const orderedNames = ['TTFB', 'FCP', 'LCP', 'CLS', 'INP'] as const; + const ordered = orderedNames + .map((name) => latestVitals.get(name)) + .filter(Boolean) as SDK.WebVitalMetric[]; + + return ( + + + {ordered.map((vital) => ( + + + {vital.rating} + } + /> + + + ))} + + + ); +}; + +/** Resource timeline waterfall visualization */ +const ResourceTimeline: React.FC<{ + timings: SDK.ResourceTimingData[]; +}> = ({ timings }) => { + if (!timings || timings.length === 0) return null; + + const maxTime = Math.max( + ...timings.map((t) => t.responseEnd || t.startTime + t.duration), + ); + const barColors = { + dns: '#8884d8', + connect: '#82ca9d', + request: '#ffc658', + response: '#ff7c43', + }; + + return ( +
+
+
+ 0ms + {formatMs(maxTime / 4)} + {formatMs(maxTime / 2)} + {formatMs((maxTime * 3) / 4)} + {formatMs(maxTime)} +
+ {timings.slice(0, 50).map((timing, idx) => { + const left = maxTime > 0 ? (timing.startTime / maxTime) * 100 : 0; + const width = + maxTime > 0 + ? Math.max((timing.duration / maxTime) * 100, 0.5) + : 0.5; + + // Breakdown segments + const dnsWidth = timing.domainLookupEnd - timing.domainLookupStart; + const connectWidth = timing.connectEnd - timing.connectStart; + const requestWidth = timing.responseStart - timing.requestStart; + const responseWidth = timing.responseEnd - timing.responseStart; + + return ( + +
+ {getFileName(timing.name)} +
+
Start: {formatMs(timing.startTime)}
+
Duration: {formatMs(timing.duration)}
+
+ Size: {formatBytes(timing.transferSize)} + {timing.fromCache ? ' (cached)' : ''} +
+ {dnsWidth > 0 &&
DNS: {formatMs(dnsWidth)}
} + {connectWidth > 0 && ( +
Connect: {formatMs(connectWidth)}
+ )} + {requestWidth > 0 && ( +
Request→TTFB: {formatMs(requestWidth)}
+ )} + {responseWidth > 0 && ( +
Response: {formatMs(responseWidth)}
+ )} +
Protocol: {timing.nextHopProtocol || 'N/A'}
+
+ } + > +
+
+ + {timing.initiatorType === 'script' ? 'JS' : 'CSS'} + + {getFileName(timing.name)} +
+
+
+
+
+ + ); + })} +
+ + + DNS + + + + Connect + + + + Request + + + + Response + + + + Cached + +
+
+
+ ); +}; + +/** Resource Timings table section */ +const ResourceTimingsSection: React.FC<{ + timings: SDK.ResourceTimingData[]; +}> = ({ timings }) => { + if (!timings || timings.length === 0) { + return ( + + + + ); + } + + const columns: ColumnsType = [ + { + title: 'Resource', + dataIndex: 'name', + key: 'name', + width: 300, + render: (url: string, record) => ( + +
+ + {record.initiatorType === 'script' ? 'JS' : 'CSS'} + + {getFileName(url)} +
+
+ ), + sorter: (a, b) => getFileName(a.name).localeCompare(getFileName(b.name)), + }, + { + title: 'Start', + dataIndex: 'startTime', + key: 'startTime', + width: 100, + render: (v: number) => formatMs(v), + sorter: (a, b) => a.startTime - b.startTime, + defaultSortOrder: 'ascend', + }, + { + title: 'Duration', + dataIndex: 'duration', + key: 'duration', + width: 100, + render: (v: number) => ( + 1000 ? 'danger' : v > 300 ? 'warning' : undefined}> + {formatMs(v)} + + ), + sorter: (a, b) => a.duration - b.duration, + }, + { + title: 'Transfer Size', + dataIndex: 'transferSize', + key: 'transferSize', + width: 120, + render: (v: number, record) => ( + + {formatBytes(v)} + {record.fromCache && ( + + cached + + )} + + ), + sorter: (a, b) => a.transferSize - b.transferSize, + }, + { + title: 'Decoded Size', + dataIndex: 'decodedBodySize', + key: 'decodedBodySize', + width: 120, + render: (v: number) => formatBytes(v), + sorter: (a, b) => a.decodedBodySize - b.decodedBodySize, + }, + { + title: 'TTFB', + key: 'ttfb', + width: 100, + render: (_: unknown, record: SDK.ResourceTimingData) => { + const ttfb = record.responseStart - record.requestStart; + return ttfb > 0 ? formatMs(ttfb) : '-'; + }, + sorter: (a, b) => + a.responseStart - a.requestStart - (b.responseStart - b.requestStart), + }, + { + title: 'Protocol', + dataIndex: 'nextHopProtocol', + key: 'protocol', + width: 90, + render: (v: string) => v || '-', + }, + ]; + + // Summary stats + const totalTransfer = timings.reduce((sum, t) => sum + t.transferSize, 0); + const cachedCount = timings.filter((t) => t.fromCache).length; + const jsTimings = timings.filter((t) => t.initiatorType === 'script'); + const cssTimings = timings.filter( + (t) => t.initiatorType === 'link' || t.initiatorType === 'css', + ); + const avgDuration = + timings.length > 0 + ? timings.reduce((sum, t) => sum + t.duration, 0) / timings.length + : 0; + const maxDuration = Math.max(...timings.map((t) => t.duration), 0); + + return ( + + + + + ({jsTimings.length} JS / {cssTimings.length} CSS) + + } + /> + + + + + + + + + + + + + Waterfall Timeline + + + + Detailed Timing + {maxDuration > 1000 && ( + <Tag color="red" style={{ marginLeft: 8 }}> + Slow resource detected ({formatMs(maxDuration)}) + </Tag> + )} + + + columns={columns} + dataSource={timings} + rowKey={(record) => `${record.name}-${record.startTime}`} + pagination={timings.length > 20 ? { pageSize: 20 } : false} + size="small" + scroll={{ x: 900 }} + /> + + ); +}; + +/** Main RuntimePerf page component */ +const RuntimePerfContent: React.FC<{ + data: SDK.RuntimePerfData; +}> = ({ data }) => { + return ( +
+ Runtime Performance + + Performance Timeline data collected from the browser at runtime. + {data.url && ( + <> + {' '} + Page: {data.url} + + )} + + + + +
+ ); +}; + +const Component: React.FC = () => { + return ( + + {(response) => { + if (!response) { + return ; + } + return ; + }} + + ); +}; + +export const Page = Component; + +export * from './constants'; diff --git a/packages/components/src/pages/index.ts b/packages/components/src/pages/index.ts index 24193a1c5..4ce92c411 100644 --- a/packages/components/src/pages/index.ts +++ b/packages/components/src/pages/index.ts @@ -9,3 +9,4 @@ export * as RuleIndex from './Resources/RuleIndex'; export * as TreeShaking from './TreeShaking'; export * as BundleDiff from './Resources/BundleDiff'; export * as Uploader from './Uploader'; +export * as RuntimePerf from './RuntimePerf'; diff --git a/packages/components/src/utils/request.test.ts b/packages/components/src/utils/request.test.ts index dff02b2e2..f13834673 100644 --- a/packages/components/src/utils/request.test.ts +++ b/packages/components/src/utils/request.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, rs } from '@rstest/core'; +import { SDK } from '@rsdoctor/types'; import { fetchJSONByUrl, postServerAPI } from './request'; describe('request utils', () => { @@ -58,4 +59,28 @@ describe('request utils', () => { }); expect(result).toStrictEqual({ ok: true }); }); + + it('postServerAPI() accepts typed request bodies', async () => { + const fetchMock = rs.fn().mockResolvedValue({ + ok: true, + json: async () => 'success', + }); + globalThis.fetch = fetchMock as typeof fetch; + process.env.NODE_ENV = 'production'; + + const result = await postServerAPI(SDK.ServerAPI.API.ApplyErrorFix, { + id: 1, + }); + const [url, init] = fetchMock.mock.calls[0]; + + expect(url).toContain('/api/apply/error/fix?_t='); + expect(init).toMatchObject({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: 1 }), + }); + expect(result).toBe('success'); + }); }); diff --git a/packages/components/src/utils/routes.ts b/packages/components/src/utils/routes.ts index ad1a3e8a5..1a32b6e1d 100644 --- a/packages/components/src/utils/routes.ts +++ b/packages/components/src/utils/routes.ts @@ -15,3 +15,7 @@ export function hasBundle(routes: Manifest.RsdoctorManifestClientRoutes[]) { routes.includes(Manifest.RsdoctorManifestClientRoutes.TreeShaking); return hasBundle; } + +export function hasRuntime(routes: Manifest.RsdoctorManifestClientRoutes[]) { + return routes.includes(Manifest.RsdoctorManifestClientRoutes.RuntimePerf); +} diff --git a/packages/core/src/inner-plugins/plugins/index.ts b/packages/core/src/inner-plugins/plugins/index.ts index 7ac86c637..35f22b40c 100644 --- a/packages/core/src/inner-plugins/plugins/index.ts +++ b/packages/core/src/inner-plugins/plugins/index.ts @@ -9,3 +9,4 @@ export * from './ensureModulesChunkGraph'; export * from './rules'; export * from './bundleTagPlugin'; export * from './resolver'; +export * from './runtimeVitals'; diff --git a/packages/core/src/inner-plugins/plugins/runtimeVitals.ts b/packages/core/src/inner-plugins/plugins/runtimeVitals.ts new file mode 100644 index 000000000..73ed9fbfa --- /dev/null +++ b/packages/core/src/inner-plugins/plugins/runtimeVitals.ts @@ -0,0 +1,62 @@ +import { Manifest, Plugin } from '@rsdoctor/types'; +import { InternalBasePlugin } from './base'; +import { createVitalsSnippet } from './runtimeVitalsSnippet'; +import { time, timeEnd } from '@rsdoctor/utils/logger'; + +export class InternalRuntimeVitalsPlugin< + T extends Plugin.BaseCompiler, +> extends InternalBasePlugin { + public readonly name = 'runtimeVitals'; + + public apply(compiler: Plugin.BaseCompiler) { + time('InternalRuntimeVitalsPlugin.apply'); + try { + const reportUrl = `${this.sdk.server.origin}${'/api/runtime/vitals/report'}`; + + compiler.hooks.compilation.tap( + this.tapPostOptions, + (compilation: Plugin.BaseCompilation) => { + if (!compilation.hooks.processAssets) return; + + compilation.hooks.processAssets.tap( + { + name: 'RsdoctorRuntimeVitals', + stage: 100, + }, + () => { + const snippet = createVitalsSnippet(reportUrl); + const { ConcatSource } = compiler.webpack.sources; + + for (const chunk of compilation.chunks) { + if (!chunk.canBeInitial()) continue; + + for (const file of chunk.files) { + if (!file.endsWith('.js')) continue; + + compilation.updateAsset( + file, + // @ts-ignore - webpack/rspack type compatibility issue + (old: unknown) => { + const source = new ConcatSource(); + source.add(old as any); + source.add(snippet); + return source; + }, + () => {}, + ); + break; + } + } + }, + ); + }, + ); + + this.sdk.addClientRoutes([ + Manifest.RsdoctorManifestClientRoutes.RuntimePerf, + ]); + } finally { + timeEnd('InternalRuntimeVitalsPlugin.apply'); + } + } +} diff --git a/packages/core/src/inner-plugins/plugins/runtimeVitalsSnippet.ts b/packages/core/src/inner-plugins/plugins/runtimeVitalsSnippet.ts new file mode 100644 index 000000000..a285204df --- /dev/null +++ b/packages/core/src/inner-plugins/plugins/runtimeVitalsSnippet.ts @@ -0,0 +1,129 @@ +/** + * Generate a self-executing script string that dynamically loads + * web-vitals from CDN and reports metrics via fetch to the given URL. + * Also collects Performance Resource Timing data for JS/CSS chunk resources. + */ +export function createVitalsSnippet(reportUrl: string): string { + const resourceTimingsReportUrl = reportUrl.replace( + '/vitals/report', + '/resource-timings/report', + ); + return ` +;(function(){ +var REPORT_URL=${JSON.stringify(reportUrl)}; +var RT_REPORT_URL=${JSON.stringify(resourceTimingsReportUrl)}; +var s=document.createElement('script'); +s.src='https://unpkg.com/web-vitals@4/dist/web-vitals.iife.js'; +s.onload=function(){ + var wv=window.webVitals; + if(!wv)return; + var report=function(m){ + try{ + var d=JSON.stringify({ + name:m.name,value:m.value,rating:m.rating,delta:m.delta, + id:m.id,navigationType:m.navigationType, + entries:m.entries.map(function(e){return typeof e.toJSON==='function'?e.toJSON():e}), + timestamp:Date.now(),url:location.href,userAgent:navigator.userAgent + }); + fetch(REPORT_URL,{method:'POST',body:d,headers:{'Content-Type':'application/json'},keepalive:true}); + }catch(e){} + }; + wv.onLCP(report);wv.onFCP(report);wv.onCLS(report);wv.onINP(report);wv.onTTFB(report); +}; +document.head.appendChild(s); + +/* Collect Performance Resource Timing for JS/CSS chunk resources */ +function collectResourceTimings(){ + try{ + if(!window.performance||!window.performance.getEntriesByType)return; + var entries=performance.getEntriesByType('resource'); + var timings=[]; + for(var i=0;i0, + nextHopProtocol:e.nextHopProtocol||'', + timestamp:Date.now() + }); + } + } + } + if(timings.length>0){ + fetch(RT_REPORT_URL,{method:'POST',body:JSON.stringify(timings),headers:{'Content-Type':'application/json'},keepalive:true}); + } + }catch(ex){} +} +/* Report resource timings after the page has fully loaded */ +if(document.readyState==='complete'){ + setTimeout(collectResourceTimings,0); +}else{ + window.addEventListener('load',function(){ + /* Delay slightly to capture late-loading async chunks */ + setTimeout(collectResourceTimings,3000); + }); +} +/* Also observe dynamically loaded resources via PerformanceObserver */ +try{ + if(window.PerformanceObserver){ + var _rtBuf=[]; + var _rtTimer=null; + var obs=new PerformanceObserver(function(list){ + var entries=list.getEntries(); + for(var i=0;i0, + nextHopProtocol:e.nextHopProtocol||'', + timestamp:Date.now() + }); + } + } + } + if(_rtTimer)clearTimeout(_rtTimer); + _rtTimer=setTimeout(function(){ + if(_rtBuf.length>0){ + fetch(RT_REPORT_URL,{method:'POST',body:JSON.stringify(_rtBuf),headers:{'Content-Type':'application/json'},keepalive:true}); + _rtBuf=[]; + } + },2000); + }); + obs.observe({type:'resource',buffered:false}); + } +}catch(ex){} +})(); +`; +} diff --git a/packages/core/src/inner-plugins/utils/config.ts b/packages/core/src/inner-plugins/utils/config.ts index 820b5461e..7616f4d90 100644 --- a/packages/core/src/inner-plugins/utils/config.ts +++ b/packages/core/src/inner-plugins/utils/config.ts @@ -42,6 +42,7 @@ function normalizeFeatures(features: any, mode: keyof typeof SDK.IMode) { bundle: features.includes('bundle'), treeShaking: features.includes('treeShaking'), lite: features.includes('lite') || mode === SDK.IMode[SDK.IMode.lite], + runtime: features.includes('runtime'), }; } return { @@ -53,6 +54,7 @@ function normalizeFeatures(features: any, mode: keyof typeof SDK.IMode) { lite: defaultBoolean(features.lite, false) || mode === SDK.IMode[SDK.IMode.lite], + runtime: defaultBoolean(features.runtime, false), }; } function normalizeLinter(linter: any) { diff --git a/packages/rspack-plugin/src/plugin.ts b/packages/rspack-plugin/src/plugin.ts index bb6a0f2b7..f17c84197 100644 --- a/packages/rspack-plugin/src/plugin.ts +++ b/packages/rspack-plugin/src/plugin.ts @@ -8,6 +8,7 @@ import { InternalPluginsPlugin, InternalResolverPlugin, InternalRulesPlugin, + InternalRuntimeVitalsPlugin, InternalSummaryPlugin, normalizeRspackUserOptions, setSDK, @@ -41,9 +42,9 @@ import { logger, time, timeEnd } from '@rsdoctor/utils/logger'; // Static flag to ensure greet message is only printed once per process let hasGreeted = false; -export class RsdoctorRspackPlugin - implements RsdoctorRspackPluginInstance -{ +export class RsdoctorRspackPlugin< + Rules extends Linter.ExtendRuleData[], +> implements RsdoctorRspackPluginInstance { public readonly name = pluginTapName; public readonly sdk: SDK.RsdoctorBuilderSDKInstance | RsdoctorPrimarySDK; @@ -86,7 +87,6 @@ export class RsdoctorRspackPlugin output.mode === SDK.IMode[SDK.IMode.brief] ? output.options || undefined : undefined, - features: { treeShaking: this.options.features.treeShaking }, }, }); this.outsideInstance = Boolean(sdkInstance); @@ -171,6 +171,12 @@ export class RsdoctorRspackPlugin ).apply(compiler); } + if (this.options.features.runtime) { + new InternalRuntimeVitalsPlugin>( + this, + ).apply(compiler); + } + new InternalRulesPlugin(this).apply(compiler); // InternalErrorReporterPlugin must called before InternalRulesPlugin, to avoid treat Rsdoctor's lint warnings/errors as Webpack's warnings/errors. diff --git a/packages/sdk/src/sdk/sdk/index.ts b/packages/sdk/src/sdk/sdk/index.ts index a471f1a57..916db16a2 100644 --- a/packages/sdk/src/sdk/sdk/index.ts +++ b/packages/sdk/src/sdk/sdk/index.ts @@ -58,6 +58,11 @@ export class RsdoctorSDK< private _packageGraph!: SDK.PackageGraphInstance; + private _runtimePerf: SDK.RuntimePerfData = { + vitals: [], + resourceTimings: [], + }; + constructor(options: T) { super(options); this.server = options.config?.noServer @@ -139,6 +144,7 @@ export class RsdoctorSDK< this._plugin = {}; this._moduleGraph = new ModuleGraph(); this._chunkGraph = new ChunkGraph(); + this._runtimePerf = { vitals: [], resourceTimings: [] }; } clearSourceMapCache(): void { @@ -302,6 +308,44 @@ export class RsdoctorSDK< ); } + reportWebVital(metric: SDK.WebVitalMetric): void { + const existing = this._runtimePerf.vitals.findIndex( + (v) => v.name === metric.name && v.id === metric.id, + ); + if (existing >= 0) { + this._runtimePerf.vitals[existing] = metric; + } else { + this._runtimePerf.vitals.push(metric); + } + + if (metric.url && !this._runtimePerf.url) { + this._runtimePerf.url = metric.url; + } + if (metric.userAgent && !this._runtimePerf.userAgent) { + this._runtimePerf.userAgent = metric.userAgent; + } + + this.onDataReport(); + } + + reportResourceTimings(timings: SDK.ResourceTimingData[]): void { + for (const timing of timings) { + const existing = this._runtimePerf.resourceTimings.findIndex( + (t) => t.name === timing.name && t.startTime === timing.startTime, + ); + if (existing < 0) { + this._runtimePerf.resourceTimings.push(timing); + } + } + // Sort by startTime for display + this._runtimePerf.resourceTimings.sort((a, b) => a.startTime - b.startTime); + this.onDataReport(); + } + + getRuntimePerfData(): SDK.RuntimePerfData { + return this._runtimePerf; + } + reportChunkGraph(data: SDK.ChunkGraphInstance): void { this._chunkGraph.addAsset(...data.getAssets()); this._chunkGraph.addChunk(...data.getChunks()); @@ -413,102 +457,63 @@ export class RsdoctorSDK< } public getStoreData(): SDK.BuilderStoreData { - // rslint-disable-next-line @typescript-eslint/no-this-alias - const ctx = this; const briefOptions = this.extraConfig?.brief; const sections = briefOptions?.jsonOptions?.sections; const isJsonType = briefOptions?.type?.includes('json'); return { - get hash() { - return ctx.hash; - }, - get root() { - return ctx.root; - }, - get envinfo() { - return ctx._envinfo; - }, - get pid() { - return ctx.pid; - }, - get errors() { - // In brief mode with sections control, check if rules section is enabled - if (isJsonType && sections && !sections.rules) { - return []; - } - return ctx._errors.map((err) => err.toData()); - }, - get configs() { - return ctx._configs.slice(); - }, - get summary() { - return { ...ctx._summary }; - }, - get resolver() { - return ctx._resolver.slice(); - }, - get loader() { - return ctx._loader.slice(); - }, - get moduleGraph() { - // In brief mode with sections control, check if moduleGraph section is enabled - if (isJsonType && sections && !sections.moduleGraph) { - return { - dependencies: [], - modules: [], - moduleGraphModules: [], - exports: [], - sideEffects: [], - variables: [], - layers: [], - }; - } - return ctx._moduleGraph.toData({ - contextPath: ctx._configs?.[0]?.config?.context || '', - briefOptions, - }); - }, - get chunkGraph() { - // In brief mode with sections control, check if chunkGraph section is enabled - if (isJsonType && sections && !sections.chunkGraph) { - return { - assets: [], - chunks: [], - entrypoints: [], - }; - } - return ctx._chunkGraph.toData(ctx.type); - }, - get moduleCodeMap() { - if (ctx.extraConfig?.mode === SDK.IMode[SDK.IMode.brief]) { - return {}; - } - return ctx._moduleGraph.toCodeData(ctx.type); - }, - get plugin() { - return { ...ctx._plugin }; - }, - get packageGraph() { - return ctx._packageGraph - ? ctx._packageGraph.toData() - : { - packages: [], + hash: this.hash, + root: this.root, + envinfo: this._envinfo, + pid: this.pid, + errors: + isJsonType && sections && !sections.rules + ? [] + : this._errors.map((err) => err.toData()), + configs: this._configs.slice(), + summary: { ...this._summary }, + resolver: this._resolver.slice(), + loader: this._loader.slice(), + moduleGraph: + isJsonType && sections && !sections.moduleGraph + ? { dependencies: [], - }; - }, - get treeShaking() { - if (ctx.extraConfig?.mode === SDK.IMode[SDK.IMode.brief]) { - return undefined; - } - if (!ctx.extraConfig?.features?.treeShaking) { - return undefined; - } - return ctx._moduleGraph.toTreeShakingData(); - }, - get otherReports() { - return { treemapReportHtml: '' }; - }, + modules: [], + moduleGraphModules: [], + exports: [], + sideEffects: [], + variables: [], + layers: [], + } + : this._moduleGraph.toData({ + contextPath: this._configs?.[0]?.config?.context || '', + briefOptions, + }), + chunkGraph: + isJsonType && sections && !sections.chunkGraph + ? { + assets: [], + chunks: [], + entrypoints: [], + } + : this._chunkGraph.toData(this.type), + moduleCodeMap: + this.extraConfig?.mode === SDK.IMode[SDK.IMode.brief] + ? {} + : this._moduleGraph.toCodeData(this.type), + plugin: { ...this._plugin }, + packageGraph: this._packageGraph + ? this._packageGraph.toData() + : { + packages: [], + dependencies: [], + }, + otherReports: { treemapReportHtml: '' }, + runtime: + this._runtimePerf.vitals.length > 0 || + this._runtimePerf.resourceTimings.length > 0 + ? this._runtimePerf + : undefined, }; } diff --git a/packages/sdk/src/sdk/server/apis/index.ts b/packages/sdk/src/sdk/server/apis/index.ts index 470beb74d..9e90dea98 100644 --- a/packages/sdk/src/sdk/server/apis/index.ts +++ b/packages/sdk/src/sdk/server/apis/index.ts @@ -1,5 +1,5 @@ export * from './alerts'; -export { DataAPI } from './data'; +export * from './data'; export * from './fs'; export * from './loader'; export * from './graph'; @@ -7,3 +7,4 @@ export * from './plugin'; export * from './project'; export * from './renderer'; export * from './resolver'; +export * from './runtime'; diff --git a/packages/sdk/src/sdk/server/apis/runtime.ts b/packages/sdk/src/sdk/server/apis/runtime.ts new file mode 100644 index 000000000..f2f194abf --- /dev/null +++ b/packages/sdk/src/sdk/server/apis/runtime.ts @@ -0,0 +1,54 @@ +import { SDK } from '@rsdoctor/types'; +import { BaseAPI } from './base'; +import { Router } from '../router'; + +export class RuntimeAPI extends BaseAPI { + @Router.post(SDK.ServerAPI.API.ReportWebVitals) + async reportVitals() { + const { req, sdk } = this.ctx; + const body = req.body as Record | undefined; + + if (!body || Object.keys(body).length === 0) { + return 'ok'; + } + + if (body.name && body.value !== undefined) { + (sdk as SDK.RsdoctorBuilderSDKInstance).reportWebVital( + body as unknown as SDK.WebVitalMetric, + ); + } + + return 'ok'; + } + + @Router.get(SDK.ServerAPI.API.GetWebVitals) + async getVitals() { + const { sdk } = this.ctx; + return (sdk as SDK.RsdoctorBuilderSDKInstance).getRuntimePerfData(); + } + + @Router.post(SDK.ServerAPI.API.ReportResourceTimings) + async reportResourceTimings() { + const { req, sdk } = this.ctx; + const body = req.body as unknown; + + if (!body || !Array.isArray(body) || body.length === 0) { + return 'ok'; + } + + (sdk as SDK.RsdoctorBuilderSDKInstance).reportResourceTimings( + body as SDK.ResourceTimingData[], + ); + + return 'ok'; + } + + @Router.get(SDK.ServerAPI.API.GetResourceTimings) + async getResourceTimings() { + const { sdk } = this.ctx; + return ( + (sdk as SDK.RsdoctorBuilderSDKInstance).getRuntimePerfData() + .resourceTimings || [] + ); + } +} diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 3c45121e9..52f2098d1 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -20,6 +20,7 @@ export enum RsdoctorClientRoutes { RuleIndex = '/resources/rules', Uploader = '/resources/uploader', EmoCheck = '/emo/check', + RuntimePerf = '/runtime/performance', } export enum RsdoctorClientDiffState { diff --git a/packages/types/src/manifest.ts b/packages/types/src/manifest.ts index f33d84b8b..d11614f91 100644 --- a/packages/types/src/manifest.ts +++ b/packages/types/src/manifest.ts @@ -27,8 +27,10 @@ export interface RsdoctorManifestSeriesData { origin?: string; } -export interface RsdoctorManifestWithShardingFiles - extends Omit { +export interface RsdoctorManifestWithShardingFiles extends Omit< + RsdoctorManifest, + 'data' +> { data: Record; /** * manifest data shareding file urls in tos, used by inner-rsdoctor. @@ -56,6 +58,7 @@ export enum RsdoctorManifestClientRoutes { BundleSize = 'Bundle.BundleSize', ModuleGraph = 'Bundle.ModuleGraph', TreeShaking = 'Bundle.TreeShaking', + RuntimePerf = 'Runtime.Performance', } export enum RsdoctorManifestClientConstant { diff --git a/packages/types/src/plugin/plugin.ts b/packages/types/src/plugin/plugin.ts index cae4b8cd4..df1f1a0b4 100644 --- a/packages/types/src/plugin/plugin.ts +++ b/packages/types/src/plugin/plugin.ts @@ -32,22 +32,21 @@ export interface RsdoctorWebpackPluginFeatures { * @default false */ lite?: boolean; + /** + * turn on it to inject web-vitals collection script into entry chunks for runtime performance profiling. + * @default false + */ + runtime?: boolean; } export interface RsdoctorPluginOptionsNormalized< Rules extends LinterType.ExtendRuleData[] = [], > extends Common.DeepRequired< - Omit< - RsdoctorWebpackPluginOptions, - | 'sdkInstance' - | 'linter' - | 'output' - | 'supports' - | 'port' - | 'brief' - | 'mode' - > - > { + Omit< + RsdoctorWebpackPluginOptions, + 'sdkInstance' | 'linter' | 'output' | 'supports' | 'port' | 'brief' | 'mode' + > +> { features: Common.DeepRequired; linter: Required>; sdkInstance?: SDK.RsdoctorBuilderSDKInstance; @@ -185,8 +184,10 @@ export interface NormalModeOptions { } // Normal Mode Type -interface NormalModeConfig - extends Omit { +interface NormalModeConfig extends Omit< + OutputBaseConfig, + 'reportCodeType' | 'mode' +> { mode?: 'normal'; reportCodeType?: ReportCodeTypeByMode<'normal'>; options?: NormalModeOptions; @@ -200,8 +201,10 @@ export interface BriefModeOptions { htmlOptions?: Config.BriefConfig; } -export interface BriefModeConfig - extends Omit { +export interface BriefModeConfig extends Omit< + OutputBaseConfig, + 'reportCodeType' | 'mode' +> { mode?: 'brief'; reportCodeType?: ReportCodeTypeByMode<'brief'>; options?: BriefModeOptions; diff --git a/packages/types/src/sdk/index.ts b/packages/types/src/sdk/index.ts index 27eb7a960..591aa5509 100644 --- a/packages/types/src/sdk/index.ts +++ b/packages/types/src/sdk/index.ts @@ -14,3 +14,4 @@ export * from './treeShaking'; export * from './envinfo'; export * from './instance'; export * from './hooks'; +export * from './runtime'; diff --git a/packages/types/src/sdk/instance.ts b/packages/types/src/sdk/instance.ts index 113db0671..34012bb55 100644 --- a/packages/types/src/sdk/instance.ts +++ b/packages/types/src/sdk/instance.ts @@ -5,6 +5,7 @@ import { ResolverData } from './resolver'; import { PluginData } from './plugin'; import { BuilderStoreData, EMOStoreData } from './result'; import { ModuleGraphInstance, ToDataType } from './module'; +import { WebVitalMetric, ResourceTimingData, RuntimePerfData } from './runtime'; import { RsdoctorManifestClientRoutes, RsdoctorManifestWithShardingFiles, @@ -51,6 +52,12 @@ export interface RsdoctorBuilderSDKInstance extends RsdoctorSDKInstance { reportSummaryData(part: Partial): void; /** Report sourceMap data */ reportSourceMap(data: RawSourceMap): void; + /** Report a web-vital metric from runtime collection */ + reportWebVital(metric: WebVitalMetric): void; + /** Report resource timing entries (chunk fetch/load timings) */ + reportResourceTimings(timings: ResourceTimingData[]): void; + /** Get collected runtime performance data */ + getRuntimePerfData(): RuntimePerfData; getClientRoutes(): RsdoctorManifestClientRoutes[]; addClientRoutes(routes: RsdoctorManifestClientRoutes[]): void; @@ -128,5 +135,4 @@ export type SDKOptionsType = { printLog?: IPrintLog; mode?: keyof typeof IMode; brief?: BriefModeOptions; - features?: { treeShaking?: boolean }; }; diff --git a/packages/types/src/sdk/result.ts b/packages/types/src/sdk/result.ts index 280382b74..ec85de929 100644 --- a/packages/types/src/sdk/result.ts +++ b/packages/types/src/sdk/result.ts @@ -1,6 +1,6 @@ import { EmoCheckData } from '../emo'; import { LoaderData } from './loader'; -import { ModuleGraphData, ModuleCodeData, TreeShakingData } from './module'; +import { ModuleGraphData, ModuleCodeData } from './module'; import { ChunkGraphData } from './chunk'; import { ResolverData } from './resolver'; import { PluginData } from './plugin'; @@ -9,6 +9,7 @@ import { ConfigData } from './config'; import { RuleStoreData } from '../rule'; import type { EnvInfo } from './envinfo'; import { PackageGraphData, OtherReports } from './package'; +import { RuntimePerfData } from './runtime'; export type ErrorsData = RuleStoreData; @@ -31,8 +32,8 @@ export interface BuilderStoreData extends StoreCommonData { chunkGraph: ChunkGraphData; packageGraph: PackageGraphData; moduleCodeMap: ModuleCodeData; - treeShaking?: TreeShakingData; otherReports?: OtherReports | undefined; + runtime?: RuntimePerfData | undefined; } export interface EMOStoreData extends StoreCommonData { @@ -43,5 +44,6 @@ export interface EMOStoreData extends StoreCommonData { * @deprecated */ export interface StoreData - extends Partial>, + extends + Partial>, BuilderStoreData {} diff --git a/packages/types/src/sdk/runtime.ts b/packages/types/src/sdk/runtime.ts new file mode 100644 index 000000000..89eb797ee --- /dev/null +++ b/packages/types/src/sdk/runtime.ts @@ -0,0 +1,61 @@ +export interface WebVitalMetric { + name: 'LCP' | 'FCP' | 'CLS' | 'INP' | 'TTFB'; + value: number; + rating: 'good' | 'needs-improvement' | 'poor'; + delta: number; + id: string; + navigationType: string; + entries: Record[]; + timestamp: number; + url?: string; + userAgent?: string; +} + +/** + * A single resource timing entry collected via the Performance Resource Timing API. + * Represents the fetch/load timing for a chunk resource (JS/CSS). + */ +export interface ResourceTimingData { + /** Full URL of the resource */ + name: string; + /** Resource type, e.g. 'script', 'link', 'css', 'fetch' */ + initiatorType: string; + /** Time (ms) from navigation start to resource fetch start */ + startTime: number; + /** Total duration (ms) of the resource load */ + duration: number; + /** Size transferred over the network (bytes, 0 if cached) */ + transferSize: number; + /** Decoded body size (bytes) */ + decodedBodySize: number; + /** Encoded body size (bytes) */ + encodedBodySize: number; + /** DNS lookup start */ + domainLookupStart: number; + /** DNS lookup end */ + domainLookupEnd: number; + /** TCP connect start */ + connectStart: number; + /** TCP connect end */ + connectEnd: number; + /** Request start */ + requestStart: number; + /** Response start (TTFB for this resource) */ + responseStart: number; + /** Response end */ + responseEnd: number; + /** Whether the resource was fetched from the browser cache */ + fromCache: boolean; + /** The protocol used (e.g. 'h2', 'http/1.1') */ + nextHopProtocol: string; + /** Timestamp when this entry was collected */ + timestamp: number; +} + +export interface RuntimePerfData { + vitals: WebVitalMetric[]; + /** Resource timing entries for chunk resources (JS/CSS) */ + resourceTimings: ResourceTimingData[]; + url?: string; + userAgent?: string; +} diff --git a/packages/types/src/sdk/server/apis/index.ts b/packages/types/src/sdk/server/apis/index.ts index 01883e424..cf5634f3b 100644 --- a/packages/types/src/sdk/server/apis/index.ts +++ b/packages/types/src/sdk/server/apis/index.ts @@ -88,6 +88,12 @@ export enum API { /** AI API */ GetChunkGraphAI = '/api/graph/chunks/graph/ai', GetChunkByIdAI = '/api/graph/chunk/id/ai', + + /** Runtime Vitals API */ + ReportWebVitals = '/api/runtime/vitals/report', + GetWebVitals = '/api/runtime/vitals', + ReportResourceTimings = '/api/runtime/resource-timings/report', + GetResourceTimings = '/api/runtime/resource-timings', } /** @@ -154,6 +160,10 @@ export interface ResponseTypes [API.GetPackageDependency]: SDK.PackageDependencyData[]; [API.GetChunkByIdAI]: ExtendedChunkData[]; [API.GetChunkGraphAI]: Omit[]; + [API.ReportWebVitals]: 'ok'; + [API.GetWebVitals]: SDK.RuntimePerfData; + [API.ReportResourceTimings]: 'ok'; + [API.GetResourceTimings]: SDK.ResourceTimingData[]; [API.GetSummaryBundles]: { asset: SDK.AssetData; modules: SDK.ModuleData[]; @@ -168,8 +178,10 @@ export interface RequestBodyTypes GraphAPIRequestBody, AlertsAPIRequestBody, ProjectAPIRequestBody { + [API.ApplyErrorFix]: { + id: number; + }; [API.ReportLoader]: LoaderData; - [API.ApplyErrorFix]: { id: number }; [API.SendAPIDataToClient]: { api: API; data: unknown; @@ -193,6 +205,8 @@ export interface RequestBodyTypes [API.GetChunkByIdAI]: { chunkId: string; }; + [API.ReportWebVitals]: SDK.WebVitalMetric; + [API.ReportResourceTimings]: SDK.ResourceTimingData[]; } export type InferResponseType = Get; diff --git a/packages/utils/src/common/data/index.ts b/packages/utils/src/common/data/index.ts index 650a17ea9..90bf20b99 100644 --- a/packages/utils/src/common/data/index.ts +++ b/packages/utils/src/common/data/index.ts @@ -254,19 +254,11 @@ export class APIDataLoader { return Promise.all([ this.loader.loadData('chunkGraph'), this.loader.loadData('moduleGraph'), - this.loader.loadData('treeShaking'), ]).then((res) => { const { moduleId } = body as SDK.ServerAPI.InferRequestBodyType; const { modules = [], dependencies = [] } = res[1] || {}; - const treeShaking = res[2]; - const sideEffectCodes = treeShaking?.sideEffectCodes[moduleId] || []; - return Graph.getModuleDetails( - moduleId, - modules, - dependencies, - sideEffectCodes, - ) as R; + return Graph.getModuleDetails(moduleId, modules, dependencies) as R; }); case SDK.ServerAPI.API.GetModulesByModuleIds: return this.loader.loadData('moduleGraph').then((res) => { @@ -551,7 +543,12 @@ export class APIDataLoader { case SDK.ServerAPI.API.GetChunkGraphAI: return this.loader.loadData('chunkGraph').then((res) => { const { assets = [] } = res || {}; - const filteredChunks = assets.map(({ content: _, ...rest }) => rest); + const filteredChunks = assets.map((asset) => { + const entries = Object.entries(asset).filter( + ([key]) => key !== 'content', + ); + return Object.fromEntries(entries); + }); return filteredChunks as R; }); @@ -586,6 +583,22 @@ export class APIDataLoader { return chunkInfo as R; }); + /** Runtime Vitals API */ + case SDK.ServerAPI.API.ReportWebVitals: + case SDK.ServerAPI.API.ReportResourceTimings: + return Promise.resolve('ok' as R); + case SDK.ServerAPI.API.GetWebVitals: + return this.loader.loadData('runtime').then((runtime) => { + return (runtime || { + vitals: [], + resourceTimings: [], + }) as R; + }); + case SDK.ServerAPI.API.GetResourceTimings: + return this.loader.loadData('runtime').then((runtime) => { + return (runtime?.resourceTimings || []) as R; + }); + case SDK.ServerAPI.API.GetDirectoriesLoaders: return Promise.all([ this.loader.loadData('root'), diff --git a/packages/utils/tests/common/data.test.ts b/packages/utils/tests/common/data.test.ts index 17a0ba08b..0e5f536dd 100644 --- a/packages/utils/tests/common/data.test.ts +++ b/packages/utils/tests/common/data.test.ts @@ -50,4 +50,59 @@ describe('test src/common/data/index.ts', () => { ); }); }); + + it('loads runtime performance api data from store data', async () => { + const runtime = { + vitals: [ + { + name: 'LCP', + value: 1200, + rating: 'good', + delta: 1200, + id: 'v1', + navigationType: 'navigate', + entries: [], + timestamp: 1, + }, + ], + resourceTimings: [ + { + name: 'http://localhost/main.js', + initiatorType: 'script', + startTime: 10, + duration: 20, + transferSize: 1024, + decodedBodySize: 2048, + encodedBodySize: 1024, + domainLookupStart: 1, + domainLookupEnd: 2, + connectStart: 2, + connectEnd: 3, + requestStart: 4, + responseStart: 5, + responseEnd: 30, + fromCache: false, + nextHopProtocol: 'h2', + timestamp: 2, + }, + ], + } satisfies SDK.RuntimePerfData; + + const loader = new APIDataLoader({ + loadData: rs.fn().mockImplementation((key) => { + if (key === 'runtime') { + return Promise.resolve(runtime); + } + return Promise.resolve(undefined); + }), + loadManifest: rs.fn(), + }); + + await expect(loader.loadAPI(SDK.ServerAPI.API.GetWebVitals)).resolves.toBe( + runtime, + ); + await expect( + loader.loadAPI(SDK.ServerAPI.API.GetResourceTimings), + ).resolves.toBe(runtime.resourceTimings); + }); }); diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 7c20ac4d7..689ee7fb9 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -10,6 +10,7 @@ import { InternalProgressPlugin, InternalResolverPlugin, InternalRulesPlugin, + InternalRuntimeVitalsPlugin, InternalSummaryPlugin, normalizeUserConfig, processCompilerConfig, @@ -29,9 +30,9 @@ import path from 'path'; // Static flag to ensure greet message is only printed once per process let hasGreeted = false; -export class RsdoctorWebpackPlugin - implements RsdoctorPluginInstance -{ +export class RsdoctorWebpackPlugin< + Rules extends Linter.ExtendRuleData[], +> implements RsdoctorPluginInstance { public readonly name = pluginTapName; public readonly options: Plugin.RsdoctorPluginOptionsNormalized; @@ -70,7 +71,6 @@ export class RsdoctorWebpackPlugin output.mode === SDK.IMode[SDK.IMode.brief] ? output.options || undefined : undefined, - features: { treeShaking: this.options.features.treeShaking }, }, }); @@ -127,6 +127,10 @@ export class RsdoctorWebpackPlugin new InternalBundleTagPlugin(this).apply(compiler); } + if (this.options.features.runtime) { + new InternalRuntimeVitalsPlugin(this).apply(compiler); + } + // InternalErrorReporterPlugin must called before InternalRulesPlugin, to avoid treat Rsdoctor's lint warnings/errors as Webpack's warnings/errors. new InternalErrorReporterPlugin(this).apply(compiler); // InternalRulesPlugin will add lint errors and warnings to Webpack compilation as Webpack's warnings/errors. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf603ecca..cecffbc14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -623,19 +623,19 @@ importers: packages/client: devDependencies: '@rsbuild/core': - specifier: ^1.7.5 + specifier: ^1.7.3 version: 1.7.5 '@rsbuild/plugin-node-polyfill': specifier: ^1.4.4 version: 1.4.4(@rsbuild/core@1.7.5) '@rsbuild/plugin-react': - specifier: ^1.4.6 + specifier: ^1.4.5 version: 1.4.6(@rsbuild/core@1.7.5) '@rsbuild/plugin-sass': - specifier: ^1.5.1 + specifier: ^1.5.0 version: 1.5.1(@rsbuild/core@1.7.5) '@rsbuild/plugin-type-check': - specifier: ^1.3.4 + specifier: ^1.3.3 version: 1.3.4(@rsbuild/core@1.7.5)(@rspack/core@2.0.0-rc.3(@swc/helpers@0.5.21))(tslib@2.8.1)(typescript@5.9.2) '@rsdoctor/components': specifier: workspace:* @@ -644,22 +644,22 @@ importers: specifier: workspace:* version: link:../types '@types/node': - specifier: 'catalog:' + specifier: ^22.8.1 version: 22.18.1 '@types/react': - specifier: 'catalog:' + specifier: ^18.3.28 version: 18.3.28 '@types/react-dom': - specifier: 'catalog:' + specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.28) antd: - specifier: 'catalog:' + specifier: 5.19.1 version: 5.19.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) normalize.css: specifier: 8.0.1 version: 8.0.1 react: - specifier: 'catalog:' + specifier: 18.3.1 version: 18.3.1 react-dom: specifier: 18.3.1 @@ -668,7 +668,7 @@ importers: specifier: ^4.1.2 version: 4.1.2(react@18.3.1) react-router-dom: - specifier: 'catalog:' + specifier: 6.30.3 version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) sirv: specifier: 3.0.2 @@ -677,7 +677,7 @@ importers: specifier: ^5.0.0 version: 5.0.0(webpack@5.105.4(@swc/core@1.15.8(@swc/helpers@0.5.21))) typescript: - specifier: 'catalog:' + specifier: ^5.9.2 version: 5.9.2 packages/components: