From 7b5d6aaf8dcff7741651fad40dbc888e2b72b8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Mon, 19 Oct 2026 15:21:07 +0300 Subject: [PATCH 1/2] feat(functions-compiler): opt-in external source maps with app paths `sourceMap: true` on `bundle`/`bundleApp` returns `map` beside `module`: an esbuild map of the final compile (not the per-function probes) with no sourcesContent and no sourceRoot. The module keeps the trailing `//# sourceMappingURL=_bundled.mjs.map` comment Cloudflare reads to remap stack traces, and otherwise matches the option-off build byte for byte. The map's sources name the request's files: per-app `fn_/` is stripped and checked against that function's files, and a function's entry becomes its optional `entryPath` (its real stored path). Every other source stays in place, so the mappings' indices hold, behind a `base44-internal:` prefix. Off by default; the public CLI never sets it. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 26 +++ packages/functions-compiler/src/bundler.ts | 141 ++++++++---- packages/functions-compiler/src/contracts.ts | 11 + .../functions-compiler/src/deno-bundle.ts | 55 ++++- packages/functions-compiler/src/source-map.ts | 70 ++++++ .../__snapshots__/source-map.e2e.test.ts.snap | 24 ++ .../test/source-map.e2e.test.ts | 207 ++++++++++++++++++ .../test/source-map.test.ts | 73 ++++++ 8 files changed, 548 insertions(+), 59 deletions(-) create mode 100644 packages/functions-compiler/src/source-map.ts create mode 100644 packages/functions-compiler/test/__snapshots__/source-map.e2e.test.ts.snap create mode 100644 packages/functions-compiler/test/source-map.e2e.test.ts create mode 100644 packages/functions-compiler/test/source-map.test.ts diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 7c172d6da..71c2654bc 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -119,6 +119,32 @@ if (result.ok) console.log(result.module); per-function status; a successful response can still contain failed functions, so a caller that needs a whole-app build must reject those itself. +### Source maps + +`sourceMap: true` on either request returns `source_map` beside `module`: an +external source map, so a stack frame from a deployed Worker can name the file a +user wrote. It is off by default, the public CLI never sets it, and with it off the +output is unchanged. With it on, the module differs only by the trailing +`//# sourceMappingURL=_bundled.mjs.map` comment, which Cloudflare reads to +remap stack traces; upload the map beside the module under that name. + +The map carries no `sourcesContent` and no `sourceRoot`. Its `sources` are +rewritten: + +- a request file keeps its path; in a per-app bundle the `fn_/` prefix + is stripped, and the path is checked against that function's own files; +- a function's entry becomes its `entryPath`, when the request sends one — the + real stored path of a function that was submitted as `main.ts`; +- everything else (the shim, the generated entry and wrappers, the virtual + runtime modules, npm and remote modules) stays in the list, because the + mappings index into it, and is prefixed `base44-internal:`. + +The per-function probe compiles `bundleApp` runs to attribute errors skip the +map, as they skip telemetry; actor entries and `compileFunctionShards` never +ask for one. Two functions in one per-app +bundle can both list a `main.ts`, so a consumer that needs to know which +function a frame belongs to reads that from the log line, not the map. + ### Process isolation `installFetchGuard()` blocks outbound `fetch` other than the dependency diff --git a/packages/functions-compiler/src/bundler.ts b/packages/functions-compiler/src/bundler.ts index bb7969b86..c47ac9faf 100644 --- a/packages/functions-compiler/src/bundler.ts +++ b/packages/functions-compiler/src/bundler.ts @@ -5,8 +5,17 @@ import { type BundleAppRequest, type BundleRequest, } from "./contracts.js"; -import { bundleToModule, type NodeModulesMode } from "./deno-bundle.js"; +import { + bundleToModule, + MAIN_MODULE, + type NodeModulesMode, +} from "./deno-bundle.js"; import { type BundleErrorItem, DenoCompatError } from "./errors.js"; +import { + appSourceMap, + functionSourceMap, + type SourceMapFunction, +} from "./source-map.js"; import { setSpanTags, withSpan } from "./tracing.js"; import { type AppFunctionEntry, @@ -23,10 +32,13 @@ export type BundleErrorStage = "deno_compat" | "esbuild"; // Response for /v1/bundle (single function). // handler_name / do_class_name are set only when the entry extends Actor. +// `source_map` is set only when the request asked for `sourceMap` (never for +// actors). export type BundleResponse = | { ok: true; module: string; + source_map?: string; main_module: string; warnings: string[]; handler_name?: string; @@ -45,11 +57,13 @@ export type AppFunctionStatus = // Response for /v1/bundle-app. `module` carries the assembled Worker (only the // functions that compiled); `functions` reports per-function status so the -// backend deploys the good ones and attributes the failures. +// backend deploys the good ones and attributes the failures. `source_map` is +// set only when the request asked for `sourceMap`. export type BundleAppResponse = | { ok: true; module: string; + source_map?: string; main_module: string; functions: AppFunctionStatus[]; activation?: ActivationMode; @@ -60,11 +74,6 @@ export type BundleAppResponse = functions: AppFunctionStatus[]; }; -// The single-module output is uploaded to Workers-for-Platforms under this -// name. Keep stable: the Python `BundlerClient`'s caller persists this in -// the script metadata's `main_module`. -const NORMALIZED_MAIN_MODULE = "_bundled.mjs"; - // Per-function compiles run concurrently; each materializes its own temp dir // and esbuild build, so peak memory scales with how many run at once — bound it. const MAX_PARALLEL_COMPILES = 4; @@ -100,7 +109,7 @@ export async function bundle(req: BundleRequest): Promise { return { ok: true, module: outcome.module, - main_module: NORMALIZED_MAIN_MODULE, + main_module: MAIN_MODULE, warnings: outcome.warnings, handler_name: actor.handlerName, do_class_name: actor.doClassName, @@ -120,12 +129,15 @@ export async function bundle(req: BundleRequest): Promise { } catch (e) { return denoCompatErrorOrThrow(e); } - const outcome = await installAndCompile(prepared); + const outcome = await installAndCompile(prepared, req.sourceMap ?? false); if (outcome.ok) { return { ok: true, module: outcome.module, - main_module: NORMALIZED_MAIN_MODULE, + ...(outcome.map + ? { source_map: functionSourceMap(outcome.map, req) } + : {}), + main_module: MAIN_MODULE, warnings: outcome.warnings, ...activationField(req.runtimeSecrets ?? false), }; @@ -154,8 +166,11 @@ export async function bundleApp( // esbuild fails the whole build on any unresolved import, so on failure we map // each error to the function it came from, drop those, and rebuild the rest. async function assembleApp(req: BundleAppRequest): Promise { - const telemetry = req.postResponseTelemetry ?? false; - const runtimeSecrets = req.runtimeSecrets ?? false; + const build: AppBuild = { + telemetry: req.postResponseTelemetry ?? false, + runtimeSecrets: req.runtimeSecrets ?? false, + sourceMap: req.sourceMap ?? false, + }; const entries: AppFunctionEntry[] = req.functions.map((fn, index) => ({ index, fn, @@ -163,21 +178,21 @@ async function assembleApp(req: BundleAppRequest): Promise { let combined: CombinedOutcome; try { - combined = await compileApp(entries, telemetry, runtimeSecrets); + combined = await compileApp(entries, build); } catch (e) { if (e instanceof DenoCompatError) - return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + return bundleAppPerFunction(entries, build); throw e; } if (combined.ok) { - return appResponse(combined.module, allOk(entries)); + return appResponse(combined, allOk(entries)); } const { byIndex, unattributable } = classifyAppErrors(combined.errors); // An error we can't pin to one function (deep in a shared dep, or no location) // means the combined build can't tell us what to drop — isolate per function. if (unattributable.length > 0) { - return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + return bundleAppPerFunction(entries, build); } const functions: AppFunctionStatus[] = entries.map((e) => @@ -192,32 +207,48 @@ async function assembleApp(req: BundleAppRequest): Promise { let rebuilt: CombinedOutcome; try { - rebuilt = await compileApp(survivors, telemetry, runtimeSecrets); + rebuilt = await compileApp(survivors, build); } catch (e) { if (e instanceof DenoCompatError) - return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + return bundleAppPerFunction(entries, build); throw e; } - if (rebuilt.ok) return appResponse(rebuilt.module, functions); + if (rebuilt.ok) return appResponse(rebuilt, functions); // Survivors still don't build — fall back rather than fail the whole app. - return bundleAppPerFunction(entries, telemetry, runtimeSecrets); + return bundleAppPerFunction(entries, build); +} + +/** How a per-app build is wrapped and emitted. `telemetry` and `runtimeSecrets` + * change the generated entry; `sourceMap` only adds a map beside the module. */ +interface AppBuild { + telemetry: boolean; + runtimeSecrets: boolean; + sourceMap: boolean; } type CombinedOutcome = - | { ok: true; module: string } + | { ok: true; module: string; map?: string } | { ok: false; errors: BundleErrorItem[] }; async function compileApp( entries: AppFunctionEntry[], - telemetry = false, - runtimeSecrets = false, + build: AppBuild, ): Promise { const outcome = await installAndCompile( - prepareApp(entries, telemetry, runtimeSecrets), + prepareApp(entries, build.telemetry, build.runtimeSecrets), + build.sourceMap, ); - return outcome.ok - ? { ok: true, module: outcome.module } - : { ok: false, errors: outcome.errors }; + if (!outcome.ok) return { ok: false, errors: outcome.errors }; + if (!outcome.map) return { ok: true, module: outcome.module }; + // Sparse on purpose: the map's `fn_/` keys are original positions, + // which survive an exclude-and-rebuild. + const functions: SourceMapFunction[] = []; + for (const { index, fn } of entries) functions[index] = fn; + return { + ok: true, + module: outcome.module, + map: appSourceMap(outcome.map, functions), + }; } function allOk(entries: AppFunctionEntry[]): AppFunctionStatus[] { @@ -225,10 +256,16 @@ function allOk(entries: AppFunctionEntry[]): AppFunctionStatus[] { } function appResponse( - module: string, + built: { module: string; map?: string }, functions: AppFunctionStatus[], ): BundleAppResponse { - return { ok: true, module, main_module: NORMALIZED_MAIN_MODULE, functions }; + return { + ok: true, + module: built.module, + ...(built.map ? { source_map: built.map } : {}), + main_module: MAIN_MODULE, + functions, + }; } export interface AppErrorClassification { @@ -265,13 +302,12 @@ export function classifyAppErrors( * the same combined-build path, so there's no second bundling engine. */ async function bundleAppPerFunction( entries: AppFunctionEntry[], - telemetry = false, - runtimeSecrets = false, + build: AppBuild, ): Promise { const probes = await mapWithConcurrency( entries, MAX_PARALLEL_COMPILES, - (entry) => probeFunction(entry, runtimeSecrets), + (entry) => probeFunction(entry, build.runtimeSecrets), ); const functions: AppFunctionStatus[] = probes.map((p) => @@ -284,8 +320,8 @@ async function bundleAppPerFunction( return { ok: false, module: null, functions }; } - const combined = await compileApp(survivors, telemetry, runtimeSecrets); - if (combined.ok) return appResponse(combined.module, functions); + const combined = await compileApp(survivors, build); + if (combined.ok) return appResponse(combined, functions); // Each survivor built alone but not together: the combined graph resolved a // shared npm package onto a version some importer can't use. Deterministic // user-dep breakage — a 500 here gets retried by the platform and surfaced @@ -294,8 +330,7 @@ async function bundleAppPerFunction( survivors, functions, combined.errors, - telemetry, - runtimeSecrets, + build, ); } @@ -308,8 +343,7 @@ async function assembleWithoutConflicting( survivors: AppFunctionEntry[], functions: AppFunctionStatus[], errors: BundleErrorItem[], - telemetry: boolean, - runtimeSecrets = false, + build: AppBuild, ): Promise { const failWith = (entries: AppFunctionEntry[], items: BundleErrorItem[]) => { for (const e of entries) { @@ -329,10 +363,10 @@ async function assembleWithoutConflicting( const blamed = survivors.filter((e) => importsConflictingPackage(e, errors)); const rest = survivors.filter((e) => !blamed.includes(e)); if (blamed.length > 0 && rest.length > 0) { - const rebuilt = await compileApp(rest, telemetry, runtimeSecrets); + const rebuilt = await compileApp(rest, build); if (rebuilt.ok) { failWith(blamed, conflictErrors); - return appResponse(rebuilt.module, functions); + return appResponse(rebuilt, functions); } // Removing the blamed functions exposed a further failure — no module was // produced, so every survivor must report it (ok:false means all failed). @@ -392,14 +426,19 @@ type FunctionProbe = * its own — the `fn_/` ones stripped, the rest (deep deps) kept as-is. * Compiles in the real `runtimeSecrets` mode so a mode-dependent failure (a * user file named the reserved activation filename) is attributed to its - * function instead of leaking out of the final assembly as a 500. */ + * function instead of leaking out of the final assembly as a 500. Its module + * is discarded, so it skips telemetry and the source map. */ async function probeFunction( entry: AppFunctionEntry, runtimeSecrets = false, ): Promise { let outcome: CombinedOutcome; try { - outcome = await compileApp([entry], false, runtimeSecrets); + outcome = await compileApp([entry], { + telemetry: false, + runtimeSecrets, + sourceMap: false, + }); } catch (e) { if (e instanceof DenoCompatError) { return { ok: false, entry, errors: [denoCompatErrorItem(e)] }; @@ -435,7 +474,7 @@ async function mapWithConcurrency( } type CompileOutcome = - | { ok: true; module: string; warnings: string[] } + | { ok: true; module: string; map?: string; warnings: string[] } | { ok: false; stage: BundleErrorStage; errors: BundleErrorItem[] }; // The resolution failures that only happen in "none" mode and that "auto" fixes. @@ -443,9 +482,12 @@ const NODE_MODULES_FALLBACK = /ERR_MODULE_NOT_FOUND|Could not find referrer npm package/; /** Compile the prepared tree to a single module via the Deno resolver engine. - * Shared by the single-function path and the combined app build. */ + * Shared by the single-function path and the combined app build. With + * `sourceMap`, the outcome carries esbuild's raw map; the caller rewrites its + * sources, since only it knows which keys are the request's files. */ async function installAndCompile( prepared: PreparedWorker, + sourceMap = false, ): Promise { return withSpan("base44.bundler.compile", async () => { // The problem this solves: we resolve npm from the shared cache without a @@ -456,13 +498,13 @@ async function installAndCompile( // whole app fails to bundle. Retry just that failure with "auto", which writes // a real node_modules where the phantom copy becomes a real directory. let mode: NodeModulesMode = "none"; - let result = await bundleToModule(prepared, mode); + let result = await bundleToModule(prepared, mode, sourceMap); if ( !result.ok && result.errors.some((e) => NODE_MODULES_FALLBACK.test(e.message)) ) { mode = "auto"; - result = await bundleToModule(prepared, mode); + result = await bundleToModule(prepared, mode, sourceMap); } // node_modules_mode:auto marks the fallback firing; with outcome it shows how // often the edge case hits and whether "auto" then rescued it. @@ -471,7 +513,12 @@ async function installAndCompile( outcome: result.ok ? "ok" : "failed", }); if (result.ok) { - return { ok: true, module: result.module, warnings: result.warnings }; + return { + ok: true, + module: result.module, + ...(result.map ? { map: result.map } : {}), + warnings: result.warnings, + }; } return { ok: false, stage: "esbuild", errors: result.errors }; }); diff --git a/packages/functions-compiler/src/contracts.ts b/packages/functions-compiler/src/contracts.ts index f49e229c2..d14ce2122 100644 --- a/packages/functions-compiler/src/contracts.ts +++ b/packages/functions-compiler/src/contracts.ts @@ -6,10 +6,16 @@ const fileMapSchema = z message: "must contain at least one file", }); +// The function's real stored path, for when `entry` is the name it was +// submitted under (a flat function sent as `main.ts`). Only the source map +// reads it; compilation still uses `entry`. +const entryPathSchema = z.string().min(1).max(200); + export const bundleRequestSchema = z .object({ entry: z.string().min(1), files: fileMapSchema, + entryPath: entryPathSchema.optional(), // Backend-evaluated post-response-telemetry flag: bake the // detached-work telemetry prelude into the generated Worker entry. postResponseTelemetry: z.boolean().optional(), @@ -17,6 +23,9 @@ export const bundleRequestSchema = z // handshake into the generated Worker entry (secrets arrive per isolate // instead of as secret_text bindings). runtimeSecrets: z.boolean().optional(), + // Return an external source map whose sources name the app's own files. + // Off by default; only Base44's bundler service sets it. + sourceMap: z.boolean().optional(), }) .refine((body) => body.entry in body.files, { message: "`entry` must be a key in `files`", @@ -27,6 +36,7 @@ export const appFunctionSchema = z name: z.string().min(1), entry: z.string().min(1), files: fileMapSchema, + entryPath: entryPathSchema.optional(), }) .refine((fn) => fn.entry in fn.files, { message: "`entry` must be a key in `files`", @@ -37,6 +47,7 @@ export const bundleAppRequestSchema = z functions: z.array(appFunctionSchema).min(1), postResponseTelemetry: z.boolean().optional(), runtimeSecrets: z.boolean().optional(), + sourceMap: z.boolean().optional(), }) .refine( (body) => diff --git a/packages/functions-compiler/src/deno-bundle.ts b/packages/functions-compiler/src/deno-bundle.ts index 297942b6c..5b2e47e62 100644 --- a/packages/functions-compiler/src/deno-bundle.ts +++ b/packages/functions-compiler/src/deno-bundle.ts @@ -30,18 +30,35 @@ import type { PreparedWorker } from "./worker-entry.js"; // "none" is the fast default and "auto" the fallback. export type NodeModulesMode = "none" | "auto"; +// The single-module output is uploaded to Workers-for-Platforms under this +// name. Keep stable: the Python `BundlerClient`'s caller persists this in +// the script metadata's `main_module`. A source-mapped build also writes it as +// esbuild's `outfile`, so the appended `//# sourceMappingURL=` comment names +// `_bundled.mjs.map` — the module name the backend uploads the map under. +export const MAIN_MODULE = "_bundled.mjs"; + type EngineResult = - | { ok: true; module: string; warnings: string[] } + | { + ok: true; + module: string; + /** Raw esbuild map (sources not yet rewritten); only when requested. */ + map?: string; + warnings: string[]; + } | { ok: false; errors: BundleErrorItem[] }; /** * Compile `prepared` to a single ESM module string. Resolution/compile failures * (user-attributable) return `{ ok: false }`; infrastructure failures (temp dir, * missing output) throw. + * + * `sourceMap` adds an external map beside the module. It changes nothing else + * about the module except the trailing `//# sourceMappingURL=` comment. */ export async function bundleToModule( prepared: PreparedWorker, nodeModulesDir: NodeModulesMode = "none", + sourceMap = false, ): Promise { const dir = await mkdtemp(path.join(tmpdir(), "b44-bundle-")); try { @@ -78,7 +95,14 @@ export async function bundleToModule( // Prepended verbatim and not minified away, which is the point: it is // the one place a compiled module says what is inside it. ...(prepared.banner ? { banner: { js: prepared.banner } } : {}), - sourcemap: false, + // A separate map file, named after the outfile. "linked", not + // "external": esbuild's "external" writes the same map but drops the + // `//# sourceMappingURL=` comment, which Cloudflare reads to remap + // stack traces. No sourcesContent: the backend already holds the + // sources, and shipping them would double the map. + ...(sourceMap + ? { sourcemap: "linked", sourcesContent: false, outfile: MAIN_MODULE } + : { sourcemap: false }), // Errors are surfaced structurally (return value / BuildFailure); keep // esbuild from dumping diagnostics to the service's stderr. logLevel: "silent", @@ -101,20 +125,27 @@ export async function bundleToModule( throw e; } + // bundle:true with code-splitting off yields exactly one module (plus its + // map when asked); anything else means a plugin claimed the entry or + // emitted extra files — a bug. const outputs = result.outputFiles ?? []; - if (outputs.length !== 1) { - // bundle:true with code-splitting off yields exactly one module; anything - // else means a plugin claimed the entry or emitted extra files — a bug. + const warnings = result.warnings.map((w) => w.text); + if (!sourceMap) { + if (outputs.length !== 1) { + throw new Error( + `expected single-module output, got ${outputs.length} file(s)`, + ); + } + return { ok: true, module: outputs[0].text, warnings }; + } + const module = outputs.find((o) => o.path.endsWith(".mjs")); + const map = outputs.find((o) => o.path.endsWith(".map")); + if (outputs.length !== 2 || !module || !map) { throw new Error( - `expected single-module output, got ${outputs.length} file(s)`, + `expected a module and its source map, got ${outputs.map((o) => path.basename(o.path)).join(", ") || "no files"}`, ); } - - return { - ok: true, - module: outputs[0].text, - warnings: result.warnings.map((w) => w.text), - }; + return { ok: true, module: module.text, map: map.text, warnings }; } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/functions-compiler/src/source-map.ts b/packages/functions-compiler/src/source-map.ts new file mode 100644 index 000000000..7ba3ba0ac --- /dev/null +++ b/packages/functions-compiler/src/source-map.ts @@ -0,0 +1,70 @@ +/** + * Rewrites an esbuild source map's `sources` so they name the app's own files. + * + * esbuild names an in-memory user file `user:` (per-app bundles nest each + * function under `user:fn_/`), and everything else by its namespace + * or by a path relative to the build's temp dir. A source that is one of the + * request's files becomes that file's path; a function's entry becomes its + * `entryPath` when the caller sent one. Every other source — the shim, the + * generated entry and wrappers, the virtual runtime modules, npm and remote + * modules — stays in the list, since the mappings address sources by index, + * and is prefixed `base44-internal:` so nothing downstream reads it as an app + * file. + */ + +import { USER_NAMESPACE } from "./esbuild/user-files.js"; + +export const INTERNAL_SOURCE_PREFIX = "base44-internal:"; + +/** The request files one function's sources are checked against. */ +export interface SourceMapFunction { + entry: string; + files: Record; + entryPath?: string; +} + +const USER_PREFIX = `${USER_NAMESPACE}:`; +const APP_FUNCTION_PATH = /^fn_(\d+)\/(.*)$/; + +/** Rewrite a single-function bundle's map. */ +export function functionSourceMap(raw: string, fn: SourceMapFunction): string { + return rewriteSources(raw, (key) => appPath(fn, key)); +} + +/** Rewrite a per-app bundle's map. `functions` is indexed by the `fn_` + * key the function's files were namespaced under. */ +export function appSourceMap( + raw: string, + functions: readonly SourceMapFunction[], +): string { + return rewriteSources(raw, (key) => { + const match = key.match(APP_FUNCTION_PATH); + const fn = match ? functions[Number(match[1])] : undefined; + return fn && match ? appPath(fn, match[2]) : undefined; + }); +} + +function appPath(fn: SourceMapFunction, key: string): string | undefined { + if (!Object.hasOwn(fn.files, key)) return undefined; + return key === fn.entry && fn.entryPath ? fn.entryPath : key; +} + +function rewriteSources( + raw: string, + appPathOf: (userKey: string) => string | undefined, +): string { + const map = JSON.parse(raw) as { + sources?: string[]; + sourceRoot?: string; + sourcesContent?: unknown; + }; + map.sources = (map.sources ?? []).map((source) => { + const app = source.startsWith(USER_PREFIX) + ? appPathOf(source.slice(USER_PREFIX.length)) + : undefined; + return app ?? `${INTERNAL_SOURCE_PREFIX}${source}`; + }); + delete map.sourceRoot; + delete map.sourcesContent; + return JSON.stringify(map); +} diff --git a/packages/functions-compiler/test/__snapshots__/source-map.e2e.test.ts.snap b/packages/functions-compiler/test/__snapshots__/source-map.e2e.test.ts.snap new file mode 100644 index 000000000..953cda98f --- /dev/null +++ b/packages/functions-compiler/test/__snapshots__/source-map.e2e.test.ts.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`rewritten sources are pinned > for the per-app entry 1`] = ` +[ + "lib/greet.ts", + "main.ts", + "base44-internal:base44-runtime-context:base44:internal/runtime-context", + "base44-internal:user:__base44_deno_shim.mjs", + "base44-internal:base44-private-data-sources:runtime-manifest-store.ts", + "base44-internal:user:__base44_fn_0.mjs", + "base44-internal:user:__base44_entry.mjs", +] +`; + +exports[`rewritten sources are pinned > for the single-function entry 1`] = ` +[ + "lib/greet.ts", + "main.ts", + "base44-internal:base44-runtime-context:base44:internal/runtime-context", + "base44-internal:user:__base44_deno_shim.mjs", + "base44-internal:base44-private-data-sources:runtime-manifest-store.ts", + "base44-internal:user:__base44_entry.mjs", +] +`; diff --git a/packages/functions-compiler/test/source-map.e2e.test.ts b/packages/functions-compiler/test/source-map.e2e.test.ts new file mode 100644 index 000000000..d87bfd1bf --- /dev/null +++ b/packages/functions-compiler/test/source-map.e2e.test.ts @@ -0,0 +1,207 @@ +/** + * `sourceMap: true` — an external map beside the module whose `sources` name + * the app's own files, so the backend can remap a stack frame to the file a + * user wrote. Everything the compiler adds stays in the list (the mappings + * index into it) behind a `base44-internal:` prefix. + */ + +import { SourceMap } from "node:module"; + +import { describe, expect, it } from "vitest"; + +import { bundle, bundleApp } from "../src/bundler"; +import { INTERNAL_SOURCE_PREFIX } from "../src/source-map"; +import { runInWorkerd } from "./workerd"; + +const MAP_COMMENT = "\n//# sourceMappingURL=_bundled.mjs.map\n"; + +/** A TypeScript function with a shared module and an npm import. `marker` + * survives minification, so a test can find its generated position. */ +function fixture(marker: string): Record { + return { + "main.ts": [ + 'import slugify from "npm:slugify@1.6.6";', + 'import { describe } from "./lib/shared.ts";', + "", + 'Deno.serve(() => new Response(describe(slugify("Hello There"))));', + "", + ].join("\n"), + "lib/shared.ts": [ + "export function describe(slug: string): string {", + ` return \`${marker}:\${slug}\`;`, + "}", + "", + ].join("\n"), + }; +} + +interface RawMap { + version: number; + sources: string[]; + mappings: string; + sourceRoot?: string; + sourcesContent?: unknown; +} + +function parse(map: string | undefined): RawMap { + expect(map).toBeTypeOf("string"); + return JSON.parse(map as string) as RawMap; +} + +/** The sources that name app files: everything without the internal prefix. */ +function appSources(map: RawMap): Set { + return new Set(map.sources.filter((s) => !s.startsWith(INTERNAL_SOURCE_PREFIX))); +} + +function expectNoInternalFileNamedAsApp(map: RawMap): void { + for (const source of map.sources) { + if (source.includes("__base44_")) { + expect(source.startsWith(INTERNAL_SOURCE_PREFIX), source).toBe(true); + } + } +} + +/** Map the first occurrence of `needle` in the module back to its origin. */ +function originOf(module: string, map: string, needle: string) { + const offset = module.indexOf(needle); + expect(offset, `"${needle}" in module`).toBeGreaterThanOrEqual(0); + const before = module.slice(0, offset); + const line = before.split("\n").length - 1; + const column = offset - (before.lastIndexOf("\n") + 1); + const entry = new SourceMap(JSON.parse(map)).findEntry(line, column); + return entry as { originalSource?: string; originalLine?: number }; +} + +describe("sourceMap off (the default)", () => { + it("returns no map and no sourceMappingURL comment", async () => { + const single = await bundle({ entry: "main.ts", files: fixture("m") }); + const app = await bundleApp({ + functions: [{ name: "alpha", entry: "main.ts", files: fixture("m") }], + }); + if (!single.ok || !app.ok) throw new Error("bundle failed"); + expect("source_map" in single).toBe(false); + expect("source_map" in app).toBe(false); + expect(single.module).not.toContain("sourceMappingURL"); + expect(app.module).not.toContain("sourceMappingURL"); + }); +}); + +describe("sourceMap on, single function", () => { + it("names exactly the app's files, and the module differs only by the trailing comment", async () => { + const files = fixture("single-marker"); + const off = await bundle({ entry: "main.ts", files }); + const on = await bundle({ entry: "main.ts", files, sourceMap: true }); + if (!off.ok || !on.ok) throw new Error("bundle failed"); + + // Cloudflare reads the comment to find the uploaded `_bundled.mjs.map`. + expect(on.module).toBe(off.module + MAP_COMMENT.slice(1)); + + const map = parse(on.source_map); + expect(map.version).toBe(3); + expect(map.sourceRoot).toBeUndefined(); + expect(map.sourcesContent).toBeUndefined(); + expect(appSources(map)).toEqual(new Set(["main.ts", "lib/shared.ts"])); + expectNoInternalFileNamedAsApp(map); + // The npm module, the shim and the generated entry stay listed. + expect(map.sources).toContain(`${INTERNAL_SOURCE_PREFIX}user:__base44_entry.mjs`); + expect(map.sources).toContain(`${INTERNAL_SOURCE_PREFIX}user:__base44_deno_shim.mjs`); + expect(map.sources.some((s) => s.includes("slugify"))).toBe(true); + }); + + it("maps a generated position back to the original file and line", async () => { + const r = await bundle({ + entry: "main.ts", + files: fixture("single-marker"), + sourceMap: true, + }); + if (!r.ok) throw new Error("bundle failed"); + const origin = originOf(r.module, r.source_map as string, "single-marker:"); + expect(origin.originalSource).toBe("lib/shared.ts"); + // Zero-based: the template literal is on the file's second line. + expect(origin.originalLine).toBe(1); + }); + + it("writes entryPath for the entry and leaves the other files alone", async () => { + const r = await bundle({ + entry: "main.ts", + entryPath: "functions/hello.ts", + files: fixture("m"), + sourceMap: true, + }); + if (!r.ok) throw new Error("bundle failed"); + expect(appSources(parse(r.source_map))).toEqual(new Set(["functions/hello.ts", "lib/shared.ts"])); + }); + + it("still runs in workerd with the comment in place", async () => { + const r = await bundle({ entry: "main.ts", files: fixture("run-marker"), sourceMap: true }); + if (!r.ok) throw new Error("bundle failed"); + const res = await runInWorkerd(r.module); + expect(res).toEqual({ status: 200, text: "run-marker:Hello-There" }); + }); +}); + +describe("sourceMap on, per-app bundle", () => { + it("strips fn_/ and applies each function's own entryPath", async () => { + const r = await bundleApp({ + functions: [ + { name: "alpha", entry: "main.ts", entryPath: "functions/alpha.ts", files: fixture("alpha-marker") }, + { name: "beta", entry: "main.ts", files: fixture("beta-marker") }, + ], + sourceMap: true, + }); + if (!r.ok) throw new Error("bundleApp failed"); + expect(r.module.endsWith(MAP_COMMENT)).toBe(true); + + const map = parse(r.source_map); + expect(map.sourcesContent).toBeUndefined(); + // alpha's entry is renamed; beta has no entryPath and keeps `main.ts`. + expect(appSources(map)).toEqual(new Set(["functions/alpha.ts", "main.ts", "lib/shared.ts"])); + expect(map.sources.filter((s) => s === "lib/shared.ts")).toHaveLength(2); + expect(map.sources.some((s) => s.startsWith("fn_"))).toBe(false); + expectNoInternalFileNamedAsApp(map); + expect(map.sources).toContain(`${INTERNAL_SOURCE_PREFIX}user:__base44_fn_0.mjs`); + expect(map.sources).toContain(`${INTERNAL_SOURCE_PREFIX}user:__base44_fn_1.mjs`); + + const origin = originOf(r.module, r.source_map as string, "beta-marker:"); + expect(origin.originalSource).toBe("lib/shared.ts"); + expect(origin.originalLine).toBe(1); + }); + + it("keeps the map's function indices right after an exclude-and-rebuild", async () => { + // fn_0 fails to resolve, so the module is rebuilt from fn_1 alone; its + // files are still namespaced `fn_1/`, and must still resolve to beta. + const r = await bundleApp({ + functions: [ + { name: "broken", entry: "main.ts", files: { "main.ts": 'import "./missing.ts";' } }, + { name: "beta", entry: "main.ts", entryPath: "functions/beta.ts", files: fixture("beta-marker") }, + ], + sourceMap: true, + }); + if (!r.ok) throw new Error("bundleApp failed"); + expect(r.functions.map((f) => f.ok)).toEqual([false, true]); + expect(appSources(parse(r.source_map))).toEqual(new Set(["functions/beta.ts", "lib/shared.ts"])); + }); +}); + +describe("rewritten sources are pinned", () => { + // No npm import, so no machine-dependent cache path lands in the list. + const files = { + "main.ts": 'import { greet } from "./lib/greet.ts";\nDeno.serve(() => new Response(greet()));\n', + "lib/greet.ts": 'export const greet = () => "hi";\n', + }; + + it("for the single-function entry", async () => { + const r = await bundle({ entry: "main.ts", files, sourceMap: true }); + if (!r.ok) throw new Error("bundle failed"); + expect(parse(r.source_map).sources).toMatchSnapshot(); + }); + + it("for the per-app entry", async () => { + const r = await bundleApp({ + functions: [{ name: "alpha", entry: "main.ts", files }], + sourceMap: true, + }); + if (!r.ok) throw new Error("bundleApp failed"); + expect(parse(r.source_map).sources).toMatchSnapshot(); + }); +}); diff --git a/packages/functions-compiler/test/source-map.test.ts b/packages/functions-compiler/test/source-map.test.ts new file mode 100644 index 000000000..b5c0c92b4 --- /dev/null +++ b/packages/functions-compiler/test/source-map.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { appSourceMap, functionSourceMap, type SourceMapFunction } from "../src/source-map"; + +const raw = (sources: string[], extra: Record = {}) => + JSON.stringify({ version: 3, sources, mappings: "AAAA", names: [], ...extra }); + +const sourcesOf = (map: string) => (JSON.parse(map) as { sources: string[] }).sources; + +describe("functionSourceMap", () => { + const fn = { entry: "main.ts", files: { "main.ts": "", "lib/a.ts": "" } }; + + it("keeps every source in place, so the mappings' indices still hold", () => { + const out = sourcesOf( + functionSourceMap( + raw(["user:lib/a.ts", "../../cache/npm/x.js", "user:main.ts", "user:__base44_entry.mjs"]), + fn, + ), + ); + expect(out).toEqual([ + "lib/a.ts", + "base44-internal:../../cache/npm/x.js", + "main.ts", + "base44-internal:user:__base44_entry.mjs", + ]); + }); + + it("replaces only the entry with entryPath", () => { + const out = sourcesOf( + functionSourceMap(raw(["user:main.ts", "user:lib/a.ts"]), { ...fn, entryPath: "functions/a.ts" }), + ); + expect(out).toEqual(["functions/a.ts", "lib/a.ts"]); + }); + + it("treats a user-namespace key that is not a request file as internal", () => { + // Object keys every record inherits must not read as request files. + const out = sourcesOf(functionSourceMap(raw(["user:constructor", "user:other.ts"]), fn)); + expect(out).toEqual(["base44-internal:user:constructor", "base44-internal:user:other.ts"]); + }); + + it("drops sourceRoot and sourcesContent", () => { + const map = JSON.parse( + functionSourceMap(raw(["user:main.ts"], { sourceRoot: "/tmp/x", sourcesContent: ["secret"] }), fn), + ); + expect(map).not.toHaveProperty("sourceRoot"); + expect(map).not.toHaveProperty("sourcesContent"); + expect(map.mappings).toBe("AAAA"); + }); +}); + +describe("appSourceMap", () => { + const functions: SourceMapFunction[] = [ + { entry: "main.ts", files: { "main.ts": "" }, entryPath: "functions/alpha.ts" }, + { entry: "main.ts", files: { "main.ts": "", "lib/b.ts": "" } }, + ]; + + it("checks each fn_/ path against that function's own files", () => { + const out = sourcesOf( + appSourceMap( + raw(["user:fn_0/main.ts", "user:fn_1/main.ts", "user:fn_1/lib/b.ts", "user:fn_0/lib/b.ts"]), + functions, + ), + ); + expect(out).toEqual(["functions/alpha.ts", "main.ts", "lib/b.ts", "base44-internal:user:fn_0/lib/b.ts"]); + }); + + it("marks an unknown function index, or a missing slot, as internal", () => { + const sparse: SourceMapFunction[] = []; + sparse[1] = functions[1]; + const out = sourcesOf(appSourceMap(raw(["user:fn_0/main.ts", "user:fn_7/main.ts", "user:fn_1/main.ts"]), sparse)); + expect(out).toEqual(["base44-internal:user:fn_0/main.ts", "base44-internal:user:fn_7/main.ts", "main.ts"]); + }); +}); From 52cda1ff7479d392b0d625f6625e6bbab54a81a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Tue, 20 Oct 2026 10:22:43 +0300 Subject: [PATCH 2/2] test(functions-compiler): map a Tail Worker crash stack through source_map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare remaps only the stacks of uncaught exceptions it captures; a stack the Worker logs itself (the per-app entry's console.error before rethrowing, or user logs) stays minified. Run a throwing function in workerd with a Tail Worker, for both entries, and map the exception's frames back to the app's files by hand — local workerd does no remapping — and pin that the logged per-app crash line stays minified. Say in the README what is remapped automatically and what a consumer must remap. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 24 +++- .../test/source-map.e2e.test.ts | 116 +++++++++++++++++- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 71c2654bc..0040cb87d 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -122,11 +122,25 @@ so a caller that needs a whole-app build must reject those itself. ### Source maps `sourceMap: true` on either request returns `source_map` beside `module`: an -external source map, so a stack frame from a deployed Worker can name the file a -user wrote. It is off by default, the public CLI never sets it, and with it off the -output is unchanged. With it on, the module differs only by the trailing -`//# sourceMappingURL=_bundled.mjs.map` comment, which Cloudflare reads to -remap stack traces; upload the map beside the module under that name. +external source map whose sources name the files a user wrote. It is off by +default, the public CLI never sets it, and with it off the output is unchanged. +With it on, the module differs only by the trailing +`//# sourceMappingURL=_bundled.mjs.map` comment; keep it, and upload the map +beside the module under that name. + +What gets remapped, and by whom: + +- **An uncaught exception** — the stack in a Tail Worker's `exceptions`. + Cloudflare remaps it with the uploaded map, after the invocation and outside + the Worker. +- **A stack the Worker logs itself stays minified**, because the map is not + available at runtime. That covers the per-app entry's `console.error(e)` + before it rethrows, and any Error user code logs. A consumer that reads those + lines remaps them itself with the same version's `source_map`. + +Local workerd does no remapping. So the tests here map a Tail exception's +frames through the map by hand, which shows the positions the runtime reports +line up with the map. Cloudflare's own remapping needs a deployed check. The map carries no `sourcesContent` and no `sourceRoot`. Its `sources` are rewritten: diff --git a/packages/functions-compiler/test/source-map.e2e.test.ts b/packages/functions-compiler/test/source-map.e2e.test.ts index d87bfd1bf..f0ae3057b 100644 --- a/packages/functions-compiler/test/source-map.e2e.test.ts +++ b/packages/functions-compiler/test/source-map.e2e.test.ts @@ -1,17 +1,19 @@ /** * `sourceMap: true` — an external map beside the module whose `sources` name - * the app's own files, so the backend can remap a stack frame to the file a - * user wrote. Everything the compiler adds stays in the list (the mappings + * the app's own files, so a stack frame can be remapped to the file a user + * wrote (by Cloudflare for an uncaught exception, by the consumer for a + * logged stack). Everything the compiler adds stays in the list (the mappings * index into it) behind a `base44-internal:` prefix. */ import { SourceMap } from "node:module"; +import { Miniflare } from "miniflare"; import { describe, expect, it } from "vitest"; import { bundle, bundleApp } from "../src/bundler"; import { INTERNAL_SOURCE_PREFIX } from "../src/source-map"; -import { runInWorkerd } from "./workerd"; +import { runInWorkerd, WFP_COMPAT_DATE } from "./workerd"; const MAP_COMMENT = "\n//# sourceMappingURL=_bundled.mjs.map\n"; @@ -205,3 +207,111 @@ describe("rewritten sources are pinned", () => { expect(parse(r.source_map).sources).toMatchSnapshot(); }); }); + +describe("a crash's stack, as a Tail Worker receives it", () => { + // Cloudflare remaps an uncaught exception's stack with the uploaded map, + // after the invocation; local workerd does not. So this maps the runtime's + // own frames through `source_map` by hand: it proves the positions workerd + // reports line up with the map. Cloudflare's remapping itself needs a + // deployed check. + const files = { + "main.ts": 'import { fail } from "./lib/fail.ts";\nDeno.serve(() => fail());\n', + "lib/fail.ts": 'export function fail(): Response {\n throw new Error("kaboom-from-lib");\n}\n', + }; + + interface TailEvent { + exceptions: { message: string; stack?: string }[]; + logs: { message: unknown[] }[]; + } + + async function crash(module: string, headers: Record): Promise { + const events: TailEvent[] = []; + const mf = new Miniflare({ + workers: [ + { + name: "fn", + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + tails: ["tail"], + }, + { + name: "tail", + modules: true, + compatibilityDate: WFP_COMPAT_DATE, + script: `export default { + async tail(events, env) { + await env.SINK.fetch("http://sink/", { method: "POST", body: JSON.stringify(events) }); + }, + };`, + serviceBindings: { + SINK: async (req) => { + events.push(...((await req.json()) as TailEvent[])); + return new Response("ok"); + }, + }, + }, + ], + }); + try { + await (await mf.dispatchFetch("http://localhost/", { headers })).text(); + const until = Date.now() + 5000; + while (events.length === 0 && Date.now() < until) { + await new Promise((r) => setTimeout(r, 20)); + } + } finally { + await mf.dispose(); + } + return events; + } + + /** Each `_bundled.mjs:line:col` frame, mapped to its original file and line. */ + function remap(stack: string, map: string): { source?: string; line: number }[] { + const sm = new SourceMap(JSON.parse(map)); + return [...stack.matchAll(/_bundled\.mjs:(\d+):(\d+)/g)].map((m) => { + const e = sm.findEntry(Number(m[1]) - 1, Number(m[2]) - 1) as { + originalSource?: string; + originalLine?: number; + }; + return { source: e.originalSource, line: (e.originalLine ?? -1) + 1 }; + }); + } + + it.each([ + ["single function", () => bundle({ entry: "main.ts", files, sourceMap: true }), {}], + [ + "per-app", + () => bundleApp({ functions: [{ name: "alpha", entry: "main.ts", files }], sourceMap: true }), + { "Base44-Function-Name": "alpha" }, + ], + ] as const)("maps back to the app's files through source_map (%s)", async (_label, build, headers) => { + const r = await build(); + if (!r.ok) throw new Error("bundle failed"); + const exceptions = (await crash(r.module, headers)).flatMap((e) => e.exceptions); + expect(exceptions.map((x) => x.message)).toEqual(["kaboom-from-lib"]); + const stack = exceptions[0].stack ?? ""; + // Minified as it leaves the runtime. + expect(stack).not.toContain("fail.ts"); + // The throw, then the handler that called it. The per-app banner shifts + // every line, so this also checks the map accounts for it. + expect(remap(stack, r.source_map as string).slice(0, 2)).toEqual([ + { source: "lib/fail.ts", line: 2 }, + { source: "main.ts", line: 2 }, + ]); + }); + + it("leaves the stack the per-app entry logs before rethrowing minified", async () => { + // Cloudflare does not remap a stack string the Worker logs itself: a + // consumer reading this line has to remap it with `source_map`. + const r = await bundleApp({ functions: [{ name: "alpha", entry: "main.ts", files }], sourceMap: true }); + if (!r.ok) throw new Error("bundleApp failed"); + const events = await crash(r.module, { "Base44-Function-Name": "alpha" }); + const line = events + .flatMap((e) => e.logs) + .map((l) => (l.message[0] as { message?: string } | undefined)?.message ?? "") + .find((m) => m.includes("kaboom-from-lib")); + expect(line).toMatch(/_bundled\.mjs:\d+:\d+/); + expect(line).not.toContain("fail.ts"); + expect(remap(line as string, r.source_map as string)[0]).toEqual({ source: "lib/fail.ts", line: 2 }); + }); +});