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..53cf9bbb 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 は最初の 2xx 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 はすべて上限未満を要求する。