1import type {
2 CallExpression,
3 ExportDefaultDeclaration,
4 ExportNamedDeclaration,
5 Expression,
6 ExpressionStatement,
7 NewExpression,
8 Program,
9 Statement,
10 VariableDeclaration,
11} from "@oxc-project/types";
12import * as fs from "node:fs/promises";
13import * as path from "node:path";
14import picomatch from "picomatch";
15import type * as rolldown from "rolldown";
16
17/**
18 * `rolldown` and `rolldown/parseAst` load `@rolldown/binding-*` at module
19 * scope, so they are imported lazily on first use — importing this module
20 * (e.g. via the `alchemy/Bundle` barrel) must never load the native
21 * binding (#562).
22 */
23const loadRolldown = () => import("rolldown");
24const loadParseAst = () => import("rolldown/parseAst");
25
26/**
27 * Default packages whose modules will receive `/*#__PURE__*\/` annotations.
28 * Mirrors what `effect-smol` ships via `babel-plugin-annotate-pure-calls`
29 * applied to its own `dist/` output.
30 *
31 * `alchemy` is included so its own resources (which consist almost entirely
32 * of `Effect.fn(...)`, `Context.Service(...)(...)`, `Layer.effect(...)` and
33 * `Data.TaggedError(...)` top-level calls) become tree-shakeable. The
34 * package already declares `"sideEffects": false`, so it is safe.
35 *
36 * `@distilled.cloud/*` is included so the generated SDK service modules
37 * (large files of top-level `const op = make(...)` operation definitions)
38 * tree-shake down to the operations a Worker actually calls. Every
39 * distilled package declares `"sideEffects": false`.
40 */
41export const DEFAULT_PURE_PACKAGES: ReadonlyArray<string> = [
42 "effect",
43 "@effect/*",
44 "alchemy",
45 "@alchemy.run/*",
46 "@distilled.cloud/*",
47];
48
49/**
50 * Options for {@link purePlugin}.
51 */
52export interface PurePluginOptions {
53 /**
54 * Extra package names or globs to annotate, in addition to
55 * {@link DEFAULT_PURE_PACKAGES}. Globs are matched with picomatch
56 * against the package name (e.g. `effect`, `@effect/cluster`).
57 *
58 * Listing a package that declares `"sideEffects": false` (or `[]`)
59 * in its `package.json` opts it into full annotation, including
60 * top-level calls whose result is discarded — see {@link purePlugin}.
61 */
62 readonly packages?: ReadonlyArray<string>;
63 /**
64 * If true, the configured `packages` list replaces the defaults
65 * entirely instead of extending them.
66 * @default false
67 */
68 readonly replaceDefaults?: boolean;
69 /**
70 * If true, also marks matched modules as side-effect free
71 * (mirrors `"sideEffects": []` in package.json), so rolldown drops
72 * unused re-exports from those packages.
73 * @default true
74 */
75 readonly markSideEffectFree?: boolean;
76}
77
78const PURE_COMMENT = "/*#__PURE__*/ ";
79const SUPPORTED_FILE_RE = /\.(?:m?[jt]sx?|cjs|cts)$/;
80
81/**
82 * Rolldown plugin that injects `/*#__PURE__*\/` annotations on top-level
83 * call/new expressions of modules belonging to the configured packages,
84 * enabling tree-shaking of `effect`, `@effect/*`, and any user-listed
85 * packages without requiring a babel post-build pass.
86 *
87 * Annotation is strictly explicit: only {@link DEFAULT_PURE_PACKAGES} and
88 * packages listed via `packages` are touched. Listing a package that
89 * declares `sideEffects: false` / `[]` is a deliberate opt-in to full
90 * annotation, including discarded-result statement calls (#949). The
91 * package owning the bundle entry gets no special treatment — apps that
92 * want their own bound calls tree-shaken list themselves explicitly.
93 */
94export const purePlugin = (
95 options: PurePluginOptions = {},
96): rolldown.Plugin => {
97 const patterns = options.replaceDefaults
98 ? [...(options.packages ?? DEFAULT_PURE_PACKAGES)]
99 : [...DEFAULT_PURE_PACKAGES, ...(options.packages ?? [])];
100 const markSideEffectFreeOpt = options.markSideEffectFree ?? true;
101 const isMatch = picomatch(patterns);
102 // Per-bundle cache of directory -> owning package metadata.
103 // Avoids walking the filesystem for every module of a large package.
104 const pkgInfoCache = new Map<string, PackageInfo | null>();
105 // Resolved absolute paths of entry modules. Marking an entry module as
106 // `moduleSideEffects: false` makes rolldown treat its top-level
107 // statements (including `console.log` etc.) as eliminable, often
108 // collapsing the whole bundle. We always preserve entries' side
109 // effects, regardless of the package they belong to.
110 const entryPaths = new Set<string>();
111
112 return {
113 name: "alchemy:annotate-pure",
114 options(opts) {
115 for (const input of inputFilePaths(opts)) entryPaths.add(input);
116 return null;
117 },
118 transform: {
119 filter: { id: SUPPORTED_FILE_RE },
120 async handler(code, id, meta) {
121 const cleanId = stripIdSuffix(id);
122 const info = await resolvePackageInfo(
123 path.dirname(cleanId),
124 pkgInfoCache,
125 );
126 // A nameless package.json (e.g. a nested `dist/package.json`
127 // holding only `{"type": "module"}`) can't be matched against the
128 // configured patterns — fall back to the path-derived
129 // `node_modules/<pkg>` name when one exists.
130 const name = info?.name ?? packageNameFromId(cleanId);
131 if (name === null || !isMatch(name)) return null;
132
133 // Only override `moduleSideEffects` when the owning package
134 // explicitly opts in via `sideEffects: false` / `[]`. Marking
135 // arbitrary modules side-effect-free could erase intentional
136 // registrations / mutations the author made at the top level of
137 // files in packages that did not declare so.
138 const isEntry = entryPaths.has(cleanId);
139 const sideEffectFreePkg = isSideEffectFree(info?.sideEffects);
140 const markSideEffectFree =
141 markSideEffectFreeOpt && !isEntry && sideEffectFreePkg;
142
143 const anchors = await collectPureAnchorsCached(code, cleanId);
144 // Annotating a call whose result is BOUND (variable initializer,
145 // export) only lets the minifier drop it when the binding itself
146 // is unused — safe everywhere. Annotating a call whose result is
147 // DISCARDED (a bare expression statement like `app.get("/", h)`)
148 // is an instruction to delete the call under minification, so it
149 // is only applied when the owning package explicitly declared
150 // itself side-effect free, and never to entry modules, whose side
151 // effects we always preserve (#949).
152 const annotateDiscarded = !isEntry && sideEffectFreePkg;
153 const positions =
154 anchors === null
155 ? []
156 : annotateDiscarded
157 ? [...anchors.bound, ...anchors.discarded]
158 : anchors.bound;
159 if (positions.length === 0) {
160 // Metadata-only result: omitting `code` tells rolldown the
161 // source was NOT transformed, so no sourcemap is expected and
162 // no SOURCEMAP_BROKEN warning is emitted.
163 return markSideEffectFree ? { moduleSideEffects: false } : null;
164 }
165 // `meta.magicString` is rolldown's native (Rust) MagicString over
166 // `code`. Returning it as `code` hands sourcemap generation to
167 // rolldown's native side (computed on a background thread). The
168 // fallback covers direct hook invocations (unit tests).
169 const s =
170 meta.magicString ??
171 new (await loadRolldown()).RolldownMagicString(code);
172 for (const anchor of positions) s.appendLeft(anchor, PURE_COMMENT);
173 return {
174 code: s,
175 moduleSideEffects: markSideEffectFree ? false : null,
176 };
177 },
178 },
179 };
180};
181
182/** Strips rolldown's `?query` / `#hash` suffixes from a module id. */
183const stripIdSuffix = (id: string): string => id.replace(/[?#].*$/, "");
184
185/**
186 * Returns the absolute paths of every real entry module declared on the
187 * rolldown input options. Handles all three input shapes (string, array,
188 * `{ name -> path }` record) and skips `\0`-prefixed virtual ids (e.g.
189 * the ones `virtualEntryPlugin` substitutes in its `pre` options hook).
190 */
191function inputFilePaths(opts: rolldown.InputOptions): string[] {
192 const raw: unknown[] =
193 typeof opts.input === "string"
194 ? [opts.input]
195 : Array.isArray(opts.input)
196 ? opts.input
197 : opts.input && typeof opts.input === "object"
198 ? Object.values(opts.input)
199 : [];
200 const cwd = opts.cwd ?? process.cwd();
201 return raw
202 .filter(
203 (entry): entry is string =>
204 typeof entry === "string" && !entry.startsWith("\0"),
205 )
206 .map((entry) => path.resolve(cwd, entry));
207}
208
209/**
210 * Metadata extracted from a `package.json`. `name` may be `null` when the
211 * file exists but has no `"name"` field (rare, but possible for private
212 * subpackage roots).
213 */
214export interface PackageInfo {
215 readonly name: string | null;
216 readonly sideEffects: unknown;
217}
218
219/**
220 * Resolves the owning {@link PackageInfo} for a directory by walking up
221 * to the nearest `package.json`. This is what makes the plugin work for
222 * workspace-linked sources (e.g. our own `packages/alchemy/src/**` when
223 * consumers import via the `worker`/`bun` conditions which resolve to
224 * `.ts`).
225 *
226 * Caches both positive and negative results per directory. Every visited
227 * directory is a descendant-or-self of the directory where the walk
228 * stops (found `package.json`, cache hit, `node_modules` boundary, or
229 * filesystem root), so backfilling all of them with the result never
230 * poisons sibling packages.
231 */
232export async function resolvePackageInfo(
233 startDir: string,
234 cache: Map<string, PackageInfo | null>,
235): Promise<PackageInfo | null> {
236 let dir = path.resolve(startDir);
237 const visited: string[] = [];
238 let result: PackageInfo | null = null;
239 // Hard ceiling to prevent runaway walks on weird paths.
240 for (let i = 0; i < 64; i++) {
241 // Never walk above a `node_modules` boundary: the owning package of a
242 // `node_modules/<pkg>/...` id lives at or below `<pkg>`. Climbing past
243 // it can latch onto an unrelated package.json higher up (e.g. a stray
244 // one at the filesystem root).
245 if (path.basename(dir) === "node_modules") break;
246 const cached = cache.get(dir);
247 if (cached !== undefined) {
248 result = cached;
249 break;
250 }
251 visited.push(dir);
252 const info = await readPackageJson(path.join(dir, "package.json"));
253 if (info !== null) {
254 result = info;
255 break;
256 }
257 const parent = path.dirname(dir);
258 if (parent === dir) break;
259 dir = parent;
260 }
261 for (const v of visited) cache.set(v, result);
262 return result;
263}
264
265async function readPackageJson(file: string): Promise<PackageInfo | null> {
266 try {
267 const contents = await fs.readFile(file, "utf8");
268 const json = JSON.parse(contents) as {
269 name?: unknown;
270 sideEffects?: unknown;
271 };
272 return {
273 name: typeof json.name === "string" ? json.name : null,
274 sideEffects: json.sideEffects,
275 };
276 } catch {
277 // package.json missing or unreadable.
278 return null;
279 }
280}
281
282/**
283 * Mirrors how rolldown / rollup interpret the `sideEffects` package.json
284 * field. We only treat `false` and `[]` as "fully side-effect free"; an
285 * array of files or `true` is treated as not safe to auto-annotate the
286 * whole package.
287 */
288function isSideEffectFree(value: unknown): boolean {
289 if (value === false) return true;
290 if (Array.isArray(value) && value.length === 0) return true;
291 return false;
292}
293
294/**
295 * Extracts the npm package name from a resolved module id by walking back to
296 * the last `node_modules/` segment. Handles scoped packages. This is the
297 * fast path that does not hit the filesystem; it works for ordinary
298 * `node_modules/<pkg>/...` ids but NOT for workspace-linked sources whose
299 * resolved path is e.g. `<repo>/packages/alchemy/src/Bundle/PurePlugin.ts`.
300 *
301 * @example
302 * packageNameFromId("/proj/node_modules/effect/dist/Effect.js")
303 * // => "effect"
304 * packageNameFromId("/proj/node_modules/@effect/cluster/dist/index.js")
305 * // => "@effect/cluster"
306 */
307export function packageNameFromId(id: string): string | null {
308 const normalized = id.replace(/\\/g, "/");
309 const idx = normalized.lastIndexOf("/node_modules/");
310 if (idx === -1) return null;
311 const rest = normalized.slice(idx + "/node_modules/".length);
312 const parts = rest.split("/");
313 if (parts.length === 0 || parts[0] === "") return null;
314 if (parts[0].startsWith("@")) {
315 if (parts.length < 2) return null;
316 return `${parts[0]}/${parts[1]}`;
317 }
318 return parts[0];
319}
320
321/**
322 * Process-wide memo of {@link collectPureAnchors} results keyed by module id.
323 *
324 * Parsing with oxc dominates the plugin's main-thread CPU (it showed up as
325 * ~60% of the profile of a test run), and the same modules — effect/alchemy
326 * dist files — are re-scanned for EVERY bundle built in the process. The
327 * single-process test runner builds dozens of worker bundles per run, so
328 * this cache eliminates all repeat parses. The stored `code` is compared on
329 * lookup, so a changed file (e.g. dev watch mode) never serves a stale
330 * result.
331 */
332const anchorsCache = new Map<
333 string,
334 { readonly code: string; readonly anchors: PureAnchors | null }
335>();
336const ANCHORS_CACHE_MAX = 10_000;
337
338const collectPureAnchorsCached = async (
339 code: string,
340 filename: string,
341): Promise<PureAnchors | null> => {
342 const cached = anchorsCache.get(filename);
343 if (cached !== undefined && cached.code === code) {
344 return cached.anchors;
345 }
346 const anchors = await collectPureAnchors(code, filename);
347 if (anchorsCache.size >= ANCHORS_CACHE_MAX) {
348 anchorsCache.clear();
349 }
350 anchorsCache.set(filename, { code, anchors });
351 return anchors;
352};
353
354/**
355 * Offsets at which `/*#__PURE__*\/` may be inserted, split by whether the
356 * call's result is consumed.
357 */
358export interface PureAnchors {
359 /**
360 * Calls whose result is bound — variable initializers, exports,
361 * assignment right-hand sides. Annotating these only lets the minifier
362 * drop the call when the binding itself is unused, so it is safe for
363 * any module.
364 */
365 readonly bound: number[];
366 /**
367 * Top-level calls whose result is discarded (bare expression
368 * statements such as `app.get("/", handler)`). Annotating these tells
369 * the minifier the whole statement is deletable, so they are only safe
370 * in packages that explicitly declare `sideEffects: false` / `[]`.
371 */
372 readonly discarded: number[];
373}
374
375/**
376 * Parses `code` and returns the offsets at which `/*#__PURE__*\/` must be
377 * inserted — before every top-level `CallExpression` callee / `new`
378 * keyword — classified by whether the call result is bound or discarded.
379 * Returns `null` if the file does not need to be modified (parse failure
380 * or no annotations needed).
381 */
382export async function collectPureAnchors(
383 code: string,
384 filename: string,
385): Promise<PureAnchors | null> {
386 const { parseAst } = await loadParseAst();
387 let program: Program;
388 try {
389 // Use TS lang so the parser tolerates TS syntax (`as`, `satisfies`,
390 // non-null assertions) even when scanning published .js dist files
391 // — TS is a strict superset of JS for our purposes here.
392 program = parseAst(code, { sourceType: "module", lang: "ts" }, filename);
393 } catch {
394 return null;
395 }
396
397 const bound: number[] = [];
398 const discarded: number[] = [];
399
400 const visitCall = (
401 call: CallExpression | NewExpression,
402 isDiscarded: boolean,
403 ) => {
404 if (isIIFE(call)) return;
405 // For `new X()`, anchor BEFORE the `new` keyword so we get
406 // `/*#__PURE__*/ new X()` (matches babel-plugin-annotate-pure-calls).
407 const anchor =
408 call.type === "NewExpression" ? call.start : call.callee.start;
409 if (alreadyAnnotated(code, anchor)) return;
410 (isDiscarded ? discarded : bound).push(anchor);
411 };
412
413 const visitExpression = (
414 expr: Expression | null | undefined,
415 isDiscarded: boolean,
416 ) => {
417 if (!expr) return;
418 switch (expr.type) {
419 case "CallExpression":
420 case "NewExpression":
421 visitCall(expr, isDiscarded);
422 return;
423 case "SequenceExpression": {
424 // All but the last expression's results are always discarded;
425 // the last one takes the surrounding context.
426 const exprs = expr.expressions;
427 for (let i = 0; i < exprs.length; i++) {
428 visitExpression(exprs[i], i < exprs.length - 1 ? true : isDiscarded);
429 }
430 return;
431 }
432 case "ParenthesizedExpression":
433 visitExpression(expr.expression, isDiscarded);
434 return;
435 case "LogicalExpression":
436 visitExpression(expr.left, isDiscarded);
437 visitExpression(expr.right, isDiscarded);
438 return;
439 case "ConditionalExpression":
440 visitExpression(expr.consequent, isDiscarded);
441 visitExpression(expr.alternate, isDiscarded);
442 return;
443 case "AssignmentExpression":
444 // The right-hand side's result is stored in the target, so it is
445 // bound even when the assignment appears in statement position.
446 visitExpression(expr.right, false);
447 return;
448 case "TSAsExpression":
449 case "TSSatisfiesExpression":
450 case "TSNonNullExpression":
451 case "TSTypeAssertion":
452 visitExpression(expr.expression, isDiscarded);
453 return;
454 case "ChainExpression": {
455 const inner = expr.expression;
456 if (inner.type === "CallExpression") visitCall(inner, isDiscarded);
457 return;
458 }
459 default:
460 return;
461 }
462 };
463
464 const visitTopLevel = (node: Statement) => {
465 switch (node.type) {
466 case "ExpressionStatement":
467 visitExpression((node as ExpressionStatement).expression, true);
468 return;
469 case "VariableDeclaration":
470 for (const decl of (node as VariableDeclaration).declarations) {
471 visitExpression(decl.init, false);
472 }
473 return;
474 case "ExportNamedDeclaration": {
475 const decl = (node as ExportNamedDeclaration).declaration;
476 if (decl) visitTopLevel(decl as Statement);
477 return;
478 }
479 case "ExportDefaultDeclaration": {
480 const decl = (node as ExportDefaultDeclaration).declaration;
481 if (
482 decl &&
483 decl.type !== "FunctionDeclaration" &&
484 decl.type !== "ClassDeclaration" &&
485 decl.type !== "TSInterfaceDeclaration"
486 ) {
487 visitExpression(decl as Expression, false);
488 }
489 return;
490 }
491 default:
492 return;
493 }
494 };
495
496 for (const node of program.body) {
497 // Directive nodes (e.g. "use strict") have type "ExpressionStatement"
498 // with a `directive` field and a string literal expression — they
499 // cannot contain calls, so visiting them is a safe no-op.
500 visitTopLevel(node as Statement);
501 }
502
503 return bound.length === 0 && discarded.length === 0
504 ? null
505 : { bound, discarded };
506}
507
508function isIIFE(node: CallExpression | NewExpression): boolean {
509 let callee: Expression = node.callee;
510 while (callee.type === "ParenthesizedExpression") {
511 callee = callee.expression;
512 }
513 return (
514 callee.type === "FunctionExpression" ||
515 callee.type === "ArrowFunctionExpression"
516 );
517}
518
519function alreadyAnnotated(code: string, pos: number): boolean {
520 const start = Math.max(0, pos - 32);
521 const slice = code.slice(start, pos);
522 return slice.includes("/*#__PURE__*/") || slice.includes("/*@__PURE__*/");
523}