Skip to content

Explore Rolldown tree-shaking of unused service methods - #10378

Closed
effect-bot wants to merge 8 commits into
mainfrom
agent/nelson-effect-bot/962bab027890
Closed

effect-bot wants to merge 8 commits into
mainfrom
agent/nelson-effect-bot/962bab027890

Conversation

@effect-bot

@effect-bot effect-bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Proof of concept: a Rolldown build that deletes Effect service methods a program can't observe, with no Rolldown fork. For a closed NodeRuntime.runMain program that only calls FileSystem.readFile, it drops every other NodeFileSystem method, their helpers, and the node:crypto/node:os imports, with identical output. Anything it can't prove safe falls back to the unoptimized bundle, which is most real programs today (see the recommendation).

Closes EFF-3380

How it works

ServiceShaking.bundle(options) in @effect/bundle (private tooling package) runs two Rolldown builds:

  1. A post transform records every module. internal/analysis.ts then computes, for each service key string, which methods are read.
  2. A second build deletes provider object-literal properties nobody demands. Rolldown's statement-level shaker removes the helpers only those properties used.

If any module transforms differently in the second build, the first build's output is returned. This covers every module, not just the edited ones.

Demand is keyed on the service key:

  • Consumers. yield* K, K.use/useSync, Effect.map/flatMap/andThen/tap(K, f), K.pipe(...), Effect.service(K), Context.get*(ctx, K), and the Effect.updateService callback. The value may only be read through static members or destructuring, or spread into another provider literal. Reading an inherited Object.prototype member (valueOf, hasOwnProperty, ...) bails the key.
  • Providers. Layer.succeed/effect/sync, Effect.provideService(Effect), Effect.updateService, K.context, Context.make/add, and K.of. A literal is pruned only when a bounded flow proves three things:
    • It reaches a Context only through those sites, via Effect.succeed/sync/map/flatMap/tap/acquireRelease/gen, pipes, single-use consts, and non-escaping synchronous functions. Async and generator functions are not followed.
    • Every callback that sees it before then (release, tap) only reads static members.
    • The Layer or Context holding it stays inside the bundle. Its only allowed destinations are Effect.provide, Layer.launch, the Layer.merge*/provide* combinators, consts, and factory functions whose calls are followed in turn. Reaching an entry export or anything else bails the key.
  • Parameter spreads. { ...impl } passes demand on to argument literals, plus static reads of the parameter.
  • Receivers. Methods are resolved through identifiers, consts, function declarations and IIFEs. Their this reads are demanded on every object that can hold them, including objects the method is spread into. A method that can't be resolved keeps every property.
  • Purity. Only side-effect-free values are deleted: identifiers, functions, literals, namespace members, and pure-annotated calls. Removal also skips comments before the separator.

Bailouts

Per key: the service value escapes, the key is used in an unrecognised way, the key is created outside a declaration, or the Layer holding a provider escapes. A callback that reads arguments, or a receiver function that is reassigned, counts as unknown.

Global (nothing is pruned):

  • Open entry points. The entry module may export only primitive constants. Importers can run code inside a provider's scope through arguments (including getters and toString on objects passed as "data"), returned Effects, or exported transformers. None of that can be checked from inside the bundle.
  • Unanalysed imports. Any import left external, other than Node builtins.
  • Unaudited trusted APIs. Untrusted code may only reference allowedApis/allowedModules. A Fiber exposes its Context as a public property, and a Context hands out any service by key string: Context.getUnsafe with a rebuilt Context.Service("same key"), mapUnsafe, or traversal from Object.values(fiber). So APIs that hand out a Fiber or Context must bail. That includes nested callback types such as Logger.Options.fiber and RunOptions.onFiberStart. Run functions are only allowed without options, and runFork only when its result is discarded.
  • Reflection that reaches the running Fiber. The current Fiber is published as globalThis["~effect/Fiber/currentFiber"]. Redactable passes the current Context to any object holding Symbol.for("~effect/Redactable"), which Option's toString reaches through Formatter. So untrusted references to these bail:
    • globalThis/global/self/window, eval/Function, Reflect, Proxy and require;
    • Symbol beyond iterator/asyncIterator;
    • symbol-revealing Object APIs;
    • stack trace hooks.
  • Also: .context/.mapUnsafe reads, dynamic keys, namespaces used as values, dynamic import(), and writes to a key's members.

The second build must match the first in every module's code, every resolved import, and the module set. Otherwise the first build's output is returned. Edits nested inside a removed property are dropped.

Assumptions (not checked): effect and @effect/platform-* don't inspect service values or hand out a Context or Fiber except through the APIs above, and the global built-ins aren't modified by code outside the bundle.

On printing

Printing a Context or Fiber with console.log/util.inspect, Formatter.format or Effect.log currently lists each service's property names (unused: [Function: unused]). JSON.stringify and String(fiber) don't. If printed service shapes are not treated as program output, printing alone is no reason to bail. None of the remaining bailouts depends on printing: each regression fixture retrieves a service explicitly and calls typeof on a method.

Results

Same build for every column (workspace sources, annotate-pure-calls, unminified). Closed-world probes run as NodeRuntime.runMain programs, with stdout compared through a child process.

program baseline hand-trimmed ceiling shaken output
runMain + NodeFileSystem + readFile 175,818 B 142,018 B 104,489 B identical
runMain + NodeServices + readFile + Effect.log 326,496 B 305,569 B 279,961 B identical apart from the log timestamp
runMain + JSON + computed property access 175,925 B 142,125 B 104,596 B identical
runMain + a Schema decode 259,948 B = baseline Schema not audited
runMain + forkChild + Fiber.join 175,880 B = baseline Fiber APIs not audited
committed fixtures (export const main) ≈150 kB = baseline open entry point

