From f24aa9d49fff5e4cf8e63f6df09974b524c69dc7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 25 Sep 2026 17:04:25 +0200 Subject: [PATCH] feat(remix): Inject debug IDs through the Remix 3 asset server Patches createAssetServer so every browser module it serves carries a debug ID, with no config from the app. Source maps follow the other meta framework SDKs: generated but hidden unless the app configures them. Fixes #24667 Refs JS-3770 Co-Authored-By: Claude Opus 5.5 --- .../remix-v3/app/actions/controller.tsx | 3 + .../remix-v3/app/actions/public/entry.ts | 15 +- .../app/actions/public/throw-error.ts | 3 + .../test-applications/remix-v3/app/assets.ts | 5 +- .../test-applications/remix-v3/package.json | 1 + .../remix-v3/tests/debug-ids.test.ts | 71 +++++++ packages/remix/src/v3/assetServer.ts | 181 ++++++++++++++++++ packages/remix/src/v3/debugId.ts | 135 +++++++++++++ packages/remix/src/v3/index.server.ts | 2 + packages/remix/src/v3/node.mjs | 12 +- packages/remix/test/v3/assetServer.test.ts | 180 +++++++++++++++++ packages/remix/test/v3/debugId.test.ts | 93 +++++++++ .../src/orchestrion/config/remix.ts | 13 +- 13 files changed, 709 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts create mode 100644 packages/remix/src/v3/assetServer.ts create mode 100644 packages/remix/src/v3/debugId.ts create mode 100644 packages/remix/test/v3/assetServer.test.ts create mode 100644 packages/remix/test/v3/debugId.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 9325143b071d..9fcab7a0b772 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -17,6 +17,9 @@ function HomePage(handle: Handle>) {

Sentry Remix 3

+ ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index 5f0ebdc1248e..f49852d924f9 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,6 +1,19 @@ +import * as Sentry from '@sentry/browser'; import { run } from 'remix/ui'; -// No Sentry here yet: `@sentry/remix/v3/client` does not export `init` until the browser SDK lands. +import { throwError } from './throw-error.ts'; + +// `@sentry/browser` directly, since `@sentry/remix/v3/client` does not export `init` until the +// browser SDK lands. +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3061/', +}); + +document.getElementById('throw-error')?.addEventListener('click', () => { + throwError(); +}); + export const app = run({ async loadModule(moduleUrl, exportName) { let mod = await import(moduleUrl); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts new file mode 100644 index 000000000000..d8540df85001 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts @@ -0,0 +1,3 @@ +export function throwError(): never { + throw new Error('Remix 3 client error'); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index a3c153578aa6..dc553c38ce28 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -4,8 +4,11 @@ export const assets = createAssetServer({ basePath: '/assets', rootDir: process.cwd(), allowFiles: ['app/routes.ts', 'app/**/public/**'], - allowPackages: ['remix', '@sentry/remix'], + allowPackages: ['remix', '@sentry/browser', '@sentry/remix'], minify: true, + scripts: { + define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN) }, + }, watch: false, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json index 7f5e5928e5e4..2d87c056225a 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -11,6 +11,7 @@ "test:assert": "pnpm test" }, "dependencies": { + "@sentry/browser": "file:../../packed/sentry-browser-packed.tgz", "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", "remix": "3.0.0-rc.1" }, diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts new file mode 100644 index 000000000000..30a1bd90a7f6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const DEBUG_ID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; + +function getDebugId(code: string): string | undefined { + return code.match(new RegExp(`\\n//# debugId=(${DEBUG_ID})$`))?.[1]; +} + +test('every served browser module carries a debug ID', async ({ page, baseURL }) => { + const moduleUrls: string[] = []; + page.on('response', response => { + if (response.request().resourceType() === 'script' && response.url().startsWith(`${baseURL}/assets/`)) { + moduleUrls.push(response.url()); + } + }); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + expect(moduleUrls).toContainEqual(expect.stringContaining('/assets/app/actions/public/entry.ts')); + + for (const url of moduleUrls) { + const code = await (await fetch(url)).text(); + const debugId = getDebugId(code); + + expect(debugId, `${url} has no debugId comment`).toBeDefined(); + expect(code).toContain(`sentry-dbid-${debugId}`); + } +}); + +// The app does not configure source maps, so they are hidden, like the other meta framework SDKs do. +test('source maps are not exposed when the app did not ask for them', async ({ baseURL }) => { + const url = `${baseURL}/assets/app/actions/public/entry.ts`; + + const code = await (await fetch(url)).text(); + const sourceMapResponse = await fetch(`${url}.map`); + + expect(code).not.toContain('//# sourceMappingURL='); + expect(sourceMapResponse.status).toBe(404); +}); + +test('the debug ID of a module is stable across requests', async ({ baseURL }) => { + const url = `${baseURL}/assets/app/actions/public/throw-error.ts`; + + const first = getDebugId(await (await fetch(url)).text()); + const second = getDebugId(await (await fetch(url)).text()); + + expect(first).toMatch(new RegExp(`^${DEBUG_ID}$`)); + expect(second).toBe(first); +}); + +test('a client error carries the debug IDs of the modules in its stack trace', async ({ page, baseURL }) => { + const errorPromise = waitForError('remix-v3', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Remix 3 client error'; + }); + + await page.goto('/'); + await page.locator('#throw-error').click(); + + const errorEvent = await errorPromise; + + const moduleUrl = `${baseURL}/assets/app/actions/public/throw-error.ts`; + const debugId = getDebugId(await (await fetch(moduleUrl)).text()); + + expect(errorEvent.debug_meta?.images).toContainEqual({ + type: 'sourcemap', + code_file: moduleUrl, + debug_id: debugId, + }); +}); diff --git a/packages/remix/src/v3/assetServer.ts b/packages/remix/src/v3/assetServer.ts new file mode 100644 index 000000000000..f115fc11dfee --- /dev/null +++ b/packages/remix/src/v3/assetServer.ts @@ -0,0 +1,181 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { consoleSandbox } from '@sentry/core'; +import { remixChannels } from '@sentry/server-utils/orchestrion/config'; +import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from './debugId'; + +// The subset of `@remix-run/assets` types used here. `remix` is an optional peer dependency, so +// they are restated rather than imported. +interface ModuleLoadContext { + moduleUrl?: string; + [key: string]: unknown; +} + +interface ModuleLoadResult { + format: string | null | undefined; + shortCircuit?: boolean; + source?: string | ArrayBuffer | ArrayBufferView; +} + +type ModuleLoader = ( + url: string, + context: ModuleLoadContext, + nextLoad: (url: string, context?: Partial) => ModuleLoadResult, +) => ModuleLoadResult; + +interface AssetServerOptions { + // `false` is not in Remix's type, but is how an app tells Sentry it wants no source maps at all, + // since leaving the option out now means hidden source maps. + sourceMaps?: 'inline' | 'external' | false; + scripts?: { loaders?: readonly ModuleLoader[]; [key: string]: unknown }; + [key: string]: unknown; +} + +interface AssetServer { + fetch: (request: Request) => Promise; +} + +interface CreateAssetServerContext { + arguments: unknown[]; + result?: unknown; + _sentryHideSourceMaps?: boolean; +} + +// The asset server appends it after minification, so it is always the last line. +const SOURCE_MAPPING_URL_REGEX = /\n\/\/# sourceMappingURL=\S+\s*$/; + +let instrumented = false; + +/** + * Makes every Remix 3 asset server created from now on serve browser modules that carry debug IDs. + * + * Source maps follow the other meta framework SDKs: + * - `sourceMaps: false` keeps them off, with a warning that stack traces stay minified. + * - `'inline'` or `'external'` is kept as the app configured it. + * - Left out, they are generated but hidden: modules do not reference them and `.map` requests are + * not served, so the source only reaches Sentry. + * + * Has to run before the app's first `createAssetServer()` call, which usually happens while its + * modules are imported. `Sentry.init()` is too late for that, so the `@sentry/remix/v3/node` entry + * calls this. + */ +export function instrumentAssetServer(): void { + if (instrumented) { + return; + } + instrumented = true; + + diagnosticsChannel.tracingChannel(remixChannels.REMIX_CREATE_ASSET_SERVER).subscribe({ + start(data) { + const context = data as CreateAssetServerContext; + const options = context.arguments[0] as AssetServerOptions | undefined; + context._sentryHideSourceMaps = options?.sourceMaps === undefined; + context.arguments[0] = withDebugIdOptions(options); + }, + end(data) { + const { result, _sentryHideSourceMaps } = data as CreateAssetServerContext; + if (result) { + stampServedAssets(result as AssetServer, { hideSourceMaps: Boolean(_sentryHideSourceMaps) }); + } + }, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} + +/** + * Injects the debug ID snippet into every module the asset server compiles. + * + * Loaders run after the TypeScript transform and before minification, so the snippet is minified + * along with the module, and its ID is a hash of the compiled source. The module URL is part of the + * hash so two identical files get IDs of their own, since their source maps differ. + */ +export const debugIdLoader: ModuleLoader = (url, context, nextLoad) => { + const result = nextLoad(url, context); + if (result.format !== 'module' || typeof result.source !== 'string') { + return result; + } + + const debugId = getDebugId(`${context.moduleUrl ?? url}\n${result.source}`); + return { ...result, source: injectDebugIdSnippet(result.source, debugId) }; +}; + +function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServerOptions | undefined { + if (!options) { + return options; + } + + if (options.sourceMaps === false) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] Source maps are disabled in your asset server (`sourceMaps: false`). Sentry will not override this, so client stack traces stay minified.', + ); + }); + } + + const loaders = options.scripts?.loaders ?? []; + + return { + ...options, + // Hidden unless the app chose otherwise, see `stampServedAssets`. + sourceMaps: options.sourceMaps ?? 'external', + scripts: { + ...options.scripts, + // Last, so the ID also covers whatever the app's own loaders changed. + loaders: loaders.includes(debugIdLoader) ? loaders : [...loaders, debugIdLoader], + }, + }; +} + +/** + * The minifier drops comments and the asset server rebuilds source maps after the loaders ran, so + * the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads, + * are added to the served response instead. + */ +function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void { + const fetchAsset = server.fetch; + + server.fetch = async request => { + const response = await fetchAsset(request); + if (response?.status !== 200 || request.method !== 'GET') { + return response; + } + + const url = new URL(request.url); + const contentType = response.headers.get('content-type') ?? ''; + + if (contentType.includes('javascript')) { + let code = await response.text(); + if (hideSourceMaps) { + code = code.replace(SOURCE_MAPPING_URL_REGEX, ''); + } + const debugId = findDebugId(code); + return withBody(response, debugId ? `${code}\n//# debugId=${debugId}` : code); + } + + if (url.pathname.endsWith('.map')) { + if (hideSourceMaps) { + // What the asset server returns for a path it does not serve. + return null; + } + + // A source map does not contain the ID of its module, so it is read from the module itself. + url.pathname = url.pathname.slice(0, -'.map'.length); + const moduleResponse = await fetchAsset(new Request(url)); + const debugId = moduleResponse?.ok ? findDebugId(await moduleResponse.text()) : undefined; + if (!debugId) { + return response; + } + return withBody(response, addDebugIdToSourceMap(await response.text(), debugId)); + } + + return response; + }; +} + +function withBody(response: Response, body: string): Response { + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(body, { status: response.status, statusText: response.statusText, headers }); +} diff --git a/packages/remix/src/v3/debugId.ts b/packages/remix/src/v3/debugId.ts new file mode 100644 index 000000000000..92094a9fef4c --- /dev/null +++ b/packages/remix/src/v3/debugId.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto'; + +// Mirrors `stringToUUID` and `getDebugIdSnippet` in `@sentry/bundler-plugins/core`. Importing that +// entry at runtime would load the whole build plugin (a native parser, the Sentry CLI) into the server. + +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const DEBUG_ID_IDENTIFIER_REGEX = /sentry-dbid-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/; + +const INLINE_SOURCE_MAP_REGEX = /\n?\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/; + +interface SourceMap { + mappings: string; + sources?: string[]; + [key: string]: unknown; +} + +/** + * Hashes `source` into a UUID shaped ID. It has to be deterministic, because the source map upload + * runs in a different process than the server and must arrive at the same ID. + */ +export function getDebugId(source: string): string { + const hash = createHash('sha256').update(source).digest('hex'); + // RFC 4122 section 4.4: the variant nibble is one of 8, 9, a, b. + const variant = ['8', '9', 'a', 'b'][hash.charCodeAt(16) % 4] as string; + + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-${variant}${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} + +/** The snippet that registers a module's debug ID in `globalThis._sentryDebugIds`, keyed by its stack. */ +export function getDebugIdSnippet(debugId: string): string { + return `!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}");}catch(e){}}();`; +} + +/** Reads back the debug ID that {@link getDebugIdSnippet} put into a module, which survives minification. */ +export function findDebugId(code: string): string | undefined { + return code.match(DEBUG_ID_IDENTIFIER_REGEX)?.[1]; +} + +/** + * Puts the debug ID snippet on its own line at the top of `source`, so it runs before the module + * body and registers the ID even when that body throws. + * + * `source` may end in an inline source map, which is how the asset server hands a module to its + * loaders. The returned map then maps back to `source` and the asset server composes it with its + * own, so it only has to shift every line down by one. + */ +export function injectDebugIdSnippet(source: string, debugId: string): string { + const snippet = getDebugIdSnippet(debugId); + const inlineSourceMap = source.match(INLINE_SOURCE_MAP_REGEX); + + if (!inlineSourceMap?.[1]) { + return `${snippet}\n${source}`; + } + + const code = source.slice(0, inlineSourceMap.index); + const map = JSON.parse(Buffer.from(inlineSourceMap[1], 'base64').toString('utf8')) as SourceMap; + const shiftedMap = { + version: 3, + sources: map.sources?.slice(0, 1) ?? [], + names: [], + mappings: getShiftedIdentityMappings(map.mappings), + }; + + return `${snippet}\n${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(shiftedMap)).toString('base64')}`; +} + +/** Adds the debug ID to a source map, under both field names the upload tooling reads. */ +export function addDebugIdToSourceMap(sourceMap: string, debugId: string): string { + const map = JSON.parse(sourceMap) as SourceMap; + map.debug_id = debugId; + map.debugId = debugId; + return JSON.stringify(map); +} + +/** + * Maps line `n + 1` of the output to line `n` of the input, at exactly the columns the input map + * has segments for. Composition only keeps positions both maps know about, so a coarser identity + * map would lose precision. + */ +function getShiftedIdentityMappings(mappings: string): string { + let previousLine = 0; + let previousColumn = 0; + + const lines = mappings.split(';').map((line, lineIndex) => { + let column = 0; + let previousGeneratedColumn = 0; + + return line + .split(',') + .filter(Boolean) + .map(segment => { + column += decodeFirstVlq(segment); + const encoded = `${encodeVlq(column - previousGeneratedColumn)}A${encodeVlq(lineIndex - previousLine)}${encodeVlq(column - previousColumn)}`; + previousGeneratedColumn = column; + previousLine = lineIndex; + previousColumn = column; + return encoded; + }) + .join(','); + }); + + return `;${lines.join(';')}`; +} + +// A segment's first field is its generated column, relative to the previous segment on the line. +function decodeFirstVlq(segment: string): number { + let value = 0; + let factor = 1; + let index = 0; + let digit: number; + + do { + digit = BASE64_CHARS.indexOf(segment.charAt(index++)); + value += (digit % 32) * factor; + factor *= 32; + } while (digit >= 32); + + // The lowest bit is the sign. + return value % 2 ? -(value - 1) / 2 : value / 2; +} + +function encodeVlq(value: number): string { + let vlq = value < 0 ? -value * 2 + 1 : value * 2; + let encoded = ''; + + do { + const digit = vlq % 32; + vlq = Math.floor(vlq / 32); + // Sets the continuation bit. + encoded += BASE64_CHARS.charAt(vlq > 0 ? digit + 32 : digit); + } while (vlq > 0); + + return encoded; +} diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts index 107a51657aca..6ad6a11c0e1f 100644 --- a/packages/remix/src/v3/index.server.ts +++ b/packages/remix/src/v3/index.server.ts @@ -2,3 +2,5 @@ // `http.server` spans, so this is useful on its own; route parameterisation and router error capture // are what is still missing. export * from '@sentry/node'; + +export { instrumentAssetServer } from './assetServer'; diff --git a/packages/remix/src/v3/node.mjs b/packages/remix/src/v3/node.mjs index 4923bc078d97..deca2347870f 100644 --- a/packages/remix/src/v3/node.mjs +++ b/packages/remix/src/v3/node.mjs @@ -1,3 +1,11 @@ -// Replaces `--import remix/node-tsx` rather than adding a second flag. Sentry's module hook is -// registered here once the server instrumentation lands, so for now nothing is instrumented. +// Replaces `--import remix/node-tsx` rather than adding a second flag. +// +// Registers Sentry's module hook first, so the modules the app imports afterwards publish the +// channels subscribed to below. The asset server is created while the app's modules load, which is +// before `Sentry.init()` runs, so its subscriber has to be in place here. +import '@sentry/server-runtime-injection/import-hook'; +import { instrumentAssetServer } from '@sentry/remix/v3'; + +instrumentAssetServer(); + await import('remix/node-tsx'); diff --git a/packages/remix/test/v3/assetServer.test.ts b/packages/remix/test/v3/assetServer.test.ts new file mode 100644 index 000000000000..8dbc2d23d5ab --- /dev/null +++ b/packages/remix/test/v3/assetServer.test.ts @@ -0,0 +1,180 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { remixChannels } from '@sentry/server-utils/orchestrion/config'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { debugIdLoader, instrumentAssetServer } from '../../src/v3/assetServer'; +import { getDebugId, getDebugIdSnippet } from '../../src/v3/debugId'; + +type Options = Record; +type FakeFetch = (request: Request) => Promise; + +const channel = diagnosticsChannel.tracingChannel(remixChannels.REMIX_CREATE_ASSET_SERVER); + +// Stands in for orchestrion's transform, which calls the original with the channel context's +// `arguments`, so subscribers can replace them. +function createAssetServer(options: Options, fetch: FakeFetch = async () => null) { + const context = { arguments: [options] as unknown[] }; + let receivedOptions: Options | undefined; + + const server = channel.traceSync(() => { + receivedOptions = context.arguments[0] as Options; + return { fetch }; + }, context); + + return { server, receivedOptions: receivedOptions as Options }; +} + +function javascript(body: string): Response { + return new Response(body, { headers: { 'content-type': 'application/javascript; charset=utf-8', etag: 'W/"a"' } }); +} + +describe('instrumentAssetServer', () => { + beforeAll(() => { + instrumentAssetServer(); + }); + + describe('options', () => { + it('generates source maps when the app did not configure them', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets' }); + + expect(receivedOptions.sourceMaps).toBe('external'); + }); + + it('keeps source maps the app configured', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: 'inline' }); + + expect(receivedOptions.sourceMaps).toBe('inline'); + }); + + it('keeps source maps off when the app disabled them, and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: false }); + + expect(receivedOptions.sourceMaps).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Source maps are disabled in your asset server')); + warn.mockRestore(); + }); + + it('adds the debug ID loader after the app loaders', () => { + const appLoader = vi.fn(); + const { receivedOptions } = createAssetServer({ scripts: { loaders: [appLoader], define: { a: 'b' } } }); + + expect(receivedOptions.scripts).toEqual({ loaders: [appLoader, debugIdLoader], define: { a: 'b' } }); + }); + + it('does not mutate the options the app passed', () => { + const options = { basePath: '/assets' }; + createAssetServer(options); + + expect(options).toEqual({ basePath: '/assets' }); + }); + + it('adds the loader once when the same options are reused', () => { + const first = createAssetServer({}).receivedOptions; + const { receivedOptions } = createAssetServer(first); + + expect(receivedOptions.scripts.loaders).toEqual([debugIdLoader]); + }); + }); + + describe('debugIdLoader', () => { + it('injects a snippet whose ID hashes the module URL and its compiled source', () => { + const result = debugIdLoader('file:///app/entry.ts', { moduleUrl: '/assets/app/entry.ts' }, () => ({ + format: 'module', + source: 'export const a = 1;', + })); + + const debugId = getDebugId('/assets/app/entry.ts\nexport const a = 1;'); + expect(result.source).toBe(`${getDebugIdSnippet(debugId)}\nexport const a = 1;`); + }); + + it('gives identical modules at different URLs different IDs', () => { + const load = () => ({ format: 'module', source: 'export const a = 1;' }); + + const first = debugIdLoader('file:///app/a.ts', { moduleUrl: '/assets/app/a.ts' }, load); + const second = debugIdLoader('file:///app/b.ts', { moduleUrl: '/assets/app/b.ts' }, load); + + expect(first.source).not.toBe(second.source); + }); + }); + + describe('served assets', () => { + const debugId = getDebugId('module'); + const moduleCode = `${getDebugIdSnippet(debugId)}export const a=1;\n//# sourceMappingURL=/assets/app/entry.ts.map`; + + function createServer(options: Options = { sourceMaps: 'external' }) { + const fetch = vi.fn(async request => { + const { pathname } = new URL(request.url); + if (pathname === '/assets/app/entry.ts') { + return javascript(moduleCode); + } + if (pathname === '/assets/app/entry.ts.map') { + return new Response('{"version":3,"mappings":"AAAA"}', { headers: { 'content-type': 'application/json' } }); + } + if (pathname === '/assets/app/plain.js') { + return javascript('export const b=1;'); + } + return null; + }); + + return { server: createAssetServer(options, fetch).server, fetch }; + } + + it('appends the debugId comment to modules', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${moduleCode}\n//# debugId=${debugId}`); + expect(response?.headers.get('etag')).toBe('W/"a"'); + }); + + it('adds the debug ID of the module to its source map', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + + expect(await response?.json()).toEqual({ version: 3, mappings: 'AAAA', debugId, debug_id: debugId }); + }); + + describe('when the app did not configure source maps', () => { + it('does not reference the source map from modules', async () => { + const { server } = createServer({}); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${getDebugIdSnippet(debugId)}export const a=1;\n//# debugId=${debugId}`); + }); + + it('does not serve source maps', async () => { + const { server } = createServer({}); + + expect(await server.fetch(new Request('http://localhost/assets/app/entry.ts.map'))).toBeNull(); + }); + }); + + it('leaves modules without a snippet unchanged', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/plain.js')); + + expect(await response?.text()).toBe('export const b=1;'); + }); + + it('passes through requests the asset server does not handle', async () => { + const { server } = createServer(); + + expect(await server.fetch(new Request('http://localhost/other'))).toBeNull(); + }); + + it('leaves HEAD requests alone', async () => { + const { server, fetch } = createServer(); + const request = new Request('http://localhost/assets/app/entry.ts', { method: 'HEAD' }); + + await server.fetch(request); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(request); + }); + }); +}); diff --git a/packages/remix/test/v3/debugId.test.ts b/packages/remix/test/v3/debugId.test.ts new file mode 100644 index 000000000000..6c813590b835 --- /dev/null +++ b/packages/remix/test/v3/debugId.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from '../../src/v3/debugId'; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function withInlineSourceMap(code: string, map: object): string { + return `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString('base64')}`; +} + +function readInlineSourceMap(source: string): Record { + const encoded = source.match(/sourceMappingURL=data:application\/json;base64,(.+)$/)?.[1] ?? ''; + return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); +} + +describe('getDebugId', () => { + it('returns a UUID v4 shaped ID', () => { + expect(getDebugId('export const a = 1;')).toMatch(UUID_REGEX); + }); + + it('is derived from the source only', () => { + expect(getDebugId('export const a = 1;')).toBe(getDebugId('export const a = 1;')); + expect(getDebugId('export const a = 1;')).not.toBe(getDebugId('export const a = 2;')); + }); + + it('matches the ID the bundler plugins derive from the same input', () => { + // `stringToUUID('')` in `@sentry/bundler-plugins/core`. + expect(getDebugId('')).toBe('e3b0c442-98fc-4c14-9afb-f4c8996fb924'); + }); +}); + +describe('injectDebugIdSnippet', () => { + const debugId = getDebugId('module'); + + it('puts the snippet on the first line', () => { + const output = injectDebugIdSnippet('export const a = 1;', debugId); + + expect(output.split('\n')).toEqual([expect.stringContaining(`sentry-dbid-${debugId}`), 'export const a = 1;']); + }); + + it('registers the ID in `_sentryDebugIds` keyed by the stack', () => { + const globalObject = globalThis as { _sentryDebugIds?: Record }; + delete globalObject._sentryDebugIds; + + // oxlint-disable-next-line typescript/no-implied-eval + new Function(injectDebugIdSnippet('', debugId))(); + + expect(Object.values(globalObject._sentryDebugIds ?? {})).toEqual([debugId]); + delete globalObject._sentryDebugIds; + }); + + it('returns a source map that shifts the incoming positions down one line', () => { + const input = withInlineSourceMap('import a from "a";\nexport const b = a;', { + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Line 0 has segments at columns 0 and 4, line 1 at columns 0 and 6. + mappings: 'AAAA,IAAI;AACA,MAAM', + }); + + const output = injectDebugIdSnippet(input, debugId); + + expect(output.split('\n').slice(1, 3)).toEqual(['import a from "a";', 'export const b = a;']); + expect(readInlineSourceMap(output)).toEqual({ + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Identity at the same columns, one line lower. The first segment of the second line steps + // back from column 4 to 0, hence `J` (-4). + mappings: ';AAAA,IAAI;AACJ,MAAM', + }); + }); +}); + +describe('findDebugId', () => { + it('reads the ID back out of minified code', () => { + const debugId = getDebugId('module'); + const minified = injectDebugIdSnippet('', debugId).replace(/"/g, '`'); + + expect(findDebugId(minified)).toBe(debugId); + }); + + it('returns undefined for code without a snippet', () => { + expect(findDebugId('export const a = 1;')).toBeUndefined(); + }); +}); + +describe('addDebugIdToSourceMap', () => { + it('sets both field names and keeps the rest of the map', () => { + const map = JSON.parse(addDebugIdToSourceMap('{"version":3,"mappings":"AAAA"}', 'abc')); + + expect(map).toEqual({ version: 3, mappings: 'AAAA', debugId: 'abc', debug_id: 'abc' }); + }); +}); diff --git a/packages/server-utils/src/orchestrion/config/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index df89c6b5cc95..27bb2c1c0b75 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -49,11 +49,22 @@ const remixInstrumentationConfig = (dir: string): InstrumentationConfig[] => [ }, ]; -export const remixConfig = ['dist', 'dist/esm'].flatMap(remixInstrumentationConfig); +// Remix 3. The subscriber rewrites the options before the asset server reads them and wraps the +// server it returns, so browser modules carry debug IDs without any config from the app. +const remixAssetServerConfig: InstrumentationConfig[] = [ + { + channelName: 'createAssetServer', + module: { name: '@remix-run/assets', versionRange: '>=0.6.0 <1', filePath: 'dist/lib/asset-server.js' }, + functionQuery: { functionName: 'createAssetServer', kind: 'Sync' }, + }, +]; + +export const remixConfig = [...['dist', 'dist/esm'].flatMap(remixInstrumentationConfig), ...remixAssetServerConfig]; export const remixChannels = { REMIX_REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', REMIX_MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', REMIX_CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', REMIX_CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', + REMIX_CREATE_ASSET_SERVER: 'orchestrion:@remix-run/assets:createAssetServer', } as const;