diff --git a/packages/aws-lambda/scripts/_handlerBanner.ts b/packages/aws-lambda/scripts/_handlerBanner.ts new file mode 100644 index 000000000..04c9ea9d5 --- /dev/null +++ b/packages/aws-lambda/scripts/_handlerBanner.ts @@ -0,0 +1,30 @@ +/** + * CJS-interop banner prepended to the ESM handler bundle. + * + * Lambda's Node 22 runtime treats `.mjs` as ESM, which has no `require`, + * `__filename`, or `__dirname`. The handler bundle inlines CJS deps that + * assume all three exist at module scope: + * - postcss et al. call top-level `require(...)` + * - wawoff2's emscripten build (pulled in unconditionally via + * producer → fontCompression) reads `__dirname` at module scope + * Without the shims the handler throws "Dynamic require of is not + * supported" / "__dirname is not defined in ES module scope" at import time, + * before it can run — which is exactly how a freshly deployed stack crashed + * on every render (#1932). + * + * This mirrors the producer's own CJS banner (packages/producer/build.mjs); + * the handler bundle inlines producer source, so it needs the same shim. + * + * Kept in its own module (not inline in build-zip.ts, which self-executes on + * import) so build-zip.test.ts can import the exact banner, bundle a fixture + * with it, and assert the globals actually resolve. + */ +export const HANDLER_BANNER = [ + "// hyperframes-aws-lambda handler bundle", + 'import { createRequire as __hf_createRequire } from "module";', + 'import { fileURLToPath as __hf_fileURLToPath } from "url";', + 'import { dirname as __hf_dirname } from "path";', + "const require = __hf_createRequire(import.meta.url);", + "const __filename = __hf_fileURLToPath(import.meta.url);", + "const __dirname = __hf_dirname(__filename);", +].join("\n"); diff --git a/packages/aws-lambda/scripts/build-zip.test.ts b/packages/aws-lambda/scripts/build-zip.test.ts index f2c5c3a94..36ed7bc4e 100644 --- a/packages/aws-lambda/scripts/build-zip.test.ts +++ b/packages/aws-lambda/scripts/build-zip.test.ts @@ -1,14 +1,77 @@ import { describe, expect, it } from "bun:test"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; +import { HANDLER_BANNER } from "./_handlerBanner.js"; +// The handler ships as ESM (.mjs) but inlines CJS deps that assume Node's CJS +// globals exist at module scope: postcss et al. call top-level `require(...)`, +// and wawoff2's emscripten build reads `__dirname`. A freshly deployed stack +// crashed on every render (#1932) with "__dirname is not defined in ES module +// scope"; the fix is the require/__filename/__dirname shim in HANDLER_BANNER. +// +// This bundles a fixture touching all three globals with the REAL banner and +// imports the output, so it catches a dropped/renamed shim behaviourally +// rather than by grepping for literals (which survives a broken refactor). +// +// The import MUST run under Node, not the `bun test` runtime: Bun defines +// `__dirname`/`__filename` even in ESM, which would mask a missing shim and +// green-light a broken bundle. Lambda runs Node, so we spawn `node` (guaranteed +// present in CI alongside bun) to reproduce the deploy target faithfully. describe("build-zip handler banner", () => { - it("defines CommonJS path globals for inlined CJS dependencies", () => { - const source = readFileSync(fileURLToPath(new URL("./build-zip.ts", import.meta.url)), "utf8"); + it("shims require/__filename/__dirname so inlined CJS deps import under Node", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-banner-test-")); + try { + const entry = join(dir, "fixture.ts"); + const outfile = join(dir, "out.mjs"); + // Reference each CJS global at module top level, the way inlined deps do. + // If any shim is missing, importing `out.mjs` throws at eval time. + writeFileSync( + entry, + [ + "const cjsDir = __dirname;", + "const cjsFile = __filename;", + "const path = require('node:path');", + "if (typeof cjsDir !== 'string') throw new Error('__dirname missing');", + "if (typeof cjsFile !== 'string') throw new Error('__filename missing');", + "if (typeof path.join !== 'function') throw new Error('require missing');", + "console.log('BANNER_OK');", + ].join("\n"), + ); - expect(source).toContain('import { fileURLToPath as __hf_fileURLToPath } from "url";'); - expect(source).toContain('import { dirname as __hf_dirname } from "path";'); - expect(source).toContain("const __filename = __hf_fileURLToPath(import.meta.url);"); - expect(source).toContain("const __dirname = __hf_dirname(__filename);"); + esbuild.buildSync({ + bundle: true, + platform: "node", + target: "node22", + format: "esm", + entryPoints: [entry], + outfile, + banner: { js: HANDLER_BANNER }, + }); + + // Import under real Node — Lambda's runtime — not the bun test runtime. + const res = spawnSync( + "node", + [ + "--input-type=module", + "-e", + `await import(${JSON.stringify(pathToFileURL(outfile).href)});`, + ], + { encoding: "utf8" }, + ); + + // A missing shim surfaces as a non-zero exit + ReferenceError on stderr. + // Guard against a silent skip if `node` isn't on PATH (it is in CI). + expect(res.error).toBeUndefined(); + expect(res.stderr).not.toContain("is not defined in ES module scope"); + expect(res.stderr).not.toContain("Dynamic require"); + expect(res.status).toBe(0); + expect(res.stdout).toContain("BANNER_OK"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); }); diff --git a/packages/aws-lambda/scripts/build-zip.ts b/packages/aws-lambda/scripts/build-zip.ts index 09db81dbf..1a46bef04 100644 --- a/packages/aws-lambda/scripts/build-zip.ts +++ b/packages/aws-lambda/scripts/build-zip.ts @@ -48,6 +48,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; import { formatBytes } from "./_formatBytes.js"; +import { HANDLER_BANNER } from "./_handlerBanner.js"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(scriptDir, ".."); @@ -239,24 +240,10 @@ async function bundleHandler(stagingDir: string): Promise { sourcemap: false, entryPoints: [entry], outfile, - // Lambda's Node 22 runtime treats `.mjs` as ESM. Inject a real `require` - // via `createRequire` so esbuild's `__require` shim resolves to it - // instead of throwing "Dynamic require of is not supported" on - // CommonJS modules in the dependency graph (postcss, etc. that ship - // top-level `require('path')` calls). The shim does - // `typeof require !== "undefined" ? require : `, so - // making `require` a real value in module scope flips it onto the - // happy path. + // See HANDLER_BANNER (_handlerBanner.ts) for why the ESM bundle needs the + // CJS require/__filename/__dirname shims. banner: { - js: [ - "// hyperframes-aws-lambda handler bundle", - 'import { createRequire as __hf_createRequire } from "module";', - "const require = __hf_createRequire(import.meta.url);", - 'import { fileURLToPath as __hf_fileURLToPath } from "url";', - 'import { dirname as __hf_dirname } from "path";', - "const __filename = __hf_fileURLToPath(import.meta.url);", - "const __dirname = __hf_dirname(__filename);", - ].join("\n"), + js: HANDLER_BANNER, }, }); console.log(`[build-zip] bundled handler → ${outfile}`);