Each bundle call takes about 0.3–0.8 s.

Required Effect change

FileSystem.make marks Glob.make(impl) and Effect.fnUntraced(...) as /* @__PURE__ */ (patch changeset). Without the marks, glob passes impl whole and every Node method stays.

Recommendation

General use: negative. Narrow closed-world subset: positive. Rolldown needs no fork, and closed runMain applications using only audited APIs lose 14–41% of the bundle with identical output. But:

  • Any Schema, Stream, HTTP, fork or custom logger use disables pruning.
  • So does an exported entry point, or reflection near the global object.
  • Recovering forks would mean tracking Fiber values so they only reach Fiber.join/await/interrupt. That's an analysis expansion I haven't done.

The larger lever is on the Effect side: services whose methods can't be reached by key string or through the current Fiber, or explicit per-method services.

Tests

The four size tests (... drops FileSystem methods ...) now fail by design: their fixtures export main, which the entry-point rule rejects. Every other test passes on Node, Bun and Deno (36/40).

With the entry rule disabled locally, those four pass, and every regression passes through its own fix except the four scope-* cases, which need the entry rule.

Needed in a separate run:

  • Restructure the size fixtures, and the regression fixtures that currently bail only on export main, as closed-world entries run in a child process. This lets each fix be exercised on its own.
  • Add controls for:
    • the Object.values(fiber) and rebuilt-key retrieval paths;
    • computed access on plain data being allowed;
    • unaudited API rejection;
    • a NodeServices smoke test.

Fixtures cover a generator, combinators (use, flatMap, pipe), direct
Context access, overrides with a scoped mock layer and updateService, and
three bailouts (escape with computed access, Object.keys inspection, raw
string key lookup). ServiceShaking.bundle is a passthrough seam, so the
four pruning assertions fail until the plugin lands.

Refs EFF-3380
ServiceShaking.bundle runs two Rolldown builds. The first records every
transformed module; a key-level analysis then computes which methods are
read from each service key, follows provider object literals through a
bounded set of insertion shapes, and bails on any key use it does not
recognise. The second build deletes undemanded provider properties and
lets Rolldown drop the helpers only they referenced.

APIs that hand a whole Context or a Fiber to untrusted code disable the
optimization, since inspecting either prints every service value.

FileSystem.make annotates its glob and stream constructors as pure so the
derived methods can be removed.

Refs EFF-3380
@changeset-bot

changeset-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 78da062

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/graphql-generator Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-janitor effect-janitor Bot added the 4.0 label Nov 4, 2026
Differential fixtures for each confirmed miscompilation: release and tap
callbacks, Fiber exposure through Logger.make and onFiberStart (including
a discarded runFork), receiver methods passed by identifier, inherited
Object members, async factories, key mutation, a comment before a removed
property's comma, an exported Layer built outside the bundle, and a
consumer that transforms differently in the second build.

All eleven fail against the current implementation.

Refs EFF-3380
- Release and tap callbacks are consumers of the acquired provider object.
- Untrusted code may only reference an audited allow-list of trusted APIs,
  replacing the Fiber/Context deny-list; run functions are only allowed
  without RunOptions, and runFork only when its Fiber is discarded.
- Receiver reads are resolved for identifier-valued and spread-in methods;
  unresolvable receivers keep every property.
- Inherited Object.prototype members observe the whole service.
- A Layer or Context built from a provider must stay inside the bundle.
- Async and generator functions are not followed as provider factories.
- Writes to a service key's members, or untrusted key escapes, bail
  globally.
- Property removal skips comments before the separator.
- The second build is compared against every module of the first.

Refs EFF-3380
Differential cases for confirmed miscompilations: code importing the
bundle running inside a provider's scope (an Effect or Layer argument, an
exported Effect.provide transformer, an exported Effect run with the
importer's logger), the Redactable protocol and the global current Fiber
exposing the Context, an external module's logger, callbacks reading
arguments[0], a reassigned receiver function, nested provider removals,
and an import resolving differently in the second build. The text-drift
test now requires the first build's result.

All eleven new cases fail against the current implementation.

Refs EFF-3380
- The entry module may only export primitive constants, since importers can
  run code inside a provider's scope through arguments, returned Effects or
  exported transformers.
- Untrusted references to the global object, eval/Function, Reflect, Proxy,
  require, Symbol.for and symbol-revealing Object APIs, and stack trace
  hooks, disable pruning: the running Fiber is published on the global
  object and Redactable hands formatters the current Context.
- Imports left external, other than Node builtins, disable pruning.
- Callbacks that read arguments are unknown consumers.
- Reassigned functions are unresolved receivers.
- The second build is also compared by resolved imports.
- Edits nested in a removed property are dropped.

Refs EFF-3380
…essions

Fixtures other than the exported-entry cases no longer export main: they
run as closed programs in a child process and print their result, so the
entry-export bailout no longer masks the rule each one targets. Inspection
code uses plain computed reads instead of Reflect for the same reason, and
the external helper has no imports so every runtime can load it.

Adds constructor-gen and constructor-map: providers whose methods are
constructor-local consts, checking that writeOne and the demand only it
had are removed while construction-time calls, service acquisition and
release still run.

Refs EFF-3380
@effect-bot

Copy link
Copy Markdown
Contributor Author

Closing in favour of the native Rolldown implementation, which supersedes this plugin approach: effect-bot/rolldown#1

The Rolldown fork now holds the deliverable, its regression suite, its documentation and its own CI. This branch is no longer maintained.

@effect-bot effect-bot closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant