diff --git a/.cursor/skills/qwik/AGENTS.md b/.agent/skills/qwik/AGENTS.md similarity index 100% rename from .cursor/skills/qwik/AGENTS.md rename to .agent/skills/qwik/AGENTS.md diff --git a/.cursor/skills/qwik/SKILL.md b/.agent/skills/qwik/SKILL.md similarity index 100% rename from .cursor/skills/qwik/SKILL.md rename to .agent/skills/qwik/SKILL.md diff --git a/.changeset/huge-clubs-rest.md b/.changeset/huge-clubs-rest.md new file mode 100644 index 0000000..16b275a --- /dev/null +++ b/.changeset/huge-clubs-rest.md @@ -0,0 +1,9 @@ +--- +'@qwik.dev/devtools': patch +--- + +feat: add preload/build analysis tooling and richer devtools instrumentation + +- Added new `Preloads` and `Build Analysis` panels, plus an improved `Inspect` view that resolves correctly from the app base URL on deep routes. +- Added runtime instrumentation for SSR/CSR performance and preload tracking, including SSR preload snapshots, QRL-to-resource correlation, and richer diagnostics surfaced in DevTools. +- Expanded the plugin and RPC layer to generate and serve build-analysis reports, expose the new preload/performance data to the UI, and add server-side guards around build-analysis execution. diff --git a/.gitignore b/.gitignore index 41be1f3..eba8cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -65,5 +65,7 @@ testem.log Thumbs.db .vite-inspect .pnpm-store/* -related-qwik/* -.cursor/skills/* \ No newline at end of file +qwik/* +.cursor/skills/* +related-folder/** +**/.qwik-devtools/ diff --git a/packages/devtools/package.json b/packages/devtools/package.json index c781c32..72cf97f 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -3,20 +3,24 @@ "version": "0.2.8", "license": "MIT", "main": "./dist/plugin/index.mjs", + "types": "./dist/plugin/index.d.mts", "description": "Qwik devtools package", "qwik": "./dist/ui/index.qwik.mjs", "exports": { ".": { - "import": "./dist/plugin/index.js", - "types": "./dist/plugin/index.d.ts" + "import": "./dist/plugin/index.mjs", + "types": "./dist/plugin/index.d.mts" }, "./ui": { "import": "./dist/ui/index.qwik.mjs", "types": "./dist/ui/lib-types/ui/src/index.d.ts", - "style": "./dist/ui/style.css" + "style": "./dist/ui/styles.css" + }, + "./ui/styles.css": { + "import": "./dist/ui/styles.css" }, "./style": { - "import": "./dist/ui/theme.css" + "import": "./dist/ui/styles.css" } }, "files": [ @@ -35,6 +39,7 @@ "birpc": "^4.0.0", "dree": "^5.1.5", "oxc-parser": "^0.120.0", + "rollup-plugin-visualizer": "^6.0.3", "superjson": "^2.2.6", "vite-hot-client": "^2.1.0", "vite-plugin-inspect": "^11.3.3" @@ -64,4 +69,4 @@ "url": "https://github.com/QwikDev/devtools/issues" }, "homepage": "https://github.com/QwikDev/devtools#readme" -} \ No newline at end of file +} diff --git a/packages/kit/src/client.ts b/packages/kit/src/client.ts index 886db85..3257067 100644 --- a/packages/kit/src/client.ts +++ b/packages/kit/src/client.ts @@ -1,20 +1,17 @@ -import SuperJSON from 'superjson'; import { ClientFunctions, ServerFunctions } from './types'; -import { createBirpc } from 'birpc'; import { DEVTOOLS_VITE_MESSAGING_EVENT } from './constants'; import { getViteClientContext, setViteClientRpc } from './context'; +import { createSerializedRpc } from './rpc-core'; export function createClientRpc(functions: ClientFunctions) { const client = getViteClientContext(); - const rpc = createBirpc(functions, { - post: (data) => - client.send(DEVTOOLS_VITE_MESSAGING_EVENT, SuperJSON.stringify(data)), - on: (fn) => + const rpc = createSerializedRpc(functions, { + post: (data) => client.send(DEVTOOLS_VITE_MESSAGING_EVENT, data), + on: (handler) => client.on(DEVTOOLS_VITE_MESSAGING_EVENT, (data) => { - fn(SuperJSON.parse(data)); + handler(data); }), - timeout: 120_000, }); setViteClientRpc(rpc); diff --git a/packages/kit/src/constants.ts b/packages/kit/src/constants.ts index 5864b9e..350cb39 100644 --- a/packages/kit/src/constants.ts +++ b/packages/kit/src/constants.ts @@ -1,59 +1,42 @@ export const DEVTOOLS_VITE_MESSAGING_EVENT = 'qwik_tools:vite_messaging_event'; -export const USE_HOOK_LIST = [ - 'useAsyncComputed', - 'useComputed', - 'useConstant', - 'useContext', - 'useContextProvider', - 'useErrorBoundary', - 'useId', - 'useOn', - 'useOnDocument', - 'useOnWindow', - 'useResource', - 'useSerializer', - 'useServerData', - 'useSignal', - 'useStore', - 'useStyles', - 'useStylesScoped', - 'useTask', - 'useVisibleTask', - 'useLocation', - 'useNavigate', - 'usePreventNavigate', - 'useContent', - 'useDocumentHead', -] as const - - -export const VARIABLE_DECLARATION_LIST = [ - 'useStore', - 'useSignal', - 'useComputed', - 'useAsyncComputed', - 'useContext', - 'useId', - 'useStyles', - 'useStylesScoped', - 'useConstant', - 'useErrorBoundary', - 'useSerializer', - 'useServerData', - 'useLocation', - 'useNavigate', - 'useContent', - 'useDocumentHead', -] as const - -export const EXPRESSION_STATEMENT_LIST = [ - 'useVisibleTask', - 'useTask', - 'useResource', - 'useContextProvider', - 'usePreventNavigate', -] as const +const HOOK_GROUPS = { + variableDeclaration: [ + 'useStore', + 'useSignal', + 'useComputed', + 'useAsyncComputed', + 'useContext', + 'useId', + 'useStyles', + 'useStylesScoped', + 'useConstant', + 'useErrorBoundary', + 'useSerializer', + 'useServerData', + 'useLocation', + 'useNavigate', + 'useContent', + 'useDocumentHead', + ] as const, + expressionStatement: [ + 'useVisibleTask', + 'useTask', + 'useResource', + 'useContextProvider', + 'usePreventNavigate', + ] as const, + listener: ['useOn', 'useOnDocument', 'useOnWindow'] as const, + noReturn: ['useVisibleTask', 'useTask'] as const, +} as const; + +export const VARIABLE_DECLARATION_LIST = HOOK_GROUPS.variableDeclaration; +export const EXPRESSION_STATEMENT_LIST = HOOK_GROUPS.expressionStatement; +export const USE_HOOK_LIST = [ + ...VARIABLE_DECLARATION_LIST, + ...HOOK_GROUPS.listener, + ...EXPRESSION_STATEMENT_LIST, +] as const; export const QSEQ = 'q:seq'; export const QPROPS = 'q:props'; @@ -62,13 +45,14 @@ export const QTYPE = 'q:type'; export const VIRTUAL_QWIK_DEVTOOLS_KEY = 'virtual-qwik-devtools.ts'; -export const INNER_USE_HOOK= 'useCollectHooks' +export const INNER_USE_HOOK = 'useCollectHooks'; -export const QWIK_DEVTOOLS_GLOBAL_STATE = 'QWIK_DEVTOOLS_GLOBAL_STATE' +export const QWIK_DEVTOOLS_GLOBAL_STATE = 'QWIK_DEVTOOLS_GLOBAL_STATE'; +export const QWIK_PRELOADS_UPDATE_EVENT = 'qwik:preloads-update'; export const QRL_KEY = '$qrl$'; export const COMPUTED_QRL_KEY = '$computeQrl$'; export const CHUNK_KEY = '$chunk$'; export const CAPTURE_REF_KEY = '$captureRef$'; -export const NORETURN_HOOK = [ 'useVisibleTask', 'useTask'] as const \ No newline at end of file +export const NORETURN_HOOK = HOOK_GROUPS.noReturn; diff --git a/packages/kit/src/context.ts b/packages/kit/src/context.ts index b259079..b496c71 100644 --- a/packages/kit/src/context.ts +++ b/packages/kit/src/context.ts @@ -1,4 +1,3 @@ -import { target } from './shared'; import { ViteClientContext, CLIENT_CTX, @@ -8,38 +7,23 @@ import { CLIENT_RPC, } from './globals'; import { ServerRpc, ClientRpc } from './types'; +import { createGlobalAccessor } from './global-store'; -type GlobalTarget = Record; -const t = target as unknown as GlobalTarget; +const clientContextAccessor = + createGlobalAccessor(CLIENT_CTX); +const serverContextAccessor = + createGlobalAccessor(SERVER_CTX); +const serverRpcAccessor = createGlobalAccessor(SERVER_RPC); +const clientRpcAccessor = createGlobalAccessor(CLIENT_RPC); -export function getViteClientContext(): ViteClientContext { - return t[CLIENT_CTX] as ViteClientContext; -} +export const getViteClientContext = clientContextAccessor.get; +export const setViteClientContext = clientContextAccessor.set; -export function setViteClientContext(ctx: ViteClientContext) { - t[CLIENT_CTX] = ctx; -} +export const getViteServerContext = serverContextAccessor.get; +export const setViteServerContext = serverContextAccessor.set; -export function getViteServerContext() { - return t[SERVER_CTX] as ViteServerContext; -} +export const getViteServerRpc = serverRpcAccessor.get; +export const setViteServerRpc = serverRpcAccessor.set; -export function setViteServerContext(ctx: ViteServerContext) { - t[SERVER_CTX] = ctx; -} - -export function getViteServerRpc() { - return t[SERVER_RPC] as ServerRpc; -} - -export function setViteServerRpc(rpc: ServerRpc) { - t[SERVER_RPC] = rpc; -} - -export function getViteClientRpc() { - return t[CLIENT_RPC] as ClientRpc; -} - -export function setViteClientRpc(rpc: ClientRpc) { - t[CLIENT_RPC] = rpc; -} +export const getViteClientRpc = clientRpcAccessor.get; +export const setViteClientRpc = clientRpcAccessor.set; diff --git a/packages/kit/src/global-store.ts b/packages/kit/src/global-store.ts new file mode 100644 index 0000000..b5cc015 --- /dev/null +++ b/packages/kit/src/global-store.ts @@ -0,0 +1,14 @@ +import { target } from './shared'; + +type GlobalStore = Record; + +const globalStore = target as unknown as GlobalStore; + +export function createGlobalAccessor(key: string) { + return { + get: () => globalStore[key] as T, + set: (value: T) => { + globalStore[key] = value; + }, + }; +} diff --git a/packages/kit/src/globals.ts b/packages/kit/src/globals.ts index 804b897..6687a4c 100644 --- a/packages/kit/src/globals.ts +++ b/packages/kit/src/globals.ts @@ -46,6 +46,82 @@ export interface QwikPerfStoreRemembered { } +export type QwikPreloadStatus = 'pending' | 'loaded' | 'error' | 'unknown'; +export type QwikPreloadSource = + | 'initial-dom' + | 'mutation' + | 'performance' + | 'qrl-correlation'; +export type QwikPreloadOriginKind = + | 'current-project' + | 'vite-plugin-injected' + | 'node_modules' + | 'virtual-module' + | 'generated' + | 'external' + | 'unknown'; +export type QwikPreloadPhase = 'csr' | 'ssr' | 'unknown'; +export type QwikPreloadMatchMode = + | 'href' + | 'normalized-href' + | 'chunk-hash' + | 'resource-name' + | 'none'; +export type QwikPreloadLoadMatchQuality = 'best-effort' | 'none'; + +export interface QwikPreloadQrlRequestRemembered { + symbol: string; + href?: string; + normalizedHref?: string; + requestedAt: number; + originKind?: QwikPreloadOriginKind; + phase?: QwikPreloadPhase; + matchedEntryId?: number; +} + +export interface QwikPreloadEntryRemembered { + id: number; + href: string; + normalizedHref: string; + rel: string; + as: string; + resourceType: string; + status: QwikPreloadStatus; + source: QwikPreloadSource; + originKind: QwikPreloadOriginKind; + phase: QwikPreloadPhase; + discoveredAt: number; + requestedAt?: number; + completedAt?: number; + importDuration?: number; + loadDuration?: number; + duration?: number; + transferSize?: number; + decodedBodySize?: number; + initiatorType?: string; + qrlSymbol?: string; + qrlRequestedAt?: number; + qrlToLoadDuration?: number; + loadMatchQuality?: QwikPreloadLoadMatchQuality; + matchedBy: QwikPreloadMatchMode; + error?: string; +} + +export type QwikSsrPreloadSnapshotRemembered = + Partial & + Pick; + +export interface QwikPreloadStoreRemembered { + entries: QwikPreloadEntryRemembered[]; + qrlRequests: QwikPreloadQrlRequestRemembered[]; + startedAt: number; + clear: () => void; + _id: number; + _initialized: boolean; + _byHref: Record; + _byId: Record; +} + export interface DevtoolsRenderStats { /** * In-memory performance store written by devtools instrumentation. @@ -67,6 +143,8 @@ declare global { * Written by `@devtools/plugin` instrumentation. */ __QWIK_PERF__?: QwikPerfStoreRemembered; + __QWIK_PRELOADS__?: QwikPreloadStoreRemembered; + __QWIK_SSR_PRELOADS__?: QwikSsrPreloadSnapshotRemembered[]; } } @@ -76,6 +154,7 @@ declare global { namespace NodeJS { interface Process { __QWIK_SSR_PERF__?: QwikPerfEntryRemembered[]; + __QWIK_SSR_PRELOADS__?: QwikSsrPreloadSnapshotRemembered[]; __QWIK_SSR_PERF_SET__?: Set; __QWIK_SSR_PERF_ID__?: number; __QWIK_SSR_PERF_INDEX__?: Record; diff --git a/packages/kit/src/rpc-core.ts b/packages/kit/src/rpc-core.ts new file mode 100644 index 0000000..f587114 --- /dev/null +++ b/packages/kit/src/rpc-core.ts @@ -0,0 +1,24 @@ +import SuperJSON from 'superjson'; +import { createBirpc } from 'birpc'; + +interface RpcChannel { + post: (serialized: string) => void; + on: (handler: (serialized: string) => void) => void; +} + +const RPC_TIMEOUT = 120_000; + +function parseRpcPayload(payload: unknown) { + return SuperJSON.parse(String(payload)); +} + +export function createSerializedRpc< + RemoteFunctions extends object, + LocalFunctions extends object, +>(functions: LocalFunctions, channel: RpcChannel) { + return createBirpc(functions, { + post: (data) => channel.post(SuperJSON.stringify(data)), + on: (handler) => channel.on((data) => handler(parseRpcPayload(data))), + timeout: RPC_TIMEOUT, + }); +} diff --git a/packages/kit/src/server.ts b/packages/kit/src/server.ts index 25b8d79..b639857 100644 --- a/packages/kit/src/server.ts +++ b/packages/kit/src/server.ts @@ -1,20 +1,42 @@ -import SuperJSON from 'superjson'; import { ClientFunctions, ServerFunctions } from './types'; -import { createBirpc } from 'birpc'; import { DEVTOOLS_VITE_MESSAGING_EVENT } from './constants'; import { setViteServerRpc, getViteServerContext } from './context'; +import { createSerializedRpc } from './rpc-core'; + +export interface ServerRpcRequestContext { + client?: unknown; +} + +let currentServerRpcRequestContext: ServerRpcRequestContext | undefined; + +export function getServerRpcRequestContext() { + return currentServerRpcRequestContext; +} + +function runWithServerRpcRequestContext( + context: ServerRpcRequestContext, + fn: () => void, +) { + const previous = currentServerRpcRequestContext; + currentServerRpcRequestContext = context; + try { + fn(); + } finally { + currentServerRpcRequestContext = previous; + } +} export function createServerRpc(functions: ServerFunctions) { const server = getViteServerContext(); - const rpc = createBirpc(functions, { - post: (data) => - server.ws.send(DEVTOOLS_VITE_MESSAGING_EVENT, SuperJSON.stringify(data)), - on: (fn) => - server.ws.on(DEVTOOLS_VITE_MESSAGING_EVENT, (data: any) => { - fn(SuperJSON.parse(data)); + const rpc = createSerializedRpc(functions, { + post: (data) => server.ws.send(DEVTOOLS_VITE_MESSAGING_EVENT, data), + on: (handler) => + server.ws.on(DEVTOOLS_VITE_MESSAGING_EVENT, (data: any, client: unknown) => { + runWithServerRpcRequestContext({ client }, () => { + handler(data); + }); }), - timeout: 120_000, }); setViteServerRpc(rpc); diff --git a/packages/kit/src/types.ts b/packages/kit/src/types.ts index bb27cd8..6beeb76 100644 --- a/packages/kit/src/types.ts +++ b/packages/kit/src/types.ts @@ -1,12 +1,51 @@ import { BirpcReturn } from 'birpc'; import { type Dree } from 'dree'; -import { VARIABLE_DECLARATION_LIST, EXPRESSION_STATEMENT_LIST } from './constants'; +import { + VARIABLE_DECLARATION_LIST, + EXPRESSION_STATEMENT_LIST, +} from './constants'; + export { Type as RouteType } from 'dree'; export interface ClientFunctions { healthCheck(): boolean; } +export interface DependenciesStatus { + phase: 'idle' | 'phase1' | 'phase2' | 'done' | 'error'; + loaded: number; + total: number; + startedAt: number | null; + finishedAt: number | null; + error?: string; +} + +export interface PackageInstallResult { + success: boolean; + error?: string; +} + +export interface BuildAnalysisStatus { + exists: boolean; + reportPath: string; + buildCommand: string | null; + canTriggerBuild: boolean; + buildTriggerHint?: string; +} + +export interface BuildAnalysisRunResult { + success: boolean; + error?: string; +} + +export interface ModuleLookupResult { + pathId: string; + modules: any; + error?: string; +} + +export type ParsedQwikCodeResult = Omit[]; + export interface ServerFunctions { healthCheck(): boolean; getAssetsFromPublicDir: () => Promise; @@ -14,25 +53,18 @@ export interface ServerFunctions { getRoutes: () => any; getQwikPackages: () => Promise<[string, string][]>; getAllDependencies: () => Promise; - getDependenciesStatus: () => Promise<{ - phase: 'idle' | 'phase1' | 'phase2' | 'done' | 'error'; - loaded: number; - total: number; - startedAt: number | null; - finishedAt: number | null; - error?: string; - }>; + getDependenciesStatus: () => Promise; refreshDependencies: () => Promise; installPackage: ( packageName: string, isDev?: boolean, - ) => Promise<{ success: boolean; error?: string }>; - getModulesByPathIds: (pathIds: string | string[]) => Promise<{ - pathId: string; - modules: any; - error?: string; - }[]>; - parseQwikCode: (code: string) => Promise[]>; + ) => Promise; + getBuildAnalysisStatus: () => Promise; + buildBuildAnalysisReport: () => Promise; + getModulesByPathIds: ( + pathIds: string | string[], + ) => Promise; + parseQwikCode: (code: string) => Promise; } export type ServerRpc = BirpcReturn; @@ -75,17 +107,19 @@ export interface Component { file: string; } -export type Category = 'variableDeclaration' | 'expressionStatement' | 'listener' +export type Category = + | 'variableDeclaration' + | 'expressionStatement' + | 'listener'; export type HookType = | (typeof VARIABLE_DECLARATION_LIST)[number] | (typeof EXPRESSION_STATEMENT_LIST)[number] - | 'customhook' + | 'customhook'; export interface ParsedStructure { - variableName: string - hookType: HookType - category: Category - __start__?: number - data?: any + variableName: string; + hookType: HookType; + category: Category; + __start__?: number; + data?: any; } - diff --git a/packages/playgrounds/vite.config.mts b/packages/playgrounds/vite.config.mts index a8cd0a5..d04980b 100644 --- a/packages/playgrounds/vite.config.mts +++ b/packages/playgrounds/vite.config.mts @@ -24,7 +24,7 @@ export default defineConfig(({ command, mode }): UserConfig => { plugins: [qwikRouter(), qwikVite(), tsconfigPaths(), qwikDevtools()], build: { rollupOptions: { - external: ['path'], + external: ['path', '@qwik.dev/router/service-worker'], }, }, optimizeDeps: { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index c03a060..3da5a79 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -21,7 +21,8 @@ "test": "vitest --run" }, "dependencies": { - "oxc-parser": "^0.120.0" + "oxc-parser": "^0.120.0", + "rollup-plugin-visualizer": "^6.0.3" }, "devDependencies": { "@babel/types": "^7.29.0", diff --git a/packages/plugin/src/build-analysis/index.ts b/packages/plugin/src/build-analysis/index.ts new file mode 100644 index 0000000..3f35cd0 --- /dev/null +++ b/packages/plugin/src/build-analysis/index.ts @@ -0,0 +1,302 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { getServerRpcRequestContext } from '@devtools/kit'; +import type { + BuildAnalysisRunResult, + BuildAnalysisStatus, + ServerFunctions, +} from '@devtools/kit'; +import { visualizer } from 'rollup-plugin-visualizer'; +import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'; +import type { ServerContext } from '../types'; +import { detectPackageManager } from '../npm'; +import { + getBuildAnalysisRpcGuardError, + getBuildAnalysisRpcGuardHint, + isBuildAnalysisRpcAllowed, +} from './security'; + +const BUILD_ANALYSIS_VIEW_PATH = '/__qwik_devtools/build-analysis/report'; +const BUILD_ANALYSIS_DIR = path.join('.qwik-devtools', 'build-analysis'); +const BUILD_ANALYSIS_FILE = 'visualizer.html'; + +function findNearestPackageRoot(startDir: string): string { + let currentDir = path.resolve(startDir); + + for (let i = 0; i < 100; i++) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + return currentDir; + } + + const parent = path.dirname(currentDir); + if (parent === currentDir) break; + currentDir = parent; + } + + return path.resolve(startDir); +} + +function resolveBuildAnalysisHtmlPath(rootDir: string) { + const projectRoot = findNearestPackageRoot(rootDir); + return path.join(projectRoot, BUILD_ANALYSIS_DIR, BUILD_ANALYSIS_FILE); +} + +function resolveBuildAnalysisDirPath(rootDir: string) { + const projectRoot = findNearestPackageRoot(rootDir); + return path.join(projectRoot, BUILD_ANALYSIS_DIR); +} + +function createPlaceholderHtml(reportPath: string) { + const escapedPath = reportPath.replace(/&/g, '&').replace(/ + + + + + Build Analysis + + + +
+

Build Analysis Not Generated Yet

+

Run your project build once. The visualizer plugin will create an HTML report after the build finishes.

+

Expected file:

+ ${escapedPath} +
+ +`; +} + +function createBuildAnalysisServePlugin(): Plugin { + let reportPath = resolveBuildAnalysisHtmlPath(process.cwd()); + + return { + name: 'vite-plugin-qwik-devtools-build-analysis-viewer', + apply: 'serve', + configResolved(config) { + reportPath = resolveBuildAnalysisHtmlPath(config.root); + }, + configureServer(server) { + attachBuildAnalysisMiddleware(server, () => reportPath); + }, + }; +} + +function attachBuildAnalysisMiddleware( + server: ViteDevServer, + getReportPath: () => string, +) { + server.middlewares.use(BUILD_ANALYSIS_VIEW_PATH, async (_req, res) => { + const reportPath = getReportPath(); + const html = (await fileExists(reportPath)) + ? await fsp.readFile(reportPath, 'utf8') + : createPlaceholderHtml(reportPath); + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + }); +} + +function createBuildAnalysisPrepPlugin(): Plugin { + let reportPath = resolveBuildAnalysisHtmlPath(process.cwd()); + let reportDir = resolveBuildAnalysisDirPath(process.cwd()); + + return { + name: 'vite-plugin-qwik-devtools-build-analysis-prep', + apply: 'build', + configResolved(config: ResolvedConfig) { + reportPath = resolveBuildAnalysisHtmlPath(config.root); + reportDir = resolveBuildAnalysisDirPath(config.root); + }, + async buildStart() { + await fsp.rm(reportDir, { recursive: true, force: true }); + await fsp.mkdir(path.dirname(reportPath), { recursive: true }); + }, + }; +} + +async function fileExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + +async function readPackageJson(rootDir: string) { + const packageJsonPath = path.join(findNearestPackageRoot(rootDir), 'package.json'); + const packageJsonContent = await fsp.readFile(packageJsonPath, 'utf8'); + return JSON.parse(packageJsonContent) as { + scripts?: Record; + }; +} + +async function resolveBuildScript(rootDir: string) { + const projectRoot = findNearestPackageRoot(rootDir); + const packageJson = await readPackageJson(projectRoot); + const scripts = packageJson.scripts ?? {}; + const scriptName = scripts['build.client'] ? 'build.client' : scripts.build ? 'build' : null; + + if (!scriptName) { + return { + projectRoot, + packageManager: null, + scriptName: null, + command: null, + }; + } + + const packageManager = await detectPackageManager(projectRoot); + const command = + packageManager === 'yarn' + ? `yarn ${scriptName}` + : `${packageManager} run ${scriptName}`; + + return { + projectRoot, + packageManager, + scriptName, + command, + }; +} + +async function runBuildScript(rootDir: string): Promise { + const resolved = await resolveBuildScript(rootDir); + + if (!resolved.packageManager || !resolved.scriptName || !resolved.command) { + return { + success: false, + error: 'No build script found. Expected "build.client" or "build" in package.json.', + }; + } + + const { packageManager, projectRoot, scriptName } = resolved; + const args = + packageManager === 'yarn' ? [scriptName] : ['run', scriptName]; + + return new Promise((resolve) => { + const child = spawn(packageManager, args, { + cwd: projectRoot, + env: process.env, + stdio: 'pipe', + }); + + let output = ''; + + child.stdout.on('data', (chunk) => { + output += String(chunk); + }); + + child.stderr.on('data', (chunk) => { + output += String(chunk); + }); + + child.on('error', (error) => { + resolve({ + success: false, + error: error.message, + }); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({ success: true }); + return; + } + + resolve({ + success: false, + error: output.trim() || `Build exited with code ${code ?? 'unknown'}.`, + }); + }); + }); +} + +export function getBuildAnalysisFunctions( + ctx: ServerContext, +): Pick { + return { + async getBuildAnalysisStatus(): Promise { + const reportPath = resolveBuildAnalysisHtmlPath(ctx.config.root); + const { command } = await resolveBuildScript(ctx.config.root); + const rpcClient = getServerRpcRequestContext()?.client; + const canTriggerBuild = isBuildAnalysisRpcAllowed(rpcClient); + + return { + exists: await fileExists(reportPath), + reportPath, + buildCommand: command, + canTriggerBuild, + buildTriggerHint: + command && !canTriggerBuild ? getBuildAnalysisRpcGuardHint() : undefined, + }; + }, + async buildBuildAnalysisReport(): Promise { + const rpcClient = getServerRpcRequestContext()?.client; + if (!isBuildAnalysisRpcAllowed(rpcClient)) { + return { + success: false, + error: getBuildAnalysisRpcGuardError(), + }; + } + + return runBuildScript(ctx.config.root); + }, + }; +} + +export function createBuildAnalysisPlugins(): Plugin[] { + const reportPath = resolveBuildAnalysisHtmlPath(process.cwd()); + + return [ + createBuildAnalysisServePlugin(), + createBuildAnalysisPrepPlugin(), + visualizer({ + filename: reportPath, + template: 'treemap', + gzipSize: true, + brotliSize: true, + open: false, + }) as Plugin, + ]; +} diff --git a/packages/plugin/src/build-analysis/security.test.ts b/packages/plugin/src/build-analysis/security.test.ts new file mode 100644 index 0000000..08dccdf --- /dev/null +++ b/packages/plugin/src/build-analysis/security.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'vitest'; +import { + getRpcClientRemoteAddress, + isBuildAnalysisRpcAllowed, + isLoopbackAddress, + isRemoteBuildAnalysisEnabled, +} from './security'; + +describe('build analysis RPC security', () => { + test('allows loopback websocket clients by default', () => { + expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '127.0.0.1' } }, {})).toBe(true); + expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '::1' } }, {})).toBe(true); + expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '::ffff:127.0.0.1' } }, {})).toBe( + true, + ); + expect( + isBuildAnalysisRpcAllowed( + { socket: { _socket: { remoteAddress: '127.0.0.1' } } }, + {}, + ), + ).toBe(true); + }); + + test('rejects non-loopback websocket clients by default', () => { + expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '192.168.1.10' } }, {})).toBe( + false, + ); + expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '10.0.0.22' } }, {})).toBe(false); + expect(isBuildAnalysisRpcAllowed({}, {})).toBe(false); + }); + + test('supports explicit env opt-in for remote execution', () => { + expect( + isBuildAnalysisRpcAllowed( + { socket: { remoteAddress: '192.168.1.10' } }, + { QWIK_DEVTOOLS_ALLOW_REMOTE_BUILD_ANALYSIS: 'true' }, + ), + ).toBe(true); + expect( + isRemoteBuildAnalysisEnabled({ QWIK_DEVTOOLS_ALLOW_REMOTE_BUILD_ANALYSIS: '1' }), + ).toBe(true); + }); + + test('extracts remote address from vite websocket client shapes', () => { + expect(getRpcClientRemoteAddress({ socket: { remoteAddress: '127.0.0.1' } })).toBe( + '127.0.0.1', + ); + expect( + getRpcClientRemoteAddress({ socket: { _socket: { remoteAddress: '127.0.0.1' } } }), + ).toBe('127.0.0.1'); + expect(getRpcClientRemoteAddress({ _socket: { remoteAddress: '127.0.0.1' } })).toBe( + '127.0.0.1', + ); + expect(getRpcClientRemoteAddress(null)).toBeUndefined(); + }); + + test('recognizes supported loopback formats', () => { + expect(isLoopbackAddress('127.0.0.1')).toBe(true); + expect(isLoopbackAddress('::1')).toBe(true); + expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true); + expect(isLoopbackAddress('192.168.1.10')).toBe(false); + }); +}); diff --git a/packages/plugin/src/build-analysis/security.ts b/packages/plugin/src/build-analysis/security.ts new file mode 100644 index 0000000..e8343f2 --- /dev/null +++ b/packages/plugin/src/build-analysis/security.ts @@ -0,0 +1,92 @@ +const REMOTE_BUILD_ANALYSIS_ENV = 'QWIK_DEVTOOLS_ALLOW_REMOTE_BUILD_ANALYSIS'; +const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); + +type RpcClientSocket = { + remoteAddress?: string | null; + socket?: unknown; + _socket?: unknown; +}; + +type RpcClientLike = { + socket?: RpcClientSocket | null; + _socket?: RpcClientSocket | null; +}; + +export function isRemoteBuildAnalysisEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const rawValue = env[REMOTE_BUILD_ANALYSIS_ENV]; + if (!rawValue) return false; + return TRUTHY_ENV_VALUES.has(rawValue.trim().toLowerCase()); +} + +export function getRpcClientRemoteAddress(client: unknown): string | undefined { + if (!client || typeof client !== 'object') { + return undefined; + } + + const candidate = client as RpcClientLike; + return ( + getSocketRemoteAddress(candidate.socket) ?? getSocketRemoteAddress(candidate._socket) + ); +} + +function getSocketRemoteAddress(socket: unknown): string | undefined { + if (!socket || typeof socket !== 'object') { + return undefined; + } + + const queue: RpcClientSocket[] = [socket as RpcClientSocket]; + const seen = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) { + continue; + } + + seen.add(current); + + if (typeof current.remoteAddress === 'string' && current.remoteAddress.length > 0) { + return current.remoteAddress; + } + + if (current.socket && typeof current.socket === 'object') { + queue.push(current.socket as RpcClientSocket); + } + + if (current._socket && typeof current._socket === 'object') { + queue.push(current._socket as RpcClientSocket); + } + } + + return undefined; +} + +export function isLoopbackAddress(address: string | undefined): boolean { + if (!address) return false; + if (address === '127.0.0.1' || address === '::1') return true; + if (address.startsWith('::ffff:')) { + return isLoopbackAddress(address.slice('::ffff:'.length)); + } + return false; +} + +export function isBuildAnalysisRpcAllowed( + client: unknown, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (isRemoteBuildAnalysisEnabled(env)) { + return true; + } + + return isLoopbackAddress(getRpcClientRemoteAddress(client)); +} + +export function getBuildAnalysisRpcGuardError(): string { + return `Refusing to run the project build from a non-local DevTools RPC client. Reconnect from localhost or set ${REMOTE_BUILD_ANALYSIS_ENV}=1 to opt in to remote build-analysis execution.`; +} + +export function getBuildAnalysisRpcGuardHint(): string { + return `Automatic rebuild is unavailable from this DevTools client. Reconnect from localhost or set ${REMOTE_BUILD_ANALYSIS_ENV}=1 to opt in to remote build-analysis execution.`; +} diff --git a/packages/plugin/src/parse/componentBodies.ts b/packages/plugin/src/parse/componentBodies.ts new file mode 100644 index 0000000..465e05c --- /dev/null +++ b/packages/plugin/src/parse/componentBodies.ts @@ -0,0 +1,108 @@ +import { traverseProgram } from './traverse'; + +export interface ComponentBodyRange { + insertPos: number; + bodyStart: number; + bodyEnd: number; + exportName?: string; +} + +export function findAllComponentBodyRangesFromProgram( + program: unknown, +): ComponentBodyRange[] { + const ranges: ComponentBodyRange[] = []; + + traverseProgram(program, { + enter: (path) => { + const range = getComponentBodyRange(path.node, path.parent); + if (range) { + ranges.push(range); + } + }, + }); + + return deduplicateRanges(ranges); +} + +function getComponentBodyRange( + node: any, + parent: any, +): ComponentBodyRange | null { + if (!isComponentCall(node)) { + return null; + } + + const body = getComponentFunctionBody(node); + if (!body?.range) { + return null; + } + + const [bodyStart, bodyEnd] = body.range as [number, number]; + + return { + insertPos: bodyStart + 1, + bodyStart, + bodyEnd, + exportName: detectExportName(parent), + }; +} + +function isComponentCall(node: any): boolean { + return ( + node?.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + node.callee.name === 'component$' + ); +} + +function getComponentFunctionBody( + node: any, +): { range?: [number, number] } | null { + const componentFactory = node.arguments?.[0]; + const isFunctionFactory = + componentFactory?.type === 'ArrowFunctionExpression' || + componentFactory?.type === 'FunctionExpression'; + + if (!isFunctionFactory) { + return null; + } + + return componentFactory.body?.type === 'BlockStatement' + ? componentFactory.body + : null; +} + +function detectExportName(parent: any): string | undefined { + if (!parent) return undefined; + + if (parent.type === 'ExportDefaultDeclaration') { + return ''; + } + + if (parent.type === 'VariableDeclarator') { + const identifier = parent.id; + if ( + identifier?.type === 'Identifier' && + typeof identifier.name === 'string' + ) { + return identifier.name; + } + } + + return undefined; +} + +function deduplicateRanges(ranges: ComponentBodyRange[]): ComponentBodyRange[] { + const seen = new Set(); + + return ranges + .filter((range) => { + if (seen.has(range.insertPos)) { + return false; + } + + seen.add(range.insertPos); + return true; + }) + .sort((left, right) => left.insertPos - right.insertPos); +} diff --git a/packages/plugin/src/parse/hookTracker.ts b/packages/plugin/src/parse/hookTracker.ts index f6708b4..c15fc91 100644 --- a/packages/plugin/src/parse/hookTracker.ts +++ b/packages/plugin/src/parse/hookTracker.ts @@ -20,6 +20,7 @@ import { } from './helpers'; import { INNER_USE_HOOK } from '@devtools/kit'; import type { InjectionTask } from './types'; +import { applySourceEdits } from './sourceEdits'; // ============================================================================ // Main Entry @@ -55,7 +56,7 @@ export function injectHookTrackers(code: string): string { }, }); - return applyTasks(code, tasks); + return applySourceEdits(code, tasks); } // ============================================================================ @@ -84,7 +85,13 @@ function processVariableDeclarator( // Custom hook if (isCustomHook(normalizedName)) { if (hasCollecthookAfterByVariableId(code, declEnd, variableId)) return null; - const payload = buildCollecthookPayload(indent, variableId, 'customhook', 'VariableDeclarator', variableId); + const payload = buildCollecthookPayload( + indent, + variableId, + 'customhook', + 'VariableDeclarator', + variableId, + ); return { kind: 'insert', pos: declEnd, text: '\n' + payload }; } @@ -92,7 +99,13 @@ function processVariableDeclarator( if (!isKnownHook(normalizedName)) return null; if (hasCollecthookAfterByVariableId(code, declEnd, variableId)) return null; - const payload = buildCollecthookPayload(indent, variableId, normalizedName, 'VariableDeclarator', variableId); + const payload = buildCollecthookPayload( + indent, + variableId, + normalizedName, + 'VariableDeclarator', + variableId, + ); return { kind: 'insert', pos: declEnd, text: '\n' + payload }; } @@ -123,8 +136,15 @@ function processExpressionStatement( // Known hook (expression form) if (isKnownHook(normalizedName)) { - if (hasCollecthookAfterByVariableName(code, stmtEnd, normalizedName)) return null; - const payload = buildCollecthookPayload(indent, normalizedName, normalizedName, 'expressionStatement', 'undefined'); + if (hasCollecthookAfterByVariableName(code, stmtEnd, normalizedName)) + return null; + const payload = buildCollecthookPayload( + indent, + normalizedName, + normalizedName, + 'expressionStatement', + 'undefined', + ); return { task: { kind: 'insert', pos: stmtEnd, text: '\n' + payload }, newIndex: currentIndex, @@ -133,7 +153,13 @@ function processExpressionStatement( // Custom hook (expression form) - convert to variable declaration if (isCustomHook(normalizedName)) { - return convertToVariableDeclaration(code, stmtStart, stmtEnd, indent, currentIndex); + return convertToVariableDeclaration( + code, + stmtStart, + stmtEnd, + indent, + currentIndex, + ); } return null; @@ -152,10 +178,21 @@ function convertToVariableDeclaration( const callSource = code.slice(stmtStart, stmtEnd); const variableName = `_customhook_${currentIndex}`; const declLine = `${indent}let ${variableName} = ${trimStatementSemicolon(callSource)};\n`; - const payload = buildCollecthookPayload(indent, variableName, 'customhook', 'VariableDeclarator', variableName); + const payload = buildCollecthookPayload( + indent, + variableName, + 'customhook', + 'VariableDeclarator', + variableName, + ); return { - task: { kind: 'replace', start: stmtStart, end: stmtEnd, text: declLine + payload }, + task: { + kind: 'replace', + start: stmtStart, + end: stmtEnd, + text: declLine + payload, + }, newIndex: currentIndex + 1, }; } @@ -171,32 +208,34 @@ interface HookInfo { } function extractHookInfo(node: any): HookInfo | null { - const init = node.init; - if (!isAstNodeLike(init) || init.type !== 'CallExpression') return null; - - const callee = (init as any).callee; - if (!isAstNodeLike(callee) || callee.type !== 'Identifier') return null; - - const hookName = normalizeHookName((callee as any).name as string); - const normalizedName = normalizeQrlHookName(hookName); const variableId = getVariableIdentifierName(node.id); - if (!variableId) return null; - return { hookName, normalizedName, variableId }; + const hookCall = extractHookCall(node.init); + if (!hookCall) return null; + + return { ...hookCall, variableId }; +} + +function extractExpressionHookInfo( + node: any, +): { hookName: string; normalizedName: string } | null { + return extractHookCall(node.expression); } -function extractExpressionHookInfo(node: any): { hookName: string; normalizedName: string } | null { - const expr = node.expression; - if (!isAstNodeLike(expr) || expr.type !== 'CallExpression') return null; +function extractHookCall( + node: unknown, +): { hookName: string; normalizedName: string } | null { + if (!isAstNodeLike(node) || node.type !== 'CallExpression') return null; - const callee = (expr as any).callee; + const callee = (node as any).callee; if (!isAstNodeLike(callee) || callee.type !== 'Identifier') return null; const hookName = normalizeHookName((callee as any).name as string); - const normalizedName = normalizeQrlHookName(hookName); - - return { hookName, normalizedName }; + return { + hookName, + normalizedName: normalizeQrlHookName(hookName), + }; } // ============================================================================ @@ -209,35 +248,12 @@ function getParentRange(parent: any): [number, number] | null { return [range[0], range[1]]; } -function getPositionInfo(code: string, range: [number, number]): { declStart: number; declEnd: number; indent: string } { +function getPositionInfo( + code: string, + range: [number, number], +): { declStart: number; declEnd: number; indent: string } { const [declStart, declEnd] = range; const lineStart = findLineStart(code, declStart); const indent = readIndent(code, lineStart); return { declStart, declEnd, indent }; } - -// ============================================================================ -// Task Application -// ============================================================================ - -function applyTasks(code: string, tasks: InjectionTask[]): string { - if (tasks.length === 0) return code; - - // Sort from last to first to keep positions stable - tasks.sort((a, b) => { - const aPos = a.kind === 'insert' ? a.pos : a.start; - const bPos = b.kind === 'insert' ? b.pos : b.start; - return bPos - aPos; - }); - - let result = code; - for (const task of tasks) { - if (task.kind === 'insert') { - result = result.slice(0, task.pos) + task.text + result.slice(task.pos); - } else { - result = result.slice(0, task.start) + task.text + result.slice(task.end); - } - } - return result; -} - diff --git a/packages/plugin/src/parse/initInjector.ts b/packages/plugin/src/parse/initInjector.ts index b033c57..c46068c 100644 --- a/packages/plugin/src/parse/initInjector.ts +++ b/packages/plugin/src/parse/initInjector.ts @@ -3,10 +3,11 @@ * Injects collecthook setup and render stats at the beginning of each component body */ -import { ComponentBodyRange } from './traverse'; +import type { ComponentBodyRange } from './componentBodies'; import { readIndent } from './helpers'; import { INNER_USE_HOOK } from '@devtools/kit'; -import type { InjectOptions, InitTask } from './types'; +import type { InjectOptions, InsertTask } from './types'; +import { applySourceEdits } from './sourceEdits'; // ============================================================================ // Main Entry @@ -20,7 +21,7 @@ export function injectInitHooks( bodies: ComponentBodyRange[], options?: InjectOptions, ): string { - const tasks: InitTask[] = []; + const tasks: InsertTask[] = []; for (const body of bodies) { const task = createInitTask(code, body, options); @@ -29,7 +30,7 @@ export function injectInitHooks( } } - return applyTasks(code, tasks); + return applySourceEdits(code, tasks); } // ============================================================================ @@ -43,7 +44,7 @@ function createInitTask( code: string, body: ComponentBodyRange, options?: InjectOptions, -): InitTask | null { +): InsertTask | null { const { insertPos, exportName } = body; // Skip if already has collecthook initialization @@ -52,18 +53,17 @@ function createInitTask( } // Calculate insertion position - const { insertIndex, prefixNewline } = calculateInsertPosition(code, insertPos); + const { insertIndex, prefixNewline } = calculateInsertPosition( + code, + insertPos, + ); const indent = readIndent(code, insertIndex); // Build initialization code const componentArg = buildComponentArg(options?.path, exportName); const initLine = `${prefixNewline}${indent}const collecthook = ${INNER_USE_HOOK}(${componentArg})\n`; - return { - start: insertIndex, - end: insertIndex, - text: initLine, - }; + return { kind: 'insert', pos: insertIndex, text: initLine }; } // ============================================================================ @@ -75,7 +75,10 @@ function hasExistingCollecthook(code: string, insertPos: number): boolean { return /const\s+collecthook\s*=\s*useCollectHooks\s*\(/.test(lookahead); } -function calculateInsertPosition(code: string, insertPos: number): { +function calculateInsertPosition( + code: string, + insertPos: number, +): { insertIndex: number; prefixNewline: string; } { @@ -95,14 +98,20 @@ function calculateInsertPosition(code: string, insertPos: number): { /** * Builds the component argument string for collecthook initialization */ -function buildComponentArg(path: string | undefined, exportName: string | undefined): string { +function buildComponentArg( + path: string | undefined, + exportName: string | undefined, +): string { const rawArg = String(path ?? ''); const baseArg = rawArg.split('?')[0].split('#')[0]; const suffix = buildComponentSuffix(baseArg, exportName); return JSON.stringify(`${baseArg}${suffix}`); } -function buildComponentSuffix(baseArg: string, exportName: string | undefined): string { +function buildComponentSuffix( + baseArg: string, + exportName: string | undefined, +): string { if (exportName && typeof exportName === 'string') { return `_${exportName}`; } @@ -117,20 +126,3 @@ function buildComponentSuffix(baseArg: string, exportName: string | undefined): const name = file.replace(/\.[^.]+$/, ''); return name ? `_${name.replace(/-/g, '_')}` : ''; } - - -// ============================================================================ -// Task Application -// ============================================================================ - -function applyTasks(code: string, tasks: InitTask[]): string { - // Sort from last to first to keep positions stable - tasks.sort((a, b) => b.start - a.start); - - let result = code; - for (const task of tasks) { - result = result.slice(0, task.start) + task.text + result.slice(task.end); - } - return result; -} - diff --git a/packages/plugin/src/parse/parse.ts b/packages/plugin/src/parse/parse.ts index 1120e13..f7041d7 100644 --- a/packages/plugin/src/parse/parse.ts +++ b/packages/plugin/src/parse/parse.ts @@ -3,7 +3,8 @@ * Transforms Qwik component code by injecting devtools hooks */ -import { parseProgram, findAllComponentBodyRangesFromProgram } from './traverse'; +import { parseProgram } from './traverse'; +import { findAllComponentBodyRangesFromProgram } from './componentBodies'; import { injectInitHooks } from './initInjector'; import { injectHookTrackers } from './hookTracker'; @@ -15,7 +16,10 @@ export type { InjectOptions } from './types'; * Phase 1: Inject initialization hooks (collecthook setup + render stats) * Phase 2: Inject collecthook calls for individual hooks */ -export function parseQwikCode(code: string, options?: { path?: string}): string { +export function parseQwikCode( + code: string, + options?: { path?: string }, +): string { const program = parseProgram(code); const componentBodies = findAllComponentBodyRangesFromProgram(program); diff --git a/packages/plugin/src/parse/sourceEdits.ts b/packages/plugin/src/parse/sourceEdits.ts new file mode 100644 index 0000000..9ccf12a --- /dev/null +++ b/packages/plugin/src/parse/sourceEdits.ts @@ -0,0 +1,27 @@ +import type { SourceEdit } from './types'; + +export function applySourceEdits(code: string, edits: SourceEdit[]): string { + if (edits.length === 0) { + return code; + } + + const orderedEdits = [...edits].sort( + (left, right) => getEditStart(right) - getEditStart(left), + ); + let result = code; + + for (const edit of orderedEdits) { + if (edit.kind === 'insert') { + result = result.slice(0, edit.pos) + edit.text + result.slice(edit.pos); + continue; + } + + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end); + } + + return result; +} + +function getEditStart(edit: SourceEdit): number { + return edit.kind === 'insert' ? edit.pos : edit.start; +} diff --git a/packages/plugin/src/parse/traverse.ts b/packages/plugin/src/parse/traverse.ts index 45200d8..cd1272b 100644 --- a/packages/plugin/src/parse/traverse.ts +++ b/packages/plugin/src/parse/traverse.ts @@ -24,13 +24,6 @@ export type Visitor = { [type: string]: VisitFn | VisitObj | undefined; }; -export interface ComponentBodyRange { - insertPos: number; - bodyStart: number; - bodyEnd: number; - exportName?: string; -} - // ============================================================================ // Parser // ============================================================================ @@ -68,7 +61,10 @@ function callVisitor( if (specific) specific(path); // Call type-specific handler - const handler = visitor[path.node && (path.node as any).type] as VisitFn | VisitObj | undefined; + const handler = visitor[path.node && (path.node as any).type] as + | VisitFn + | VisitObj + | undefined; if (!handler) return; if (typeof handler === 'function' && hook === 'enter') { @@ -85,7 +81,11 @@ function callVisitor( /** * Traverses an AST program with a visitor pattern */ -export function traverseProgram(program: unknown, visitor: Visitor, state?: any): void { +export function traverseProgram( + program: unknown, + visitor: Visitor, + state?: any, +): void { let shouldStopAll = false; function traverse( @@ -105,8 +105,12 @@ export function traverseProgram(program: unknown, visitor: Visitor, state?: any) key, index, state, - stop: () => { shouldStopAll = true; }, - skip: () => { shouldSkipChildren = true; }, + stop: () => { + shouldStopAll = true; + }, + skip: () => { + shouldSkipChildren = true; + }, }; // Enter phase @@ -137,89 +141,11 @@ export function traverseProgram(program: unknown, visitor: Visitor, state?: any) /** * Parses code and traverses the resulting AST */ -export function traverseQwik(code: string, visitor: Visitor, state?: any): void { +export function traverseQwik( + code: string, + visitor: Visitor, + state?: any, +): void { const program = parseProgram(code); traverseProgram(program, visitor, state); } - -// ============================================================================ -// Component Body Detection -// ============================================================================ - -/** - * Finds all component$ function bodies in the AST and returns their positions - */ -export function findAllComponentBodyRangesFromProgram(program: unknown): ComponentBodyRange[] { - const ranges: ComponentBodyRange[] = []; - - traverseProgram(program, { - enter: (path) => { - const node: any = path.node; - if (!node || node.type !== 'CallExpression') return; - - // Check if this is a component$ call - const callee = node.callee; - if (!callee || callee.type !== 'Identifier' || callee.name !== 'component$') return; - - // Get the function argument - const firstArg = node.arguments?.[0]; - const isFunction = - firstArg?.type === 'ArrowFunctionExpression' || - firstArg?.type === 'FunctionExpression'; - if (!isFunction) return; - - // Get the function body block - const body = firstArg.body; - if (body?.type !== 'BlockStatement' || !Array.isArray(body.range)) return; - - const start = body.range[0] as number; - const end = body.range[1] as number; - - // Detect export name from parent context - const exportName = detectExportName(path.parent); - - ranges.push({ - insertPos: start + 1, - bodyStart: start, - bodyEnd: end, - exportName, - }); - }, - }); - - // De-duplicate and sort by position ascending - return deduplicateAndSort(ranges); -} - -/** - * Detects the export name from the parent node context - */ -function detectExportName(parent: any): string | undefined { - if (!parent) return undefined; - - if (parent.type === 'ExportDefaultDeclaration') { - return ''; - } - - if (parent.type === 'VariableDeclarator') { - const id = parent.id; - if (id?.type === 'Identifier' && typeof id.name === 'string') { - return id.name; - } - } - - return undefined; -} - -/** - * Removes duplicate ranges and sorts by insertPos ascending - */ -function deduplicateAndSort(ranges: ComponentBodyRange[]): ComponentBodyRange[] { - const seen = new Set(); - const unique = ranges.filter((r) => { - if (seen.has(r.insertPos)) return false; - seen.add(r.insertPos); - return true; - }); - return unique.sort((a, b) => a.insertPos - b.insertPos); -} diff --git a/packages/plugin/src/parse/types.ts b/packages/plugin/src/parse/types.ts index b47832c..f9c1684 100644 --- a/packages/plugin/src/parse/types.ts +++ b/packages/plugin/src/parse/types.ts @@ -7,7 +7,11 @@ export interface InjectOptions { } export type InsertTask = { kind: 'insert'; pos: number; text: string }; -export type ReplaceTask = { kind: 'replace'; start: number; end: number; text: string }; -export type InjectionTask = InsertTask | ReplaceTask; -export type InitTask = { start: number; end: number; text: string }; - +export type ReplaceTask = { + kind: 'replace'; + start: number; + end: number; + text: string; +}; +export type SourceEdit = InsertTask | ReplaceTask; +export type InjectionTask = SourceEdit; diff --git a/packages/plugin/src/plugin/index.ts b/packages/plugin/src/plugin/index.ts index b322f8f..420ea4d 100644 --- a/packages/plugin/src/plugin/index.ts +++ b/packages/plugin/src/plugin/index.ts @@ -1,5 +1,6 @@ import { type Plugin } from 'vite'; import VueInspector from 'vite-plugin-inspect'; +import { createBuildAnalysisPlugins } from '../build-analysis'; import { devtoolsPlugin } from './devtools'; import { statisticsPlugin } from './statistics'; @@ -14,6 +15,7 @@ export function qwikDevtools(): Plugin[] { devtoolsPlugin(), { ...VueInspector(), apply: 'serve' }, statisticsPlugin(), + ...createBuildAnalysisPlugins(), // Add more plugins here as needed ]; } diff --git a/packages/plugin/src/plugin/statistics.ts b/packages/plugin/src/plugin/statistics.ts index cb758a4..13a157e 100644 --- a/packages/plugin/src/plugin/statistics.ts +++ b/packages/plugin/src/plugin/statistics.ts @@ -1,288 +1,46 @@ import type { Plugin } from 'vite'; -import { debug } from 'debug'; -import perfLazyWrapperPreamble from '../virtualmodules/perfLazyWrapperPreamble'; -import { isVirtualId, normalizeId } from '../virtualmodules/virtualModules'; +import { normalizeId } from '../virtualmodules/virtualModules'; +import { attachSsrPerfInjectorMiddleware } from './statistics/ssrPerfMiddleware'; +import { + findQwikLazyComponentExports, + isPerfVirtualModuleId, + rewriteComponentQrlImport, + shouldTransformStatisticsSource, + wrapQwikLazyComponentExports, +} from './statistics/sourceTransforms'; /** * Statistics plugin: collect Qwik render performance. * - * Responsibilities (kept similar to `plugin/devtools.ts` structure): - * - Transform: rewrite `componentQrl` imports to a virtual module - * - Transform: wrap Qwik-generated lazy render modules (`_component_`) to record perf entries - * - Dev SSR: inject SSR perf snapshot into final HTML - * - * The virtual module source for `componentQrl` is registered in: - * - `packages/plugin/src/virtualmodules/qwikComponentProxy.ts` - * and is served by the core devtools plugin (`plugin/devtools.ts`) via `virtualmodules/virtualModules.ts`. - * - * Data sinks: - * - **CSR**: `window.__QWIK_PERF__ = { ssr: [], csr: [] }` - * - **SSR**: stored on `process` (preferred) or `globalThis` as `__QWIK_SSR_PERF__` + * Responsibilities: + * - Rewrite `componentQrl` imports to a proxy virtual module + * - Wrap generated lazy component modules so CSR perf can be recorded + * - Inject SSR perf snapshots into dev HTML responses */ - -// ============================================================================ -// Constants -// ============================================================================ - -const PERF_VIRTUAL_ID = 'virtual:qwik-component-proxy'; -const log = debug('qwik:devtools:perf'); - -type AnyRecord = Record; - -// ============================================================================ -// Shared env helpers -// ============================================================================ - -function getStoreForSSR(): AnyRecord { - // NOTE: Vite SSR module-runner may execute in an isolated context; `globalThis` may not be - // the same as the dev server's `globalThis`. Using `process` is usually cross-context. - return typeof process !== 'undefined' && process - ? (process as unknown as AnyRecord) - : (globalThis as AnyRecord); -} - -function isFromNodeModules(cleanId: string): boolean { - return cleanId.includes('/node_modules/') || cleanId.includes('\\node_modules\\'); -} - -function isUiLibBuildOutput(cleanId: string): boolean { - // Avoid rewriting the already-built UI library (`.qwik.mjs` etc). - return cleanId.includes('/packages/ui/lib/') || cleanId.includes('\\packages\\ui\\lib\\'); -} - -function shouldTransformSource(id: string): boolean { - const cleanId = normalizeId(id); - // We intentionally do NOT skip all virtual modules: Qwik (and Vite) generate many `\0` ids, - // and SSR instrumentation needs to cover them. We only skip third-party deps / build outputs. - return !isFromNodeModules(cleanId) && !isUiLibBuildOutput(cleanId); -} - -function isPerfVirtualModuleId(id: string): boolean { - return isVirtualId(id, PERF_VIRTUAL_ID); -} - -// ============================================================================ -// Transform: rewrite componentQrl import -// ============================================================================ - -function rewriteComponentQrlImport(code: string, id: string): { code: string; changed: boolean } { - if (!code.includes('@qwik.dev/core') || !code.includes('componentQrl')) { - return { code, changed: false }; - } - - // Match: `import { ... componentQrl ... } from '@qwik.dev/core'` - const importRe = /import\s*\{([^}]*)\}\s*from\s*['"]@qwik\.dev\/core['"]/g; - let changed = false; - const next = code.replace(importRe, (match, imports) => { - const importList = String(imports) - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - const hasComponentQrl = importList.some( - (imp) => imp === 'componentQrl' || imp.startsWith('componentQrl ') - ); - if (!hasComponentQrl) return match; - - changed = true; - log('rewrite componentQrl import %O', { - id, - isVirtual: normalizeId(id).startsWith('\0'), - }); - - // Filter out `componentQrl` (including `componentQrl as alias`) - const filteredImports = importList.filter( - (imp) => imp !== 'componentQrl' && !imp.startsWith('componentQrl ') - ); - - if (filteredImports.length === 0) { - // Only `componentQrl` was imported: fully replace it - return `import { componentQrl } from '${PERF_VIRTUAL_ID}'`; - } - - // Keep other imports and add the virtual `componentQrl` - return `import { ${filteredImports.join( - ', ' - )} } from '@qwik.dev/core';\nimport { componentQrl } from '${PERF_VIRTUAL_ID}'`; - }); - - return { code: next, changed }; -} - -// ============================================================================ -// Transform: wrap Qwik lazy render modules (`_component_`) -// ============================================================================ - -function findQwikLazyComponentExports(code: string): string[] { - // Match: `export const XXX_component_HASH = ...` - const exportRe = /export\s+const\s+(\w+_component_\w+)\s*=/g; - const exports: string[] = []; - let match: RegExpExecArray | null; - - while ((match = exportRe.exec(code)) !== null) { - exports.push(match[1]); - } - - return exports; -} - -function replaceExportWithOriginal(code: string, exportName: string): string { - return code.replace( - new RegExp(`export\\s+const\\s+${exportName}\\s*=`), - `const __original_${exportName}__ =` - ); -} - -function appendWrappedExport(code: string, exportName: string, id: string): string { - return ( - code + - ` -export const ${exportName} = __qwik_wrap__(__original_${exportName}__, '${exportName}', '${id}'); -` - ); -} - -function wrapQwikLazyComponentExports(params: { - code: string; - id: string; - exports: string[]; -}): { code: string; changed: boolean } { - const { exports, id } = params; - if (exports.length === 0) return { code: params.code, changed: false }; - - log('wrap _component_ exports %O', { id, count: exports.length }); - - let modifiedCode = perfLazyWrapperPreamble + params.code; - - // Replace each export by wrapping the original function - for (const exportName of exports) { - modifiedCode = replaceExportWithOriginal(modifiedCode, exportName); - modifiedCode = appendWrappedExport(modifiedCode, exportName, id); - } - - return { code: modifiedCode, changed: true }; -} - -// ============================================================================ -// Dev SSR: inject SSR perf snapshot into HTML -// ============================================================================ - -function createSsrPerfInjectionScript(entries: unknown[]): string { - // Inject SSR perf data into the final HTML page so the UI can display it. - return ` -`; -} - -type MiddlewareNext = (err?: unknown) => void; -type MinimalMiddlewareReq = { headers: Record; url?: string }; -type MinimalMiddlewareRes = { - write: (...args: any[]) => any; - end: (...args: any[]) => any; - setHeader: (name: string, value: any) => void; -}; - -function attachSsrPerfInjectorMiddleware(server: any) { - // In dev SSR, Qwik streams HTML; `transformIndexHtml` often runs before SSR rendering finishes, - // causing `__QWIK_SSR_PERF__` to still be empty at injection time. - // Instead, intercept the final HTML response and inject after SSR completes. - server.middlewares.use((req: MinimalMiddlewareReq, res: MinimalMiddlewareRes, next: MiddlewareNext) => { - const accept = req.headers.accept || ''; - if (!accept.includes('text/html')) return next(); - - // The SSR collector uses "global accumulation + de-dupe". - // Do NOT clear it per-request, otherwise we may inject empty data if sampling hasn't occurred yet. - const store = getStoreForSSR() as unknown as Record; - - const originalWrite = res.write.bind(res); - const originalEnd = res.end.bind(res); - - let body = ''; - - res.write = function ( - chunk: unknown, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void - ): boolean { - if (chunk) { - body += typeof chunk === 'string' ? chunk : Buffer.from(chunk as any).toString(); - } - // Don't flush immediately; inject on `end` (in dev it's OK to sacrifice streaming) - if (typeof encodingOrCallback === 'function') encodingOrCallback(); - if (typeof callback === 'function') callback(); - return true; - } as typeof res.write; - - res.end = function ( - chunk?: unknown, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void - ): typeof res { - if (chunk) { - body += typeof chunk === 'string' ? chunk : Buffer.from(chunk as any).toString(); - } - - if (body.includes('')) { - const rawArr = store.__QWIK_SSR_PERF__; - const entries = Array.isArray(rawArr) ? rawArr : []; - log('inject ssr perf %O', { url: req.url, total: entries.length }); - const script = createSsrPerfInjectionScript(entries); - - // Place at the start of `` so it runs earlier than other head scripts - body = body.replace(/]*)?>/i, (m) => `${m}${script}`); - } - - try { - res.setHeader('Content-Length', Buffer.byteLength(body)); - } catch { - // ignore - } - - originalWrite(body); - - if (typeof encodingOrCallback === 'function') encodingOrCallback(); - if (typeof callback === 'function') callback(); - - return originalEnd(); - } as typeof res.end; - - next(); - }); -} - -// ============================================================================ -// Plugin factory (similar entry-point style to devtools.ts) -// ============================================================================ - export function statisticsPlugin(): Plugin { return { name: 'vite:qwik-component-proxy-transform', enforce: 'post', apply: 'serve', transform(code, id) { - // Avoid rewriting imports inside the perf virtual module itself (otherwise `originalComponentQrl` - // could become self-referential/undefined) if (isPerfVirtualModuleId(id)) return null; - - // By default, skip dependencies and build outputs (otherwise we'd transform node_modules / ui's `.qwik.mjs` as well) - if (!shouldTransformSource(id)) return null; + if (!shouldTransformStatisticsSource(id)) return null; let modifiedCode = code; let hasChanges = false; - // 1) Replace `componentQrl` import from `@qwik.dev/core` -> virtual module const rewritten = rewriteComponentQrlImport(modifiedCode, id); modifiedCode = rewritten.code; hasChanges = hasChanges || rewritten.changed; - // 2) Handle Qwik-generated lazy render function modules (`_component_`) const cleanId = normalizeId(id); if (cleanId.includes('_component_')) { const exports = findQwikLazyComponentExports(code); - const wrapped = wrapQwikLazyComponentExports({ code: modifiedCode, id, exports }); + const wrapped = wrapQwikLazyComponentExports({ + code: modifiedCode, + id, + exports, + }); modifiedCode = wrapped.code; hasChanges = hasChanges || wrapped.changed; } @@ -295,4 +53,4 @@ export function statisticsPlugin(): Plugin { attachSsrPerfInjectorMiddleware(server); }, }; -} \ No newline at end of file +} diff --git a/packages/plugin/src/plugin/statistics/constants.ts b/packages/plugin/src/plugin/statistics/constants.ts new file mode 100644 index 0000000..af08ed0 --- /dev/null +++ b/packages/plugin/src/plugin/statistics/constants.ts @@ -0,0 +1,6 @@ +import { debug } from 'debug'; + +export const PERF_VIRTUAL_ID = 'virtual:qwik-component-proxy'; +export const log = debug('qwik:devtools:perf'); + +export type AnyRecord = Record; diff --git a/packages/plugin/src/plugin/statistics/sourceTransforms.test.ts b/packages/plugin/src/plugin/statistics/sourceTransforms.test.ts new file mode 100644 index 0000000..6b2ed40 --- /dev/null +++ b/packages/plugin/src/plugin/statistics/sourceTransforms.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; +import { wrapQwikLazyComponentExports } from './sourceTransforms'; + +describe('sourceTransforms', () => { + test('escapes Windows module ids in wrapped lazy component exports', () => { + const id = 'C:\\Users\\alice\\src\\app\\entry_component_abc123.tsx'; + const source = ` +export const Foo_component_abc123 = () => null; +`; + + const result = wrapQwikLazyComponentExports({ + code: source, + id, + exports: ['Foo_component_abc123'], + }); + + expect(result.changed).toBe(true); + expect(result.code).toContain(JSON.stringify(id)); + expect(result.code).toContain( + '__qwik_wrap__(__original_Foo_component_abc123__, "Foo_component_abc123"', + ); + expect(result.code).not.toContain(`'${id}'`); + }); +}); diff --git a/packages/plugin/src/plugin/statistics/sourceTransforms.ts b/packages/plugin/src/plugin/statistics/sourceTransforms.ts new file mode 100644 index 0000000..03e00cd --- /dev/null +++ b/packages/plugin/src/plugin/statistics/sourceTransforms.ts @@ -0,0 +1,124 @@ +import perfLazyWrapperPreamble from '../../virtualmodules/perfLazyWrapperPreamble'; +import { isVirtualId, normalizeId } from '../../virtualmodules/virtualModules'; +import { PERF_VIRTUAL_ID, log } from './constants'; + +export function shouldTransformStatisticsSource(id: string): boolean { + const cleanId = normalizeId(id); + return !isFromNodeModules(cleanId) && !isUiLibBuildOutput(cleanId); +} + +export function isPerfVirtualModuleId(id: string): boolean { + return isVirtualId(id, PERF_VIRTUAL_ID); +} + +export function rewriteComponentQrlImport( + code: string, + id: string, +): { code: string; changed: boolean } { + if (!code.includes('@qwik.dev/core') || !code.includes('componentQrl')) { + return { code, changed: false }; + } + + const importRe = /import\s*\{([^}]*)\}\s*from\s*['"]@qwik\.dev\/core['"]/g; + let changed = false; + + const nextCode = code.replace(importRe, (match, imports) => { + const importList = String(imports) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + const hasComponentQrl = importList.some( + (item) => item === 'componentQrl' || item.startsWith('componentQrl '), + ); + if (!hasComponentQrl) { + return match; + } + + changed = true; + log('rewrite componentQrl import %O', { + id, + isVirtual: normalizeId(id).startsWith('\0'), + }); + + const preservedImports = importList.filter( + (item) => item !== 'componentQrl' && !item.startsWith('componentQrl '), + ); + + if (preservedImports.length === 0) { + return `import { componentQrl } from '${PERF_VIRTUAL_ID}'`; + } + + return [ + `import { ${preservedImports.join(', ')} } from '@qwik.dev/core';`, + `import { componentQrl } from '${PERF_VIRTUAL_ID}'`, + ].join('\n'); + }); + + return { code: nextCode, changed }; +} + +export function findQwikLazyComponentExports(code: string): string[] { + const exportRe = /export\s+const\s+(\w+_component_\w+)\s*=/g; + const exports: string[] = []; + let match: RegExpExecArray | null; + + while ((match = exportRe.exec(code)) !== null) { + exports.push(match[1]); + } + + return exports; +} + +export function wrapQwikLazyComponentExports(params: { + code: string; + id: string; + exports: string[]; +}): { code: string; changed: boolean } { + const { code, exports, id } = params; + if (exports.length === 0) { + return { code, changed: false }; + } + + log('wrap _component_ exports %O', { id, count: exports.length }); + + let modifiedCode = perfLazyWrapperPreamble + code; + for (const exportName of exports) { + modifiedCode = replaceExportWithOriginal(modifiedCode, exportName); + modifiedCode = appendWrappedExport(modifiedCode, exportName, id); + } + + return { code: modifiedCode, changed: true }; +} + +function isFromNodeModules(cleanId: string): boolean { + return ( + cleanId.includes('/node_modules/') || cleanId.includes('\\node_modules\\') + ); +} + +function isUiLibBuildOutput(cleanId: string): boolean { + return ( + cleanId.includes('/packages/ui/lib/') || + cleanId.includes('\\packages\\ui\\lib\\') + ); +} + +function replaceExportWithOriginal(code: string, exportName: string): string { + return code.replace( + new RegExp(`export\\s+const\\s+${exportName}\\s*=`), + `const __original_${exportName}__ =`, + ); +} + +function appendWrappedExport( + code: string, + exportName: string, + id: string, +): string { + const serializedExportName = JSON.stringify(exportName); + const serializedId = JSON.stringify(id); + return `${code} +export const ${exportName} = __qwik_wrap__(__original_${exportName}__, ${serializedExportName}, ${serializedId}); +`; +} diff --git a/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.test.ts b/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.test.ts new file mode 100644 index 0000000..fc4c357 --- /dev/null +++ b/packages/plugin/src/plugin/statistics/ssrPerfMiddleware.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from 'vitest'; +import { + attachSsrPerfInjectorMiddleware, + collectSsrPreloadEntries, + extractSsrPreloadEntriesFromHtml, + injectSsrDevtoolsIntoHtml, +} from './ssrPerfMiddleware'; + +describe('ssr preload middleware helpers', () => { + test('extracts preload links from html', () => { + const html = ` + + + + + + + + `; + + expect(extractSsrPreloadEntriesFromHtml(html)).toEqual([ + expect.objectContaining({ + href: '/build/q-a.js', + rel: 'modulepreload', + resourceType: 'script', + phase: 'ssr', + }), + expect.objectContaining({ + href: '/build/q-b.css', + rel: 'preload', + as: 'style', + resourceType: 'style', + phase: 'ssr', + }), + ]); + }); + + test('merges html preload links with optional server snapshot entries', () => { + const html = ``; + const entries = collectSsrPreloadEntries(html, { + __QWIK_SSR_PRELOADS__: [ + { + href: '/build/q-a.js', + loadDuration: 12, + loadMatchQuality: 'best-effort', + qrlSymbol: 's_q_a', + }, + ], + }); + + expect(entries).toEqual([ + expect.objectContaining({ + href: '/build/q-a.js', + phase: 'ssr', + loadDuration: 12, + loadMatchQuality: 'best-effort', + qrlSymbol: 's_q_a', + }), + ]); + }); + + test('injects preload and perf scripts into html', () => { + const html = ''; + const nextHtml = injectSsrDevtoolsIntoHtml( + html, + { + __QWIK_SSR_PERF__: [{ component: 'App', phase: 'ssr', duration: 1 }], + __QWIK_SSR_PRELOADS__: [{ href: '/build/q-a.js', phase: 'ssr', loadDuration: 9 }], + }, + '/demo', + ); + + expect(nextHtml).toContain('qwik:ssr-perf'); + expect(nextHtml).toContain('qwik:ssr-preloads'); + expect(nextHtml).toContain('window.__QWIK_SSR_PRELOADS__'); + expect(nextHtml).toContain('/build/q-a.js'); + }); + + test('normalizes array accept headers before checking for html requests', () => { + let middleware: + | (( + req: { headers: Record; url?: string }, + res: { + write: (...args: any[]) => any; + end: (...args: any[]) => any; + setHeader: (name: string, value: any) => void; + }, + next: (err?: unknown) => void, + ) => void) + | undefined; + + attachSsrPerfInjectorMiddleware({ + middlewares: { + use(fn: typeof middleware) { + middleware = fn; + }, + }, + }); + + expect(middleware).toBeTypeOf('function'); + + const html = ''; + let written = ''; + let ended = false; + const headers = new Map(); + const res = { + write(chunk: unknown) { + written += String(chunk); + return true; + }, + end(chunk?: unknown) { + if (chunk) { + written += String(chunk); + } + ended = true; + return this; + }, + setHeader(name: string, value: number) { + headers.set(name, value); + }, + }; + const processWithPerf = process as typeof process & { + __QWIK_SSR_PERF__?: unknown[]; + }; + processWithPerf.__QWIK_SSR_PERF__ = [{ component: 'App', phase: 'ssr', duration: 1 }]; + + try { + middleware!( + { + headers: { + accept: ['application/xhtml+xml', 'text/html'], + }, + url: '/demo', + }, + res, + () => {}, + ); + + expect(ended).toBe(false); + res.end(html); + } finally { + delete processWithPerf.__QWIK_SSR_PERF__; + } + + expect(headers.get('Content-Length')).toBeGreaterThan(0); + expect(written).toContain('qwik:ssr-perf'); + expect(ended).toBe(true); + expect(written).toContain(''); + expect(written).toContain('`; +} + +function createSsrPreloadInjectionScript(entries: SsrPreloadSnapshotEntry[]): string { + const serializedEntries = JSON.stringify(entries); + return ` +`; +} + +function parseAttributes(raw: string): Record { + const attrs: Record = {}; + const attrRe = /([^\s=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let attrMatch: RegExpExecArray | null; + + while ((attrMatch = attrRe.exec(raw)) !== null) { + const [, key, dq, sq, bare] = attrMatch; + attrs[key.toLowerCase()] = dq || sq || bare || ''; + } + + return attrs; +} + +function inferResourceType(rel: string, asValue: string, href: string) { + if (asValue) return asValue; + if (rel === 'modulepreload') return 'script'; + const cleanHref = href.split('#')[0].split('?')[0]; + const ext = cleanHref.includes('.') ? cleanHref.slice(cleanHref.lastIndexOf('.') + 1).toLowerCase() : ''; + if (['js', 'mjs', 'cjs'].includes(ext)) return 'script'; + if (ext === 'css') return 'style'; + return 'other'; +} diff --git a/packages/plugin/src/rpc/index.ts b/packages/plugin/src/rpc/index.ts index 6c4c605..88e413e 100644 --- a/packages/plugin/src/rpc/index.ts +++ b/packages/plugin/src/rpc/index.ts @@ -1,5 +1,6 @@ import { ServerFunctions } from '@devtools/kit'; import { getAssetsFunctions } from '../assets'; +import { getBuildAnalysisFunctions } from '../build-analysis'; import { ServerContext } from '../types'; import { getRouteFunctions } from '../routes'; import { getNpmFunctions } from '../npm'; @@ -13,6 +14,7 @@ export function getServerFunctions(ctx: ServerContext): ServerFunctions { ...getComponentsFunctions(ctx), ...getRouteFunctions(ctx), ...getNpmFunctions(ctx), + ...getBuildAnalysisFunctions(ctx), ...getModulesContent(ctx), }; } diff --git a/packages/plugin/src/virtualmodules/virtualModules.ts b/packages/plugin/src/virtualmodules/virtualModules.ts index bdc03d6..8e53ce5 100644 --- a/packages/plugin/src/virtualmodules/virtualModules.ts +++ b/packages/plugin/src/virtualmodules/virtualModules.ts @@ -27,7 +27,7 @@ export const VIRTUAL_MODULES: VirtualModuleConfig[] = [ key: 'virtual:qwik-component-proxy', source: qwikComponentProxySource, hookName: '', - } + }, ]; // ============================================================================ @@ -97,4 +97,3 @@ export function transformRootFile(code: string): string { return code; } - diff --git a/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx b/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx index 7903141..116bfc7 100644 --- a/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx +++ b/packages/ui/src/components/DevtoolsButton/DevtoolsButton.tsx @@ -1,5 +1,5 @@ import { component$, useSignal, $, useTask$ } from '@qwik.dev/core'; -import type { State } from '../../types/state'; // Assuming State type is defined elsewhere +import type { State } from '../../types/state'; interface DevtoolsButtonProps { state: State; diff --git a/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx b/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx index 573b0c1..18ddc1d 100644 --- a/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx +++ b/packages/ui/src/components/DevtoolsPanel/DevtoolsPanel.tsx @@ -1,21 +1,315 @@ -import { component$, Slot, useTask$, isBrowser } from '@qwik.dev/core'; +import { + $, + component$, + isBrowser, + Slot, + type Signal, + useSignal, + useTask$, + useVisibleTask$, +} from '@qwik.dev/core'; import { State } from '../../types/state'; -import { IconXMark } from '../Icons/Icons'; +import { + IconArrowsPointingIn, + IconArrowsPointingOut, + IconXMark, +} from '../Icons/Icons'; interface DevtoolsPanelProps { state: State; } -export const DevtoolsPanel = component$(({ state }: DevtoolsPanelProps) => { +type ResizeDirection = + | 'n' + | 's' + | 'e' + | 'w' + | 'ne' + | 'nw' + | 'se' + | 'sw'; + +type InteractionMode = 'drag' | ResizeDirection | null; + +const WINDOW_MARGIN = 24; +const COMPACT_MARGIN = 8; +const DEFAULT_WIDTH = 1180; +const DEFAULT_HEIGHT = 760; +const MIN_WIDTH = 320; +const MIN_HEIGHT = 280; +const MIN_WIDTH_MD = 520; +const MIN_HEIGHT_MD = 360; +const FULLSCREEN_PANEL_STYLE = { + left: '0px', + top: '0px', + width: '100vw', + height: '100vh', +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getWindowMargin() { + return window.innerWidth < 640 || window.innerHeight < 640 + ? COMPACT_MARGIN + : WINDOW_MARGIN; +} + +function getMinWidth() { + const margin = getWindowMargin(); + const preferredWidth = window.innerWidth >= 768 ? MIN_WIDTH_MD : MIN_WIDTH; + return Math.min(preferredWidth, Math.max(window.innerWidth - margin * 2, 240)); +} + +function getMinHeight() { + const margin = getWindowMargin(); + const preferredHeight = window.innerHeight >= 768 ? MIN_HEIGHT_MD : MIN_HEIGHT; + return Math.min( + preferredHeight, + Math.max(window.innerHeight - margin * 2, 220), + ); +} + +function getMaxWidth() { + const margin = getWindowMargin(); + return Math.max(window.innerWidth - margin * 2, getMinWidth()); +} + +function getMaxHeight() { + const margin = getWindowMargin(); + return Math.max(window.innerHeight - margin * 2, getMinHeight()); +} + +function normalizeBounds(bounds: State['panelBounds']) { + const margin = getWindowMargin(); + const width = clamp(bounds.width, getMinWidth(), getMaxWidth()); + const height = clamp(bounds.height, getMinHeight(), getMaxHeight()); + const maxX = Math.max(margin, window.innerWidth - width - margin); + const maxY = Math.max(margin, window.innerHeight - height - margin); + + return { + x: clamp(bounds.x, margin, maxX), + y: clamp(bounds.y, margin, maxY), + width, + height, + }; +} + +function createDefaultBounds() { + const margin = getWindowMargin(); + const width = Math.min(DEFAULT_WIDTH, getMaxWidth()); + const height = Math.min(DEFAULT_HEIGHT, getMaxHeight()); + + return normalizeBounds({ + x: window.innerWidth - width - margin, + y: window.innerHeight - height - margin, + width, + height, + }); +} + +function hasValidBounds(bounds: State['panelBounds']) { + return ( + bounds.width > 0 && + bounds.height > 0 && + bounds.x >= 0 && + bounds.y >= 0 + ); +} + +function getInteractionCursor(mode: InteractionMode) { + switch (mode) { + case 'drag': + return 'grabbing'; + case 'n': + case 's': + return 'ns-resize'; + case 'e': + case 'w': + return 'ew-resize'; + case 'ne': + case 'sw': + return 'nesw-resize'; + case 'nw': + case 'se': + return 'nwse-resize'; + default: + return ''; + } +} + +function resetPointerStyles() { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; +} + +function updatePanelBounds( + panelBounds: Signal, + state: State, + bounds: State['panelBounds'], +) { + const nextBounds = { ...bounds }; + panelBounds.value = nextBounds; + state.panelBounds.x = nextBounds.x; + state.panelBounds.y = nextBounds.y; + state.panelBounds.width = nextBounds.width; + state.panelBounds.height = nextBounds.height; +} + +export const DevtoolsPanel = component$((props: DevtoolsPanelProps) => { + const interactionMode = useSignal(null); + const startMousePosition = useSignal({ x: 0, y: 0 }); + const startBounds = useSignal({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + const panelBounds = useSignal({ + ...props.state.panelBounds, + }); + + const stopInteraction = $(() => { + interactionMode.value = null; + resetPointerStyles(); + }); + + const handleMouseMove = $((event: MouseEvent) => { + if (!interactionMode.value || props.state.isPanelFullscreen) return; + + const deltaX = event.clientX - startMousePosition.value.x; + const deltaY = event.clientY - startMousePosition.value.y; + + if (interactionMode.value === 'drag') { + updatePanelBounds( + panelBounds, + props.state, + normalizeBounds({ + ...startBounds.value, + x: startBounds.value.x + deltaX, + y: startBounds.value.y + deltaY, + }), + ); + return; + } + + const direction = interactionMode.value; + const minWidth = getMinWidth(); + const minHeight = getMinHeight(); + const margin = getWindowMargin(); + let nextX = startBounds.value.x; + let nextY = startBounds.value.y; + let nextWidth = startBounds.value.width; + let nextHeight = startBounds.value.height; + + if (direction.includes('e')) { + nextWidth = clamp( + startBounds.value.width + deltaX, + minWidth, + window.innerWidth - startBounds.value.x - margin, + ); + } + + if (direction.includes('s')) { + nextHeight = clamp( + startBounds.value.height + deltaY, + minHeight, + window.innerHeight - startBounds.value.y - margin, + ); + } + + if (direction.includes('w')) { + const maxX = startBounds.value.x + startBounds.value.width - minWidth; + nextX = clamp(startBounds.value.x + deltaX, margin, maxX); + nextWidth = startBounds.value.width - (nextX - startBounds.value.x); + } + + if (direction.includes('n')) { + const maxY = startBounds.value.y + startBounds.value.height - minHeight; + nextY = clamp(startBounds.value.y + deltaY, margin, maxY); + nextHeight = startBounds.value.height - (nextY - startBounds.value.y); + } + + updatePanelBounds( + panelBounds, + props.state, + normalizeBounds({ + x: nextX, + y: nextY, + width: nextWidth, + height: nextHeight, + }), + ); + }); + + const handleDragStart = $((event: MouseEvent) => { + if (event.button !== 0 || props.state.isPanelFullscreen) return; + + event.preventDefault(); + event.stopPropagation(); + + const normalizedBounds = hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(); + + updatePanelBounds(panelBounds, props.state, normalizedBounds); + interactionMode.value = 'drag'; + startMousePosition.value = { x: event.clientX, y: event.clientY }; + startBounds.value = { ...normalizedBounds }; + document.body.style.userSelect = 'none'; + document.body.style.cursor = getInteractionCursor('drag'); + }); + + const handleResizeStart = $((direction: ResizeDirection, event: MouseEvent) => { + if (event.button !== 0 || props.state.isPanelFullscreen) return; + + event.preventDefault(); + event.stopPropagation(); + + const normalizedBounds = hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(); + + updatePanelBounds(panelBounds, props.state, normalizedBounds); + interactionMode.value = direction; + startMousePosition.value = { x: event.clientX, y: event.clientY }; + startBounds.value = { ...normalizedBounds }; + document.body.style.userSelect = 'none'; + document.body.style.cursor = getInteractionCursor(direction); + }); + + const handleToggleFullscreen = $(() => { + if (!isBrowser) return; + + if (props.state.isPanelFullscreen) { + props.state.isPanelFullscreen = false; + updatePanelBounds( + panelBounds, + props.state, + normalizeBounds(props.state.lastPanelBounds ?? createDefaultBounds()), + ); + return; + } + + const normalizedBounds = hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(); + updatePanelBounds(panelBounds, props.state, normalizedBounds); + props.state.lastPanelBounds = { ...normalizedBounds }; + props.state.isPanelFullscreen = true; + interactionMode.value = null; + resetPointerStyles(); + }); + useTask$(({ cleanup }) => { const handleKeyPress = (e: KeyboardEvent) => { - if (!state) return; if (e.key === '`' && e.metaKey) { - state.isOpen = !state.isOpen; + props.state.isOpen = !props.state.isOpen; } - // Add Escape key to close - if (e.key === 'Escape' && state.isOpen) { - state.isOpen = false; + + if (e.key === 'Escape' && props.state.isOpen) { + props.state.isOpen = false; } }; @@ -27,28 +321,213 @@ export const DevtoolsPanel = component$(({ state }: DevtoolsPanelProps) => { }); }); + useTask$(({ track, cleanup }) => { + track(() => interactionMode.value); + + if (!interactionMode.value || !isBrowser) return; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', stopInteraction); + window.addEventListener('blur', stopInteraction); + + cleanup(() => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', stopInteraction); + window.removeEventListener('blur', stopInteraction); + resetPointerStyles(); + }); + }); + + useVisibleTask$(({ track, cleanup }) => { + track(() => props.state.isOpen); + track(() => props.state.isPanelFullscreen); + + if (!props.state.isOpen) return; + + if (!props.state.isPanelFullscreen) { + updatePanelBounds( + panelBounds, + props.state, + hasValidBounds(props.state.panelBounds) + ? normalizeBounds(props.state.panelBounds) + : createDefaultBounds(), + ); + } + + const handleWindowResize = () => { + if (props.state.isPanelFullscreen) return; + updatePanelBounds( + panelBounds, + props.state, + hasValidBounds(panelBounds.value) + ? normalizeBounds(panelBounds.value) + : createDefaultBounds(), + ); + }; + + window.addEventListener('resize', handleWindowResize); + + cleanup(() => { + window.removeEventListener('resize', handleWindowResize); + resetPointerStyles(); + }); + }); + + const hasBounds = hasValidBounds(panelBounds.value); + const panelStyle = props.state.isPanelFullscreen + ? FULLSCREEN_PANEL_STYLE + : hasBounds + ? { + left: `${panelBounds.value.x}px`, + top: `${panelBounds.value.y}px`, + width: `${panelBounds.value.width}px`, + height: `${panelBounds.value.height}px`, + } + : { + right: `${WINDOW_MARGIN}px`, + bottom: `${WINDOW_MARGIN}px`, + width: `min(calc(100vw - ${WINDOW_MARGIN * 2}px), ${DEFAULT_WIDTH}px)`, + height: `min(calc(100vh - ${WINDOW_MARGIN * 2}px), ${DEFAULT_HEIGHT}px)`, + }; + return ( <>
{ - state.isOpen = false; + props.state.isOpen = false; }} /> +
{ + event.stopPropagation(); + }} > - - + {!props.state.isPanelFullscreen && ( + <> + + +
+
+ +
+ +
+ ); diff --git a/packages/ui/src/components/Icons/Icons.tsx b/packages/ui/src/components/Icons/Icons.tsx index a2a4cdd..fc6b724 100644 --- a/packages/ui/src/components/Icons/Icons.tsx +++ b/packages/ui/src/components/Icons/Icons.tsx @@ -215,6 +215,47 @@ export const IconClockOutline = component$((props: IconProps) => { ); }); +export const IconChartBarOutline = component$((props: IconProps) => { + return ( + + {props.title ? {props.title} : null} + + + + + ); +}); + +export const IconArrowDownTray = component$((props: IconProps) => { + return ( + + {props.title ? {props.title} : null} + + + + ); +}); + export const IconXMark = component$((props: IconProps) => { return ( @@ -228,3 +269,38 @@ export const IconXMark = component$((props: IconProps) => { ); }); +export const IconArrowsPointingOut = component$((props: IconProps) => { + return ( + + {props.title ? {props.title} : null} + + + + ); +}); + +export const IconArrowsPointingIn = component$((props: IconProps) => { + return ( + + {props.title ? {props.title} : null} + + + + ); +}); diff --git a/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx b/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx index bf3b528..ef21a4b 100644 --- a/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx +++ b/packages/ui/src/components/ThemeToggle/QwikThemeToggle.tsx @@ -1,7 +1,7 @@ import { component$, event$, isServer } from '@qwik.dev/core'; import { themeStorageKey } from '../router-head/theme-script'; import { IconMoonOutline, IconSparkles, IconSunOutline } from '../Icons/Icons'; -import './themToggle.css' +import './themeToggle.css'; type ThemeName = 'dark' | 'light' | 'auto'; export const getTheme = (): ThemeName => { diff --git a/packages/ui/src/components/ThemeToggle/themToggle.css b/packages/ui/src/components/ThemeToggle/themeToggle.css similarity index 100% rename from packages/ui/src/components/ThemeToggle/themToggle.css rename to packages/ui/src/components/ThemeToggle/themeToggle.css diff --git a/packages/ui/src/devtools.tsx b/packages/ui/src/devtools.tsx index b671a25..c9008e6 100644 --- a/packages/ui/src/devtools.tsx +++ b/packages/ui/src/devtools.tsx @@ -1,260 +1 @@ -import { - component$, - useStore, - noSerialize, - useVisibleTask$, -} from '@qwik.dev/core'; -import { tryCreateHotContext } from 'vite-hot-client'; -import { - IconBoltOutline, - IconClockOutline, - IconCodeBracketSolid, - IconCubeOutline, - IconDiagram3, - IconFolderTree, - IconMegaphoneMini, - IconPhotoOutline, -} from './components/Icons/Icons'; -import { - createClientRpc, - getViteClientRpc, - setViteClientContext, - type RoutesInfo, - RouteType, -} from '@devtools/kit'; -import './global.css'; -import { Tab } from './components/Tab/Tab'; -import { TabContent } from './components/TabContent/TabContent'; -import { Overview } from './features/Overview/Overview'; -import { State } from './types/state'; -import { Assets } from './features/Assets/Assets'; -import { Routes } from './features/Routes/Routes'; -import { TabTitle } from './components/TabTitle/TabTitle'; -import { RenderTree } from './features/RenderTree/RenderTree'; -import { DevtoolsButton } from './components/DevtoolsButton/DevtoolsButton'; -import { DevtoolsContainer } from './components/DevtoolsContainer/DevtoolsContainer'; -import { DevtoolsPanel } from './components/DevtoolsPanel/DevtoolsPanel'; -import { Packages } from './features/Packages/Packages'; -import { Inspect } from './features/inspect/Inspect'; -import { QwikThemeToggle } from './components/ThemeToggle/QwikThemeToggle'; -import { ThemeScript as QwikThemeScript } from './components/ThemeToggle/theme-script'; -import { CodeBreack } from './features/CodeBreack/CodeBreack'; -import { Performance } from './features/Performance/Performance'; -import { debug } from 'debug'; - -const log = debug('qwik:devtools:devtools'); -function getClientRpcFunctions() { - return { - healthCheck: () => true, - }; -} - -function toDevtoolsRoutes(routes: any): RoutesInfo[] { - const children: RoutesInfo[] = routes?.children || []; - const directories: RoutesInfo[] = children.filter( - (child) => child.type === RouteType.DIRECTORY, - ); - - return [ - { - relativePath: '', - name: 'index', - type: RouteType.DIRECTORY, - path: '', - isSymbolicLink: false, - children: undefined, - }, - ...directories, - ]; -} - -export const QwikDevtools = component$(() => { - const state = useStore({ - isOpen: false, - activeTab: 'overview', - npmPackages: [], - assets: [], - components: [], - routes: undefined, - allDependencies: [], - isLoadingDependencies: false, - }); - - useVisibleTask$(async () => { - const hot = await tryCreateHotContext(undefined, ['/']); - if (!hot) { - throw new Error('Vite Hot Context not connected'); - } - - setViteClientContext(hot); - createClientRpc(getClientRpcFunctions()); - - const rpc = getViteClientRpc(); - - // Group 1: load most data in parallel, each failure is isolated. - const group1 = Promise.allSettled([ - rpc.getAssetsFromPublicDir(), - rpc.getComponents(), - rpc.getRoutes(), - rpc.getQwikPackages(), - ]); - - // Group 2: load dependencies separately to keep a dedicated loading state. - state.isLoadingDependencies = true; - const depsPromise = rpc - .getAllDependencies() - .then((allDeps) => { - state.allDependencies = allDeps; - }) - .catch((error) => { - log('Failed to load all dependencies:', error); - }) - .finally(() => { - state.isLoadingDependencies = false; - }); - - const [assetsRes, componentsRes, routesRes, packagesRes] = await group1; - - if (assetsRes.status === 'fulfilled') { - state.assets = assetsRes.value; - } else { - log('Failed to load assets:', assetsRes.reason); - } - - if (componentsRes.status === 'fulfilled') { - state.components = componentsRes.value; - } else { - log('Failed to load components:', componentsRes.reason); - } - - if (routesRes.status === 'fulfilled') { - state.routes = noSerialize(toDevtoolsRoutes(routesRes.value)); - } else { - log('Failed to load routes:', routesRes.reason); - } - - if (packagesRes.status === 'fulfilled') { - state.npmPackages = packagesRes.value; - } else { - log('Failed to load Qwik packages:', packagesRes.reason); - } - - await depsPromise; - }); - - return ( - <> - - - - - {state.isOpen && ( - -
- - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- -
- {state.activeTab === 'overview' && ( - -
- Qwik Logo -

Qwik DevTools

-
- -
- )} - {state.activeTab === 'assets' && ( - - -
- - Total Size:{' '} - {( - state.assets?.reduce( - (acc, asset) => acc + asset.size, - 0, - ) / 1024 - ).toFixed(2)}{' '} - KB - - Count: {state.assets?.length || 0} -
- - -
- )} - {state.activeTab === 'packages' && ( - - - - - )} - {state.activeTab === 'routes' && ( - - - - - )} - {state.activeTab === 'inspect' && ( - - - - )} - {state.activeTab === 'renderTree' && ( - - - - - )} - {state.activeTab === 'codeBreack' && ( - - - - - )} - {state.activeTab === 'performance' && ( - - - - - )} -
- - )} - - - ); -}); +export { QwikDevtools } from './devtools/QwikDevtools'; diff --git a/packages/ui/src/devtools/DevtoolsContent.tsx b/packages/ui/src/devtools/DevtoolsContent.tsx new file mode 100644 index 0000000..9f6aede --- /dev/null +++ b/packages/ui/src/devtools/DevtoolsContent.tsx @@ -0,0 +1,120 @@ +import { component$ } from '@qwik.dev/core'; +import type { AssetInfo } from '@devtools/kit'; +import { TabContent } from '../components/TabContent/TabContent'; +import { TabTitle } from '../components/TabTitle/TabTitle'; +import { Assets } from '../features/Assets/Assets'; +import { BuildAnalysis } from '../features/BuildAnalysis/BuildAnalysis'; +import { CodeBreak } from '../features/CodeBreak/CodeBreak'; +import { Inspect } from '../features/Inspect/Inspect'; +import { Overview } from '../features/Overview/Overview'; +import { Packages } from '../features/Packages/Packages'; +import { Performance } from '../features/Performance/Performance'; +import { Preloads } from '../features/Preloads/Preloads'; +import { RenderTree } from '../features/RenderTree/RenderTree'; +import { Routes } from '../features/Routes/Routes'; +import type { DevtoolsState } from './state'; + +interface DevtoolsContentProps { + state: DevtoolsState; +} + +function formatAssetSummary(assets: AssetInfo[]) { + const totalSizeInKb = + assets.reduce((totalSize, asset) => totalSize + asset.size, 0) / 1024; + + return { + count: assets.length, + totalSizeInKb: totalSizeInKb.toFixed(2), + }; +} + +export const DevtoolsContent = component$(({ state }) => { + const assetSummary = formatAssetSummary(state.assets); + + switch (state.activeTab) { + case 'overview': + return ( + +
+ Qwik Logo +

Qwik DevTools

+
+ +
+ ); + case 'assets': + return ( + + +
+ Total Size: {assetSummary.totalSizeInKb} KB + Count: {assetSummary.count} +
+ +
+ ); + case 'packages': + return ( + + + + + ); + case 'routes': + return ( + + + + + ); + case 'inspect': + return ( + + + + ); + case 'renderTree': + return ( + + + + + ); + case 'codeBreak': + return ( + + + + + ); + case 'performance': + return ( + + + + + ); + case 'preloads': + return ( + + + + + ); + case 'buildAnalysis': + return ( + + + + + ); + default: + return null; + } +}); diff --git a/packages/ui/src/devtools/DevtoolsSidebar.tsx b/packages/ui/src/devtools/DevtoolsSidebar.tsx new file mode 100644 index 0000000..399946c --- /dev/null +++ b/packages/ui/src/devtools/DevtoolsSidebar.tsx @@ -0,0 +1,25 @@ +import { component$ } from '@qwik.dev/core'; +import { Tab } from '../components/Tab/Tab'; +import { QwikThemeToggle } from '../components/ThemeToggle/QwikThemeToggle'; +import type { DevtoolsState } from './state'; +import { devtoolsTabs } from './tabs'; + +interface DevtoolsSidebarProps { + state: DevtoolsState; +} + +export const DevtoolsSidebar = component$(({ state }) => { + return ( +
+ {devtoolsTabs.map((tab) => ( + + {tab.renderIcon()} + + ))} + +
+ +
+
+ ); +}); diff --git a/packages/ui/src/devtools/QwikDevtools.tsx b/packages/ui/src/devtools/QwikDevtools.tsx new file mode 100644 index 0000000..347fffa --- /dev/null +++ b/packages/ui/src/devtools/QwikDevtools.tsx @@ -0,0 +1,38 @@ +import { component$, useStore, useVisibleTask$ } from '@qwik.dev/core'; +import { DevtoolsButton } from '../components/DevtoolsButton/DevtoolsButton'; +import { DevtoolsContainer } from '../components/DevtoolsContainer/DevtoolsContainer'; +import { DevtoolsPanel } from '../components/DevtoolsPanel/DevtoolsPanel'; +import { ThemeScript as QwikThemeScript } from '../components/ThemeToggle/theme-script'; +import '../global.css'; +import { DevtoolsContent } from './DevtoolsContent'; +import { loadDevtoolsData } from './rpc'; +import { createDevtoolsState, type DevtoolsState } from './state'; +import { DevtoolsSidebar } from './DevtoolsSidebar'; +import { ensurePreloadRuntime } from '../runtime/preloads'; + +export const QwikDevtools = component$(() => { + const state = useStore(createDevtoolsState()); + + useVisibleTask$(async () => { + ensurePreloadRuntime(); + await loadDevtoolsData(state); + }); + + return ( + <> + + + + + {state.isOpen && ( + + +
+ +
+
+ )} +
+ + ); +}); diff --git a/packages/ui/src/devtools/rpc.ts b/packages/ui/src/devtools/rpc.ts new file mode 100644 index 0000000..a5bdd75 --- /dev/null +++ b/packages/ui/src/devtools/rpc.ts @@ -0,0 +1,103 @@ +import { + createClientRpc, + getViteClientRpc, + setViteClientContext, + type RoutesInfo, + RouteType, +} from '@devtools/kit'; +import { noSerialize } from '@qwik.dev/core'; +import debug from 'debug'; +import { tryCreateHotContext } from 'vite-hot-client'; +import type { DevtoolsState } from './state'; + +const log = debug('qwik:devtools:devtools'); + +function getClientRpcFunctions() { + return { + healthCheck: () => true, + }; +} + +function toDevtoolsRoutes(routes: any): RoutesInfo[] { + const children: RoutesInfo[] = routes?.children || []; + const directories: RoutesInfo[] = children.filter( + (child) => child.type === RouteType.DIRECTORY, + ); + + return [ + { + relativePath: '', + name: 'index', + type: RouteType.DIRECTORY, + path: '', + isSymbolicLink: false, + children: undefined, + }, + ...directories, + ]; +} + +async function connectRpc() { + const hot = await tryCreateHotContext(undefined, ['/']); + if (!hot) { + throw new Error('Vite Hot Context not connected'); + } + + setViteClientContext(hot); + createClientRpc(getClientRpcFunctions()); + + return getViteClientRpc(); +} + +export async function loadDevtoolsData(state: DevtoolsState) { + const rpc = await connectRpc(); + + const primaryDataPromise = Promise.allSettled([ + rpc.getAssetsFromPublicDir(), + rpc.getComponents(), + rpc.getRoutes(), + rpc.getQwikPackages(), + ]); + + state.isLoadingDependencies = true; + const dependenciesPromise = rpc + .getAllDependencies() + .then((allDependencies) => { + state.allDependencies = allDependencies; + }) + .catch((error) => { + log('Failed to load all dependencies:', error); + }) + .finally(() => { + state.isLoadingDependencies = false; + }); + + const [assetsResult, componentsResult, routesResult, packagesResult] = + await primaryDataPromise; + + if (assetsResult.status === 'fulfilled') { + state.assets = assetsResult.value; + } else { + log('Failed to load assets:', assetsResult.reason); + } + + if (componentsResult.status === 'fulfilled') { + state.components = componentsResult.value; + } else { + log('Failed to load components:', componentsResult.reason); + } + + if (routesResult.status === 'fulfilled') { + state.routes = noSerialize(toDevtoolsRoutes(routesResult.value)); + } else { + log('Failed to load routes:', routesResult.reason); + } + + if (packagesResult.status === 'fulfilled') { + state.npmPackages = packagesResult.value; + } else { + log('Failed to load Qwik packages:', packagesResult.reason); + } + + await dependenciesPromise; +} diff --git a/packages/ui/src/devtools/state.ts b/packages/ui/src/devtools/state.ts new file mode 100644 index 0000000..a0dffeb --- /dev/null +++ b/packages/ui/src/devtools/state.ts @@ -0,0 +1,56 @@ +import type { AssetInfo, Component, NpmInfo, RoutesInfo } from '@devtools/kit'; +import type { NoSerialize } from '@qwik.dev/core'; + +export type DevtoolsTabId = + | 'overview' + | 'packages' + | 'renderTree' + | 'routes' + | 'assets' + | 'inspect' + | 'codeBreak' + | 'performance' + | 'preloads' + | 'buildAnalysis'; + +export interface DevtoolsPanelBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface DevtoolsState { + isOpen: boolean; + isPanelFullscreen: boolean; + activeTab: DevtoolsTabId; + npmPackages: NpmInfo; + assets: AssetInfo[]; + components: Component[]; + routes: NoSerialize | undefined; + allDependencies: unknown[]; + isLoadingDependencies: boolean; + panelBounds: DevtoolsPanelBounds; + lastPanelBounds: DevtoolsPanelBounds | null; +} + +export function createDevtoolsState(): DevtoolsState { + return { + isOpen: false, + isPanelFullscreen: false, + activeTab: 'overview', + npmPackages: [], + assets: [], + components: [], + routes: undefined, + allDependencies: [], + isLoadingDependencies: false, + panelBounds: { + x: 0, + y: 0, + width: 0, + height: 0, + }, + lastPanelBounds: null, + }; +} diff --git a/packages/ui/src/devtools/tabs.tsx b/packages/ui/src/devtools/tabs.tsx new file mode 100644 index 0000000..ffac233 --- /dev/null +++ b/packages/ui/src/devtools/tabs.tsx @@ -0,0 +1,73 @@ +import type { JSXOutput } from '@qwik.dev/core'; +import { + IconArrowDownTray, + IconBoltOutline, + IconChartBarOutline, + IconClockOutline, + IconCodeBracketSolid, + IconCubeOutline, + IconDiagram3, + IconFolderTree, + IconMegaphoneMini, + IconPhotoOutline, +} from '../components/Icons/Icons'; +import type { DevtoolsTabId } from './state'; + +export interface DevtoolsTabConfig { + id: DevtoolsTabId; + title: string; + renderIcon: () => JSXOutput; +} + +export const devtoolsTabs: DevtoolsTabConfig[] = [ + { + id: 'overview', + title: 'Overview', + renderIcon: () => , + }, + { + id: 'packages', + title: 'Packages', + renderIcon: () => , + }, + { + id: 'renderTree', + title: 'Render Tree', + renderIcon: () => , + }, + { + id: 'routes', + title: 'Routes', + renderIcon: () => , + }, + { + id: 'assets', + title: 'Assets', + renderIcon: () => , + }, + { + id: 'inspect', + title: 'Inspect', + renderIcon: () => , + }, + { + id: 'codeBreak', + title: 'Code Break', + renderIcon: () => , + }, + { + id: 'performance', + title: 'Performance', + renderIcon: () => , + }, + { + id: 'preloads', + title: 'Preloads', + renderIcon: () => , + }, + { + id: 'buildAnalysis', + title: 'Build Analysis', + renderIcon: () => , + }, +]; diff --git a/packages/ui/src/features/BuildAnalysis/BuildAnalysis.tsx b/packages/ui/src/features/BuildAnalysis/BuildAnalysis.tsx new file mode 100644 index 0000000..abf89f0 --- /dev/null +++ b/packages/ui/src/features/BuildAnalysis/BuildAnalysis.tsx @@ -0,0 +1,186 @@ +import { getViteClientRpc } from '@devtools/kit'; +import { + $, + component$, + useSignal, + useVisibleTask$, +} from '@qwik.dev/core'; + +const BUILD_ANALYSIS_VIEW_PATH = '/__qwik_devtools/build-analysis/report'; + +export const BuildAnalysis = component$(() => { + const iframeVersion = useSignal(0); + const isChecking = useSignal(true); + const isBuilding = useSignal(false); + const hasReport = useSignal(false); + const canTriggerBuild = useSignal(false); + const reportPath = useSignal(''); + const buildCommand = useSignal(null); + const buildTriggerHint = useSignal(''); + const errorMessage = useSignal(''); + + const loadStatus = $(async () => { + isChecking.value = true; + errorMessage.value = ''; + + try { + const rpc = getViteClientRpc(); + const status = await rpc.getBuildAnalysisStatus(); + + hasReport.value = status.exists; + canTriggerBuild.value = status.canTriggerBuild; + reportPath.value = status.reportPath; + buildCommand.value = status.buildCommand; + buildTriggerHint.value = status.buildTriggerHint || ''; + } catch (error) { + errorMessage.value = + error instanceof Error ? error.message : 'Failed to load build analysis status.'; + } finally { + isChecking.value = false; + } + }); + + const refreshFrame = $(() => { + iframeVersion.value += 1; + }); + + const buildReport = $(async () => { + if (!buildCommand.value) { + errorMessage.value = + 'No build script found. Expected "build.client" or "build" in package.json.'; + return; + } + + if (!canTriggerBuild.value) { + errorMessage.value = + buildTriggerHint.value || 'Automatic rebuild is unavailable from this DevTools client.'; + return; + } + + const confirmed = + globalThis.confirm?.( + `Build analysis needs a fresh build to generate the report.\n\nRun: ${buildCommand.value} ?`, + ) ?? true; + + if (!confirmed) { + return; + } + + isBuilding.value = true; + errorMessage.value = ''; + + try { + const rpc = getViteClientRpc(); + const result = await rpc.buildBuildAnalysisReport(); + + if (!result.success) { + errorMessage.value = result.error || 'Build failed.'; + return; + } + + await loadStatus(); + iframeVersion.value += 1; + } catch (error) { + errorMessage.value = + error instanceof Error ? error.message : 'Build failed.'; + } finally { + isBuilding.value = false; + } + }); + + useVisibleTask$(async () => { + await loadStatus(); + }); + + return ( +
+
+
+
Build Analysis
+
+ This tab embeds the HTML report generated by the visualizer plugin + after a build. +
+ {buildCommand.value ? ( + + {buildCommand.value} + + ) : null} + {buildTriggerHint.value ? ( +
+ {buildTriggerHint.value} +
+ ) : null} + {errorMessage.value ? ( +
+ {errorMessage.value} +
+ ) : null} +
+
+ + {isChecking.value ? ( +
+ Checking build analysis report... +
+ ) : hasReport.value ? ( + <> +
+ {buildCommand.value && canTriggerBuild.value ? ( + + ) : null} + +
+ +
+