From 6983e8c2b145c5c1acbc694e2366636c77b1cf60 Mon Sep 17 00:00:00 2001 From: hmjn Date: Tue, 14 Jul 2026 22:18:08 +0900 Subject: [PATCH 1/2] perf(server): measure and improve dev startup Consolidate route generation, defer dev RPC initialization, and add isolated cold-start measurements. --- apps/server/package.json | 1 + apps/server/scripts/e2e-server.ts | 182 +------ apps/server/scripts/isolated-runtime.ts | 181 +++++++ .../scripts/measure-dev-startup-browser.ts | 192 ++++++++ .../scripts/measure-dev-startup-schema.ts | 18 + apps/server/scripts/measure-dev-startup.ts | 465 ++++++++++++++++++ .../infrastructure/server-route-bootstrap.ts | 17 + apps/server/src/routes/api/rpc.$.ts | 4 +- .../api/sources.$mediaSourceId.$mediaId.ts | 4 +- ...urces.$mediaSourceId.thumbnail.$mediaId.ts | 4 +- apps/server/vite.config.ts | 201 +++++--- docs/design/web-rendering-strategy.md | 34 ++ 12 files changed, 1062 insertions(+), 241 deletions(-) create mode 100644 apps/server/scripts/isolated-runtime.ts create mode 100644 apps/server/scripts/measure-dev-startup-browser.ts create mode 100644 apps/server/scripts/measure-dev-startup-schema.ts create mode 100644 apps/server/scripts/measure-dev-startup.ts create mode 100644 apps/server/src/infrastructure/server-route-bootstrap.ts diff --git a/apps/server/package.json b/apps/server/package.json index c40b4a25..cce3cfaa 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,6 +18,7 @@ "db:dump": "bun scripts/dump-db.ts", "db:restore": "bun scripts/restore-db.ts", "lancedb:sync-slow": "bun scripts/sync-lancedb-slow.ts", + "measure:dev-startup": "bun scripts/measure-dev-startup.ts", "db:studio": "drizzle-kit studio", "lint": "biome lint ./src", "format": "biome format --fix ./src", diff --git a/apps/server/scripts/e2e-server.ts b/apps/server/scripts/e2e-server.ts index 52fe12c0..a913068e 100644 --- a/apps/server/scripts/e2e-server.ts +++ b/apps/server/scripts/e2e-server.ts @@ -1,24 +1,9 @@ -import { lstat, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { lstat, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { PGlite } from "@electric-sql/pglite"; -import { defaultAppConfig } from "@solid-imager/core/domain/config/config-schema"; -import { mediaGenerationInfo, medias, mediaSources } from "@solid-imager/db/schema"; -import { drizzle } from "drizzle-orm/pglite"; -import { migrate } from "drizzle-orm/pglite/migrator"; -import sharp from "sharp"; -import { - E2E_PRIMARY_FILE_NAME, - E2E_PRIMARY_MEDIA_ID, - E2E_SIMILAR_FILE_NAME, - E2E_SIMILAR_MEDIA_ID, - E2E_SOURCE_ID, - E2E_SOURCE_NAME, - getE2eMediaDir, - getE2eRuntimeDir, - getFixtureMediaPath, -} from "../src/tests/e2e/support/fixture"; +import { assertSafeRuntimeDir, prepareIsolatedRuntime } from "./isolated-runtime"; +import { getE2eRuntimeDir } from "../src/tests/e2e/support/fixture"; type E2eMode = "dev" | "production"; @@ -44,163 +29,6 @@ function getPort(): string { return port; } -function assertSafeRuntimeDir(runtimeDir: string): void { - const resolvedRoot = path.resolve(allowedRuntimeRoot); - const relative = path.relative(resolvedRoot, runtimeDir); - if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error( - `E2E_RUNTIME_DIR must be a child of ${resolvedRoot}, received ${runtimeDir}`, - ); - } -} - -function createImageSvg(accentColor: string, backgroundColor: string): Buffer { - return Buffer.from(` - - - - - - `); -} - -async function seedMediaFixtures(runtimeDir: string): Promise { - const mediaDir = getE2eMediaDir(); - const thumbnailDir = path.join(runtimeDir, "thumbnails", E2E_SOURCE_ID); - await mkdir(mediaDir, { recursive: true }); - await mkdir(thumbnailDir, { recursive: true }); - - const primaryPath = getFixtureMediaPath(E2E_PRIMARY_FILE_NAME); - const similarPath = getFixtureMediaPath(E2E_SIMILAR_FILE_NAME); - await Promise.all([ - sharp(createImageSvg("#4f46e5", "#e0e7ff")).png().toFile(primaryPath), - sharp(createImageSvg("#6366f1", "#eef2ff")).png().toFile(similarPath), - ]); - - await Promise.all([ - sharp(primaryPath) - .resize({ width: 512, height: 512, fit: "inside" }) - .webp() - .toFile(path.join(thumbnailDir, `${E2E_PRIMARY_MEDIA_ID}.webp`)), - sharp(similarPath) - .resize({ width: 512, height: 512, fit: "inside" }) - .webp() - .toFile(path.join(thumbnailDir, `${E2E_SIMILAR_MEDIA_ID}.webp`)), - ]); - - const [primaryStats, similarStats] = await Promise.all([ - stat(primaryPath), - stat(similarPath), - ]); - const pgliteDir = path.join(runtimeDir, "pglite"); - const client = new PGlite(pgliteDir); - const db = drizzle(client); - - try { - await migrate(db, { migrationsFolder: path.join(appRoot, "drizzle") }); - const seededAt = new Date(); - await db.insert(mediaSources).values({ - id: E2E_SOURCE_ID, - name: E2E_SOURCE_NAME, - description: "Isolated media source for browser E2E tests", - type: "local", - connectionInfo: { path: mediaDir }, - createdAt: seededAt, - updatedAt: seededAt, - }); - await db.insert(medias).values([ - { - id: E2E_PRIMARY_MEDIA_ID, - mediaSourceId: E2E_SOURCE_ID, - filePath: E2E_PRIMARY_FILE_NAME, - fileName: E2E_PRIMARY_FILE_NAME, - mediaType: "image", - width: 256, - height: 256, - fileSize: primaryStats.size, - description: "Primary E2E fixture media", - createdAt: primaryStats.birthtime, - modifiedAt: primaryStats.mtime, - indexedAt: seededAt, - status: "active", - }, - { - id: E2E_SIMILAR_MEDIA_ID, - mediaSourceId: E2E_SOURCE_ID, - filePath: E2E_SIMILAR_FILE_NAME, - fileName: E2E_SIMILAR_FILE_NAME, - mediaType: "image", - width: 256, - height: 256, - fileSize: similarStats.size, - description: "Similarity candidate E2E fixture media", - createdAt: similarStats.birthtime, - modifiedAt: similarStats.mtime, - indexedAt: seededAt, - status: "active", - }, - ]); - await db.insert(mediaGenerationInfo).values([ - { - mediaId: E2E_PRIMARY_MEDIA_ID, - metadata: { fixture: "e2e-primary" }, - }, - { - mediaId: E2E_SIMILAR_MEDIA_ID, - metadata: { fixture: "e2e-similar" }, - }, - ]); - } finally { - await client.close(); - } -} - -async function createRouteTreePlaceholder(runtimeDir: string): Promise { - const frozenRouteTreePath = path.join(runtimeDir, "routeTree.e2e.ts"); - - // The Vite router plugin must replace this before application modules are - // transformed. A placeholder makes generation failures fail this isolated - // run instead of silently falling back to a checked-in route tree. - await writeFile( - frozenRouteTreePath, - "export const __e2eRouteTreePlaceholder = true;\n", - ); - return frozenRouteTreePath; -} - -async function prepareE2eEnvironment(runtimeDir: string): Promise { - await rm(runtimeDir, { recursive: true, force: true }); - await mkdir(runtimeDir, { recursive: true }); - - const config = { - ...defaultAppConfig, - jobs: { - ...defaultAppConfig.jobs, - concurrency: 1, - aiConcurrency: 1, - pollIntervalMs: 100, - enableAutoTagging: false, - enableAutoCcipExtraction: false, - }, - storage: { - ...defaultAppConfig.storage, - thumbnailDir: path.join(runtimeDir, "thumbnails"), - }, - lancedb: { - ...defaultAppConfig.lancedb, - autoFullSync: false, - cacheDir: path.join(runtimeDir, "lancedb-cache"), - ccipVectorDir: path.join(runtimeDir, "lancedb-ccip"), - }, - }; - await writeFile( - path.join(runtimeDir, "config.json"), - JSON.stringify(config, null, 2), - ); - await seedMediaFixtures(runtimeDir); - return await createRouteTreePlaceholder(runtimeDir); -} - function serverEnvironment( runtimeDir: string, routeTreePath: string, @@ -315,6 +143,6 @@ async function startServer( const mode = getMode(); const runtimeDir = getE2eRuntimeDir(); -assertSafeRuntimeDir(runtimeDir); -const routeTreePath = await prepareE2eEnvironment(runtimeDir); +assertSafeRuntimeDir(runtimeDir, allowedRuntimeRoot); +const { routeTreePath } = await prepareIsolatedRuntime(runtimeDir); await startServer(mode, serverEnvironment(runtimeDir, routeTreePath)); diff --git a/apps/server/scripts/isolated-runtime.ts b/apps/server/scripts/isolated-runtime.ts new file mode 100644 index 00000000..b0cc5389 --- /dev/null +++ b/apps/server/scripts/isolated-runtime.ts @@ -0,0 +1,181 @@ +import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { PGlite } from '@electric-sql/pglite'; +import { defaultAppConfig } from '@solid-imager/core/domain/config/config-schema'; +import { mediaGenerationInfo, medias, mediaSources } from '@solid-imager/db/schema'; +import { drizzle } from 'drizzle-orm/pglite'; +import { migrate } from 'drizzle-orm/pglite/migrator'; +import sharp from 'sharp'; +import { + E2E_PRIMARY_FILE_NAME, + E2E_PRIMARY_MEDIA_ID, + E2E_SIMILAR_FILE_NAME, + E2E_SIMILAR_MEDIA_ID, + E2E_SOURCE_ID, + E2E_SOURCE_NAME, +} from '../src/tests/e2e/support/fixture'; + +const appRoot = path.resolve(import.meta.dir, '..'); + +export type IsolatedRuntime = { + routeTreePath: string; +}; + +export function assertSafeRuntimeDir( + runtimeDir: string, + allowedRuntimeRoot: string, +): void { + const resolvedRoot = path.resolve(allowedRuntimeRoot); + const relative = path.relative(resolvedRoot, runtimeDir); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error( + `Runtime directory must be a child of ${resolvedRoot}, received ${runtimeDir}`, + ); + } +} + +function createImageSvg(accentColor: string, backgroundColor: string): Buffer { + return Buffer.from(` + + + + + + `); +} + +async function seedMediaFixtures(runtimeDir: string): Promise { + const mediaDir = path.join(runtimeDir, 'media'); + const thumbnailDir = path.join(runtimeDir, 'thumbnails', E2E_SOURCE_ID); + await mkdir(mediaDir, { recursive: true }); + await mkdir(thumbnailDir, { recursive: true }); + + const primaryPath = path.join(mediaDir, E2E_PRIMARY_FILE_NAME); + const similarPath = path.join(mediaDir, E2E_SIMILAR_FILE_NAME); + await Promise.all([ + sharp(createImageSvg('#4f46e5', '#e0e7ff')).png().toFile(primaryPath), + sharp(createImageSvg('#6366f1', '#eef2ff')).png().toFile(similarPath), + ]); + + await Promise.all([ + sharp(primaryPath) + .resize({ width: 512, height: 512, fit: 'inside' }) + .webp() + .toFile(path.join(thumbnailDir, `${E2E_PRIMARY_MEDIA_ID}.webp`)), + sharp(similarPath) + .resize({ width: 512, height: 512, fit: 'inside' }) + .webp() + .toFile(path.join(thumbnailDir, `${E2E_SIMILAR_MEDIA_ID}.webp`)), + ]); + + const [primaryStats, similarStats] = await Promise.all([ + stat(primaryPath), + stat(similarPath), + ]); + const pgliteDir = path.join(runtimeDir, 'pglite'); + const client = new PGlite(pgliteDir); + const db = drizzle(client); + + try { + await migrate(db, { migrationsFolder: path.join(appRoot, 'drizzle') }); + const seededAt = new Date(); + await db.insert(mediaSources).values({ + id: E2E_SOURCE_ID, + name: E2E_SOURCE_NAME, + description: 'Isolated media source for browser tests', + type: 'local', + connectionInfo: { path: mediaDir }, + createdAt: seededAt, + updatedAt: seededAt, + }); + await db.insert(medias).values([ + { + id: E2E_PRIMARY_MEDIA_ID, + mediaSourceId: E2E_SOURCE_ID, + filePath: E2E_PRIMARY_FILE_NAME, + fileName: E2E_PRIMARY_FILE_NAME, + mediaType: 'image', + width: 256, + height: 256, + fileSize: primaryStats.size, + description: 'Primary browser fixture media', + createdAt: primaryStats.birthtime, + modifiedAt: primaryStats.mtime, + indexedAt: seededAt, + status: 'active', + }, + { + id: E2E_SIMILAR_MEDIA_ID, + mediaSourceId: E2E_SOURCE_ID, + filePath: E2E_SIMILAR_FILE_NAME, + fileName: E2E_SIMILAR_FILE_NAME, + mediaType: 'image', + width: 256, + height: 256, + fileSize: similarStats.size, + description: 'Similarity candidate browser fixture media', + createdAt: similarStats.birthtime, + modifiedAt: similarStats.mtime, + indexedAt: seededAt, + status: 'active', + }, + ]); + await db.insert(mediaGenerationInfo).values([ + { + mediaId: E2E_PRIMARY_MEDIA_ID, + metadata: { fixture: 'e2e-primary' }, + }, + { + mediaId: E2E_SIMILAR_MEDIA_ID, + metadata: { fixture: 'e2e-similar' }, + }, + ]); + } finally { + await client.close(); + } +} + +async function createRouteTreePlaceholder(runtimeDir: string): Promise { + const routeTreePath = path.join(runtimeDir, 'routeTree.generated.ts'); + await writeFile( + routeTreePath, + 'export const __isolatedRouteTreePlaceholder = true;\n', + ); + return routeTreePath; +} + +export async function prepareIsolatedRuntime( + runtimeDir: string, +): Promise { + await rm(runtimeDir, { recursive: true, force: true }); + await mkdir(runtimeDir, { recursive: true }); + + const config = { + ...defaultAppConfig, + jobs: { + ...defaultAppConfig.jobs, + concurrency: 1, + aiConcurrency: 1, + pollIntervalMs: 100, + enableAutoTagging: false, + enableAutoCcipExtraction: false, + }, + storage: { + ...defaultAppConfig.storage, + thumbnailDir: path.join(runtimeDir, 'thumbnails'), + }, + lancedb: { + ...defaultAppConfig.lancedb, + autoFullSync: false, + cacheDir: path.join(runtimeDir, 'lancedb-cache'), + ccipVectorDir: path.join(runtimeDir, 'lancedb-ccip'), + }, + }; + await writeFile( + path.join(runtimeDir, 'config.json'), + JSON.stringify(config, null, 2), + ); + await seedMediaFixtures(runtimeDir); + + return { routeTreePath: await createRouteTreePlaceholder(runtimeDir) }; +} diff --git a/apps/server/scripts/measure-dev-startup-browser.ts b/apps/server/scripts/measure-dev-startup-browser.ts new file mode 100644 index 00000000..3c79089b --- /dev/null +++ b/apps/server/scripts/measure-dev-startup-browser.ts @@ -0,0 +1,192 @@ +import { chromium, type Browser, type BrowserContext } from 'playwright'; +import type { BrowserMeasurement } from './measure-dev-startup-schema'; + +const maximumStartupWaitMs = 120_000; + +function getArgument(index: number, name: string): string { + const value = process.argv[index]; + if (!value) { + throw new Error(`${name} must be provided`); + } + return value; +} + +const baseUrl = getArgument(2, 'base URL'); +const startedAtEpochMs = Number(getArgument(3, 'start timestamp')); +if (!Number.isFinite(startedAtEpochMs)) { + throw new Error('start timestamp must be a finite number'); +} + +function elapsedMs(): number { + return Date.now() - startedAtEpochMs; +} + +function wait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function canFetchStaticAsset(): Promise { + const process = Bun.spawn( + [ + 'curl', + '--insecure', + '--max-time', + '1', + '--silent', + '--output', + '/dev/null', + '--write-out', + '%{http_code}', + `${baseUrl}/favicon.ico`, + ], + { stdout: 'pipe', stderr: 'ignore' }, + ); + const [exitCode, output] = await Promise.all([ + process.exited, + new Response(process.stdout).text(), + ]); + const status = Number(output); + return ( + exitCode === 0 && + Number.isInteger(status) && + status >= 200 && + status < 500 + ); +} + +async function waitForStaticMiddleware(): Promise { + const deadline = elapsedMs() + maximumStartupWaitMs; + while (elapsedMs() < deadline) { + if (await canFetchStaticAsset()) { + return elapsedMs(); + } + await wait(50); + } + throw new Error( + `Dev server did not serve static assets within ${maximumStartupWaitMs}ms`, + ); +} + +async function runBrowserMeasurement(): Promise { + const staticReadyAtMs = await waitForStaticMiddleware(); + const browserSetupStartedAtMs = elapsedMs(); + let browser: Browser | undefined; + let context: BrowserContext | undefined; + + try { + browser = await chromium.launch({ headless: true }); + context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + const browserReadyAtMs = elapsedMs(); + const browserErrors: string[] = []; + const serverFailures: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') { + browserErrors.push(message.text()); + } + }); + page.on('pageerror', (error) => { + browserErrors.push(error.message); + }); + page.on('response', (response) => { + if ( + response.url().startsWith(baseUrl) && + response.status() >= 500 + ) { + serverFailures.push(`${response.status()} ${response.url()}`); + } + }); + + const firstNavigationStartedAtMs = elapsedMs(); + const response = await page.goto(`${baseUrl}/config`, { + waitUntil: 'commit', + timeout: 30_000, + }); + const firstSsrHeadersAtMs = elapsedMs(); + if (!response) { + throw new Error('First SSR navigation did not return a response'); + } + if (response.status() >= 500) { + throw new Error(`First SSR navigation returned HTTP ${response.status()}`); + } + const ssrHtml = await response.text(); + const firstSsrHtmlAtMs = elapsedMs(); + if (!ssrHtml.includes('Save Changes')) { + throw new Error('First SSR response did not include the settings form'); + } + + await page.waitForFunction( + () => document.documentElement.dataset.hydrated === 'true', + undefined, + { timeout: 30_000 }, + ); + await page + .getByRole('tab', { name: 'AI', exact: true }) + .click({ timeout: 30_000 }); + await page + .getByRole('heading', { name: 'AI Service', exact: true }) + .waitFor({ state: 'visible', timeout: 30_000 }); + const interactionReadyAtMs = elapsedMs(); + + const isConfigGetRpcRequest = (request: { + url(): string; + method(): string; + }) => { + const url = new URL(request.url()); + return url.pathname === '/api/rpc/config/get' && request.method() !== 'OPTIONS'; + }; + const isConfigGetRpcResponse = (response: { + url(): string; + request(): { method(): string }; + }) => { + const url = new URL(response.url()); + return ( + url.pathname === '/api/rpc/config/get' && + response.request().method() !== 'OPTIONS' + ); + }; + const firstRpcRequestPromise = page + .waitForRequest(isConfigGetRpcRequest, { timeout: 30_000 }) + .then(elapsedMs); + const rpcResponsePromise = page.waitForResponse( + isConfigGetRpcResponse, + { timeout: 30_000 }, + ); + const rpcCall = page.evaluate(async () => { + const clientModulePath = '/src/infrastructure/api-clients/orpc-client.ts'; + const { orpc } = await import(/* @vite-ignore */ clientModulePath); + await orpc.config.get(); + }); + const firstRpcRequestAtMs = await firstRpcRequestPromise; + const rpcResponse = await rpcResponsePromise; + const firstRpcResponseAtMs = elapsedMs(); + await rpcCall; + if (!rpcResponse.ok()) { + throw new Error(`First RPC request returned HTTP ${rpcResponse.status()}`); + } + if (browserErrors.length > 0) { + throw new Error(`Browser errors: ${browserErrors.join(' | ')}`); + } + if (serverFailures.length > 0) { + throw new Error(`Server failures: ${serverFailures.join(' | ')}`); + } + + return { + staticReadyAtMs, + browserSetupAfterStaticReadyMs: + browserReadyAtMs - browserSetupStartedAtMs, + firstSsrHeadersAtMs, + firstSsrHtmlAtMs, + interactionReadyAtMs, + firstNavigationStartedAtMs, + firstRpcRequestAtMs, + firstRpcResponseAtMs, + browserErrors, + serverFailures, + }; + } finally { + await Promise.allSettled([context?.close(), browser?.close()]); + } +} + +console.log(JSON.stringify(await runBrowserMeasurement())); diff --git a/apps/server/scripts/measure-dev-startup-schema.ts b/apps/server/scripts/measure-dev-startup-schema.ts new file mode 100644 index 00000000..45e0f3af --- /dev/null +++ b/apps/server/scripts/measure-dev-startup-schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const browserMeasurementSchema = z + .object({ + staticReadyAtMs: z.number().nonnegative(), + browserSetupAfterStaticReadyMs: z.number().nonnegative(), + firstSsrHeadersAtMs: z.number().nonnegative(), + firstSsrHtmlAtMs: z.number().nonnegative(), + interactionReadyAtMs: z.number().nonnegative(), + firstNavigationStartedAtMs: z.number().nonnegative(), + firstRpcRequestAtMs: z.number().nonnegative(), + firstRpcResponseAtMs: z.number().nonnegative(), + browserErrors: z.array(z.string()), + serverFailures: z.array(z.string()), + }) + .strict(); + +export type BrowserMeasurement = z.infer; diff --git a/apps/server/scripts/measure-dev-startup.ts b/apps/server/scripts/measure-dev-startup.ts new file mode 100644 index 00000000..d35da4c3 --- /dev/null +++ b/apps/server/scripts/measure-dev-startup.ts @@ -0,0 +1,465 @@ +import { randomUUID } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { assertSafeRuntimeDir, prepareIsolatedRuntime } from './isolated-runtime'; +import { + browserMeasurementSchema, + type BrowserMeasurement, +} from './measure-dev-startup-schema'; + +type ProfileName = 'baseline' | 'without-mkcert' | 'without-devtools'; + +type MeasurementProfile = { + name: ProfileName; + disableMkcert: boolean; + disableDevtools: boolean; +}; + +type WorkerMilestones = { + viteListeningAtMs?: number; + rpcResponseFinishedAtMs?: number; + workerStartedAtMs?: number; + maintenanceStartedAtMs?: number; + workerStartCount: number; + maintenanceStartCount: number; + output: string; +}; + +const appRoot = path.resolve(import.meta.dir, '..'); +const runtimeRoot = path.join(tmpdir(), 'solid-imager-dev-startup'); +const outputWaitAfterRpcMs = 5_000; +const rpcResponseFinishedMessage = + 'Dev startup measurement: matched RPC response finished'; + +function getProfile(): MeasurementProfile { + const requested = process.argv.find((argument) => + argument.startsWith('--profile='), + )?.slice('--profile='.length); + + if (!requested || requested === 'baseline') { + return { + name: 'baseline', + disableMkcert: false, + disableDevtools: false, + }; + } + if (requested === 'without-mkcert') { + return { + name: requested, + disableMkcert: true, + disableDevtools: false, + }; + } + if (requested === 'without-devtools') { + return { + name: requested, + disableMkcert: false, + disableDevtools: true, + }; + } + throw new Error( + '--profile must be baseline, without-mkcert, or without-devtools', + ); +} + +async function findAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Failed to allocate a local port')); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +function wait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function getEnvironment( + runtimeDir: string, + routeTreePath: string, + port: number, + hmrPort: number, + profile: MeasurementProfile, +): Record { + const inherited = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => { + if ( + value === undefined || + key === 'E2E' || + key.startsWith('E2E_') || + key.startsWith('CONFIG_') || + key.startsWith('DEV_STARTUP_') + ) { + return []; + } + return [[key, value]]; + }), + ); + + return { + ...inherited, + DEV_STARTUP_MEASUREMENT: '1', + DEV_STARTUP_PORT: String(port), + DEV_STARTUP_HMR_PORT: String(hmrPort), + DEV_STARTUP_RUNTIME_DIR: runtimeDir, + DEV_STARTUP_ROUTE_TREE_PATH: routeTreePath, + DEV_STARTUP_DISABLE_MKCERT: profile.disableMkcert ? '1' : '0', + DEV_STARTUP_DISABLE_DEVTOOLS: profile.disableDevtools ? '1' : '0', + DB_HOST: 'pglite', + PGLITE_DATA_DIR: path.join(runtimeDir, 'pglite'), + CONFIG_PATH: path.join(runtimeDir, 'config.json'), + NITRO_HOST: '127.0.0.1', + NITRO_PORT: String(port), + NODE_ENV: 'development', + PORT: String(port), + }; +} + +function captureMilestone( + text: string, + startedAt: number, + milestones: WorkerMilestones, +): void { + milestones.output = `${milestones.output}${text}`.slice(-20_000); + if ( + milestones.viteListeningAtMs === undefined && + milestones.output.includes('➜ Local:') + ) { + milestones.viteListeningAtMs = performance.now() - startedAt; + } + if ( + milestones.rpcResponseFinishedAtMs === undefined && + milestones.output.includes(rpcResponseFinishedMessage) + ) { + milestones.rpcResponseFinishedAtMs = performance.now() - startedAt; + } + if ( + milestones.workerStartedAtMs === undefined && + milestones.output.includes('Job processing worker started') + ) { + milestones.workerStartedAtMs = performance.now() - startedAt; + } + if ( + milestones.maintenanceStartedAtMs === undefined && + milestones.output.includes('Starting startup checks') + ) { + milestones.maintenanceStartedAtMs = performance.now() - startedAt; + } + milestones.workerStartCount = Math.max( + milestones.workerStartCount, + (milestones.output.match(/Job processing worker started/g) ?? []).length, + ); + milestones.maintenanceStartCount = Math.max( + milestones.maintenanceStartCount, + (milestones.output.match(/Starting startup checks/g) ?? []).length, + ); +} + +async function observeOutput( + stream: ReadableStream | null, + startedAt: number, + milestones: WorkerMilestones, + signal: AbortSignal, +): Promise { + if (!stream) { + return; + } + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const cancelReader = () => { + void reader.cancel(); + }; + signal.addEventListener('abort', cancelReader, { once: true }); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + captureMilestone(decoder.decode(value, { stream: true }), startedAt, milestones); + } + captureMilestone(decoder.decode(), startedAt, milestones); + } catch (error) { + if (!signal.aborted) { + throw error; + } + } finally { + signal.removeEventListener('abort', cancelReader); + reader.releaseLock(); + } +} + +async function readOutput( + stream: ReadableStream | null, +): Promise { + if (!stream) { + return ''; + } + return await new Response(stream).text(); +} + +async function waitForWorkerMilestones( + milestones: WorkerMilestones, +): Promise { + const deadline = performance.now() + outputWaitAfterRpcMs; + while ( + performance.now() < deadline && + (milestones.rpcResponseFinishedAtMs === undefined || + milestones.workerStartedAtMs === undefined || + milestones.maintenanceStartedAtMs === undefined) + ) { + await wait(50); + } +} + +function roundMilliseconds(value: number): number { + return Math.round(value * 10) / 10; +} + +function parseBrowserMeasurement(output: string): BrowserMeasurement { + return browserMeasurementSchema.parse(JSON.parse(output)); +} + +async function runMeasurement(): Promise { + const profile = getProfile(); + const runtimeDir = path.join(runtimeRoot, `${profile.name}-${randomUUID()}`); + assertSafeRuntimeDir(runtimeDir, runtimeRoot); + const [port, hmrPort] = await Promise.all([ + findAvailablePort(), + findAvailablePort(), + ]); + const { routeTreePath } = await prepareIsolatedRuntime(runtimeDir); + const environment = getEnvironment( + runtimeDir, + routeTreePath, + port, + hmrPort, + profile, + ); + const protocol = profile.disableMkcert ? 'http' : 'https'; + const baseUrl = `${protocol}://127.0.0.1:${port}`; + const milestones: WorkerMilestones = { + workerStartCount: 0, + maintenanceStartCount: 0, + output: '', + }; + + let childProcess: ReturnType | undefined; + let browserMeasurementProcess: ReturnType | undefined; + let outputAbortController: AbortController | undefined; + let serverOutputTasks: Promise[] = []; + let browserStdoutTask: Promise | undefined; + let browserStderrTask: Promise | undefined; + let succeeded = false; + + try { + const startedAt = performance.now(); + const startedAtEpochMs = Date.now(); + const spawnedChildProcess = Bun.spawn([process.execPath, 'run', 'dev'], { + cwd: appRoot, + env: environment, + stdout: 'pipe', + stderr: 'pipe', + }); + childProcess = spawnedChildProcess; + outputAbortController = new AbortController(); + serverOutputTasks = [ + observeOutput( + spawnedChildProcess.stdout, + startedAt, + milestones, + outputAbortController.signal, + ), + observeOutput( + spawnedChildProcess.stderr, + startedAt, + milestones, + outputAbortController.signal, + ), + ]; + + const spawnedBrowserMeasurementProcess = Bun.spawn( + [ + process.execPath, + 'scripts/measure-dev-startup-browser.ts', + baseUrl, + String(startedAtEpochMs), + ], + { + cwd: appRoot, + stdout: 'pipe', + stderr: 'pipe', + }, + ); + browserMeasurementProcess = spawnedBrowserMeasurementProcess; + browserStdoutTask = readOutput(spawnedBrowserMeasurementProcess.stdout); + browserStderrTask = readOutput(spawnedBrowserMeasurementProcess.stderr); + + const completion = await Promise.race([ + spawnedBrowserMeasurementProcess.exited.then((code) => ({ + source: 'browser' as const, + code, + })), + spawnedChildProcess.exited.then((code) => ({ + source: 'server' as const, + code, + })), + ]); + if (completion.source === 'server') { + throw new Error( + `Dev server exited with code ${completion.code} before browser measurement completed`, + ); + } + + const [browserOutput, browserErrorOutput] = await Promise.all([ + browserStdoutTask, + browserStderrTask, + ]); + if (completion.code !== 0) { + throw new Error( + `Browser measurement exited with code ${completion.code}: ${browserErrorOutput}`, + ); + } + const browserMeasurement = parseBrowserMeasurement(browserOutput); + if (milestones.viteListeningAtMs === undefined) { + throw new Error('Vite listening log was not observed'); + } + + await waitForWorkerMilestones(milestones); + if (milestones.rpcResponseFinishedAtMs === undefined) { + throw new Error('Dev RPC response completion was not observed'); + } + if (milestones.workerStartedAtMs === undefined) { + throw new Error('Background worker did not start after the first RPC'); + } + if (milestones.maintenanceStartedAtMs === undefined) { + throw new Error('Maintenance checks did not start after the first RPC'); + } + if (milestones.workerStartCount !== 1) { + throw new Error( + `Expected one background worker start, observed ${milestones.workerStartCount}`, + ); + } + if (milestones.maintenanceStartCount !== 1) { + throw new Error( + `Expected one maintenance start, observed ${milestones.maintenanceStartCount}`, + ); + } + + const rpcResponseFinishedAtMs = milestones.rpcResponseFinishedAtMs; + const workerStartedAtMs = milestones.workerStartedAtMs; + const maintenanceStartedAtMs = milestones.maintenanceStartedAtMs; + const viteListeningAtMs = milestones.viteListeningAtMs; + if (workerStartedAtMs < rpcResponseFinishedAtMs) { + throw new Error('Background worker started before the first RPC response finished'); + } + + console.log( + JSON.stringify( + { + profile: profile.name, + startupToListeningMs: roundMilliseconds(viteListeningAtMs), + startupToStaticReadyMs: roundMilliseconds( + browserMeasurement.staticReadyAtMs, + ), + listeningToStaticReadyMs: roundMilliseconds( + browserMeasurement.staticReadyAtMs - viteListeningAtMs, + ), + browserSetupAfterStaticReadyMs: roundMilliseconds( + browserMeasurement.browserSetupAfterStaticReadyMs, + ), + staticReadyToFirstSsrHeadersMs: roundMilliseconds( + browserMeasurement.firstSsrHeadersAtMs - + browserMeasurement.staticReadyAtMs, + ), + staticReadyToFirstSsrHtmlMs: roundMilliseconds( + browserMeasurement.firstSsrHtmlAtMs - + browserMeasurement.staticReadyAtMs, + ), + browserReadyToFirstSsrHeadersMs: roundMilliseconds( + browserMeasurement.firstSsrHeadersAtMs - + browserMeasurement.staticReadyAtMs - + browserMeasurement.browserSetupAfterStaticReadyMs, + ), + firstSsrHtmlToInteractiveMs: roundMilliseconds( + browserMeasurement.interactionReadyAtMs - + browserMeasurement.firstSsrHtmlAtMs, + ), + firstNavigationTotalMs: roundMilliseconds( + browserMeasurement.interactionReadyAtMs - + browserMeasurement.firstNavigationStartedAtMs, + ), + firstRpcRequestToResponseMs: roundMilliseconds( + browserMeasurement.firstRpcResponseAtMs - + browserMeasurement.firstRpcRequestAtMs, + ), + rpcResponseFinishedToWorkerStartedMs: roundMilliseconds( + workerStartedAtMs - rpcResponseFinishedAtMs, + ), + rpcResponseFinishedToMaintenanceStartedMs: roundMilliseconds( + maintenanceStartedAtMs - rpcResponseFinishedAtMs, + ), + workerStartCount: milestones.workerStartCount, + maintenanceStartCount: milestones.maintenanceStartCount, + }, + null, + 2, + ), + ); + succeeded = true; + } finally { + if (browserMeasurementProcess) { + browserMeasurementProcess.kill(); + await browserMeasurementProcess.exited; + } + if (childProcess) { + try { + childProcess.kill(); + } finally { + await childProcess.exited; + outputAbortController?.abort(); + await Promise.all(serverOutputTasks); + } + } + if (succeeded) { + await rm(runtimeDir, { recursive: true, force: true }); + } else { + if (milestones.output) { + console.error(`Dev server output:\n${milestones.output}`); + } + if (browserStdoutTask || browserStderrTask) { + const browserOutput = await Promise.all([ + browserStdoutTask ?? Promise.resolve(''), + browserStderrTask ?? Promise.resolve(''), + ]); + if (browserOutput[0]) { + console.error(`Browser measurement output:\n${browserOutput[0]}`); + } + if (browserOutput[1]) { + console.error(`Browser measurement error:\n${browserOutput[1]}`); + } + } + console.error(`Measurement runtime kept at ${runtimeDir}`); + } + } +} + +await runMeasurement(); diff --git a/apps/server/src/infrastructure/server-route-bootstrap.ts b/apps/server/src/infrastructure/server-route-bootstrap.ts new file mode 100644 index 00000000..d583b13e --- /dev/null +++ b/apps/server/src/infrastructure/server-route-bootstrap.ts @@ -0,0 +1,17 @@ +import { bootstrap, initServices } from "./bootstrap"; + +/** + * Initializes dependencies used by file-route server handlers. + * + * In development, the Vite oRPC middleware owns the background worker + * lifecycle and starts it once after the first RPC response. File-route + * module graphs must therefore initialize services only, otherwise an image + * or media request can create a second worker during initial rendering. + */ +export function bootstrapServerRoute(): void { + if (import.meta.env.DEV) { + initServices(); + return; + } + bootstrap(); +} diff --git a/apps/server/src/routes/api/rpc.$.ts b/apps/server/src/routes/api/rpc.$.ts index a21fda7d..39400e0d 100644 --- a/apps/server/src/routes/api/rpc.$.ts +++ b/apps/server/src/routes/api/rpc.$.ts @@ -1,8 +1,8 @@ import { RPCHandler } from "@orpc/server/fetch"; import { createFileRoute } from "@tanstack/solid-router"; import { appRouter } from "~/domain/shared/api-contract"; -import { bootstrap } from "~/infrastructure/bootstrap"; import { logger } from "~/infrastructure/logger"; +import { bootstrapServerRoute } from "~/infrastructure/server-route-bootstrap"; const handler = new RPCHandler(appRouter); @@ -10,7 +10,7 @@ export const Route = createFileRoute("/api/rpc/$")({ server: { handlers: { ANY: async ({ request }) => { - bootstrap(); + bootstrapServerRoute(); const { response } = await handler.handle(request, { prefix: "/api/rpc", context: {}, diff --git a/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts b/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts index c2963d06..37bbf1ea 100644 --- a/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts +++ b/apps/server/src/routes/api/sources.$mediaSourceId.$mediaId.ts @@ -3,7 +3,7 @@ import { getContentTypeFromExtension } from "@solid-imager/core/domain/media/uti import { localConnectionSchema } from "@solid-imager/core/domain/sources/schemas"; import { createFileRoute } from "@tanstack/solid-router"; import { services } from "~/application/registry"; -import { bootstrap } from "~/infrastructure/bootstrap"; +import { bootstrapServerRoute } from "~/infrastructure/server-route-bootstrap"; const resolveSafePath = (basePath: string, targetPath: string): string => { const resolvedPath = path.resolve(basePath, targetPath); @@ -22,7 +22,7 @@ export const Route = createFileRoute("/api/sources/$mediaSourceId/$mediaId")({ server: { handlers: { GET: async ({ params }) => { - bootstrap(); + bootstrapServerRoute(); const { mediaSourceId, mediaId } = params; const media = await services.getMediaRepository().findById(mediaId); diff --git a/apps/server/src/routes/api/sources.$mediaSourceId.thumbnail.$mediaId.ts b/apps/server/src/routes/api/sources.$mediaSourceId.thumbnail.$mediaId.ts index 394a00b8..58575c16 100644 --- a/apps/server/src/routes/api/sources.$mediaSourceId.thumbnail.$mediaId.ts +++ b/apps/server/src/routes/api/sources.$mediaSourceId.thumbnail.$mediaId.ts @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/solid-router"; -import { bootstrap } from "~/infrastructure/bootstrap"; import { getThumbnailPath } from "~/infrastructure/jobs/thumbnails"; +import { bootstrapServerRoute } from "~/infrastructure/server-route-bootstrap"; export const Route = createFileRoute( "/api/sources/$mediaSourceId/thumbnail/$mediaId", @@ -8,7 +8,7 @@ export const Route = createFileRoute( server: { handlers: { GET: async ({ params }) => { - bootstrap(); + bootstrapServerRoute(); const { mediaSourceId, mediaId } = params; const thumbnailPath = getThumbnailPath(mediaSourceId, mediaId); const file = Bun.file(thumbnailPath); diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 7f8d1f5a..6c65c429 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -5,7 +5,6 @@ import { defineConfig } from "vite-plus"; import type { Plugin } from "vite-plus"; import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/solid-start/plugin/vite"; -import { tanstackRouter } from "@tanstack/router-plugin/vite"; import solidPlugin from "vite-plugin-solid"; import { nitro } from "nitro/vite"; import { devtools } from "@tanstack/devtools-vite"; @@ -13,36 +12,56 @@ import mkcert from "vite-plugin-mkcert"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const isE2e = process.env.E2E === "1"; +const isDevStartupMeasurement = process.env.DEV_STARTUP_MEASUREMENT === "1"; const defaultRouteTreePath = path.resolve(__dirname, "src/routeTree.gen.ts"); -function getRequiredE2eEnvironment(name: string): string { +type IsolatedDevConfig = { + port: number; + hmrPort: number; + runtimeDir: string; + routeTreePath: string; +}; + +function getRequiredEnvironment(name: string, mode: string): string { const value = process.env[name]; if (!value) { - throw new Error(`${name} must be set when E2E=1`); + throw new Error(`${name} must be set when ${mode}`); } return value; } -function getE2ePort(name: "E2E_PORT" | "E2E_HMR_PORT", fallback: number): number { - if (!isE2e) { - return fallback; - } - const port = Number.parseInt(getRequiredE2eEnvironment(name), 10); +function getPort(name: string, mode: string): number { + const port = Number.parseInt(getRequiredEnvironment(name, mode), 10); if (!Number.isInteger(port) || port < 1 || port > 65_535) { - throw new Error(`${name} must be an integer between 1 and 65535 when E2E=1`); + throw new Error( + `${name} must be an integer between 1 and 65535 when ${mode}`, + ); } return port; } -const e2ePort = getE2ePort("E2E_PORT", 3100); -const e2eHmrPort = getE2ePort("E2E_HMR_PORT", 3101); +function getIsolatedDevConfig(): IsolatedDevConfig | undefined { + if (!(isE2e || isDevStartupMeasurement)) { + return undefined; + } + + const prefix = isE2e ? "E2E" : "DEV_STARTUP"; + const mode = isE2e ? "E2E=1" : "DEV_STARTUP_MEASUREMENT=1"; + return { + port: getPort(`${prefix}_PORT`, mode), + hmrPort: getPort(`${prefix}_HMR_PORT`, mode), + runtimeDir: path.resolve(getRequiredEnvironment(`${prefix}_RUNTIME_DIR`, mode)), + routeTreePath: path.resolve( + getRequiredEnvironment(`${prefix}_ROUTE_TREE_PATH`, mode), + ), + }; +} -const routeTreePath = isE2e - ? path.resolve(getRequiredE2eEnvironment("E2E_ROUTE_TREE_PATH")) - : defaultRouteTreePath; +const isolatedDevConfig = getIsolatedDevConfig(); +const routeTreePath = isolatedDevConfig?.routeTreePath ?? defaultRouteTreePath; const e2eNitroOutput = isE2e ? (() => { - const dir = path.resolve(getRequiredE2eEnvironment("E2E_OUTPUT_DIR")); + const dir = path.resolve(getRequiredEnvironment("E2E_OUTPUT_DIR", "E2E=1")); return { dir, serverDir: path.join(dir, "server"), @@ -50,14 +69,19 @@ const e2eNitroOutput = isE2e }; })() : undefined; -const e2eViteCacheDir = isE2e - ? path.join(getRequiredE2eEnvironment("E2E_RUNTIME_DIR"), "vite-cache") +const viteCacheDir = isolatedDevConfig + ? path.join(isolatedDevConfig.runtimeDir, "vite-cache") : undefined; -const e2eRouterTmpDir = isE2e - ? path.join(getRequiredE2eEnvironment("E2E_RUNTIME_DIR"), "tanstack-tmp") +const routerTmpDir = isolatedDevConfig + ? path.join(isolatedDevConfig.runtimeDir, "tanstack-tmp") : undefined; const workspaceRoot = path.resolve(__dirname, "../.."); const routeFileIgnorePattern = "^components$"; +const shouldUseMkcert = + !isE2e && + (!isDevStartupMeasurement || process.env.DEV_STARTUP_DISABLE_MKCERT !== "1"); +const shouldUseDevtools = + !isDevStartupMeasurement || process.env.DEV_STARTUP_DISABLE_DEVTOOLS !== "1"; type RuntimeImport = (specifier: string) => Promise; @@ -86,11 +110,9 @@ const bypassSecFetchDestPlugin = (): Plugin => ({ }, }); -const devOrpcNodeMiddlewarePlugin = (): Plugin => ({ - name: "dev-orpc-node-middleware", - apply: "serve", - async configureServer(server) { - const [{ appRouter }, { bootstrap }, { logger }] = await Promise.all([ +const loadDevRpcHandler = async () => { + const [{ appRouter }, { initServices, startBackgroundWorker }, { logger }] = + await Promise.all([ runtimeImport( serverModuleUrl("src/domain/shared/api-contract.ts"), ), @@ -101,7 +123,51 @@ const devOrpcNodeMiddlewarePlugin = (): Plugin => ({ serverModuleUrl("src/infrastructure/logger.ts"), ), ]); - const handler = new RPCHandler(appRouter); + + return { + handler: new RPCHandler(appRouter), + initServices, + startBackgroundWorker, + logger, + }; +}; + +let devRpcHandlerPromise: ReturnType | undefined; +type DevRpcHandler = Awaited>; + +function getDevRpcHandler(): ReturnType { + devRpcHandlerPromise ??= loadDevRpcHandler(); + return devRpcHandlerPromise; +} + +const devOrpcNodeMiddlewarePlugin = (): Plugin => ({ + name: "dev-orpc-node-middleware", + apply: "serve", + configureServer(server) { + let backgroundWorkerStarted = false; + let startBackgroundWorker: DevRpcHandler["startBackgroundWorker"] | undefined; + let logger: DevRpcHandler["logger"] | undefined; + + const startBackgroundWorkerOnce = () => { + if (backgroundWorkerStarted || !startBackgroundWorker) { + return; + } + backgroundWorkerStarted = true; + try { + if (isDevStartupMeasurement) { + logger?.info( + "Dev startup measurement: matched RPC response finished", + ); + } + startBackgroundWorker(); + } catch (error) { + backgroundWorkerStarted = false; + logger?.error( + { err: error }, + "Failed to start background worker after dev RPC response", + ); + } + }; server.middlewares.use(async (req, res, next) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; @@ -111,11 +177,39 @@ const devOrpcNodeMiddlewarePlugin = (): Plugin => ({ } try { - bootstrap(); - const { matched } = await handler.handle(req, res, { + const devRpcHandler = await getDevRpcHandler(); + logger = devRpcHandler.logger; + const { handler, initServices } = devRpcHandler; + startBackgroundWorker = devRpcHandler.startBackgroundWorker; + initServices(); + + let matched = false; + let responseFinished = false; + const startAfterSuccessfulResponse = () => { + responseFinished = true; + if ( + matched && + res.statusCode >= 200 && + res.statusCode < 300 + ) { + startBackgroundWorkerOnce(); + } + }; + res.once("finish", startAfterSuccessfulResponse); + + const result = await handler.handle(req, res, { prefix: "/api/rpc", context: {}, }); + matched = result.matched; + if ( + matched && + responseFinished && + res.statusCode >= 200 && + res.statusCode < 300 + ) { + startBackgroundWorkerOnce(); + } if (!matched) { logger.warn( @@ -124,10 +218,9 @@ const devOrpcNodeMiddlewarePlugin = (): Plugin => ({ ); res.statusCode = 404; res.end("Not Found"); - return; } } catch (error) { - logger.error({ err: error }, "Dev RPC middleware failed"); + logger?.error({ err: error }, "Dev RPC middleware failed"); next(error); } }); @@ -135,20 +228,19 @@ const devOrpcNodeMiddlewarePlugin = (): Plugin => ({ }); export default defineConfig({ - cacheDir: e2eViteCacheDir, - server: isE2e + cacheDir: viteCacheDir, + server: isolatedDevConfig ? { host: "127.0.0.1", - port: e2ePort, + port: isolatedDevConfig.port, strictPort: true, - // Keep the dev-only transform and HMR client active. This catches the - // class of regressions that production builds cannot reproduce, while - // using a run-specific socket avoids the developer server's port. + // Keep the dev-only transform and HMR client active while isolating + // measurement and E2E runs from the developer server's sockets. hmr: { - protocol: "ws", + protocol: shouldUseMkcert ? "wss" : "ws", host: "127.0.0.1", - port: e2eHmrPort, - clientPort: e2eHmrPort, + port: isolatedDevConfig.hmrPort, + clientPort: isolatedDevConfig.hmrPort, }, fs: { allow: [workspaceRoot, path.dirname(routeTreePath)], @@ -172,12 +264,16 @@ export default defineConfig({ tsconfigPaths: true, }, plugins: [ - ...(isE2e ? [] : [mkcert()]), + ...(shouldUseMkcert ? [mkcert()] : []), bypassSecFetchDestPlugin(), devOrpcNodeMiddlewarePlugin(), - devtools({ - consolePiping: { enabled: false }, - }), + ...(shouldUseDevtools + ? [ + devtools({ + consolePiping: { enabled: false }, + }), + ] + : []), nitro( e2eNitroOutput ? { @@ -185,27 +281,16 @@ export default defineConfig({ } : undefined, ), - tanstackRouter({ - target: "solid", - autoCodeSplitting: true, - routeFileIgnorePattern, - tmpDir: e2eRouterTmpDir, - // TanStack Start owns route generation below. Keep this standalone plugin - // disabled during E2E so two generators do not race on the same runtime - // file. - generatedRouteTree: routeTreePath, - enableRouteGeneration: !isE2e, - }), tailwindcss(), - tanstackStart({ - router: { + tanstackStart({ + router: { // The generator matches each directory name, not its full path. routeFileIgnorePattern, - // Start's generator powers its client route-tree transform, so keep it - // enabled. Its output path is isolated above during E2E. + // Start is the only route-tree and code-splitting plugin for this app. + // Its output path is isolated above for E2E and measurements. generatedRouteTree: routeTreePath, enableRouteGeneration: true, - tmpDir: e2eRouterTmpDir, + tmpDir: routerTmpDir, }, }), solidPlugin({ ssr: true }), diff --git a/docs/design/web-rendering-strategy.md b/docs/design/web-rendering-strategy.md index 0125a952..2402ceaf 100644 --- a/docs/design/web-rendering-strategy.md +++ b/docs/design/web-rendering-strategy.md @@ -111,6 +111,40 @@ Issue #594 着手前に isolated E2E fixture で計測した代表値。単位 計測値はマシン負荷で変動するため、上記そのものを assertion には使わない。回帰検知には次の余裕を持たせた budget を使う。 +### dev server 起動・初回アクセスの計測 + +`bun run --cwd apps/server measure:dev-startup` は、通常の `bun dev` と同じ Vite plugin 構成を、PGlite・メディア・route tree を含む隔離した `/tmp` runtime で起動する。ユーザーの DB、設定、メディアへ接続してはならない。サーバーと browser 計測を別プロセスにし、TLS の準備が SSR 計測へ混入しないようにする。 + +```bash +# 標準の dev 構成 +bun run --cwd apps/server measure:dev-startup + +# mkcert / Devtools の固定コストを個別に比較する +bun run --cwd apps/server measure:dev-startup -- --profile=without-mkcert +bun run --cwd apps/server measure:dev-startup -- --profile=without-devtools +``` + +各実行は次を JSON で返す。値の大小を比較する際は、同一ホストで複数回実行し、profile 以外の条件を変えない。 + +1. `startupToListeningMs`: `bun dev` spawn から Vite の `Local` listen log まで。 +2. `startupToStaticReadyMs` / `listeningToStaticReadyMs`: spawn / listening から Vite が static な `/favicon.ico` を返すまで。単なる TCP listen より厳しい「実際にアクセス可能」の値であり、アプリ route は warm-up しない。 +3. `browserSetupAfterStaticReadyMs`: browser harness の起動コスト。SSR 値と分離して報告する。 +4. `staticReadyToFirstSsrHeadersMs` / `staticReadyToFirstSsrHtmlMs`: static ready から、cold の `/config` SSR 応答まで。 +5. `browserReadyToFirstSsrHeadersMs`: browser setup を除いた SSR 側の待機時間。 +6. `firstSsrHtmlToInteractiveMs`: SSR HTML 受信から hydration 後の Settings の AI tab 操作成功まで。 +7. `firstRpcRequestToResponseMs`: hydration 後に明示的に発火する短命 `config.get` RPC の request-to-response 時間。`/sources` の SSE は初回 RPC として数えない。 +8. `rpcResponseFinishedToWorkerStartedMs` / `rpcResponseFinishedToMaintenanceStartedMs`: measurement profile 専用の server marker を基準にした worker / maintenance の開始時刻。`workerStartCount` と `maintenanceStartCount` はともに 1 を要求する。 + +2026-07-14 の cold run の代表値は次のとおり。単発値は採用判断の唯一の根拠にはせず、特に Devtools の有効/無効は複数回比較する。通常 dev の Devtools は既定で維持し、profile は寄与を調べるためだけに使う。 + +| profile | static ready | static ready → SSR HTML | SSR HTML → interactive | navigation total | RPC | +|---|---:|---:|---:|---:|---:| +| baseline | 11347 | 3481 | 2240 | 5642 | 72 | +| without mkcert | 11217 | 3680 | 2292 | 5878 | 51 | +| without Devtools | 11376 | 3171 | 1871 | 4944 | 61 | + +通常 dev では TanStack Start だけを route tree と code splitting の owner とする。`@tanstack/router-plugin` を別に登録して同じ output を生成してはならない。dev oRPC middleware の server-only import は最初の RPC まで lazy にし、worker / maintenance は最初の成功 RPC 応答後に一度だけ開始する。TanStack Start の server route graph は dev では `initServices` のみを行い、thumbnail や media body の request が二本目の worker を作らないようにする。SSR loader が必要とする `initServices` は router 側で維持する。 + ## E2E budget `apps/server/src/tests/e2e/route-reload.spec.ts` を性能と重複通信の回帰条件の信頼できる情報源とする。時間 assertion はすべて上限未満を要求する。 From 52f6a6558c34cdd33a4a08404254271fd8b84bb2 Mon Sep 17 00:00:00 2001 From: hmjn Date: Tue, 14 Jul 2026 22:40:29 +0900 Subject: [PATCH 2/2] docs(server): clarify dev worker startup trigger --- docs/design/web-rendering-strategy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/web-rendering-strategy.md b/docs/design/web-rendering-strategy.md index 2402ceaf..53cf9bbb 100644 --- a/docs/design/web-rendering-strategy.md +++ b/docs/design/web-rendering-strategy.md @@ -143,7 +143,7 @@ bun run --cwd apps/server measure:dev-startup -- --profile=without-devtools | without mkcert | 11217 | 3680 | 2292 | 5878 | 51 | | without Devtools | 11376 | 3171 | 1871 | 4944 | 61 | -通常 dev では TanStack Start だけを route tree と code splitting の owner とする。`@tanstack/router-plugin` を別に登録して同じ output を生成してはならない。dev oRPC middleware の server-only import は最初の RPC まで lazy にし、worker / maintenance は最初の成功 RPC 応答後に一度だけ開始する。TanStack Start の server route graph は dev では `initServices` のみを行い、thumbnail や media body の request が二本目の worker を作らないようにする。SSR loader が必要とする `initServices` は router 側で維持する。 +通常 dev では TanStack Start だけを route tree と code splitting の owner とする。`@tanstack/router-plugin` を別に登録して同じ output を生成してはならない。dev oRPC middleware の server-only import は最初の RPC まで lazy にし、worker / maintenance は最初の 2xx RPC 応答完了後に一度だけ開始する。TanStack Start の server route graph は dev では `initServices` のみを行い、thumbnail や media body の request が二本目の worker を作らないようにする。SSR loader が必要とする `initServices` は router 側で維持する。 ## E2E budget