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