diff --git a/.changeset/prune-unroutable-ssr-island.md b/.changeset/prune-unroutable-ssr-island.md new file mode 100644 index 0000000000..4d8838a0c9 --- /dev/null +++ b/.changeset/prune-unroutable-ssr-island.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Stop shipping the SSR page/asset module island inside the background and integration-recovery function clones. Those entries overwrite `url.pathname` unconditionally before delegating to `main.mjs`, so they can never route to the page or asset handlers they inherited — yet Netlify zips and uploads every function separately, so the island was paid for on every deploy. The pruner walks the clone's real import graph (including backtick dynamic imports) and refuses to prune at all when a relative dynamic import cannot be resolved statically. Measured on calendar: total upload 42.2MB → 35.8MB. diff --git a/.changeset/shrink-serverless-function-payloads.md b/.changeset/shrink-serverless-function-payloads.md new file mode 100644 index 0000000000..5f80cc9d59 --- /dev/null +++ b/.changeset/shrink-serverless-function-payloads.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Cut serverless function payloads across every app. `@xterm/*` is now stubbed out of the SSR graph by default (it is only reachable through a `React.lazy` boundary the server can never take), and `formatExtensionHtml` loads `prettier/standalone` plus the four plugins the HTML printer actually reaches instead of prettier's main entry, which `import()`s all 13 parsers and inlines ~3.5MB of flow/typescript/yaml parsers. Measured: calendar 46.7MB → 21.1MB, docs 51MB → 26MB. diff --git a/packages/core/src/deploy/build.spec.ts b/packages/core/src/deploy/build.spec.ts index 98cc6da599..399cbf93d1 100644 --- a/packages/core/src/deploy/build.spec.ts +++ b/packages/core/src/deploy/build.spec.ts @@ -70,6 +70,7 @@ import { shouldBundleFfmpegStaticForServerless, writeSingleTemplateNetlifyRedirects, } from "./build.js"; +import { pruneSsrIslandFromRewritingClone } from "./function-bundle.js"; import { IMMUTABLE_ASSET_CACHE_CONTROL } from "./immutable-assets.js"; import { renderNetlifyStaticHeaders, @@ -3444,3 +3445,67 @@ describe("bundleImportsLibsqlNativeAddon", () => { expect(bundleImportsLibsqlNativeAddon(dir)).toBe(false); }); }); + +describe("pruneSsrIslandFromRewritingClone", () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "ssr-island-")); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const REWRITING_ENTRY = + 'const url = new URL(req.url);\nurl.pathname = "/_x";\n'; + + function scaffold(): void { + fs.writeFileSync( + path.join(dir, "main.mjs"), + 'import "./_...page_.get.mjs";\nimport "./_process-run.mjs";\n', + ); + // Rolldown emits backtick dynamic imports; a quote-only scan would miss this + // edge and delete a chunk the background function still needs. + fs.writeFileSync( + path.join(dir, "_process-run.mjs"), + "export const run = () => import(`./keep.mjs`);\n", + ); + fs.writeFileSync(path.join(dir, "keep.mjs"), "export default 1;\n"); + fs.writeFileSync( + path.join(dir, "_...page_.get.mjs"), + 'import "./page-only.mjs";\n', + ); + fs.writeFileSync(path.join(dir, "page-only.mjs"), "export default 2;\n"); + } + + it("drops the page island and keeps what the background entry still reaches", () => { + scaffold(); + + pruneSsrIslandFromRewritingClone(dir, REWRITING_ENTRY); + + expect(fs.existsSync(path.join(dir, "_...page_.get.mjs"))).toBe(false); + expect(fs.existsSync(path.join(dir, "page-only.mjs"))).toBe(false); + expect(fs.existsSync(path.join(dir, "main.mjs"))).toBe(true); + expect(fs.existsSync(path.join(dir, "keep.mjs"))).toBe(true); + }); + + it("refuses to prune a clone whose entry does not rewrite the pathname", () => { + scaffold(); + + expect(() => + pruneSsrIslandFromRewritingClone(dir, "export default handler;\n"), + ).toThrow(/rewrites url\.pathname/); + }); + + it("prunes nothing when a relative dynamic import cannot be resolved", () => { + scaffold(); + fs.writeFileSync( + path.join(dir, "_process-run.mjs"), + "export const run = (n) => import(`./${n}.mjs`);\n", + ); + + expect(pruneSsrIslandFromRewritingClone(dir, REWRITING_ENTRY)).toBe(0); + expect(fs.existsSync(path.join(dir, "page-only.mjs"))).toBe(true); + }); +}); diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index 9d661bc3c2..6891096a76 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -72,7 +72,11 @@ import { createAgentNativeConfigContext, loadResolvedAgentNativeConfig, } from "../vite/agent-native-config-loader.js"; -import { cloneServerBundleForFunction, copyDir } from "./function-bundle.js"; +import { + cloneServerBundleForFunction, + copyDir, + pruneSsrIslandFromRewritingClone, +} from "./function-bundle.js"; import { collectImmutableAssetPaths, IMMUTABLE_ASSET_CACHE_CONTROL, @@ -3523,6 +3527,17 @@ export const config = { }; `; fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), entry); + { + // The clone rewrites url.pathname unconditionally, so it can never + // route to the SSR page/asset handlers it inherited. Netlify zips and + // uploads every function separately, so that island is paid for twice. + const freed = pruneSsrIslandFromRewritingClone(dest, entry); + if (freed > 0) { + console.log( + `[deploy] Pruned ${(freed / 1024 / 1024).toFixed(1)}MB of unroutable SSR modules from ${path.basename(dest)}.`, + ); + } + } assertEmittedBackgroundFunctionOnDisk(dest, backgroundName); console.log( `[build] Emitted durable-background function "${backgroundName}" into the ` + @@ -3628,6 +3643,17 @@ export const config = { }; `; fs.writeFileSync(path.join(dest, `${functionName}.mjs`), entry); + { + // The clone rewrites url.pathname unconditionally, so it can never route to + // the SSR page/asset handlers it inherited. Netlify zips and uploads every + // function separately, so that island is paid for on every deploy. + const freed = pruneSsrIslandFromRewritingClone(dest, entry); + if (freed > 0) { + console.log( + `[deploy] Pruned ${(freed / 1024 / 1024).toFixed(1)}MB of unroutable SSR modules from ${path.basename(dest)}.`, + ); + } + } } /** diff --git a/packages/core/src/deploy/function-bundle.ts b/packages/core/src/deploy/function-bundle.ts index e72711eae0..c114529272 100644 --- a/packages/core/src/deploy/function-bundle.ts +++ b/packages/core/src/deploy/function-bundle.ts @@ -98,3 +98,102 @@ function copyTree( } } } + +/** Nitro's SSR page/asset route entries. Only the `/*` server function uses these. */ +const SSR_ENTRY_FILES = [ + "_...page_.get.mjs", + "_...page_.head.mjs", + "_...asset_.get.mjs", +]; + +// Rolldown emits `import(`./x.mjs`)` with BACKTICKS; a quote-only pattern +// under-reports the graph by tens of MB and would delete reachable chunks. +const SPECIFIER = /(?:from|import|require)\s*\(?\s*(["'`])([^"'`]*)\1/g; + +// `import(`${pkg}/server`)` is a BARE specifier and can never name a local +// chunk. Only a specifier that is relative BEFORE the interpolation is +// genuinely unresolvable, and that is when we must not guess. +const RELATIVE_INTERPOLATED = /import\s*\(\s*`\s*[./][^`]*\$\{/; + +/** + * Every file reachable from `main.mjs`, treating `removed` as already deleted. + * Returns null when the graph cannot be resolved statically, so the caller + * prunes nothing rather than deleting a chunk something still imports. + */ +function reachableFiles(dir: string, removed: Set): Set | null { + const seen = new Set(); + const visit = (file: string): boolean => { + if (seen.has(file)) return true; + seen.add(file); + let src: string; + try { + src = fs.readFileSync(file, "utf-8"); + } catch { + return true; + } + if (RELATIVE_INTERPOLATED.test(src)) return false; + SPECIFIER.lastIndex = 0; + for (let m: RegExpExecArray | null; (m = SPECIFIER.exec(src)); ) { + const spec = m[2]; + if (!spec.startsWith(".")) continue; + const base = path.resolve(path.dirname(file), spec); + const hit = [ + base, + `${base}.mjs`, + `${base}.js`, + path.join(base, "index.mjs"), + ].find((c) => fs.existsSync(c) && fs.statSync(c).isFile()); + // Docs prose inside the bundle contains path-shaped strings that are not + // imports; a specifier resolving to nothing is not an edge. + if (!hit) continue; + if (removed.has(path.relative(dir, hit))) continue; + if (!visit(hit)) return false; + } + return true; + }; + return visit(path.join(dir, "main.mjs")) ? seen : null; +} + +/** + * Drop the SSR page/asset island from a clone that can never route to it. + * + * The background and integration-recovery entries overwrite `url.pathname` + * unconditionally before delegating to `main.mjs`, so those clones cannot reach + * the page or asset handlers — yet each shipped a full copy of that island, and + * Netlify zips and uploads every function separately. Deleting from the clone is + * safe under hard links: only an in-place WRITE would reach the source bundle. + */ +export function pruneSsrIslandFromRewritingClone( + dest: string, + entryText: string, +): number { + if (!/^\s*url\.pathname\s*=/m.test(entryText)) { + throw new Error( + "[deploy] SSR prune requires a clone entry that rewrites url.pathname unconditionally", + ); + } + const full = reachableFiles(dest, new Set()); + const lean = reachableFiles(dest, new Set(SSR_ENTRY_FILES)); + if (!full || !lean) { + console.warn( + "[deploy] SSR prune skipped: unresolvable relative dynamic import in the clone.", + ); + return 0; + } + + let bytes = 0; + for (const file of full) { + if (lean.has(file)) continue; + try { + bytes += fs.statSync(file).size; + fs.rmSync(file, { force: true }); + } catch { + // coercion-ok: a file already gone is the goal state, and its bytes were + // read before the unlink, so the reported total stays honest. + } + } + for (const name of SSR_ENTRY_FILES) { + fs.rmSync(path.join(dest, name), { force: true }); + } + return bytes; +} diff --git a/packages/core/src/deploy/workspace-deploy.ts b/packages/core/src/deploy/workspace-deploy.ts index 4a8d3f12f4..b4c8155cae 100644 --- a/packages/core/src/deploy/workspace-deploy.ts +++ b/packages/core/src/deploy/workspace-deploy.ts @@ -52,7 +52,10 @@ import { assertEmittedBackgroundFunctionOnDisk, isRecurringJobsDeployEnabled, } from "./build.js"; -import { cloneServerBundleForFunction } from "./function-bundle.js"; +import { + cloneServerBundleForFunction, + pruneSsrIslandFromRewritingClone, +} from "./function-bundle.js"; import { collectImmutableAssetPaths, IMMUTABLE_ASSET_CACHE_HEADERS, @@ -1012,6 +1015,17 @@ export const config = { // is the function entrypoint, mirroring patchNetlifyFunctionEntry. fs.rmSync(path.join(dest, "server.mjs"), { force: true }); fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), server); + { + // The clone rewrites url.pathname unconditionally, so it can never + // route to the SSR page/asset handlers it inherited. Netlify zips and + // uploads every function separately, so that island is paid for twice. + const freed = pruneSsrIslandFromRewritingClone(dest, server); + if (freed > 0) { + console.log( + `[deploy] Pruned ${(freed / 1024 / 1024).toFixed(1)}MB of unroutable SSR modules from ${path.basename(dest)}.`, + ); + } + } assertEmittedBackgroundFunctionOnDisk(dest, backgroundName); console.log( `[workspace-deploy] Emitted durable-background function "${backgroundName}" ` + @@ -1119,6 +1133,17 @@ export const config = { }; `; fs.writeFileSync(path.join(dest, `${functionName}.mjs`), entry); + { + // The clone rewrites url.pathname unconditionally, so it can never route to + // the SSR page/asset handlers it inherited. Netlify zips and uploads every + // function separately, so that island is paid for on every deploy. + const freed = pruneSsrIslandFromRewritingClone(dest, entry); + if (freed > 0) { + console.log( + `[deploy] Pruned ${(freed / 1024 / 1024).toFixed(1)}MB of unroutable SSR modules from ${path.basename(dest)}.`, + ); + } + } } function patchNetlifyFunctionEntry( diff --git a/packages/core/src/extensions/content-patch.ts b/packages/core/src/extensions/content-patch.ts index b5ed207d96..c2b64a2957 100644 --- a/packages/core/src/extensions/content-patch.ts +++ b/packages/core/src/extensions/content-patch.ts @@ -137,10 +137,22 @@ async function applyExtensionContentUpdateUnchecked( export async function formatExtensionHtml(content: string): Promise { try { - const prettier = await import("prettier"); - const formatted = await prettier.format(content, { + // prettier's main entry `import()`s all 13 parser plugins, so a bundler + // inlines ~3.5MB of flow/typescript/yaml/markdown parsers just to format + // HTML. Load the standalone core plus only the plugins the HTML printer + // reaches, which still formats embedded