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";