diff --git a/.changeset/olive-donkeys-shave.md b/.changeset/olive-donkeys-shave.md new file mode 100644 index 000000000..77021024b --- /dev/null +++ b/.changeset/olive-donkeys-shave.md @@ -0,0 +1,7 @@ +--- +"eve": patch +--- + +Run authored instrumentation before the rest of the server bundle, so OpenTelemetry auto-instrumentations can patch dependencies such as `pg`. + +`agent/instrumentation.ts`'s `setup` callback previously ran from a Nitro plugin. Plugin bodies are inlined into the bundled entry, while the entry's dependencies — including externals — are hoisted static imports above them, and ESM evaluates every import before any body code. A driver was therefore already loaded by the time `registerOTel` installed its require hook, so no spans were produced for it. The instrumentation module is now emitted as its own chunk and imported on the entry's first line, ahead of every hoisted import. diff --git a/docs/guides/instrumentation.md b/docs/guides/instrumentation.md index f9d9df9c8..e596576d9 100644 --- a/docs/guides/instrumentation.md +++ b/docs/guides/instrumentation.md @@ -58,6 +58,23 @@ You are responsible for ensuring any observability or eval provider is approved The third configurable surface, [runtime context events](#runtime-context), attaches per-model-call values to these spans. +### Auto-instrumenting a dependency + +Auto-instrumentations such as `@opentelemetry/auto-instrumentations-node` patch a package as the module loader hands it over, so the package has to reach the loader at runtime. eve runs `setup` before the rest of the server bundle, which covers the timing half of that requirement; the remaining half is yours: a package that eve bundles never passes through the loader at all and cannot be patched. + +Keep any package you want auto-instrumented external, so eve traces it into the hosted output and imports it at runtime: + +```ts title="agent/agent.ts" +export default defineAgent({ + model: "anthropic/claude-sonnet-5", + build: { + externalDependencies: ["pg"], + }, +}); +``` + +Without this, an instrumentation registers cleanly and simply never reports spans for that package. + ## Runtime context _Runtime context_ is an [AI SDK concept](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text): a user-defined object that flows through a generation lifecycle. eve exposes it through `events["step.started"]`, a callback that runs once eve has assembled the model input for an attempt and returns `{ runtimeContext }`. Because eve registers the AI SDK's OpenTelemetry integration with runtime context enabled, those returned values ride onto the model-call span and its children. The field is named `runtimeContext`, not `metadata`, because AI SDK v7 carries per-call attributes on runtime context rather than a dedicated metadata field. diff --git a/packages/eve/src/harness/instrumentation-config.test.ts b/packages/eve/src/harness/instrumentation-config.test.ts index af254587d..67766ee22 100644 --- a/packages/eve/src/harness/instrumentation-config.test.ts +++ b/packages/eve/src/harness/instrumentation-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; /** * Regression coverage for the instrumentation-config chunk-isolation failure. @@ -21,6 +21,14 @@ import { describe, expect, it, vi } from "vitest"; * module loaders holding their own copies of the file. */ describe("instrumentation-config chunk-isolation regression", () => { + // The setup-invoked latch lives on `globalThis`, so it outlives + // `vi.resetModules()` and would otherwise leak between tests. + beforeEach(() => { + delete (globalThis as Record)[ + Symbol.for("eve.harness-instrumentation-setup-invoked") + ]; + }); + it("a config registered in one module evaluation is visible from another", async () => { vi.resetModules(); const moduleA = await import("#harness/instrumentation-config.js"); @@ -70,4 +78,21 @@ describe("instrumentation-config chunk-isolation regression", () => { expect(setup).toHaveBeenCalledExactlyOnceWith({ agentName: "weather-agent" }); }); + + it("invokes the setup callback once across separate module evaluations", async () => { + // The bundler reaches this module from both the entry's instrumentation + // preload chunk and the Nitro plugin, so registration can run twice. + // Registering OTel providers twice would double-instrument the process. + const setup = vi.fn(); + + vi.resetModules(); + const moduleA = await import("#harness/instrumentation-config.js"); + moduleA.registerInstrumentationConfig({ setup }, { agentName: "weather-agent" }); + + vi.resetModules(); + const moduleB = await import("#harness/instrumentation-config.js"); + moduleB.registerInstrumentationConfig({ setup }, { agentName: "weather-agent" }); + + expect(setup).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/eve/src/harness/instrumentation-config.ts b/packages/eve/src/harness/instrumentation-config.ts index 509340fc0..19c89b647 100644 --- a/packages/eve/src/harness/instrumentation-config.ts +++ b/packages/eve/src/harness/instrumentation-config.ts @@ -6,23 +6,34 @@ import type { /** * Process-global store for the authored instrumentation config. * - * Populated at server startup by the generated Nitro instrumentation plugin - * when the user's `agent/instrumentation.ts` has a default export produced - * by `defineInstrumentation()`. The harness reads from this at turn time - * to decide whether telemetry is enabled and which settings to pass to the + * Populated at server startup by the generated instrumentation module when + * the user's `agent/instrumentation.ts` has a default export produced by + * `defineInstrumentation()`. The harness reads from this at turn time to + * decide whether telemetry is enabled and which settings to pass to the * AI SDK. * - * Rooted on `globalThis` so the generated Nitro instrumentation plugin - * (which Nitro keeps external by `file://` URL) and the bundled harness - * chunk (which Nitro inlines via the package's `#harness/*` import alias) - * share one source of truth, even though they resolve to two distinct ESM - * module instances. See `context/key.ts` and + * Rooted on `globalThis` so every copy of that module shares one source of + * truth: the bundler emits it as the entry's instrumentation preload chunk + * and as a Nitro plugin (which Nitro keeps external by `file://` URL), and + * the bundled harness chunk (which Nitro inlines via the package's + * `#harness/*` import alias) is a third instance. See `context/key.ts` and * `runtime/sessions/runtime-session.ts` for the established pattern. */ const INSTRUMENTATION_CONFIG_GLOBAL_KEY = Symbol.for("eve.harness-instrumentation-config"); +/** + * Tracks whether `setup` already ran in this process. Rooted on + * `globalThis` for the same reason as the config itself: the instrumentation + * preload chunk and the Nitro plugin can resolve to two distinct module + * instances, and `setup` must still run only once. + */ +const INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY = Symbol.for( + "eve.harness-instrumentation-setup-invoked", +); + interface InstrumentationConfigGlobal { [INSTRUMENTATION_CONFIG_GLOBAL_KEY]?: InstrumentationDefinition; + [INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY]?: boolean; } const globalContainer = globalThis as typeof globalThis & InstrumentationConfigGlobal; @@ -31,8 +42,10 @@ const globalContainer = globalThis as typeof globalThis & InstrumentationConfigG * Registers the authored instrumentation config and invokes its `setup` * callback with the resolved agent name. * - * Called once by the generated instrumentation Nitro plugin at server - * startup. Subsequent calls overwrite the previous value. + * Called at server startup by the generated instrumentation module, which + * the bundler emits both as the entry's preload chunk and as a Nitro + * plugin. Subsequent calls overwrite the stored config, but `setup` runs + * only on the first. * * @internal — not part of the public API. */ @@ -40,7 +53,11 @@ export function registerInstrumentationConfig( config: InstrumentationDefinition, context: InstrumentationSetupContext, ): void { - if (config.setup !== undefined) { + if ( + config.setup !== undefined && + globalContainer[INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY] !== true + ) { + globalContainer[INSTRUMENTATION_SETUP_INVOKED_GLOBAL_KEY] = true; config.setup(context); } globalContainer[INSTRUMENTATION_CONFIG_GLOBAL_KEY] = config; diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.scenario.test.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.scenario.test.ts new file mode 100644 index 000000000..5f413eaa1 --- /dev/null +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.scenario.test.ts @@ -0,0 +1,87 @@ +import { execFile } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { buildWithNitroRolldown } from "#internal/bundler/nitro-rolldown.js"; +import { createInstrumentationPreloadPlugin } from "#internal/bundler/instrumentation-preload-plugin.js"; +import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; + +const execFileAsync = promisify(execFile); +const createScratchDirectory = useTemporaryDirectories(); + +const EXTERNAL_PACKAGE_NAME = "fake-db-driver"; + +/** + * Writes an app whose entry imports an external package, mirroring how a + * bundled Nitro server reaches a driver such as `pg`. The external records + * whether instrumentation already ran when it was loaded, which is the + * property the preload has to guarantee. + */ +function createScratchApp(directory: string): { entryPath: string; instrumentationPath: string } { + const packageDirectory = join(directory, "node_modules", EXTERNAL_PACKAGE_NAME); + mkdirSync(packageDirectory, { recursive: true }); + writeFileSync( + join(packageDirectory, "package.json"), + JSON.stringify({ name: EXTERNAL_PACKAGE_NAME, version: "1.0.0", type: "module" }), + ); + writeFileSync( + join(packageDirectory, "index.js"), + "globalThis.__loadOrder.push(`external:${globalThis.__instrumented === true}`);\nexport const connect = () => undefined;\n", + ); + + const entryPath = join(directory, "entry.mjs"); + writeFileSync( + entryPath, + `import { connect } from "${EXTERNAL_PACKAGE_NAME}";\nglobalThis.__loadOrder.push("entry-body");\nexport default connect;\n`, + ); + + const instrumentationPath = join(directory, "instrumentation.mjs"); + writeFileSync( + instrumentationPath, + 'globalThis.__instrumented = true;\nglobalThis.__loadOrder.push("instrumentation");\nexport default function installInstrumentationPlugin() {}\n', + ); + + return { entryPath, instrumentationPath }; +} + +async function bundleWithPreload(directory: string): Promise { + const { entryPath, instrumentationPath } = createScratchApp(directory); + const outDir = join(directory, "out"); + + await buildWithNitroRolldown({ + cwd: directory, + input: entryPath, + platform: "node", + external: [EXTERNAL_PACKAGE_NAME], + plugins: [createInstrumentationPreloadPlugin(instrumentationPath)], + output: { dir: outDir, entryFileNames: "index.mjs", format: "esm", sourcemap: false }, + }); + + return outDir; +} + +describe("instrumentation preload (bundled)", () => { + it("evaluates instrumentation before the entry's external imports", async () => { + const directory = await createScratchDirectory("eve-instrumentation-preload-"); + const outDir = await bundleWithPreload(directory); + + // Node resolves the external from the scratch app's node_modules, so the + // built entry runs the same way the hosted server would. + const { stdout } = await execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + `globalThis.__loadOrder = []; + await import(${JSON.stringify(join(outDir, "index.mjs"))}); + console.log(JSON.stringify(globalThis.__loadOrder));`, + ], + { cwd: directory }, + ); + + expect(JSON.parse(stdout.trim())).toEqual(["instrumentation", "external:true", "entry-body"]); + }); +}); diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts new file mode 100644 index 000000000..9fd80483b --- /dev/null +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { + createInstrumentationPreloadPlugin, + INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, +} from "#internal/bundler/instrumentation-preload-plugin.js"; + +const INSTRUMENTATION_MODULE_PATH = "/app/.eve/host/compiled-artifacts-instrumentation.mjs"; + +interface EmittedChunk { + readonly type: "chunk"; + readonly id: string; + readonly fileName: string; +} + +/** Minimal stand-in for the Rollup/Rolldown plugin context. */ +function createBundlerContext() { + const emitted: EmittedChunk[] = []; + + return { + emitted, + emitFile(file: EmittedChunk): string { + emitted.push(file); + return `ref-${emitted.length}`; + }, + }; +} + +function renderEntry(input: { + readonly code?: string; + readonly fileName: string; + readonly isEntry?: boolean; +}) { + const plugin = createInstrumentationPreloadPlugin(INSTRUMENTATION_MODULE_PATH); + const context = createBundlerContext(); + plugin.buildStart.call(context); + + return plugin.renderChunk.call(context, input.code ?? "console.log('entry');", { + fileName: input.fileName, + isEntry: input.isEntry ?? true, + }); +} + +describe("createInstrumentationPreloadPlugin", () => { + it("emits the instrumentation module as its own chunk", () => { + const plugin = createInstrumentationPreloadPlugin(INSTRUMENTATION_MODULE_PATH); + const context = createBundlerContext(); + + plugin.buildStart.call(context); + + expect(context.emitted).toEqual([ + { + type: "chunk", + id: INSTRUMENTATION_MODULE_PATH, + fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, + }, + ]); + }); + + it("imports the preload on the entry's first line, ahead of hoisted imports", () => { + const result = renderEntry({ + code: 'import { Pool } from "pg";\nconsole.log(Pool);', + fileName: "index.mjs", + }); + + expect(result?.code.split("\n")[0]).toBe( + `import "./${INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME}";`, + ); + expect(result?.code).toContain('import { Pool } from "pg";'); + }); + + it("maps the shifted chunk back to its original line", () => { + const result = renderEntry({ fileName: "index.mjs" }); + + expect(result?.map.mappings.startsWith(";")).toBe(true); + expect(result?.map.sources).toEqual(["index.mjs"]); + }); + + it("resolves the preload relative to a nested entry chunk", () => { + const result = renderEntry({ fileName: "functions/__server.func/index.mjs" }); + + expect(result?.code.split("\n")[0]).toBe( + `import "../../${INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME}";`, + ); + }); + + it("does not prepend the import twice when rendered again", () => { + const plugin = createInstrumentationPreloadPlugin(INSTRUMENTATION_MODULE_PATH); + const context = createBundlerContext(); + plugin.buildStart.call(context); + const chunk = { fileName: "index.mjs", isEntry: true }; + + const first = plugin.renderChunk.call(context, "console.log('entry');", chunk); + const second = plugin.renderChunk.call(context, first?.code ?? "", chunk); + + expect(second).toBeNull(); + }); + + it("leaves shared chunks alone", () => { + const result = renderEntry({ fileName: "_libs/drizzle-orm.mjs", isEntry: false }); + + expect(result).toBeNull(); + }); + + it("does not make the preload chunk import itself", () => { + const result = renderEntry({ fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME }); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts new file mode 100644 index 000000000..d66507f39 --- /dev/null +++ b/packages/eve/src/internal/bundler/instrumentation-preload-plugin.ts @@ -0,0 +1,114 @@ +import { dirname, relative } from "node:path/posix"; + +import { + createPrependedLineSourceMap, + type PrependedLineSourceMap, +} from "#internal/bundler/prepended-line-source-map.js"; + +/** + * Name of the emitted chunk that registers authored instrumentation. + * Underscore-prefixed to match Nitro's convention for generated output that + * is not a route. + */ +export const INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME = "_eve-instrumentation.mjs"; + +/** Bundler hook context this plugin needs from Rollup and Rolldown. */ +interface InstrumentationPreloadPluginContext { + emitFile(file: { type: "chunk"; id: string; fileName: string }): string; +} + +interface RenderedChunk { + readonly fileName?: string; + readonly isEntry?: boolean; +} + +/** The subset of the rolldown/rollup plugin shape this plugin implements. */ +export interface InstrumentationPreloadBundlerPlugin { + readonly name: string; + buildStart(this: InstrumentationPreloadPluginContext): void; + renderChunk( + this: InstrumentationPreloadPluginContext, + code: string, + chunk?: RenderedChunk, + ): { code: string; map: PrependedLineSourceMap } | null; +} + +/** + * Resolves the specifier an entry chunk uses to import the preload chunk. + * Both names are bundler-relative and always use forward slashes. + */ +function resolvePreloadSpecifier(chunkFileName: string, preloadFileName: string): string { + const chunkDirectory = dirname(chunkFileName); + const relativePath = + chunkDirectory === "." ? preloadFileName : relative(chunkDirectory, preloadFileName); + + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} + +/** + * Creates a bundler plugin that runs authored instrumentation before any + * other module in the bundle. + * + * Registering instrumentation from a Nitro plugin is too late to patch + * anything: plugin bodies are inlined into the bundled entry, while the + * entry's dependencies — including externals such as `pg` — are hoisted + * static imports above them. ESM evaluates every import before any body + * code, so a module loaded that way is already resolved by the time an + * OpenTelemetry instrumentation registers its require hook. Reordering the + * Nitro plugin list cannot fix that, because the ordering is a property of + * import hoisting rather than of the plugin list. + * + * So the instrumentation module is emitted as its own chunk and imported on + * the entry's first line, ahead of every hoisted import. Compatible with + * both Rollup and Rolldown. + */ +export function createInstrumentationPreloadPlugin( + instrumentationModulePath: string, +): InstrumentationPreloadBundlerPlugin { + return { + name: "eve-instrumentation-preload", + buildStart() { + // An explicit `fileName` is emitted verbatim, so the chunk's name is + // the constant rather than something to read back with `getFileName`. + this.emitFile({ + type: "chunk", + id: instrumentationModulePath, + fileName: INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, + }); + }, + renderChunk(code, chunk) { + // The preload is emitted as an entry chunk of its own; it must not + // import itself. + if ( + chunk?.isEntry !== true || + chunk.fileName === undefined || + chunk.fileName === INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME + ) { + return null; + } + + const specifier = resolvePreloadSpecifier( + chunk.fileName, + INSTRUMENTATION_PRELOAD_CHUNK_FILE_NAME, + ); + const importLine = `import ${JSON.stringify(specifier)};`; + + // eve registers one plugin instance under both the Rolldown and the + // Rollup Nitro config, so every hook here runs twice per build. + // Without this guard the entry is rendered with the import already on + // it and gets a second copy, offsetting the source map twice. + if (code.startsWith(importLine)) { + return null; + } + + return { + code: `${importLine}\n${code}`, + map: createPrependedLineSourceMap({ + insertedLineCount: 1, + source: chunk.fileName, + sourceContent: code, + }), + }; + }, + }; +} diff --git a/packages/eve/src/internal/bundler/prepended-line-source-map.ts b/packages/eve/src/internal/bundler/prepended-line-source-map.ts new file mode 100644 index 000000000..be7d157b8 --- /dev/null +++ b/packages/eve/src/internal/bundler/prepended-line-source-map.ts @@ -0,0 +1,66 @@ +/** + * Source map shared by the bundler plugins that prepend lines to a chunk. + */ +export interface PrependedLineSourceMap { + readonly version: 3; + readonly sources: readonly string[]; + readonly sourcesContent: readonly string[]; + readonly names: readonly string[]; + readonly mappings: string; +} + +/** + * Builds the source map for a chunk that had `insertedLineCount` lines + * prepended to it, so positions in the emitted chunk still resolve to the + * original line. + */ +export function createPrependedLineSourceMap({ + insertedLineCount, + source, + sourceContent, +}: { + insertedLineCount: number; + source: string; + sourceContent: string; +}): PrependedLineSourceMap { + const originalLineCount = sourceContent.split("\n").length; + const lineMappings = Array.from({ length: originalLineCount }, (_, index) => + encodeVlqFields(index === 0 ? [0, 0, 0, 0] : [0, 0, 1, 0]), + ); + + return { + version: 3, + sources: [source], + sourcesContent: [sourceContent], + names: [], + mappings: `${";".repeat(insertedLineCount)}${lineMappings.join(";")}`, + }; +} + +const BASE64_VLQ_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const VLQ_BASE_SHIFT = 5; +const VLQ_BASE = 1 << VLQ_BASE_SHIFT; +const VLQ_BASE_MASK = VLQ_BASE - 1; +const VLQ_CONTINUATION_BIT = VLQ_BASE; + +function encodeVlqFields(fields: readonly number[]): string { + return fields.map((field) => encodeVlqInteger(field)).join(""); +} + +function encodeVlqInteger(value: number): string { + let vlq = value < 0 ? (-value << 1) + 1 : value << 1; + let encoded = ""; + + do { + let digit = vlq & VLQ_BASE_MASK; + vlq >>>= VLQ_BASE_SHIFT; + + if (vlq > 0) { + digit |= VLQ_CONTINUATION_BIT; + } + + encoded += BASE64_VLQ_CHARS[digit]; + } while (vlq > 0); + + return encoded; +} diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index 08cae87b3..4bdad5e0e 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -18,6 +18,7 @@ import { import { createProductionNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { createCompiledSandboxBackendPrunePlugin } from "#internal/nitro/host/compiled-sandbox-backend-prune-plugin.js"; import { createExtensionScopePlugin } from "#internal/bundler/extension-scope-plugin.js"; +import { createInstrumentationPreloadPlugin } from "#internal/bundler/instrumentation-preload-plugin.js"; import { configureDevelopmentNitroRoutes, configureProductionNitroRoutes, @@ -640,10 +641,16 @@ function createApplicationNitroBundlerConfiguration( packageNamespace: mount.packageNamespace, })), ); + const instrumentationPluginPath = preparedHost.compiledArtifacts.instrumentationPluginPath; + const instrumentationPreloadPlugin = + instrumentationPluginPath === undefined + ? null + : createInstrumentationPreloadPlugin(instrumentationPluginPath); const nitroBundlerPlugins = [ compiledSandboxBackendPrunePlugin, createOptionalEngineDependencyPlugin(unconfiguredOptionalEnginePackages), extensionScopePlugin, + instrumentationPreloadPlugin, ].filter((plugin) => plugin !== null); const nitroRolldownConfig = createNitroBundlerConfig(nitroBundlerPlugins); const nitroRollupConfig = createNitroBundlerConfig(nitroBundlerPlugins); diff --git a/packages/eve/src/internal/node-esm-compat-banner.ts b/packages/eve/src/internal/node-esm-compat-banner.ts index 5705e2f83..b3bcc0aec 100644 --- a/packages/eve/src/internal/node-esm-compat-banner.ts +++ b/packages/eve/src/internal/node-esm-compat-banner.ts @@ -1,3 +1,8 @@ +import { + createPrependedLineSourceMap, + type PrependedLineSourceMap, +} from "#internal/bundler/prepended-line-source-map.js"; + /** * Options for {@link buildNodeEsmCompatBanner} and * {@link createNodeEsmCompatBannerPlugin}. @@ -84,15 +89,7 @@ interface BannerPlugin { renderChunk( code: string, chunk?: { readonly fileName?: string }, - ): { code: string; map: SourceMap } | null; -} - -interface SourceMap { - readonly version: 3; - readonly sources: readonly string[]; - readonly sourcesContent: readonly string[]; - readonly names: readonly string[]; - readonly mappings: string; + ): { code: string; map: PrependedLineSourceMap } | null; } /** @@ -123,54 +120,3 @@ export function createNodeEsmCompatBannerPlugin( }, }; } - -function createPrependedLineSourceMap({ - insertedLineCount, - source, - sourceContent, -}: { - insertedLineCount: number; - source: string; - sourceContent: string; -}): SourceMap { - const originalLineCount = sourceContent.split("\n").length; - const lineMappings = Array.from({ length: originalLineCount }, (_, index) => - encodeVlqFields(index === 0 ? [0, 0, 0, 0] : [0, 0, 1, 0]), - ); - - return { - version: 3, - sources: [source], - sourcesContent: [sourceContent], - names: [], - mappings: `${";".repeat(insertedLineCount)}${lineMappings.join(";")}`, - }; -} - -const BASE64_VLQ_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -const VLQ_BASE_SHIFT = 5; -const VLQ_BASE = 1 << VLQ_BASE_SHIFT; -const VLQ_BASE_MASK = VLQ_BASE - 1; -const VLQ_CONTINUATION_BIT = VLQ_BASE; - -function encodeVlqFields(fields: readonly number[]): string { - return fields.map((field) => encodeVlqInteger(field)).join(""); -} - -function encodeVlqInteger(value: number): string { - let vlq = value < 0 ? (-value << 1) + 1 : value << 1; - let encoded = ""; - - do { - let digit = vlq & VLQ_BASE_MASK; - vlq >>>= VLQ_BASE_SHIFT; - - if (vlq > 0) { - digit |= VLQ_CONTINUATION_BIT; - } - - encoded += BASE64_VLQ_CHARS[digit]; - } while (vlq > 0); - - return encoded; -} diff --git a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts index 3b5958c25..375701bdc 100644 --- a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts +++ b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts @@ -895,8 +895,10 @@ describe("app runtime dependency tracing", () => { .map(async (entry) => { const source = await readFile(join(serverFunctionDirectory, entry), "utf8"); - return source.includes("__fixtureInstrumentationDep") || - source.includes("fixture-instrumentation-dep") + // Match the global the authored module assigns, not the bare + // package name: that also appears in the traced dependency's + // own chunk, which does not run the authored module. + return source.includes("__fixtureInstrumentationDep") ? join(serverFunctionDirectory, entry) : null; }),