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..16adf5cf2697 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" }, @@ -46,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": { @@ -69,6 +72,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..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 @@ -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 test starts a server of its own. 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' + ? undefined + : `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..469fda4e6b6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/init-in-server-entry.test.ts @@ -0,0 +1,56 @@ +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'); + +// 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}/`), + ); + + // 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); + + 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..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 @@ -26,6 +26,20 @@ 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) { + // 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; + }, + }; +} + 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 +95,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..9320d0f7054d --- /dev/null +++ b/packages/tanstackstart-react/src/vite/nitroPreloadServerEntry.ts @@ -0,0 +1,169 @@ +import type { Plugin, Rollup } from 'vite'; +import { logDebug, NITRO_ENVIRONMENT, type NitroOptions } from './nitroSourceMaps'; + +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 Nitro = { options: Pick }; + +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({ debug }: { debug?: boolean }): Plugin & { + nitro: { name: string; setup: (nitro: Nitro) => void }; +} { + let serviceEntryIds: string[] = []; + + return { + name: 'sentry-tanstackstart-nitro-preload-server-entry', + apply: 'build', + 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.', + ); + 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.options.plugins.push(PRELOAD_MODULE_ID); + }, + }, + }; +} + +/** + * 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. + */ +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 info = graph.getModuleInfo(id); + if (!info) { + // Without the imports of a module, assume the worst. + return true; + } + queue.push(...info.importedIds); + } + 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`. Which services are awaited is only known after the build. + */ +export function renderPreloadModule(entries: string[], debug?: boolean): string { + if (entries.length === 0) { + return 'export default function sentryPreloadServerEntry() {}'; + } + + // 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'); +} + +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 6d56f81a63e7..3768dd1116a7 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'; @@ -25,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. @@ -99,6 +112,10 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); + if (options.preloadServerEntry !== false) { + plugins.push(makePreloadNitroServerEntryPlugin({ debug: options.debug })); + } + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); 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..75a9780d7ad4 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/nitroPreloadServerEntry.test.ts @@ -0,0 +1,251 @@ +// @vitest-environment node +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + makePreloadNitroServerEntryPlugin, + parseServiceEntries, + renderPreloadModule, + setAwaitedServices, +} from '../../src/vite/nitroPreloadServerEntry'; + +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 module of `nitro/vite` that imports the services lazily. +function servicesModule(entries: Record): string { + return ` +function lazyService(loader) { + let promise, mod + return { fetch(req) { return loader().then(mod => mod.fetch(req)) } } +} + +export const viteServices = { +${Object.entries(entries) + .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: [], ...options } }; +} + +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); +} + +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 nitro = createNitro({ plugins: ['./plugins/user'] }); + + plugin.nitro.setup(nitro); + + 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 nitro = createNitro(options); + + plugin.nitro.setup(nitro); + + expect(nitro.options.plugins).toEqual([]); + }); + + 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 load(plugin, { + [servicesModuleId]: servicesModule({ ssr: SSR_ENTRY, api: API_ENTRY }), + }); + + expect(code).toContain(`import(${JSON.stringify(SSR_ENTRY)})`); + expect(code).toContain(`import(${JSON.stringify(API_ENTRY)})`); + }, + ); + + it('imports nothing without the services module of Nitro', async () => { + const plugin = makePreloadNitroServerEntryPlugin({}); + + const code = await load(plugin, {}); + + expect(code).not.toContain('import('); + expect(code).toContain('export default function'); + }); + + 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 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(result?.code).toContain('const awaitedServices = [true,false];'); + }); + + 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 result = renderChunk(plugin, code ?? '', { [SSR_ENTRY]: ['unknown'] }); + + expect(result?.code).toContain('const awaitedServices = [false];'); + }); + + it('leaves chunks without the preload module alone', () => { + const plugin = makePreloadNitroServerEntryPlugin({}); + + expect(renderChunk(plugin, 'export {}', {}, ['/app/other.mjs'])).toBeNull(); + }); +}); + +describe('parseServiceEntries', () => { + it('reads the service entries with escaped characters in their paths', () => { + const entry = 'C:\\app\\services\\"ssr"\\index.js'; + + expect(parseServiceEntries(servicesModule({ ssr: entry }))).toEqual([entry]); + }); + + it('reads nothing from a module of another shape', () => { + expect(parseServiceEntries('export const viteServices = {};')).toEqual([]); + }); +}); + +describe('renderPreloadModule', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-tanstackstart-preload-')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + 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); + } + + // 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(); + + await importModule(code(entry)); + + expect((globalThis as { __preloadTestEntryRan?: string }).__preloadTestEntryRan).not.toBe(tempDir); + }); + + it('does not fail when a service entry throws', async () => { + 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");'); + + await importModule(setAwaitedServices(renderPreloadModule([entry], true), [true])); + + 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 = writeEntry('await new Promise(() => {});'); + + const preload = await importModule(setAwaitedServices(renderPreloadModule([entry]), [true])); + + expect(preload.default).toEqual(expect.any(Function)); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('The first request may not be traced.')); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index e757cd9d6173..654c014456bf 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,11 +128,19 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, ]); }); + 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'; @@ -162,6 +179,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, ]); }); @@ -179,6 +197,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, ]); }); @@ -196,6 +215,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, ]); @@ -214,6 +234,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockMiddlewarePlugin, ]); }); @@ -232,6 +253,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockMiddlewarePlugin, ]); }); @@ -250,6 +272,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, ]); }); @@ -285,6 +308,7 @@ describe('sentryTanstackStart()', () => { mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, + mockPreloadNitroServerEntryPlugin, mockMiddlewarePlugin, ]); });