scripts/edge-bundle-modules.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<string, string>;
}
/** 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<string, string>,
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/."),
};