Skip to content

Make Metro serializer a pass-through in load mode - #67

Merged
ChALkeR merged 2 commits into
mainfrom
claude/stasis-metro-bundle-load-qs2sh3
Jul 17, 2026
Merged

ChALkeR merged 2 commits into
mainfrom
claude/stasis-metro-bundle-load-qs2sh3

Conversation

@exo-nikita

Copy link
Copy Markdown
Collaborator

Summary

Refactors the StasisMetro serializer plugin to be a transparent pass-through when bundle=load is active, rather than throwing an error. This enables a single metro.config.js to wire both the serializer and transformer halves permanently, with the mode (capture vs. load) determining which is active.

Key Changes

  • Serializer behavior in load mode: Changed from rejecting bundle=load with an error to silently passing through (returning early without capturing or writing anything). This allows the base serializer to produce output unmodified.

  • Permanent wiring pattern: Updated documentation and examples to show that both StasisMetro serializer and the companion metro-transformer should be wired in metro.config.js permanently. The mode selection (via EXODUS_STASIS_BUNDLE or stasis.config.json) determines which half is active, rather than requiring separate configurations.

  • Load-bearing symmetry: Clarified that this design is necessary because metro.config.js itself is attested at capture time (frozen in the bundle/lockfile). A load/frozen run must execute the exact same config byte-for-byte, so neither the serializer nor transformer can demand mode-specific edits.

  • Test restructuring:

    • Replaced the "bundle=load rejection" test with a comprehensive pass-through test that verifies the serializer doesn't capture, write, or interfere with re-serialization during dev-server rebuilds
    • Added a separate test for the one-shot guard that prevents watch/dev-server rebuilds during capture mode
    • Added STASIS_TEST_METRO_SERIALIZE_TWICE environment variable to model dev-server rebuild scenarios
  • Documentation updates: Expanded comments in both metro.js and metro-transformer.js to explain the symmetric pass-through design and why permanent wiring is required.

