From 7f73c67e62d27d9c81cc54e037ad7d60cb8e270b Mon Sep 17 00:00:00 2001 From: Matt Boon Date: Tue, 21 Jul 2026 19:05:45 +0200 Subject: [PATCH] fix(transforms): strip server-only data hooks from the client artifact The browser artifact is compiled from the same source file as the server one, so `getServerData`/`getStaticData`/`getStaticPaths` bodies shipped to the client along with everything they import. A page whose loader reached `node:crypto` therefore linked against the node-builtin noop polyfill and hydration died with "does not provide an export named 'createHash'". esbuild cannot fix this for us: in transform mode (as opposed to bundle mode) it never drops an import, because it cannot prove the module is side-effect free. So the browser pipeline gains a stage that empties the hooks' bodies, keeping their bindings valid for any surviving export clause, and then drops the import bindings they were the last user of. The stage works on the `CodeParser` AST, for the reason `rendering/rsc/export-extractor.ts` already does, and as the equivalent passes in Next.js (SWC) and React Router (Babel) do: text matching cannot tell an exported hook from a private function of the same name, and counts braces inside regular expression literals. An import that falls out of use is reduced to a side-effect import rather than deleted, because this pass has no side-effect information about the module it points at. Node built-ins are the exception, since in the browser they resolve to a noop polyfill with nothing to preserve. This matches what esbuild does with an external import whose bindings go unused. Fixes bug 2 of the reproducer matrix. --- src/transforms/pipeline/index.ts | 2 + .../browser-server-exports-strip.test.ts | 479 ++++++++++++++++++ .../stages/browser-server-exports-strip.ts | 326 ++++++++++++ src/transforms/pipeline/stages/index.ts | 1 + 4 files changed, 808 insertions(+) create mode 100644 src/transforms/pipeline/stages/browser-server-exports-strip.test.ts create mode 100644 src/transforms/pipeline/stages/browser-server-exports-strip.ts diff --git a/src/transforms/pipeline/index.ts b/src/transforms/pipeline/index.ts index 53e63bbd69..5b3493f29a 100644 --- a/src/transforms/pipeline/index.ts +++ b/src/transforms/pipeline/index.ts @@ -21,6 +21,7 @@ import type { TransformResult, } from "./types.ts"; import { + browserServerExportsStripPlugin, compilePlugin, cssStripPlugin, finalizePlugin, @@ -50,6 +51,7 @@ const BROWSER_PIPELINE: TransformPlugin[] = [ parsePlugin, compilePlugin, cssStripPlugin, // Strip CSS imports before they hit import resolution + browserServerExportsStripPlugin, // Drop server-only hooks + their now-unused imports resolveImportsPlugin, // Unified import resolution finalizePlugin, ]; diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts new file mode 100644 index 0000000000..29becfe26c --- /dev/null +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts @@ -0,0 +1,479 @@ +import "#veryfront/schemas/_test-setup.ts"; +import "../../plugins/__tests__/code-parser-setup.ts"; +import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + browserServerExportsStripPlugin, + stripServerOnlyExports, +} from "./browser-server-exports-strip.ts"; +import type { TransformContext } from "../types.ts"; + +function assertNotIncludes(haystack: string, needle: string): void { + assertEquals(haystack.includes(needle), false, `expected not to find ${needle} in:\n${haystack}`); +} + +/** Identifier occurrences, so "kept the import" and "kept the binding" differ. */ +function occurrences(haystack: string, name: string): number { + return haystack.match(new RegExp(`\\b${name}\\b`, "g"))?.length ?? 0; +} + +describe("browser-server-exports-strip", () => { + describe("emptying server-only hooks", () => { + it("empties an exported async function declaration body", async () => { + const code = [ + `import { hashOf } from "../lib/uses-crypto.js";`, + `async function getServerData(ctx) {`, + ` return { props: { hashed: hashOf("hello") } };`, + `}`, + `function Page() { return null; }`, + `export { getServerData, Page as default };`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `hashOf("hello")`); + // The binding survives so the export clause stays valid. + assertStringIncludes(result, "getServerData"); + assertStringIncludes(result, "Page as default"); + assertStringIncludes(result, "return null"); + }); + + it("empties a directly exported function declaration", async () => { + const code = `export function getStaticPaths() { return db.query(); }`; + const result = await stripServerOnlyExports(code); + assertNotIncludes(result, "db.query"); + assertStringIncludes(result, "getStaticPaths"); + }); + + it("replaces an exported arrow initialiser", async () => { + const code = `export const getStaticData = async (ctx) => ({ props: { x: secret() } });`; + const result = await stripServerOnlyExports(code); + assertNotIncludes(result, "secret()"); + assertStringIncludes(result, "getStaticData"); + }); + + it("handles all three hooks in one module", async () => { + const code = [ + `export async function getServerData() { return serverOne(); }`, + `export function getStaticData() { return serverTwo(); }`, + `export const getStaticPaths = () => serverThree();`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "serverOne"); + assertNotIncludes(result, "serverTwo"); + assertNotIncludes(result, "serverThree"); + }); + + it("leaves a module without server hooks untouched", async () => { + const code = `import { x } from "./x.js";\nexport default function Page() { return x; }`; + assertEquals(await stripServerOnlyExports(code), code); + }); + + it("does not treat a same-named string as a declaration", async () => { + const code = + `const label = "getServerData";\nexport default function Page() { return label; }`; + assertEquals(await stripServerOnlyExports(code), code); + }); + + // Regression: a private helper is ordinary client code. + it("leaves a non-exported function of the same name alone", async () => { + const code = [ + `function getServerData() { return computeOnClient(); }`, + `export default function Page() { return getServerData(); }`, + ].join("\n"); + + assertEquals(await stripServerOnlyExports(code), code); + }); + + it("leaves a local declaration that is only aliased to a hook name alone", async () => { + // `other` is the local declaration; `getServerData` is only its public + // name, so the client-side body of `other` must survive. + const code = [ + `function other() { return computeOnClient(); }`, + `export { other as getServerData };`, + ].join("\n"); + + assertEquals(await stripServerOnlyExports(code), code); + }); + + it("empties a hook declared before a separate export clause", async () => { + const code = [ + `function getStaticData() { return readSecret(); }`, + `export { getStaticData };`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "getStaticData"); + }); + + // Regression: `}` inside a regular expression literal used to end the body. + it("keeps client code that follows a regular expression containing braces", async () => { + const code = [ + `export async function getServerData() { return { props: { p: readSecret() } }; }`, + `export default function Page() {`, + ` const cleaned = "a}b".replace(/[{}]/g, "");`, + ` return cleaned.split(/\\}/).length;`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "cleaned"); + assertStringIncludes(result, "split"); + }); + + it("keeps client code after a division that looks like a regular expression", async () => { + const code = [ + `export function getStaticData() { return readSecret(); }`, + `export default function Page(a, b) {`, + ` const ratio = (a + b) / 2 / (a || 1);`, + ` return { ratio };`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "ratio"); + }); + + it("handles a template literal with braces and interpolation", async () => { + const code = [ + "export function getStaticData() { return readSecret(); }", + "export default function Page(name) {", + " return `hello ${name} }{ ${`${name}`}`;", + "}", + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "hello "); + }); + + it("handles minified single-line input", async () => { + const code = + `import{hashOf as h}from"../lib/uses-crypto.js";export async function getServerData(){return{props:{v:h("x")}}}export default function P(){return 1}`; + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, `h("x")`); + assertNotIncludes(result, "hashOf"); + assertStringIncludes(result, "getServerData"); + }); + + it("handles TSX with types and JSX", async () => { + const code = [ + `import { hashOf } from "../lib/uses-crypto.js";`, + `import type { DataContext } from "veryfront";`, + `export async function getServerData(_ctx: DataContext) {`, + ` return { props: { hashed: hashOf("hello") } };`, + `}`, + `export default function Page({ hashed }: { hashed: string }) {`, + ` return
{hashed}
;`, + `}`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertNotIncludes(result, `hashOf("hello")`); + assertStringIncludes(result, "hashed"); + }); + + it("leaves a module that does not parse unchanged", async () => { + const code = `export function getServerData( { this is not javascript`; + assertEquals(await stripServerOnlyExports(code), code); + }); + }); + + describe("import bindings", () => { + // Regression: deleting the statement dropped the module's top-level side + // effects with it. + it("reduces an unreferenced project import to a side-effect import", async () => { + const code = [ + `import { loadOnStart } from "./client-init-and-data.ts";`, + `export async function getServerData() { return loadOnStart(); }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + // The module still evaluates, so its side effects survive. + assertStringIncludes(result, "./client-init-and-data.ts"); + // The binding that caused the link-time failure is gone. + assertEquals(occurrences(result, "loadOnStart"), 0); + }); + + it("removes an unreferenced node builtin import outright", async () => { + const code = [ + `import { createHash } from "node:crypto";`, + `export async function getServerData() { return createHash("sha256"); }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "node:crypto"); + assertEquals(occurrences(result, "createHash"), 0); + }); + + it("keeps an import that the client still references", async () => { + const code = [ + `import { formatDate } from "../lib/dates.js";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page(props) { return formatDate(props.at); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "formatDate"); + assertStringIncludes(result, "../lib/dates.js"); + }); + + it("keeps an import when only one of its bindings is used", async () => { + const code = [ + `import { a, b } from "./x.js";`, + `export async function getServerData() { return b(); }`, + `export default function Page() { return a(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "a, b"); + }); + + it("keeps a bare side-effect import untouched", async () => { + const code = [ + `import "../lib/polyfill.js";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertStringIncludes(result, "polyfill.js"); + }); + + it("keeps a default import the client renders with", async () => { + const code = [ + `import React from "react";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page() { return React.createElement("p"); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertStringIncludes(result, `from "react"`); + }); + + it("reduces a namespace import the client no longer uses", async () => { + const code = [ + `import * as helpers from "../lib/util-bag.js";`, + `export async function getServerData() { return helpers.load(); }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "../lib/util-bag.js"); + assertEquals(occurrences(result, "helpers"), 0); + }); + + it("does not count a matching property name as a reference", async () => { + const code = [ + `import { hashOf } from "../lib/uses-crypto.js";`, + `export async function getServerData() { return hashOf("x"); }`, + `export default function Page(props) { return props.hashOf; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + // `props.hashOf` is a property name, not a reference to the import. + assertStringIncludes(result, "props.hashOf"); + assertStringIncludes(result, "../lib/uses-crypto.js"); + assertEquals(occurrences(result, "hashOf"), 1); + }); + + it("counts a computed property access as a reference", async () => { + const code = [ + `import { key } from "../lib/keys.js";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page(props) { return props[key]; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertStringIncludes(result, "{ key }"); + }); + + it("counts a JSX component as a reference", async () => { + const code = [ + `import Badge from "../components/Badge.tsx";`, + `export async function getServerData() { return { props: {} }; }`, + `export default function Page() { return ; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + assertStringIncludes(result, "Badge from"); + }); + }); + + // Regression: the scan used to count identifiers by matching text, so a name + // that survived only in inert text kept a server-only import alive. + describe("inert text is not a reference", () => { + it("does not count a line comment mention", async () => { + const code = [ + `import { createHash } from "node:crypto";`, + `// createHash only ever runs in getServerData`, + `export async function getServerData() { return createHash("sha256"); }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertNotIncludes(result, "node:crypto"); + }); + + it("does not count a block comment mention", async () => { + const code = [ + `import { createHash } from "node:crypto";`, + `/* createHash hashes the slug on the server */`, + `export async function getServerData() { return createHash("sha256"); }`, + `export default function Page() { return null; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + assertNotIncludes(result, "node:crypto"); + }); + + it("does not count a string literal mention", async () => { + const code = [ + `import { hashOf } from "../lib/uses-crypto.js";`, + `export async function getServerData() { return hashOf("x"); }`, + `export default function Page() { return "hashOf"; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + // Only the string survives, so the import kept no binding. + assertEquals(occurrences(result, "hashOf"), 1); + assertStringIncludes(result, `"hashOf"`); + assertStringIncludes(result, "../lib/uses-crypto.js"); + }); + + it("does not count a template literal mention", async () => { + const code = [ + 'import { hashOf } from "../lib/uses-crypto.js";', + 'export async function getServerData() { return hashOf("x"); }', + "export default function Page() { return `hashOf`; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertEquals(occurrences(result, "hashOf"), 1); + assertStringIncludes(result, "../lib/uses-crypto.js"); + }); + + it("counts a template literal interpolation, which is real code", async () => { + const code = [ + 'import { formatLabel } from "../lib/labels.js";', + "export async function getServerData() { return { props: {} }; }", + "export default function Page() { return `x ${formatLabel()} y`; }", + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertStringIncludes(result, "{ formatLabel }"); + assertStringIncludes(result, "../lib/labels.js"); + }); + + it("does not count a JSX text node mention", async () => { + const code = [ + `import { hashOf } from "../lib/uses-crypto.js";`, + `export async function getServerData() { return hashOf("x"); }`, + `export default function Page() { return

hashOf

; }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code, "page.tsx"); + + assertEquals(occurrences(result, "hashOf"), 1); + assertStringIncludes(result, "../lib/uses-crypto.js"); + }); + }); + + describe("declaration forms", () => { + // Regression: a private helper that shares a hook's name is client code, + // even when the module really does export a hook elsewhere. + it("leaves a private same-named declaration alone beside a real hook", async () => { + const code = [ + `function getServerData() { return computeOnClient(); }`, + `export function getStaticData() { return readSecret(); }`, + `export default function Page() { return getServerData(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "computeOnClient"); + }); + + it("leaves a local aliased to a hook name alone beside a real hook", async () => { + const code = [ + `function other() { return computeOnClient(); }`, + `export { other as getServerData };`, + `export function getStaticData() { return readSecret(); }`, + ].join("\n"); + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "computeOnClient"); + }); + + it("empties a hook declared as an exported function expression", async () => { + const code = `export const getServerData = async function () { return readSecret(); };`; + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "getServerData"); + }); + + it("empties a hook declared as a directly exported async function", async () => { + const code = `export async function getServerData() { return readSecret(); }`; + + const result = await stripServerOnlyExports(code); + + assertNotIncludes(result, "readSecret"); + assertStringIncludes(result, "getServerData"); + }); + }); + + describe("plugin", () => { + function ctx(code: string, target: "browser" | "ssr"): TransformContext { + return { code, target, filePath: "pages/test.tsx" } as TransformContext; + } + + it("drops the server-only import chain from the client artifact", async () => { + const code = [ + `import { hashOf } from "@/lib/uses-crypto";`, + `export async function getServerData(_ctx) {`, + ` return { props: { hashed: hashOf("hello") } };`, + `}`, + `function TestD({ hashed }) { return hashed; }`, + `export { TestD as default };`, + ].join("\n"); + + const result = await browserServerExportsStripPlugin.transform(ctx(code, "browser")); + + assertEquals(occurrences(result, "hashOf"), 0); + assertStringIncludes(result, "@/lib/uses-crypto"); + assertStringIncludes(result, "TestD as default"); + }); + + it("does not run for the ssr target", () => { + assertEquals(browserServerExportsStripPlugin.condition?.(ctx("", "ssr")), false); + assertEquals(browserServerExportsStripPlugin.condition?.(ctx("", "browser")), true); + }); + }); +}); diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts new file mode 100644 index 0000000000..8a6cf45ef7 --- /dev/null +++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts @@ -0,0 +1,326 @@ +/** + * Browser Server-Exports Strip Stage: empties server-only data hooks in the + * client artifact, then drops the import bindings that only they used. + * + * `getServerData`, `getStaticData` and `getStaticPaths` run exclusively on the + * server, but the browser artifact is compiled from the same source file. Their + * bodies therefore ship to the client along with everything they import, so a + * page whose loader reaches `node:crypto` links against the node-builtin noop + * polyfill and hydration dies with: + * + * The requested module '.../node-noop.js' does not provide an export + * named 'createHash' + * + * esbuild cannot solve this for us: in transform mode (as opposed to bundle + * mode) it never drops an import, because it cannot prove the module is free of + * side effects. + * + * The pass runs on the AST from the `CodeParser` contract, for the same reason + * `rendering/rsc/export-extractor.ts` does: a module is not text. Matching + * declarations by hand means a private function that shares a hook's name gets + * emptied, a `}` inside a regular expression literal ends a body early, and a + * minified statement parses differently from the one a developer wrote. + * + * Two rules keep it conservative: + * + * - Only an exported declaration is emptied. A private helper called + * `getServerData` is ordinary client code. + * - An import whose bindings all fall out of use is reduced to a side-effect + * import rather than deleted, because this pass knows nothing about the + * top-level code of the module it points at. Node built-ins are the + * exception: in the browser they resolve to a noop polyfill, so there is no + * side effect to keep. This matches what esbuild does with an external + * import whose bindings go unused. + * + * Anything that cannot be parsed leaves the module exactly as it was. + */ + +import { tryResolve } from "#veryfront/extensions/contracts.ts"; +import type { ASTNode, CodeParser } from "#veryfront/extensions/parser/index.ts"; +import { rendererLogger as logger } from "#veryfront/utils"; +import type { TransformContext, TransformPlugin } from "../types.ts"; +import { TransformStage } from "../types.ts"; + +/** Exports that only ever execute on the server. */ +const SERVER_ONLY_EXPORTS = ["getServerData", "getStaticData", "getStaticPaths"]; + +/** Source the stub nodes are lifted from, so no node shape is hand-built. */ +const STUB_SOURCE = `function __vfStub() { throw new Error("server-only"); } +const __vfStubInit = function () { throw new Error("server-only"); };`; + +type Node = Record & { type: string }; + +function isNode(value: unknown): value is Node { + return typeof value === "object" && value !== null && + typeof (value as { type?: unknown }).type === "string"; +} + +function children(node: Node): Node[] { + const found: Node[] = []; + + for (const [key, value] of Object.entries(node)) { + if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue; + + if (Array.isArray(value)) { + for (const entry of value) if (isNode(entry)) found.push(entry); + continue; + } + if (isNode(value)) found.push(value); + } + + return found; +} + +/** + * Walk every node in the tree. Returning `false` from `visit` skips the + * subtree, which is how import statements stay out of the reference count. + */ +function walk(node: Node, visit: (node: Node) => boolean | void): void { + if (visit(node) === false) return; + for (const child of children(node)) walk(child, visit); +} + +function nodeName(value: unknown): string | null { + if (!isNode(value)) return null; + const name = value.name; + return typeof name === "string" ? name : null; +} + +function bodyOf(ast: ASTNode): Node[] { + const program = (ast as { program?: unknown }).program; + const source = isNode(program) ? program : (ast as unknown as Node); + const body = source.body; + return Array.isArray(body) ? body.filter(isNode) : []; +} + +/** The stub body and stub initialiser, parsed rather than constructed. */ +async function parseStubs(parser: CodeParser): Promise<{ body: Node; init: Node } | null> { + const ast = await parser.parse({ code: STUB_SOURCE, filePath: "vf-stub.ts" }); + const [fn, variable] = bodyOf(ast); + + const body = fn?.body; + const declarations = variable?.declarations; + const init = Array.isArray(declarations) && isNode(declarations[0]) + ? (declarations[0] as Node).init + : undefined; + + if (!isNode(body) || !isNode(init)) return null; + return { body, init }; +} + +/** Names this module exports from its own local declarations. */ +function exportedLocalNames(body: Node[]): Set { + const names = new Set(); + + for (const statement of body) { + if (statement.type !== "ExportNamedDeclaration") continue; + if (statement.exportKind === "type") continue; + + // `export { getServerData }` and `export { getServerData as data }`: the + // local name is what a declaration in this module is called. The reverse, + // `export { other as getServerData }`, exports `other` and must not touch + // a same-named local. + for (const specifier of Array.isArray(statement.specifiers) ? statement.specifiers : []) { + if (!isNode(specifier)) continue; + if (specifier.exportKind === "type") continue; + // A re-export (`export { x } from "./y"`) has no local declaration to + // empty, so recording the name is harmless. + const local = nodeName(specifier.local); + if (local) names.add(local); + } + + const declaration = statement.declaration; + if (!isNode(declaration)) continue; + + const direct = nodeName(declaration.id); + if (direct) names.add(direct); + + for ( + const declarator of Array.isArray(declaration.declarations) ? declaration.declarations : [] + ) { + if (!isNode(declarator)) continue; + const name = nodeName(declarator.id); + if (name) names.add(name); + } + } + + return names; +} + +/** + * Empty the body of every exported server-only hook. Emptying rather than + * deleting keeps the binding, so an export clause or re-export stays valid. + */ +function emptyServerOnlyHooks( + body: Node[], + exported: Set, + stubs: { body: Node; init: Node }, +): boolean { + const targets = SERVER_ONLY_EXPORTS.filter((name) => exported.has(name)); + if (targets.length === 0) return false; + + let changed = false; + + const declarationsIn = (statement: Node): Node[] => { + const declaration = statement.type === "ExportNamedDeclaration" + ? statement.declaration + : statement; + return isNode(declaration) ? [declaration] : []; + }; + + for (const statement of body) { + for (const declaration of declarationsIn(statement)) { + if (declaration.type === "FunctionDeclaration") { + const name = nodeName(declaration.id); + if (!name || !targets.includes(name)) continue; + declaration.body = structuredClone(stubs.body); + changed = true; + continue; + } + + if (declaration.type !== "VariableDeclaration") continue; + + for ( + const declarator of Array.isArray(declaration.declarations) ? declaration.declarations : [] + ) { + if (!isNode(declarator)) continue; + const name = nodeName(declarator.id); + if (!name || !targets.includes(name)) continue; + declarator.init = structuredClone(stubs.init); + changed = true; + } + } + } + + return changed; +} + +/** + * Identifiers the module reads, ignoring import statements and the positions + * where an identifier is a fixed name rather than a reference (`a.hashOf`, + * `{ hashOf: 1 }`). Over-counting only ever keeps an import. + */ +function referencedIdentifiers(body: Node[]): Set { + const referenced = new Set(); + // Filled in as each parent is visited, which always happens before its + // children. + const fixedNames = new WeakSet(); + + const markFixedName = (node: Node): void => { + const property = node.type === "MemberExpression" || node.type === "OptionalMemberExpression" + ? node.property + : node.type === "ObjectProperty" || node.type === "ObjectMethod" || + node.type === "ClassMethod" || node.type === "ClassProperty" + ? node.key + : undefined; + + if (node.computed === true) return; + if (isNode(property)) fixedNames.add(property); + }; + + for (const statement of body) { + if (statement.type === "ImportDeclaration") continue; + + walk(statement, (node) => { + if (node.type === "ImportDeclaration") return false; + + markFixedName(node); + + if (node.type === "Identifier" || node.type === "JSXIdentifier") { + if (fixedNames.has(node)) return true; + const name = nodeName(node); + if (name) referenced.add(name); + } + + return true; + }); + } + + return referenced; +} + +/** Local binding names an import statement introduces. */ +function importedBindings(statement: Node): string[] { + const bindings: string[] = []; + + for (const specifier of Array.isArray(statement.specifiers) ? statement.specifiers : []) { + if (!isNode(specifier)) continue; + const name = nodeName(specifier.local); + if (name) bindings.push(name); + } + + return bindings; +} + +/** + * Reduce imports nothing references any more to side-effect imports, and drop + * them outright when they point at a Node built-in. + */ +function dropUnusedImportBindings(body: Node[]): Node[] { + const referenced = referencedIdentifiers(body); + + return body.filter((statement) => { + if (statement.type !== "ImportDeclaration") return true; + if (statement.importKind === "type") return true; + + const bindings = importedBindings(statement); + // Already a side-effect import: nothing to drop. + if (bindings.length === 0) return true; + if (bindings.some((binding) => referenced.has(binding))) return true; + + const source = isNode(statement.source) ? statement.source.value : undefined; + if (typeof source === "string" && source.startsWith("node:")) return false; + + statement.specifiers = []; + return true; + }); +} + +function setBody(ast: ASTNode, body: Node[]): void { + const program = (ast as { program?: unknown }).program; + const target = isNode(program) ? program : (ast as unknown as Node); + target.body = body; +} + +/** + * Empty the server-only hooks in `code` and drop the import bindings they were + * the last user of. Returns `code` unchanged when there is nothing to do, when + * no parser is registered, or when the module does not parse. + */ +export async function stripServerOnlyExports(code: string, filePath?: string): Promise { + // Cheap pre-check: no mention of a hook means no parse. + if (!SERVER_ONLY_EXPORTS.some((name) => code.includes(name))) return code; + + const parser = tryResolve("CodeParser"); + if (!parser) return code; + + try { + const stubs = await parseStubs(parser); + if (!stubs) return code; + + const ast = await parser.parse({ code, filePath: filePath ?? "module.tsx" }); + const body = bodyOf(ast); + + if (!emptyServerOnlyHooks(body, exportedLocalNames(body), stubs)) return code; + + setBody(ast, dropUnusedImportBindings(body)); + + const generated = await parser.generate(ast); + return generated.code; + } catch (error) { + logger.debug("Left the module unchanged", { + filePath, + reason: error instanceof Error ? error.message : String(error), + }); + return code; + } +} + +export const browserServerExportsStripPlugin: TransformPlugin = { + name: "browser-server-exports-strip", + // After esbuild compile and CSS strip, before any import resolution, so the + // dropped bindings are never rewritten or pre-fetched. + stage: TransformStage.COMPILE + 0.6, + condition: (ctx: TransformContext) => ctx.target === "browser", + transform: (ctx: TransformContext) => stripServerOnlyExports(ctx.code, ctx.filePath), +}; diff --git a/src/transforms/pipeline/stages/index.ts b/src/transforms/pipeline/stages/index.ts index b0c62a0c01..cae26236db 100644 --- a/src/transforms/pipeline/stages/index.ts +++ b/src/transforms/pipeline/stages/index.ts @@ -7,6 +7,7 @@ export { parsePlugin } from "./parse.ts"; export { compilePlugin } from "./compile.ts"; export { cssStripPlugin } from "./ssr-css-strip.ts"; +export { browserServerExportsStripPlugin } from "./browser-server-exports-strip.ts"; export { resolveImportsPlugin } from "./resolve-imports.ts"; export { ssrVfModulesPlugin } from "./ssr-vf-modules.ts"; export { ssrHttpStubPlugin } from "./ssr-http-stub.ts";