From 6cde2676123a0f829e4b7fc48a61ea5aa4fe6dc9 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Nov 2026 09:54:22 +0100 Subject: [PATCH 1/6] fix(tanstackstart-react): Initialize the SDK at Nitro server startup Nitro loads TanStack Start's server entry on the first request, so an instrument import at the top of the server entry only initializes the SDK during that request. Without node --import, as on Vercel, the first request of every server instance has no http.server span. The Vite plugin now adds a Nitro plugin that imports the server entry at startup. It imports the same chunk Nitro loads lazily, so the SDK is not bundled a second time. Fixes #25970 Co-Authored-By: Claude Opus 5.5 --- .../tanstackstart-react/package.json | 7 + .../tanstackstart-react/playwright.config.mjs | 16 +- .../tests/init-in-server-entry.test.ts | 51 +++++ .../tanstackstart-react/vite.config.ts | 12 ++ .../src/vite/nitroPreloadServerEntry.ts | 125 ++++++++++++ .../src/vite/sentryTanstackStart.ts | 7 +- .../test/vite/nitroPreloadServerEntry.test.ts | 192 ++++++++++++++++++ .../test/vite/sentryTanstackStart.test.ts | 17 ++ 8 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts create mode 100644 packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts create mode 100644 packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 69c718ec1dee..802c88aa6007 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,6 +14,7 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:hoisted": "pnpm install --config.node-linker=hoisted && pnpm build", + "test:build:init-in-server-entry": "pnpm install && E2E_TEST_INIT_IN_SERVER_ENTRY=1 pnpm build", "test:build:tunnel-basepath": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", @@ -21,6 +22,7 @@ "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-static": "E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", "test:assert:tunnel-custom": "E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm test", + "test:assert:init-in-server-entry": "E2E_TEST_INIT_IN_SERVER_ENTRY=1 pnpm test init-in-server-entry", "test:assert:tunnel-basepath": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test tunnel", "test:assert:tunnel-basepath-dev": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_BASE_PATH=/app E2E_TEST_DSN=http://public@localhost:3031/1337 TEST_ENV=development pnpm test tunnel" }, @@ -69,6 +71,11 @@ "build-command": "pnpm test:build:tunnel-custom", "assert-command": "pnpm test:assert:tunnel-custom" }, + { + "label": "tanstackstart-react (init-in-server-entry)", + "build-command": "pnpm test:build:init-in-server-entry", + "assert-command": "pnpm test:assert:init-in-server-entry" + }, { "label": "tanstackstart-react (tunnel-basepath)", "build-command": "pnpm test:build:tunnel-basepath", diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs index cc18dd0ea437..3b2ac1d7ed7a 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs @@ -1,26 +1,30 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -// The DB driver tests only run in the default (proxy) variant, so the tunnel-route variants don't +// The DB driver tests only run in the default (proxy) variant, so the other variants don't // need MySQL/Redis — skip Docker there instead of booting the containers once per variant. const usesManagedTunnelRoute = (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; +const needsDocker = !usesManagedTunnelRoute && process.env.E2E_TEST_INIT_IN_SERVER_ENTRY !== '1'; // The dev server has no build output, so `--import` points at the app's own instrumentation file. // `vite dev` ignores PORT, so the port goes on the command. +// The init-in-server-entry variant initializes the SDK from the server entry instead. const startCommand = process.env.TEST_ENV === 'development' ? `NODE_OPTIONS='--import ./instrument.server.mjs' pnpm dev --port 3000` - : `pnpm start`; + : process.env.E2E_TEST_INIT_IN_SERVER_ENTRY === '1' + ? 'node .output/server/index.mjs' + : `pnpm start`; const config = getPlaywrightConfig({ startCommand, port: 3000, }); -export default usesManagedTunnelRoute - ? config - : { +export default needsDocker + ? { ...config, globalSetup: './global-setup.mjs', globalTeardown: './global-teardown.mjs', - }; + } + : config; diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts new file mode 100644 index 000000000000..9992006c724d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts @@ -0,0 +1,51 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test.skip(process.env.E2E_TEST_INIT_IN_SERVER_ENTRY !== '1', 'Runs only in the init-in-server-entry variant'); + +// A server of its own, so the request below is the first one it handles. Nitro imports the server entry, and so the +// SDK init at its top, lazily on the first request. +const PORT = 3001; + +let server: ChildProcess | undefined; + +// Playwright skips `finally` blocks of a test that times out. +test.afterEach(() => { + server?.kill(); +}); + +test('sends an http.server span for the first request of a server started without --import', async () => { + const serverProcess = spawn('node', ['.output/server/index.mjs'], { + env: { ...process.env, PORT: String(PORT), NITRO_PORT: String(PORT) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + server = serverProcess; + + let output = ''; + await new Promise((resolve, reject) => { + const onData = (data: Buffer): void => { + output += data.toString(); + if (output.includes('Listening on')) { + resolve(); + } + }; + serverProcess.stdout.on('data', onData); + serverProcess.stderr.on('data', onData); + serverProcess.once('exit', code => reject(new Error(`Server exited with code ${code}:\n${output}`))); + }); + + const spanPromise = waitForStreamedSpan( + 'tanstackstart-react', + span => + !!span.is_segment && + getSpanOp(span) === 'http.server' && + String(span.attributes['url.full']?.value ?? '').includes(`localhost:${PORT}/`), + ); + + const response = await fetch(`http://localhost:${PORT}/`); + expect(response.status).toBe(200); + + const span = await spanPromise; + expect(span.attributes['url.path']?.value).toBe('/'); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 9883b1add4b3..6f9a16f7ea4b 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -26,6 +26,17 @@ function recordChunksWithoutSourceMap() { }; } +// Hosts without `node --import`, such as Vercel, initialize the SDK with an import at the top of the server entry. +function initInServerEntry() { + return { + name: 'init-in-server-entry', + enforce: 'pre' as const, + transform(code: string, id: string) { + return id.endsWith('/src/server.ts') ? `import '../instrument.server.mjs';\n${code}` : undefined; + }, + }; +} + const tunnelRouteMode = process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off'; const useManagedTunnelRoute = tunnelRouteMode !== 'off'; const useCustomTunnelRoute = process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; @@ -81,5 +92,6 @@ export default defineConfig({ tunnelRoute, }), recordChunksWithoutSourceMap(), + process.env.E2E_TEST_INIT_IN_SERVER_ENTRY === '1' && initInServerEntry(), ], }); diff --git a/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts new file mode 100644 index 000000000000..4b63d3df5d88 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts @@ -0,0 +1,125 @@ +import * as path from 'node:path'; +import type { Plugin, Rollup } from 'vite'; +import { isNitroService } from './nitroSourceMaps'; + +const PRELOAD_PLUGIN_ID = '#sentry/tanstackstart-react/preload-server-entry'; + +// A safety net against an import cycle that the build could not detect, which would otherwise never settle. +const PRELOAD_TIMEOUT_MS = 3000; + +type Nitro = { + options: { + dev: boolean; + node: boolean; + plugins: string[]; + virtual: Record string | Promise)>; + }; +}; + +type ServiceEntry = { file: string; importsNitroRuntime: boolean }; + +/** + * Nitro imports the entry of a Vite service, such as TanStack Start's server entry, on the first request. An + * `instrument.server` import at the top of the server entry so only initializes the SDK during that request, too late + * for the HTTP instrumentation to see it. This adds a Nitro plugin that imports the service entries at server startup. + * + * Because the plugin imports the same files that Nitro imports lazily, the SDK is not bundled a second time. + */ +export function makePreloadNitroServerEntryPlugin(): Plugin & { + nitro: { name: string; setup: (nitro: Nitro) => void }; +} { + const serviceEntries = new Map(); + + return { + name: 'sentry-tanstackstart-nitro-preload-server-entry', + apply: 'build', + applyToEnvironment: isNitroService, + writeBundle(outputOptions, bundle) { + if (!outputOptions.dir) { + return; + } + + const chunks = Object.values(bundle).filter((output): output is Rollup.OutputChunk => output.type === 'chunk'); + const importsNitroRuntime = chunks.some(chunk => + [...chunk.imports, ...chunk.dynamicImports].some(isNitroRuntimeImport), + ); + const entryChunk = findServiceEntryChunk(chunks, this.environment.config.build.rolldownOptions.input); + if (!entryChunk) { + return; + } + + // Nitro imports the entry by a forward-slash path, so the bundler sees one module rather than two copies. + const file = path.resolve(outputOptions.dir, entryChunk.fileName).replace(/\\/g, '/'); + serviceEntries.set(this.environment.name, [{ file, importsNitroRuntime }]); + }, + nitro: { + name: 'sentry-tanstackstart-nitro-preload-server-entry', + setup(nitro) { + // Workers have no Node.js HTTP server to instrument, and in dev Nitro loads the services through Vite. + if (nitro.options.dev || nitro.options.node === false) { + return; + } + + // Nitro renders the template when it builds its own environment, after the service environments. + nitro.options.virtual[PRELOAD_PLUGIN_ID] = () => renderPreloadPlugin([...serviceEntries.values()].flat()); + nitro.options.plugins.push(PRELOAD_PLUGIN_ID); + }, + }, + }; +} + +/** + * Serverless platforms send the first request right after loading the Nitro server module, so the preload has to + * finish before the module does, with top-level `await`. + * + * A service that imports the Nitro runtime depends on this plugin, though, and awaiting it would never settle. Such + * services are imported without waiting, which on serverless platforms can still lose the race with the first request. + */ +export function renderPreloadPlugin(entries: ServiceEntry[]): string { + const awaited = entries.filter(entry => !entry.importsNitroRuntime); + const notAwaited = entries.filter(entry => entry.importsNitroRuntime); + const lines: string[] = []; + + if (awaited.length > 0) { + lines.push( + 'let loaded = false;', + `const preload = Promise.all([${awaited.map(entry => renderImport(entry.file)).join(', ')}]).then(() => { loaded = true; });`, + 'let timeout;', + `await Promise.race([preload, new Promise(resolve => { timeout = setTimeout(resolve, ${PRELOAD_TIMEOUT_MS}); })]);`, + 'clearTimeout(timeout);', + `if (!loaded) console.warn(${JSON.stringify( + `[Sentry] Loading the server entry at server startup took longer than ${PRELOAD_TIMEOUT_MS}ms and continues in the background. The first request may not be traced.`, + )});`, + ); + } + + lines.push(...notAwaited.map(entry => `${renderImport(entry.file)};`)); + lines.push('export default function sentryPreloadServerEntry() {}'); + + return lines.join('\n'); +} + +// Errors surface when Nitro imports the entry for the first request, as without the preload. +function renderImport(file: string): string { + return `import(${JSON.stringify(file)}).catch(() => {})`; +} + +/** The chunk that Nitro picks as the entry of a service: the one of the build input, or else the first. */ +function findServiceEntryChunk( + chunks: Rollup.OutputChunk[], + input: Rollup.InputOption | undefined, +): Rollup.OutputChunk | undefined { + const entryChunks = chunks.filter(chunk => chunk.isEntry); + const inputs = (typeof input === 'string' ? [input] : Object.values(input ?? {})).map(file => path.resolve(file)); + + return ( + entryChunks.find(chunk => chunk.facadeModuleId && inputs.includes(path.resolve(chunk.facadeModuleId))) ?? + entryChunks[0] + ); +} + +function isNitroRuntimeImport(id: string): boolean { + return ( + id === 'nitro' || id.startsWith('nitro/') || id.startsWith('#nitro/') || /[\\/]node_modules[\\/]nitro[\\/]/.test(id) + ); +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 6d56f81a63e7..cb2e57f6c86c 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,6 +2,7 @@ import type { BuildTimeOptionsBase, ReactComponentAnnotationOptions } from '@sen import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makePreloadNitroServerEntryPlugin } from './nitroPreloadServerEntry'; import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugin } from './nitroSourceMaps'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; @@ -97,7 +98,11 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // (CommonJS) deps via `ssr.noExternal`, which the `vite dev` SSR module runner can't evaluate. plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); - plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); + plugins.push( + ...makeAddSentryVitePlugin(options), + makeComposeNitroServiceSourceMapsPlugin(), + makePreloadNitroServerEntryPlugin(), + ); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { diff --git a/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts b/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts new file mode 100644 index 000000000000..9d7b1cef7742 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import type { Rollup } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makePreloadNitroServerEntryPlugin, renderPreloadPlugin } from '../../src/vite/nitroPreloadServerEntry'; + +const PRELOAD_PLUGIN_ID = '#sentry/tanstackstart-react/preload-server-entry'; + +type TestNitro = Parameters['nitro']['setup']>[0]; + +function createNitro(options: Partial = {}): TestNitro { + return { options: { dev: false, node: true, plugins: [], virtual: {}, ...options } }; +} + +function createChunk(fileName: string, chunk: Partial = {}): Rollup.OutputChunk { + return { type: 'chunk', fileName, isEntry: false, imports: [], dynamicImports: [], ...chunk } as Rollup.OutputChunk; +} + +function writeServiceBundle( + plugin: ReturnType, + environmentName: string, + dir: string, + chunks: Rollup.OutputChunk[], + input?: Rollup.InputOption, +): void { + const bundle = Object.fromEntries(chunks.map(chunk => [chunk.fileName, chunk])); + const writeBundle = plugin.writeBundle as ( + this: { environment: { name: string; config: { build: { rolldownOptions: { input?: Rollup.InputOption } } } } }, + options: { dir?: string }, + bundle: Rollup.OutputBundle, + ) => void; + writeBundle.call( + { environment: { name: environmentName, config: { build: { rolldownOptions: { input } } } } }, + { dir }, + bundle, + ); +} + +async function renderVirtualModule(nitro: TestNitro): Promise { + const template = nitro.options.virtual[PRELOAD_PLUGIN_ID]; + return typeof template === 'function' ? template() : (template as string); +} + +describe('makePreloadNitroServerEntryPlugin', () => { + it('adds the preload plugin to the Nitro server build', () => { + const plugin = makePreloadNitroServerEntryPlugin(); + const nitro = createNitro({ plugins: ['./plugins/user'] }); + + plugin.nitro.setup(nitro); + + expect(nitro.options.plugins).toEqual(['./plugins/user', PRELOAD_PLUGIN_ID]); + expect(nitro.options.virtual[PRELOAD_PLUGIN_ID]).toEqual(expect.any(Function)); + }); + + it.each([ + { label: 'in dev', options: { dev: true } }, + { label: 'for presets without Node.js, such as workers', options: { node: false } }, + ])('does not add the preload plugin $label', ({ options }) => { + const plugin = makePreloadNitroServerEntryPlugin(); + const nitro = createNitro(options); + + plugin.nitro.setup(nitro); + + expect(nitro.options.plugins).toEqual([]); + expect(nitro.options.virtual).toEqual({}); + }); + + it('awaits the entry of each service', async () => { + const plugin = makePreloadNitroServerEntryPlugin(); + const nitro = createNitro(); + plugin.nitro.setup(nitro); + + writeServiceBundle(plugin, 'ssr', '/app/node_modules/.nitro/vite/services/ssr', [ + createChunk('index.js', { isEntry: true, imports: ['react', 'assets/routes.js'] }), + createChunk('assets/routes.js'), + ]); + + const code = await renderVirtualModule(nitro); + + expect(code).toContain('await Promise.race('); + expect(code).toContain('import("/app/node_modules/.nitro/vite/services/ssr/index.js")'); + expect(code).not.toContain('assets/routes.js'); + }); + + it('preloads only the entry chunk of the build input, like Nitro', async () => { + const plugin = makePreloadNitroServerEntryPlugin(); + const nitro = createNitro(); + plugin.nitro.setup(nitro); + + writeServiceBundle( + plugin, + 'ssr', + '/app/node_modules/.nitro/vite/services/ssr', + [ + createChunk('other.js', { isEntry: true, facadeModuleId: '/app/src/other.ts' }), + createChunk('index.js', { isEntry: true, facadeModuleId: '/app/src/server.ts' }), + ], + '/app/src/server.ts', + ); + + const code = await renderVirtualModule(nitro); + + expect(code).toContain('import("/app/node_modules/.nitro/vite/services/ssr/index.js")'); + expect(code).not.toContain('other.js'); + }); + + it.each(['nitro', 'nitro/app', '#nitro/virtual/app', '/app/node_modules/nitro/dist/runtime/app.mjs'])( + 'does not await a service that imports the Nitro runtime (%s)', + async nitroImport => { + const plugin = makePreloadNitroServerEntryPlugin(); + const nitro = createNitro(); + plugin.nitro.setup(nitro); + + writeServiceBundle(plugin, 'ssr', '/app/node_modules/.nitro/vite/services/ssr', [ + createChunk('index.js', { isEntry: true, imports: ['assets/routes.js'] }), + createChunk('assets/routes.js', { imports: [nitroImport] }), + ]); + + const code = await renderVirtualModule(nitro); + + expect(code).not.toContain('await'); + expect(code).toContain('import("/app/node_modules/.nitro/vite/services/ssr/index.js").catch(() => {});'); + }, + ); + + it('renders a plugin without imports if no service was built', async () => { + const plugin = makePreloadNitroServerEntryPlugin(); + const nitro = createNitro(); + plugin.nitro.setup(nitro); + + const code = await renderVirtualModule(nitro); + + expect(code).not.toContain('import('); + expect(code).toContain('export default function'); + }); +}); + +describe('renderPreloadPlugin', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-tanstackstart-preload-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + async function importPlugin(code: string): Promise<{ default: unknown }> { + const file = path.join(tempDir, 'plugin.mjs'); + fs.writeFileSync(file, code); + return import(pathToFileURL(file).href); + } + + it('finishes loading only after the service entries ran', async () => { + const entry = path.join(tempDir, 'entry.mjs'); + fs.writeFileSync( + entry, + 'await new Promise(resolve => setTimeout(resolve, 20)); globalThis.__preloadTestEntryRan = true;', + ); + + const plugin = await importPlugin(renderPreloadPlugin([{ file: entry, importsNitroRuntime: false }])); + + expect((globalThis as { __preloadTestEntryRan?: boolean }).__preloadTestEntryRan).toBe(true); + expect(plugin.default).toEqual(expect.any(Function)); + delete (globalThis as { __preloadTestEntryRan?: boolean }).__preloadTestEntryRan; + }); + + it('does not fail when a service entry throws', async () => { + const entry = path.join(tempDir, 'entry.mjs'); + fs.writeFileSync(entry, 'throw new Error("entry failed");'); + + const plugin = await importPlugin(renderPreloadPlugin([{ file: entry, importsNitroRuntime: false }])); + + expect(plugin.default).toEqual(expect.any(Function)); + }); + + it('stops waiting and warns when a service entry does not finish loading', { timeout: 10_000 }, async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entry = path.join(tempDir, 'entry.mjs'); + fs.writeFileSync(entry, 'await new Promise(() => {});'); + + const plugin = await importPlugin(renderPreloadPlugin([{ file: entry, importsNitroRuntime: false }])); + + expect(plugin.default).toEqual(expect.any(Function)); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('The first request may not be traced.')); + warn.mockRestore(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index e757cd9d6173..079e3fdcfd3f 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -29,6 +29,11 @@ const mockComposeNitroServiceSourceMapsPlugin: Plugin = { load: vi.fn(), }; +const mockPreloadNitroServerEntryPlugin: Plugin = { + name: 'sentry-tanstackstart-nitro-preload-server-entry', + apply: 'build', +}; + const mockEnableNitroSourceMapsPlugin: Plugin = { name: 'sentry-tanstackstart-nitro-source-maps', apply: 'build', @@ -87,6 +92,10 @@ vi.mock('../../src/vite/nitroSourceMaps', () => ({ makeEnableNitroSourceMapsPlugin: vi.fn(() => mockEnableNitroSourceMapsPlugin), })); +vi.mock('../../src/vite/nitroPreloadServerEntry', () => ({ + makePreloadNitroServerEntryPlugin: vi.fn(() => mockPreloadNitroServerEntryPlugin), +})); + vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); @@ -119,6 +128,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, ]); @@ -162,6 +172,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, ]); }); @@ -179,6 +190,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, ]); }); @@ -196,6 +208,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, ]); @@ -214,6 +227,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockMiddlewarePlugin, ]); }); @@ -232,6 +246,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockMiddlewarePlugin, ]); }); @@ -250,6 +265,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, ]); }); @@ -285,6 +301,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockMiddlewarePlugin, ]); }); From 5c0c075fd1b225db256ebf85dfe9e409241fac32 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Nov 2026 10:03:39 +0100 Subject: [PATCH 2/6] test(tanstackstart-react): Use convention constants in server entry init test Co-Authored-By: Claude Opus 5.5 --- .../test-applications/tanstackstart-react/package.json | 1 + .../tests/init-in-server-entry.test.ts | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 802c88aa6007..16adf5cf2697 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -48,6 +48,7 @@ "vite": "8.3.4", "vite-tsconfig-paths": "^5.1.4", "@playwright/test": "1.64.1", + "@sentry/conventions": "0.30.0", "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts index 9992006c724d..ebfdf6c41246 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts @@ -1,6 +1,8 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { expect, test } from '@playwright/test'; import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; test.skip(process.env.E2E_TEST_INIT_IN_SERVER_ENTRY !== '1', 'Runs only in the init-in-server-entry variant'); @@ -39,13 +41,13 @@ test('sends an http.server span for the first request of a server started withou 'tanstackstart-react', span => !!span.is_segment && - getSpanOp(span) === 'http.server' && - String(span.attributes['url.full']?.value ?? '').includes(`localhost:${PORT}/`), + getSpanOp(span) === HTTP_SERVER && + String(span.attributes[URL_FULL]?.value ?? '').includes(`localhost:${PORT}/`), ); const response = await fetch(`http://localhost:${PORT}/`); expect(response.status).toBe(200); const span = await spanPromise; - expect(span.attributes['url.path']?.value).toBe('/'); + expect(span.attributes[URL_PATH]?.value).toBe('/'); }); From dd01be2f8909d45bb13018f8688ac78bf6c38a3b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Nov 2026 10:59:02 +0100 Subject: [PATCH 3/6] fix(tanstackstart-react): Preload exactly the services that Nitro loads Read the service entries from Nitro's vite-services module instead of deriving them, so the preload uses the same specifiers and skips server environments that are not Nitro services. Add a preloadServerEntry option to opt out, and check in the e2e test that the preload did not time out. Co-Authored-By: Claude Opus 5.5 --- .../tests/init-in-server-entry.test.ts | 3 + .../tanstackstart-react/vite.config.ts | 3 +- .../src/vite/nitroPreloadServerEntry.ts | 77 +++++----- .../src/vite/sentryTanstackStart.ts | 22 ++- .../test/vite/nitroPreloadServerEntry.test.ts | 133 +++++++++++------- .../test/vite/sentryTanstackStart.test.ts | 7 + 6 files changed, 147 insertions(+), 98 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts index ebfdf6c41246..469fda4e6b6d 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts @@ -45,6 +45,9 @@ test('sends an http.server span for the first request of a server started withou String(span.attributes[URL_FULL]?.value ?? '').includes(`localhost:${PORT}/`), ); + // The preload timing out would still initialize the SDK before the server listens, so the test would pass anyway. + expect(output).not.toContain('The first request may not be traced'); + const response = await fetch(`http://localhost:${PORT}/`); expect(response.status).toBe(200); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 6f9a16f7ea4b..51bab6b0b2f8 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -32,7 +32,8 @@ function initInServerEntry() { name: 'init-in-server-entry', enforce: 'pre' as const, transform(code: string, id: string) { - return id.endsWith('/src/server.ts') ? `import '../instrument.server.mjs';\n${code}` : undefined; + // On the first line, so that the server source maps stay correct without a map for this change. + return id.endsWith('/src/server.ts') ? { code: `import '../instrument.server.mjs';${code}`, map: null } : undefined; }, }; } diff --git a/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts index 4b63d3df5d88..d03ca1c20aba 100644 --- a/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts +++ b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts @@ -1,18 +1,20 @@ -import * as path from 'node:path'; -import type { Plugin, Rollup } from 'vite'; +import type { Plugin } from 'vite'; import { isNitroService } from './nitroSourceMaps'; const PRELOAD_PLUGIN_ID = '#sentry/tanstackstart-react/preload-server-entry'; +const VITE_SERVICES_ID = '#nitro/virtual/vite-services'; // A safety net against an import cycle that the build could not detect, which would otherwise never settle. const PRELOAD_TIMEOUT_MS = 3000; +type NitroTemplate = string | (() => string | Promise); + type Nitro = { options: { dev: boolean; node: boolean; plugins: string[]; - virtual: Record string | Promise)>; + virtual: Record; }; }; @@ -22,35 +24,21 @@ type ServiceEntry = { file: string; importsNitroRuntime: boolean }; * Nitro imports the entry of a Vite service, such as TanStack Start's server entry, on the first request. An * `instrument.server` import at the top of the server entry so only initializes the SDK during that request, too late * for the HTTP instrumentation to see it. This adds a Nitro plugin that imports the service entries at server startup. - * - * Because the plugin imports the same files that Nitro imports lazily, the SDK is not bundled a second time. */ export function makePreloadNitroServerEntryPlugin(): Plugin & { nitro: { name: string; setup: (nitro: Nitro) => void }; } { - const serviceEntries = new Map(); + const servicesImportingNitroRuntime = new Map(); return { name: 'sentry-tanstackstart-nitro-preload-server-entry', apply: 'build', applyToEnvironment: isNitroService, - writeBundle(outputOptions, bundle) { - if (!outputOptions.dir) { - return; - } - - const chunks = Object.values(bundle).filter((output): output is Rollup.OutputChunk => output.type === 'chunk'); - const importsNitroRuntime = chunks.some(chunk => - [...chunk.imports, ...chunk.dynamicImports].some(isNitroRuntimeImport), + writeBundle(_outputOptions, bundle) { + const importsNitroRuntime = Object.values(bundle).some( + output => output.type === 'chunk' && [...output.imports, ...output.dynamicImports].some(isNitroRuntimeImport), ); - const entryChunk = findServiceEntryChunk(chunks, this.environment.config.build.rolldownOptions.input); - if (!entryChunk) { - return; - } - - // Nitro imports the entry by a forward-slash path, so the bundler sees one module rather than two copies. - const file = path.resolve(outputOptions.dir, entryChunk.fileName).replace(/\\/g, '/'); - serviceEntries.set(this.environment.name, [{ file, importsNitroRuntime }]); + servicesImportingNitroRuntime.set(this.environment.name, importsNitroRuntime); }, nitro: { name: 'sentry-tanstackstart-nitro-preload-server-entry', @@ -60,14 +48,41 @@ export function makePreloadNitroServerEntryPlugin(): Plugin & { return; } - // Nitro renders the template when it builds its own environment, after the service environments. - nitro.options.virtual[PRELOAD_PLUGIN_ID] = () => renderPreloadPlugin([...serviceEntries.values()].flat()); + // Nitro renders the templates when it builds its own environment, after the service environments. + nitro.options.virtual[PRELOAD_PLUGIN_ID] = async () => { + const services = await readViteServices(nitro.options.virtual[VITE_SERVICES_ID]); + return renderPreloadPlugin( + services.map(({ name, file }) => ({ + file, + // A service that was not checked could import the Nitro runtime. + importsNitroRuntime: servicesImportingNitroRuntime.get(name) ?? true, + })), + ); + }; nitro.options.plugins.push(PRELOAD_PLUGIN_ID); }, }, }; } +/** + * Reads the services and the entry files that Nitro imports lazily from its internal `vite-services` module. Importing + * the very same specifiers makes the bundler load the same modules, rather than a second copy of the SDK. If the module + * changes shape, nothing is preloaded. + */ +export async function readViteServices(template: NitroTemplate | undefined): Promise<{ name: string; file: string }[]> { + const code = typeof template === 'function' ? await template() : template; + if (!code) { + return []; + } + + const services = []; + for (const match of code.matchAll(/\[("(?:[^"\\]|\\.)*")\]: lazyService\(\(\) => import\(("(?:[^"\\]|\\.)*")\)\)/g)) { + services.push({ name: JSON.parse(match[1]!) as string, file: JSON.parse(match[2]!) as string }); + } + return services; +} + /** * Serverless platforms send the first request right after loading the Nitro server module, so the preload has to * finish before the module does, with top-level `await`. @@ -104,20 +119,6 @@ function renderImport(file: string): string { return `import(${JSON.stringify(file)}).catch(() => {})`; } -/** The chunk that Nitro picks as the entry of a service: the one of the build input, or else the first. */ -function findServiceEntryChunk( - chunks: Rollup.OutputChunk[], - input: Rollup.InputOption | undefined, -): Rollup.OutputChunk | undefined { - const entryChunks = chunks.filter(chunk => chunk.isEntry); - const inputs = (typeof input === 'string' ? [input] : Object.values(input ?? {})).map(file => path.resolve(file)); - - return ( - entryChunks.find(chunk => chunk.facadeModuleId && inputs.includes(path.resolve(chunk.facadeModuleId))) ?? - entryChunks[0] - ); -} - function isNitroRuntimeImport(id: string): boolean { return ( id === 'nitro' || id.startsWith('nitro/') || id.startsWith('#nitro/') || /[\\/]node_modules[\\/]nitro[\\/]/.test(id) diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index cb2e57f6c86c..6b64c69c8bc1 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -26,6 +26,18 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { */ autoInstrumentMiddleware?: boolean; + /** + * If this flag is `true`, Nitro builds import the server entry at server startup instead of on the first request. This + * way, an SDK init at the top of the server entry runs before the server handles requests, for hosts without + * `node --import`. The Nitro server module then waits for the server entry with top-level `await`. + * + * Set to `false` to keep loading the server entry on the first request, for example if you initialize the SDK with + * `node --import`. + * + * @default true + */ + preloadServerEntry?: boolean; + /** * Options related to react component name annotations. * Disabled by default, unless a value is set for this option. @@ -98,11 +110,11 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // (CommonJS) deps via `ssr.noExternal`, which the `vite dev` SSR module runner can't evaluate. plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); - plugins.push( - ...makeAddSentryVitePlugin(options), - makeComposeNitroServiceSourceMapsPlugin(), - makePreloadNitroServerEntryPlugin(), - ); + plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); + + if (options.preloadServerEntry !== false) { + plugins.push(makePreloadNitroServerEntryPlugin()); + } // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { diff --git a/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts b/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts index 9d7b1cef7742..ec9674187a87 100644 --- a/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts +++ b/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts @@ -5,38 +5,49 @@ import * as path from 'path'; import { pathToFileURL } from 'url'; import type { Rollup } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makePreloadNitroServerEntryPlugin, renderPreloadPlugin } from '../../src/vite/nitroPreloadServerEntry'; +import { + makePreloadNitroServerEntryPlugin, + readViteServices, + renderPreloadPlugin, +} from '../../src/vite/nitroPreloadServerEntry'; const PRELOAD_PLUGIN_ID = '#sentry/tanstackstart-react/preload-server-entry'; +const VITE_SERVICES_ID = '#nitro/virtual/vite-services'; +const SSR_ENTRY = '/app/node_modules/.nitro/vite/services/ssr/index.js'; + +type PreloadPlugin = ReturnType; +type TestNitro = Parameters[0]; + +// The shape of the production template of `nitro/vite` (`viteServicesTemplate`). +function viteServicesTemplate(services: Record): string { + return ` +function lazyService(loader) { + let promise, mod + return { fetch(req) { return loader().then(mod => mod.fetch(req)) } } +} -type TestNitro = Parameters['nitro']['setup']>[0]; +export const viteServices = { +${Object.entries(services) + .map(([name, entry]) => `[${JSON.stringify(name)}]: lazyService(() => import(${JSON.stringify(entry)}))`) + .join(',\n')} +}; + `; +} function createNitro(options: Partial = {}): TestNitro { return { options: { dev: false, node: true, plugins: [], virtual: {}, ...options } }; } -function createChunk(fileName: string, chunk: Partial = {}): Rollup.OutputChunk { - return { type: 'chunk', fileName, isEntry: false, imports: [], dynamicImports: [], ...chunk } as Rollup.OutputChunk; -} - -function writeServiceBundle( - plugin: ReturnType, - environmentName: string, - dir: string, - chunks: Rollup.OutputChunk[], - input?: Rollup.InputOption, -): void { - const bundle = Object.fromEntries(chunks.map(chunk => [chunk.fileName, chunk])); +function writeServiceBundle(plugin: PreloadPlugin, environmentName: string, imports: string[] = []): void { + const chunk = { type: 'chunk', fileName: 'index.js', isEntry: true, imports, dynamicImports: [] }; const writeBundle = plugin.writeBundle as ( - this: { environment: { name: string; config: { build: { rolldownOptions: { input?: Rollup.InputOption } } } } }, - options: { dir?: string }, + this: { environment: { name: string } }, + options: object, bundle: Rollup.OutputBundle, ) => void; - writeBundle.call( - { environment: { name: environmentName, config: { build: { rolldownOptions: { input } } } } }, - { dir }, - bundle, - ); + writeBundle.call({ environment: { name: environmentName } }, {}, { + 'index.js': chunk, + } as unknown as Rollup.OutputBundle); } async function renderVirtualModule(nitro: TestNitro): Promise { @@ -68,68 +79,67 @@ describe('makePreloadNitroServerEntryPlugin', () => { expect(nitro.options.virtual).toEqual({}); }); - it('awaits the entry of each service', async () => { + it('awaits the entries that Nitro imports lazily, with the same specifiers', async () => { const plugin = makePreloadNitroServerEntryPlugin(); - const nitro = createNitro(); + const windowsEntry = 'C:/app/node_modules/.nitro/vite/services/api/index.js'; + const nitro = createNitro({ + virtual: { [VITE_SERVICES_ID]: () => viteServicesTemplate({ ssr: SSR_ENTRY, api: windowsEntry }) }, + }); plugin.nitro.setup(nitro); - - writeServiceBundle(plugin, 'ssr', '/app/node_modules/.nitro/vite/services/ssr', [ - createChunk('index.js', { isEntry: true, imports: ['react', 'assets/routes.js'] }), - createChunk('assets/routes.js'), - ]); + writeServiceBundle(plugin, 'ssr', ['react']); + writeServiceBundle(plugin, 'api'); const code = await renderVirtualModule(nitro); expect(code).toContain('await Promise.race('); - expect(code).toContain('import("/app/node_modules/.nitro/vite/services/ssr/index.js")'); - expect(code).not.toContain('assets/routes.js'); + expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)})`); + expect(code).toContain(`import(${JSON.stringify(windowsEntry)})`); }); - it('preloads only the entry chunk of the build input, like Nitro', async () => { + it('does not preload server environments that are not Nitro services', async () => { const plugin = makePreloadNitroServerEntryPlugin(); - const nitro = createNitro(); + const nitro = createNitro({ virtual: { [VITE_SERVICES_ID]: viteServicesTemplate({ ssr: SSR_ENTRY }) } }); plugin.nitro.setup(nitro); - - writeServiceBundle( - plugin, - 'ssr', - '/app/node_modules/.nitro/vite/services/ssr', - [ - createChunk('other.js', { isEntry: true, facadeModuleId: '/app/src/other.ts' }), - createChunk('index.js', { isEntry: true, facadeModuleId: '/app/src/server.ts' }), - ], - '/app/src/server.ts', - ); + writeServiceBundle(plugin, 'ssr'); + writeServiceBundle(plugin, 'rsc'); const code = await renderVirtualModule(nitro); - expect(code).toContain('import("/app/node_modules/.nitro/vite/services/ssr/index.js")'); - expect(code).not.toContain('other.js'); + expect(code.match(/import\(/g)).toHaveLength(1); + expect(code).not.toContain('rsc'); }); it.each(['nitro', 'nitro/app', '#nitro/virtual/app', '/app/node_modules/nitro/dist/runtime/app.mjs'])( 'does not await a service that imports the Nitro runtime (%s)', async nitroImport => { const plugin = makePreloadNitroServerEntryPlugin(); - const nitro = createNitro(); + const nitro = createNitro({ virtual: { [VITE_SERVICES_ID]: viteServicesTemplate({ ssr: SSR_ENTRY }) } }); plugin.nitro.setup(nitro); - - writeServiceBundle(plugin, 'ssr', '/app/node_modules/.nitro/vite/services/ssr', [ - createChunk('index.js', { isEntry: true, imports: ['assets/routes.js'] }), - createChunk('assets/routes.js', { imports: [nitroImport] }), - ]); + writeServiceBundle(plugin, 'ssr', [nitroImport]); const code = await renderVirtualModule(nitro); expect(code).not.toContain('await'); - expect(code).toContain('import("/app/node_modules/.nitro/vite/services/ssr/index.js").catch(() => {});'); + expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)}).catch(() => {});`); }, ); - it('renders a plugin without imports if no service was built', async () => { + it('does not await a service whose imports were not checked', async () => { + const plugin = makePreloadNitroServerEntryPlugin(); + const nitro = createNitro({ virtual: { [VITE_SERVICES_ID]: viteServicesTemplate({ ssr: SSR_ENTRY }) } }); + plugin.nitro.setup(nitro); + + const code = await renderVirtualModule(nitro); + + expect(code).not.toContain('await'); + expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)})`); + }); + + it('preloads nothing without the vite-services module of Nitro', async () => { const plugin = makePreloadNitroServerEntryPlugin(); const nitro = createNitro(); plugin.nitro.setup(nitro); + writeServiceBundle(plugin, 'ssr'); const code = await renderVirtualModule(nitro); @@ -138,6 +148,21 @@ describe('makePreloadNitroServerEntryPlugin', () => { }); }); +describe('readViteServices', () => { + it('reads the services with escaped characters in their paths', async () => { + const entry = 'C:\\app\\services\\"ssr"\\index.js'; + + await expect(readViteServices(viteServicesTemplate({ ssr: entry }))).resolves.toEqual([ + { name: 'ssr', file: entry }, + ]); + }); + + it('reads nothing from a template of another shape', async () => { + await expect(readViteServices('export const viteServices = {};')).resolves.toEqual([]); + await expect(readViteServices(undefined)).resolves.toEqual([]); + }); +}); + describe('renderPreloadPlugin', () => { let tempDir: string; @@ -146,6 +171,7 @@ describe('renderPreloadPlugin', () => { }); afterEach(() => { + vi.restoreAllMocks(); fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -187,6 +213,5 @@ describe('renderPreloadPlugin', () => { expect(plugin.default).toEqual(expect.any(Function)); expect(warn).toHaveBeenCalledWith(expect.stringContaining('The first request may not be traced.')); - warn.mockRestore(); }); }); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 079e3fdcfd3f..654c014456bf 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -134,6 +134,13 @@ describe('sentryTanstackStart()', () => { ]); }); + it('does not return the server entry preload plugin when preloadServerEntry is false', () => { + const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false, preloadServerEntry: false }); + + expect(plugins).not.toContain(mockPreloadNitroServerEntryPlugin); + expect(plugins).toContain(mockComposeNitroServiceSourceMapsPlugin); + }); + it('returns no build-time plugins in development mode when tunnelRoute is not configured', () => { process.env.NODE_ENV = 'development'; From e552a7c84aa037f450696271700cea3b055406aa Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Nov 2026 11:05:54 +0100 Subject: [PATCH 4/6] ref(tanstackstart-react): Avoid non-null assertions when reading Nitro services Also formats the e2e Vite config. Co-Authored-By: Claude Opus 5.5 --- .../test-applications/tanstackstart-react/vite.config.ts | 4 +++- .../src/vite/nitroPreloadServerEntry.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 51bab6b0b2f8..98b35fcde5c4 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -33,7 +33,9 @@ function initInServerEntry() { enforce: 'pre' as const, transform(code: string, id: string) { // On the first line, so that the server source maps stay correct without a map for this change. - return id.endsWith('/src/server.ts') ? { code: `import '../instrument.server.mjs';${code}`, map: null } : undefined; + return id.endsWith('/src/server.ts') + ? { code: `import '../instrument.server.mjs';${code}`, map: null } + : undefined; }, }; } diff --git a/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts index d03ca1c20aba..bcf82fea4174 100644 --- a/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts +++ b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts @@ -77,8 +77,12 @@ export async function readViteServices(template: NitroTemplate | undefined): Pro } const services = []; - for (const match of code.matchAll(/\[("(?:[^"\\]|\\.)*")\]: lazyService\(\(\) => import\(("(?:[^"\\]|\\.)*")\)\)/g)) { - services.push({ name: JSON.parse(match[1]!) as string, file: JSON.parse(match[2]!) as string }); + for (const [, name, file] of code.matchAll( + /\[("(?:[^"\\]|\\.)*")\]: lazyService\(\(\) => import\(("(?:[^"\\]|\\.)*")\)\)/g, + )) { + if (name && file) { + services.push({ name: JSON.parse(name) as string, file: JSON.parse(file) as string }); + } } return services; } From 7c082184d31f4bf3b14ac2d3f56e6a9a1d4f7d09 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 4 Nov 2026 11:09:03 +0100 Subject: [PATCH 5/6] chore: Retrigger CI Co-Authored-By: Claude Opus 5.5 From 3e30f13ca470220b59f09d3b6ad8407cbaba9621 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 5 Nov 2026 16:17:04 +0100 Subject: [PATCH 6/6] fix(tanstackstart-react): Find Nitro services and import cycles in the Nitro build Older Nitro betas generate the module that imports the services as #nitro-vite-setup, so the preload did nothing there. The preload module now loads that module itself, under either name. The service build leaves some packages as plain imports, so it could not see when one of them imports the Nitro runtime. Whether a service entry imports the preload module is now decided from the module graph of the Nitro build. Also logs with debug enabled, shares the Nitro options type, and no longer starts an unused app server in the init-in-server-entry e2e variant. Co-Authored-By: Claude Opus 5.5 --- .../tanstackstart-react/playwright.config.mjs | 4 +- .../src/vite/nitroPreloadServerEntry.ts | 203 +++++++++------ .../src/vite/nitroSourceMaps.ts | 26 +- .../src/vite/sentryTanstackStart.ts | 2 +- .../test/vite/nitroPreloadServerEntry.test.ts | 246 ++++++++++-------- 5 files changed, 279 insertions(+), 202 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs index 3b2ac1d7ed7a..f7b1fb795306 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/playwright.config.mjs @@ -8,12 +8,12 @@ const needsDocker = !usesManagedTunnelRoute && process.env.E2E_TEST_INIT_IN_SERV // The dev server has no build output, so `--import` points at the app's own instrumentation file. // `vite dev` ignores PORT, so the port goes on the command. -// The init-in-server-entry variant initializes the SDK from the server entry instead. +// The init-in-server-entry test starts a server of its own. const startCommand = process.env.TEST_ENV === 'development' ? `NODE_OPTIONS='--import ./instrument.server.mjs' pnpm dev --port 3000` : process.env.E2E_TEST_INIT_IN_SERVER_ENTRY === '1' - ? 'node .output/server/index.mjs' + ? undefined : `pnpm start`; const config = getPlaywrightConfig({ diff --git a/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts index bcf82fea4174..9320d0f7054d 100644 --- a/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts +++ b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts @@ -1,130 +1,169 @@ -import type { Plugin } from 'vite'; -import { isNitroService } from './nitroSourceMaps'; +import type { Plugin, Rollup } from 'vite'; +import { logDebug, NITRO_ENVIRONMENT, type NitroOptions } from './nitroSourceMaps'; -const PRELOAD_PLUGIN_ID = '#sentry/tanstackstart-react/preload-server-entry'; -const VITE_SERVICES_ID = '#nitro/virtual/vite-services'; +const PRELOAD_MODULE_ID = '#sentry/tanstackstart-react/preload-server-entry'; +const RESOLVED_PRELOAD_MODULE_ID = `\0${PRELOAD_MODULE_ID}`; + +// The module that imports the service entries lazily. Nitro renamed it, so this lists the names, newest first. +const NITRO_SERVICES_MODULE_IDS = ['#nitro/virtual/vite-services', '#nitro-vite-setup']; + +// Replaced with the services to await once the module graph is complete. Left as is, no service is awaited. +const AWAITED_SERVICES_PLACEHOLDER = '__SENTRY_PRELOAD_AWAITED_SERVICES__'; // A safety net against an import cycle that the build could not detect, which would otherwise never settle. const PRELOAD_TIMEOUT_MS = 3000; -type NitroTemplate = string | (() => string | Promise); - -type Nitro = { - options: { - dev: boolean; - node: boolean; - plugins: string[]; - virtual: Record; - }; -}; +type Nitro = { options: Pick }; -type ServiceEntry = { file: string; importsNitroRuntime: boolean }; +type ModuleGraph = Pick; /** * Nitro imports the entry of a Vite service, such as TanStack Start's server entry, on the first request. An * `instrument.server` import at the top of the server entry so only initializes the SDK during that request, too late * for the HTTP instrumentation to see it. This adds a Nitro plugin that imports the service entries at server startup. */ -export function makePreloadNitroServerEntryPlugin(): Plugin & { +export function makePreloadNitroServerEntryPlugin({ debug }: { debug?: boolean }): Plugin & { nitro: { name: string; setup: (nitro: Nitro) => void }; } { - const servicesImportingNitroRuntime = new Map(); + let serviceEntryIds: string[] = []; return { name: 'sentry-tanstackstart-nitro-preload-server-entry', apply: 'build', - applyToEnvironment: isNitroService, - writeBundle(_outputOptions, bundle) { - const importsNitroRuntime = Object.values(bundle).some( - output => output.type === 'chunk' && [...output.imports, ...output.dynamicImports].some(isNitroRuntimeImport), + enforce: 'pre', + applyToEnvironment: environment => environment.name === NITRO_ENVIRONMENT, + resolveId(id) { + return id === PRELOAD_MODULE_ID ? RESOLVED_PRELOAD_MODULE_ID : null; + }, + async load(id) { + if (id !== RESOLVED_PRELOAD_MODULE_ID) { + return null; + } + + const entries = await loadServiceEntries(this); + serviceEntryIds = await Promise.all(entries.map(async entry => (await this.resolve(entry, id))?.id ?? entry)); + logDebug( + debug, + entries.length > 0 + ? `Loading the Nitro service entries at server startup: ${entries.join(', ')}` + : 'Found no Nitro service entries to load at server startup.', ); - servicesImportingNitroRuntime.set(this.environment.name, importsNitroRuntime); + return renderPreloadModule(entries, debug); + }, + renderChunk(code, chunk) { + if (!chunk.moduleIds.includes(RESOLVED_PRELOAD_MODULE_ID)) { + return null; + } + + const awaited = serviceEntryIds.map(entryId => !importsModule(this, entryId, RESOLVED_PRELOAD_MODULE_ID)); + serviceEntryIds.forEach((entryId, index) => { + if (!awaited[index]) { + logDebug( + debug, + `Not waiting for ${entryId} at server startup, as it imports the Nitro runtime. The first request may not be traced on serverless platforms.`, + ); + } + }); + // Only the rest of the placeholder's line moves, so the source map can stay as is. + return { code: setAwaitedServices(code, awaited), map: null }; }, nitro: { name: 'sentry-tanstackstart-nitro-preload-server-entry', setup(nitro) { // Workers have no Node.js HTTP server to instrument, and in dev Nitro loads the services through Vite. if (nitro.options.dev || nitro.options.node === false) { + logDebug(debug, 'Not loading the Nitro service entries at server startup in dev or without Node.js.'); return; } - - // Nitro renders the templates when it builds its own environment, after the service environments. - nitro.options.virtual[PRELOAD_PLUGIN_ID] = async () => { - const services = await readViteServices(nitro.options.virtual[VITE_SERVICES_ID]); - return renderPreloadPlugin( - services.map(({ name, file }) => ({ - file, - // A service that was not checked could import the Nitro runtime. - importsNitroRuntime: servicesImportingNitroRuntime.get(name) ?? true, - })), - ); - }; - nitro.options.plugins.push(PRELOAD_PLUGIN_ID); + nitro.options.plugins.push(PRELOAD_MODULE_ID); }, }, }; } /** - * Reads the services and the entry files that Nitro imports lazily from its internal `vite-services` module. Importing - * the very same specifiers makes the bundler load the same modules, rather than a second copy of the SDK. If the module - * changes shape, nothing is preloaded. + * Reads the service entries from the module that Nitro generates to import them lazily. Importing the very same + * specifiers makes the bundler load the same modules, rather than a second copy of the SDK. If the module changes + * shape, nothing is preloaded. */ -export async function readViteServices(template: NitroTemplate | undefined): Promise<{ name: string; file: string }[]> { - const code = typeof template === 'function' ? await template() : template; - if (!code) { - return []; +async function loadServiceEntries(context: Pick): Promise { + for (const id of NITRO_SERVICES_MODULE_IDS) { + const resolved = await context.resolve(id); + if (resolved && !resolved.external) { + const { code } = await context.load(resolved); + return parseServiceEntries(code ?? ''); + } + } + return []; +} + +export function parseServiceEntries(code: string): string[] { + const entries = []; + for (const [, entry] of code.matchAll(/\]: lazyService\(\(\) => import\(("(?:[^"\\]|\\.)*")\)\)/g)) { + if (entry) { + entries.push(JSON.parse(entry) as string); + } } + return entries; +} + +/** + * Whether a module statically imports the target, directly or through other modules. A service entry that imports the + * preload module would wait for it at startup, while the preload module waits for the entry. + */ +export function importsModule(graph: ModuleGraph, moduleId: string, targetId: string): boolean { + const seen = new Set(); + const queue = [moduleId]; + for (let id = queue.shift(); id !== undefined; id = queue.shift()) { + if (id === targetId) { + return true; + } + if (seen.has(id)) { + continue; + } + seen.add(id); - const services = []; - for (const [, name, file] of code.matchAll( - /\[("(?:[^"\\]|\\.)*")\]: lazyService\(\(\) => import\(("(?:[^"\\]|\\.)*")\)\)/g, - )) { - if (name && file) { - services.push({ name: JSON.parse(name) as string, file: JSON.parse(file) as string }); + const info = graph.getModuleInfo(id); + if (!info) { + // Without the imports of a module, assume the worst. + return true; } + queue.push(...info.importedIds); } - return services; + return false; } /** * Serverless platforms send the first request right after loading the Nitro server module, so the preload has to - * finish before the module does, with top-level `await`. - * - * A service that imports the Nitro runtime depends on this plugin, though, and awaiting it would never settle. Such - * services are imported without waiting, which on serverless platforms can still lose the race with the first request. + * finish before the module does, with top-level `await`. Which services are awaited is only known after the build. */ -export function renderPreloadPlugin(entries: ServiceEntry[]): string { - const awaited = entries.filter(entry => !entry.importsNitroRuntime); - const notAwaited = entries.filter(entry => entry.importsNitroRuntime); - const lines: string[] = []; - - if (awaited.length > 0) { - lines.push( - 'let loaded = false;', - `const preload = Promise.all([${awaited.map(entry => renderImport(entry.file)).join(', ')}]).then(() => { loaded = true; });`, - 'let timeout;', - `await Promise.race([preload, new Promise(resolve => { timeout = setTimeout(resolve, ${PRELOAD_TIMEOUT_MS}); })]);`, - 'clearTimeout(timeout);', - `if (!loaded) console.warn(${JSON.stringify( - `[Sentry] Loading the server entry at server startup took longer than ${PRELOAD_TIMEOUT_MS}ms and continues in the background. The first request may not be traced.`, - )});`, - ); +export function renderPreloadModule(entries: string[], debug?: boolean): string { + if (entries.length === 0) { + return 'export default function sentryPreloadServerEntry() {}'; } - lines.push(...notAwaited.map(entry => `${renderImport(entry.file)};`)); - lines.push('export default function sentryPreloadServerEntry() {}'); - - return lines.join('\n'); -} - -// Errors surface when Nitro imports the entry for the first request, as without the preload. -function renderImport(file: string): string { - return `import(${JSON.stringify(file)}).catch(() => {})`; + // Errors surface when Nitro imports the entry for the first request, as without the preload. + const onError = debug + ? `error => console.warn(${JSON.stringify('[Sentry] Loading a Nitro service entry at server startup failed:')}, error)` + : '() => {}'; + + return [ + `const preloads = [${entries.map(entry => `import(${JSON.stringify(entry)}).catch(${onError})`).join(', ')}];`, + `const awaitedServices = ${JSON.stringify(AWAITED_SERVICES_PLACEHOLDER)};`, + 'const awaited = preloads.filter((_, index) => awaitedServices[index] === true);', + 'if (awaited.length > 0) {', + ' let loaded = false;', + ' let timeout;', + ` await Promise.race([Promise.all(awaited).then(() => { loaded = true; }), new Promise(resolve => { timeout = setTimeout(resolve, ${PRELOAD_TIMEOUT_MS}); })]);`, + ' clearTimeout(timeout);', + ` if (!loaded) console.warn(${JSON.stringify( + `[Sentry] Loading the server entry at server startup took longer than ${PRELOAD_TIMEOUT_MS}ms and continues in the background. The first request may not be traced.`, + )});`, + '}', + 'export default function sentryPreloadServerEntry() {}', + ].join('\n'); } -function isNitroRuntimeImport(id: string): boolean { - return ( - id === 'nitro' || id.startsWith('nitro/') || id.startsWith('#nitro/') || /[\\/]node_modules[\\/]nitro[\\/]/.test(id) - ); +export function setAwaitedServices(code: string, awaited: boolean[]): string { + return code.replace(new RegExp(`(["'])${AWAITED_SERVICES_PLACEHOLDER}\\1`), JSON.stringify(awaited)); } diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts index f759cc125fc2..e24eb4f834ba 100644 --- a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite'; /** `nitro/vite` builds the final server in an environment with this name. */ -const NITRO_ENVIRONMENT = 'nitro'; +export const NITRO_ENVIRONMENT = 'nitro'; /** The environment Vite passes to `applyToEnvironment`. */ export type PluginEnvironment = Parameters>[0]; @@ -16,17 +16,20 @@ type NitroSourceMapConfig = { rolldownConfig?: BundlerSourceMapConfig; }; -/** The parts of the Nitro instance that this plugin uses. */ -type Nitro = { - options: { - sourcemap: unknown; - experimental: { sourcemapMinify?: false }; - // c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more. - // It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771 - _c12?: { layers?: { config?: NitroSourceMapConfig | null }[] }; - }; +/** The parts of the Nitro options that the Sentry Nitro modules use. */ +export type NitroOptions = { + dev: boolean; + node: boolean; + plugins: string[]; + sourcemap: unknown; + experimental: { sourcemapMinify?: false }; + // c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more. + // It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771 + _c12?: { layers?: { config?: NitroSourceMapConfig | null }[] }; }; +type Nitro = { options: Pick }; + /** * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only * there because Sentry enabled them. @@ -152,7 +155,8 @@ function hasNitroSourceMapSetting(config: NitroSourceMapConfig | null | undefine ); } -function logDebug(debug: boolean | undefined, message: string): void { +/** Logs a message when the `debug` option is enabled. */ +export function logDebug(debug: boolean | undefined, message: string): void { if (debug) { // eslint-disable-next-line no-console console.log(`[Sentry] ${message}`); diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 6b64c69c8bc1..3768dd1116a7 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -113,7 +113,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); if (options.preloadServerEntry !== false) { - plugins.push(makePreloadNitroServerEntryPlugin()); + plugins.push(makePreloadNitroServerEntryPlugin({ debug: options.debug })); } // middleware auto-instrumentation diff --git a/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts b/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts index ec9674187a87..75a9780d7ad4 100644 --- a/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts +++ b/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts @@ -3,23 +3,29 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'url'; -import type { Rollup } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makePreloadNitroServerEntryPlugin, - readViteServices, - renderPreloadPlugin, + parseServiceEntries, + renderPreloadModule, + setAwaitedServices, } from '../../src/vite/nitroPreloadServerEntry'; -const PRELOAD_PLUGIN_ID = '#sentry/tanstackstart-react/preload-server-entry'; -const VITE_SERVICES_ID = '#nitro/virtual/vite-services'; +const PRELOAD_MODULE_ID = '#sentry/tanstackstart-react/preload-server-entry'; +const RESOLVED_PRELOAD_MODULE_ID = `\0${PRELOAD_MODULE_ID}`; const SSR_ENTRY = '/app/node_modules/.nitro/vite/services/ssr/index.js'; +const API_ENTRY = 'C:/app/node_modules/.nitro/vite/services/api/index.js'; type PreloadPlugin = ReturnType; type TestNitro = Parameters[0]; +type LoadContext = { + resolve: (id: string, importer?: string) => Promise<{ id: string; external: boolean } | null>; + load: (resolved: { id: string }) => Promise<{ code: string | null }>; +}; +type RenderChunkContext = { getModuleInfo: (id: string) => { importedIds: string[] } | null }; -// The shape of the production template of `nitro/vite` (`viteServicesTemplate`). -function viteServicesTemplate(services: Record): string { +// The shape of the production module of `nitro/vite` that imports the services lazily. +function servicesModule(entries: Record): string { return ` function lazyService(loader) { let promise, mod @@ -27,7 +33,7 @@ function lazyService(loader) { } export const viteServices = { -${Object.entries(services) +${Object.entries(entries) .map(([name, entry]) => `[${JSON.stringify(name)}]: lazyService(() => import(${JSON.stringify(entry)}))`) .join(',\n')} }; @@ -35,135 +41,128 @@ ${Object.entries(services) } function createNitro(options: Partial = {}): TestNitro { - return { options: { dev: false, node: true, plugins: [], virtual: {}, ...options } }; + return { options: { dev: false, node: true, plugins: [], ...options } }; } -function writeServiceBundle(plugin: PreloadPlugin, environmentName: string, imports: string[] = []): void { - const chunk = { type: 'chunk', fileName: 'index.js', isEntry: true, imports, dynamicImports: [] }; - const writeBundle = plugin.writeBundle as ( - this: { environment: { name: string } }, - options: object, - bundle: Rollup.OutputBundle, - ) => void; - writeBundle.call({ environment: { name: environmentName } }, {}, { - 'index.js': chunk, - } as unknown as Rollup.OutputBundle); +async function load(plugin: PreloadPlugin, modules: Record): Promise { + const context: LoadContext = { + resolve: async id => (id in modules || id.startsWith('/') || id.startsWith('C:') ? { id, external: false } : null), + load: async ({ id }) => ({ code: modules[id] ?? null }), + }; + const hook = plugin.load as (this: LoadContext, id: string) => Promise; + return hook.call(context, RESOLVED_PRELOAD_MODULE_ID); } -async function renderVirtualModule(nitro: TestNitro): Promise { - const template = nitro.options.virtual[PRELOAD_PLUGIN_ID]; - return typeof template === 'function' ? template() : (template as string); +function renderChunk( + plugin: PreloadPlugin, + code: string, + graph: Record, + moduleIds = [RESOLVED_PRELOAD_MODULE_ID], +): { code: string } | null { + const context: RenderChunkContext = { + getModuleInfo: id => (graph[id] ? { importedIds: graph[id] } : null), + }; + const hook = plugin.renderChunk as ( + this: RenderChunkContext, + code: string, + chunk: { moduleIds: string[] }, + ) => { code: string } | null; + return hook.call(context, code, { moduleIds }); } describe('makePreloadNitroServerEntryPlugin', () => { it('adds the preload plugin to the Nitro server build', () => { - const plugin = makePreloadNitroServerEntryPlugin(); + const plugin = makePreloadNitroServerEntryPlugin({}); const nitro = createNitro({ plugins: ['./plugins/user'] }); plugin.nitro.setup(nitro); - expect(nitro.options.plugins).toEqual(['./plugins/user', PRELOAD_PLUGIN_ID]); - expect(nitro.options.virtual[PRELOAD_PLUGIN_ID]).toEqual(expect.any(Function)); + expect(nitro.options.plugins).toEqual(['./plugins/user', PRELOAD_MODULE_ID]); }); it.each([ { label: 'in dev', options: { dev: true } }, { label: 'for presets without Node.js, such as workers', options: { node: false } }, ])('does not add the preload plugin $label', ({ options }) => { - const plugin = makePreloadNitroServerEntryPlugin(); + const plugin = makePreloadNitroServerEntryPlugin({}); const nitro = createNitro(options); plugin.nitro.setup(nitro); expect(nitro.options.plugins).toEqual([]); - expect(nitro.options.virtual).toEqual({}); }); - it('awaits the entries that Nitro imports lazily, with the same specifiers', async () => { - const plugin = makePreloadNitroServerEntryPlugin(); - const windowsEntry = 'C:/app/node_modules/.nitro/vite/services/api/index.js'; - const nitro = createNitro({ - virtual: { [VITE_SERVICES_ID]: () => viteServicesTemplate({ ssr: SSR_ENTRY, api: windowsEntry }) }, - }); - plugin.nitro.setup(nitro); - writeServiceBundle(plugin, 'ssr', ['react']); - writeServiceBundle(plugin, 'api'); + it.each(['#nitro/virtual/vite-services', '#nitro-vite-setup'])( + 'imports the service entries of the Nitro module %s, with the same specifiers', + async servicesModuleId => { + const plugin = makePreloadNitroServerEntryPlugin({}); - const code = await renderVirtualModule(nitro); + const code = await load(plugin, { + [servicesModuleId]: servicesModule({ ssr: SSR_ENTRY, api: API_ENTRY }), + }); - expect(code).toContain('await Promise.race('); - expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)})`); - expect(code).toContain(`import(${JSON.stringify(windowsEntry)})`); - }); + expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)})`); + expect(code).toContain(`import(${JSON.stringify(API_ENTRY)})`); + }, + ); - it('does not preload server environments that are not Nitro services', async () => { - const plugin = makePreloadNitroServerEntryPlugin(); - const nitro = createNitro({ virtual: { [VITE_SERVICES_ID]: viteServicesTemplate({ ssr: SSR_ENTRY }) } }); - plugin.nitro.setup(nitro); - writeServiceBundle(plugin, 'ssr'); - writeServiceBundle(plugin, 'rsc'); + it('imports nothing without the services module of Nitro', async () => { + const plugin = makePreloadNitroServerEntryPlugin({}); - const code = await renderVirtualModule(nitro); + const code = await load(plugin, {}); - expect(code.match(/import\(/g)).toHaveLength(1); - expect(code).not.toContain('rsc'); + expect(code).not.toContain('import('); + expect(code).toContain('export default function'); }); - it.each(['nitro', 'nitro/app', '#nitro/virtual/app', '/app/node_modules/nitro/dist/runtime/app.mjs'])( - 'does not await a service that imports the Nitro runtime (%s)', - async nitroImport => { - const plugin = makePreloadNitroServerEntryPlugin(); - const nitro = createNitro({ virtual: { [VITE_SERVICES_ID]: viteServicesTemplate({ ssr: SSR_ENTRY }) } }); - plugin.nitro.setup(nitro); - writeServiceBundle(plugin, 'ssr', [nitroImport]); + it('awaits only the service entries that do not import the preload module', async () => { + const plugin = makePreloadNitroServerEntryPlugin({}); + const code = await load(plugin, { + '#nitro/virtual/vite-services': servicesModule({ ssr: SSR_ENTRY, api: API_ENTRY }), + }); - const code = await renderVirtualModule(nitro); + const result = renderChunk(plugin, code ?? '', { + [SSR_ENTRY]: ['react'], + react: [], + // Through a dependency that the service build did not bundle. + [API_ENTRY]: ['some-lib'], + 'some-lib': ['/app/node_modules/nitro/dist/runtime/app.mjs'], + '/app/node_modules/nitro/dist/runtime/app.mjs': ['#nitro/virtual/plugins'], + '#nitro/virtual/plugins': [RESOLVED_PRELOAD_MODULE_ID], + }); - expect(code).not.toContain('await'); - expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)}).catch(() => {});`); - }, - ); + expect(result?.code).toContain('const awaitedServices = [true,false];'); + }); - it('does not await a service whose imports were not checked', async () => { - const plugin = makePreloadNitroServerEntryPlugin(); - const nitro = createNitro({ virtual: { [VITE_SERVICES_ID]: viteServicesTemplate({ ssr: SSR_ENTRY }) } }); - plugin.nitro.setup(nitro); + it('does not await a service entry with unknown imports', async () => { + const plugin = makePreloadNitroServerEntryPlugin({}); + const code = await load(plugin, { '#nitro/virtual/vite-services': servicesModule({ ssr: SSR_ENTRY }) }); - const code = await renderVirtualModule(nitro); + const result = renderChunk(plugin, code ?? '', { [SSR_ENTRY]: ['unknown'] }); - expect(code).not.toContain('await'); - expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)})`); + expect(result?.code).toContain('const awaitedServices = [false];'); }); - it('preloads nothing without the vite-services module of Nitro', async () => { - const plugin = makePreloadNitroServerEntryPlugin(); - const nitro = createNitro(); - plugin.nitro.setup(nitro); - writeServiceBundle(plugin, 'ssr'); - - const code = await renderVirtualModule(nitro); + it('leaves chunks without the preload module alone', () => { + const plugin = makePreloadNitroServerEntryPlugin({}); - expect(code).not.toContain('import('); - expect(code).toContain('export default function'); + expect(renderChunk(plugin, 'export {}', {}, ['/app/other.mjs'])).toBeNull(); }); }); -describe('readViteServices', () => { - it('reads the services with escaped characters in their paths', async () => { +describe('parseServiceEntries', () => { + it('reads the service entries with escaped characters in their paths', () => { const entry = 'C:\\app\\services\\"ssr"\\index.js'; - await expect(readViteServices(viteServicesTemplate({ ssr: entry }))).resolves.toEqual([ - { name: 'ssr', file: entry }, - ]); + expect(parseServiceEntries(servicesModule({ ssr: entry }))).toEqual([entry]); }); - it('reads nothing from a template of another shape', async () => { - await expect(readViteServices('export const viteServices = {};')).resolves.toEqual([]); - await expect(readViteServices(undefined)).resolves.toEqual([]); + it('reads nothing from a module of another shape', () => { + expect(parseServiceEntries('export const viteServices = {};')).toEqual([]); }); }); -describe('renderPreloadPlugin', () => { +describe('renderPreloadModule', () => { let tempDir: string; beforeEach(() => { @@ -175,43 +174,78 @@ describe('renderPreloadPlugin', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - async function importPlugin(code: string): Promise<{ default: unknown }> { - const file = path.join(tempDir, 'plugin.mjs'); + function writeEntry(code: string): string { + const entry = path.join(tempDir, 'entry.mjs'); + fs.writeFileSync(entry, code); + return entry; + } + + async function importModule(code: string): Promise<{ default: unknown }> { + const file = path.join(tempDir, 'preload.mjs'); fs.writeFileSync(file, code); return import(pathToFileURL(file).href); } - it('finishes loading only after the service entries ran', async () => { - const entry = path.join(tempDir, 'entry.mjs'); - fs.writeFileSync( - entry, - 'await new Promise(resolve => setTimeout(resolve, 20)); globalThis.__preloadTestEntryRan = true;', + // Marks the run with the directory of the test, as entries that are not awaited finish after the test. + function writeSlowEntry(): string { + return writeEntry( + `await new Promise(resolve => setTimeout(resolve, 20)); globalThis.__preloadTestEntryRan = ${JSON.stringify(tempDir)};`, ); + } + + it('finishes loading only after the awaited service entries ran', async () => { + const entry = writeSlowEntry(); + + const preload = await importModule(setAwaitedServices(renderPreloadModule([entry]), [true])); + + expect((globalThis as { __preloadTestEntryRan?: string }).__preloadTestEntryRan).toBe(tempDir); + expect(preload.default).toEqual(expect.any(Function)); + }); + + it.each([ + { + label: 'service entries that are not awaited', + code: (entry: string) => setAwaitedServices(renderPreloadModule([entry]), [false]), + }, + { + label: 'a build that did not decide which services to await', + code: (entry: string) => renderPreloadModule([entry]), + }, + ])('does not wait for $label', async ({ code }) => { + const entry = writeSlowEntry(); - const plugin = await importPlugin(renderPreloadPlugin([{ file: entry, importsNitroRuntime: false }])); + await importModule(code(entry)); - expect((globalThis as { __preloadTestEntryRan?: boolean }).__preloadTestEntryRan).toBe(true); - expect(plugin.default).toEqual(expect.any(Function)); - delete (globalThis as { __preloadTestEntryRan?: boolean }).__preloadTestEntryRan; + expect((globalThis as { __preloadTestEntryRan?: string }).__preloadTestEntryRan).not.toBe(tempDir); }); it('does not fail when a service entry throws', async () => { - const entry = path.join(tempDir, 'entry.mjs'); - fs.writeFileSync(entry, 'throw new Error("entry failed");'); + const entry = writeEntry('throw new Error("entry failed");'); + + const preload = await importModule(setAwaitedServices(renderPreloadModule([entry]), [true])); + + expect(preload.default).toEqual(expect.any(Function)); + }); + + it('logs the error of a service entry with debug enabled', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entry = writeEntry('throw new Error("entry failed");'); - const plugin = await importPlugin(renderPreloadPlugin([{ file: entry, importsNitroRuntime: false }])); + await importModule(setAwaitedServices(renderPreloadModule([entry], true), [true])); - expect(plugin.default).toEqual(expect.any(Function)); + expect(warn).toHaveBeenCalledWith( + '[Sentry] Loading a Nitro service entry at server startup failed:', + expect.objectContaining({ message: 'entry failed' }), + ); }); it('stops waiting and warns when a service entry does not finish loading', { timeout: 10_000 }, async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const entry = path.join(tempDir, 'entry.mjs'); - fs.writeFileSync(entry, 'await new Promise(() => {});'); + const entry = writeEntry('await new Promise(() => {});'); - const plugin = await importPlugin(renderPreloadPlugin([{ file: entry, importsNitroRuntime: false }])); + const preload = await importModule(setAwaitedServices(renderPreloadModule([entry]), [true])); - expect(plugin.default).toEqual(expect.any(Function)); + expect(preload.default).toEqual(expect.any(Function)); expect(warn).toHaveBeenCalledWith(expect.stringContaining('The first request may not be traced.')); }); });