/** * Pure builders and guards for the shared edge-bundle pipeline. * * Everything here is data-in/data-out — no filesystem, no esbuild, no process * exit — so `edge-bundle-lib.ts` (the IO shell that reads assets and drives * esbuild) stays thin and these pieces are trivially unit- and mutation-tested. * The three `build*Module` functions produce the source text esbuild inlines in * place of the dev/test modules that read from disk; the guards are post-build * assertions on the finished bundle text. */ /** Asset definition: [filename, exportName, contentType, pathConstant, * publishToCdn, freshRevalidation]. A fresh-revalidation asset serves with * `cache-control: no-cache` on both the Deno server (assets.ts) and the edge * bundle, so a browser revalidates it every page load — the service worker * script's requirement; every other asset is immutable for a year. */ export type AssetDef = [string, string, string, string, boolean?, boolean?]; export interface PublishedAssetUrls { origin: string; urls: Record; } /** A post-build assertion on the bundle text; `test` truthy aborts the build. */ export interface EdgeBundleGuard { message: string; test: (content: string) => boolean; } /** Sentry compile-time flags for an error-only production SDK build. */ export const SENTRY_BUILD_DEFINES = { __SENTRY_DEBUG__: "false", __SENTRY_TRACING__: "false", } as const; /** Build the inline build-info module with timestamp and commit SHA. */ export const buildBuildInfoModule = ( buildIso: string, commit: string, ): string => [ `export const BUILD_TIMESTAMP = ${JSON.stringify(buildIso)};`, `export const BUILD_COMMIT = ${JSON.stringify(commit)};`, ].join("\n"); /** Build the inline asset-paths module with cache-busted paths. */ export const buildAssetPathsModule = ( assetDefs: AssetDef[], buildTs: number, published?: PublishedAssetUrls, ): string => { const paths = assetDefs .filter(([, , , pathConst]) => pathConst) .map(([filename, , , pathConst]) => { const publishedUrl = published?.urls[filename]; if (publishedUrl) { return `export const ${pathConst} = ${JSON.stringify(publishedUrl)};`; } // Embed script should always use latest version without cache-busting const cacheBuster = pathConst === "EMBED_JS_PATH" ? "" : `?ts=${buildTs}`; return `export const ${pathConst} = "/${filename}${cacheBuster}";`; }); const cdnOrigin = published ? published.origin : null; paths.push(`export const ASSET_CDN_ORIGIN = ${JSON.stringify(cdnOrigin)};`); return paths.join("\n"); }; /** Build the inline assets module with pre-read content and handler functions. */ export const buildAssetsModule = ( assetDefs: AssetDef[], staticAssets: Record, published?: PublishedAssetUrls, ): string => { const varLines = assetDefs.flatMap(([filename], i) => published?.urls[filename] ? [] : [`const v${i} = ${JSON.stringify(staticAssets[filename])};`], ); const cacheHeader = `const CACHE_HEADERS = { "cache-control": "public, max-age=31536000, immutable" };`; const freshHeader = `const FRESH_HEADERS = { "cache-control": "no-cache" };`; const handlerLines = assetDefs.map( ([filename, exportName, contentType], i) => { const publishedUrl = published?.urls[filename]; const headers = assetDefs[i]![5] ? "FRESH_HEADERS" : "CACHE_HEADERS"; return publishedUrl ? `export const ${exportName} = () => new Response(null, { status: 302, headers: { location: ${JSON.stringify(publishedUrl)} } });` : `export const ${exportName} = () => new Response(v${i}, { headers: { "content-type": "${contentType}", ...${headers} } });`; }, ); // Mirror assets.ts's orderWidgetBody export with the inlined widget source. const orderWidget = [ `const orderJsBody = ${JSON.stringify(staticAssets["order.js"])};`, "export const orderWidgetBody = () => orderJsBody;", ].join("\n"); return [ ...varLines, cacheHeader, freshHeader, ...handlerLines, orderWidget, ].join("\n"); }; /** * Re-point a bundle's `sourceMappingURL` comment at a different filename. The * edge build renames esbuild's `edge.js.map` link to the deployed * `bunny-script.ts.map` so Sentry can pair the deployed bundle with its map. */ export const renameSourceMapLink = ( raw: string, from: string, to: string, ): string => raw.replace(`//# sourceMappingURL=${from}`, `//# sourceMappingURL=${to}`); /** * Guard: the app renders with a custom JSX runtime (#jsx), never React. If * esbuild ever falls back to the classic JSX transform, the bundle references * an undefined `React` and every page 500s at runtime ("React is not defined"). * Tests don't catch this (they run TSX under Deno), so assert on the bundle. */ export const reactRuntimeGuard = (label: string): EdgeBundleGuard => ({ message: `${label} bundle contains React.createElement — JSX automatic runtime is misconfigured (expected jsx: 'automatic', jsxImportSource: '#jsx')`, test: (content) => content.includes("React.createElement"), }); /** Guard: Sentry's optional debug and tracing paths must be compiled away. */ export const sentryTreeShakingGuard: EdgeBundleGuard = { message: "Bundle retains a Sentry compile-time flag — expected debug and tracing code to be tree-shaken", test: (content) => Object.keys(SENTRY_BUILD_DEFINES).some((flag) => content.includes(flag)), }; /** Guard: the bundle must stay under a maximum byte size (e.g. Bunny's 10MB). */ export const bundleSizeGuard = (maxBytes: number): EdgeBundleGuard => ({ message: `Bundle size exceeds the ${maxBytes} byte limit`, test: (content) => content.length > maxBytes, }); /** * Guard: the deploy bundle must avoid the native libsql binding. * `platform: "browser"` should resolve `@libsql/client` to its web export; if a * native `.node`/hrana-over-native path leaked in, the artifact balloons again. */ export const nativeLibsqlGuard: EdgeBundleGuard = { message: "Deploy bundle references a native libsql binding — expected the pure-JS web client via platform: 'browser'", test: (content) => content.includes('.node"') || content.includes("libsql/client/."), };