Implementation Details

  • The serializer checks loadBundle early and returns immediately, before the one-shot guard that prevents re-serialization during capture
  • Load mode is explicitly exempt from the one-shot guard because it legitimately re-serializes per rebuild (nothing is captured, so the stale-capture hazard can't occur)
  • The transformer already had a pass-through pattern for non-load modes; this change makes the serializer symmetric
  • Test helper now supports invoking the serializer twice to verify both capture rejection and load pass-through behavior

https://claude.ai/code/session_01L5ALMiHBj7Z1iQynnRraKM

claude added 2 commits July 17, 2026 16:55
…erializer

The StasisMetro serializer threw on bundle=load, telling the user to remove
the plugin from their serializer config in load mode. That advice is
self-defeating: metro.config.js (and the plugin wiring it carries) is itself
attested at capture -- frozen in the bundle/lockfile -- so a load/frozen run
must execute the exact same config unedited. There is no mode-specific config
to remove the plugin from.

Make the serializer a transparent pass-through under bundle=load instead:
capture nothing, write nothing, delegate to the base serializer. This mirrors
the companion worker transformer (the load half), which is already a
pass-through in every non-load mode -- so both halves are wired permanently in
one committed metro.config.js and the mode (env / stasis.config.json) picks
the active one. The check sits BEFORE the one-shot capture guard: load
legitimately re-serializes per rebuild under a dev server, and the
stale-capture hazard that guard exists for can't arise when nothing is
captured.

Also fix the documented wiring: `transformerPath` is a TOP-LEVEL Metro config
key (metro-config defaults, sibling of `transformer`), not
`transformer.transformerPath` as the removed error message and the transformer
docs claimed -- a nested key is silently ignored by Metro, which would leave a
load run building from disk.

Tests: the bundle=load rejection test becomes a pass-through test (base output
produced twice under the new dev-server-rebuild knob, tampered disk ignored,
lockfile and bundle untouched), plus a companion test pinning that capture
still refuses a second serialization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5ALMiHBj7Z1iQynnRraKM
The rationale (config is attested at capture, so load runs it unedited) was
restated in full at every site; state it once in the class note and keep the
other sites to one-line pointers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5ALMiHBj7Z1iQynnRraKM
@ChALkeR
ChALkeR merged commit 8c7334e into main Jul 17, 2026
5 checks passed
exo-nikita pushed a commit that referenced this pull request Oct 2, 2026
… edges at bundle=load

Running a Metro build from a stasis bundle died in Metro's own resolver:
metro-resolver re-derives every resolution from the filesystem (Haste map +
node_modules walk + package.json redirects) in the main process, and none of
the stasis load machinery (ESM resolve hook, CJS _resolveFilename shim, --fs
readers) sits on that path. An attested-files-only tree carries the files
resolution LANDED ON at capture, not the alias layouts probing walks through
-- e.g. `@babel/runtime/helpers/interopRequireDefault` (injected by
transform-runtime into every transformed module, so the first bare specifier
any rebuild resolves) probes a `helpers/...` layout that was never loaded and
hence never attested, while the bundle carries both the resolved target's
bytes AND the exact (parent, specifier) -> file edge. Nothing consumed those
edges on the Metro side: UnableToResolveError with the answer sitting in the
bundle.

Add the missing consumer, `@exodus/stasis/metro-resolve-request` -- the RESOLUTION
half of load mode, completing the transformer (bytes) the same way webpack's
beforeResolve redirect and esbuild's onResolve already close this seam
in-process for their bundlers:

- `resolveRequest` / `createResolveRequest(base?)` implement Metro's stable
  `resolver.resolveRequest` contract, which runs exactly where Metro resolves
  (the main process). A recorded in-scope edge is served as
  `{ type: 'sourceFile' }` via State.resolveBundled -- the same
  condition-agnostic lookup the CJS require shim uses -- so resolution can't
  drift from what was attested and can't fail on layouts the attested tree
  doesn't carry. Everything else (out-of-scope origins, unrecorded edges --
  incl. asset imports, which carry no edge) defers to `base` or Metro's
  default resolver via `context.resolveRequest`; byte integrity stays with
  the worker transformer / getFile (this is an availability bridge, not a
  new trust gate).
- Scope mirrors the transformer and the loader's resolve-hook gate: full
  scope serves every in-root origin, node_modules scope only dependency
  origins. Inert outside bundle=load (a capture run must resolve naturally --
  that IS what gets recorded), so all three Metro pieces stay permanently
  wired in one committed metro.config.js per the #67 doctrine.
- State.resolveBundled gains a `{ platform }` option: a per-platform edge map
  (`stasis bundle --metro --platforms=...`) is resolved by the platform Metro
  hands each request, so one multi-platform artifact serves every platform it
  was built for; platform-less callers (the native-require shim) now treat
  such an edge as unserved and defer instead of crashing on a Map where a
  path is expected. The reserved `.stasis/empty-module.js` target (a
  browser/react-native `false` redirect; EMPTY_MODULE_PATH moves to
  stasis-core's artifact-util as the shared writer/reader constant) is translated to
  Metro's native `{ type: 'empty' }` so it never touches disk.

Covered by tests/metro-resolve-request.test.js (+ run helper, mirroring the
transformer suite; no Metro dependency): recorded edges served even with the
target deleted from disk, unrecorded edges deferred with arguments untouched,
out-of-root origins, node_modules-scope gating both ways, non-load
pass-through, per-platform edges from a real `--metro --platforms=ios,android`
artifact (ios/android hit; web / null platform defer; flat edge; empty-module
translation), base composition, and the no-delegate contract error.
Named metro-resolve-request (not metro-resolver) so it can't be confused
with `stasis bundle --metro --metro-resolver`, which statically resolves
through the project's own metro-resolver package (stasis/src/metro-resolver.js).
Cross-referenced from the metro serializer/transformer docs; exported from
both packages and pinned in public-exports.test.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hgm1DakWvP1hDUBAeZmuKo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants