Skip to content

feat: expo bundler integration - #166

Open
elcoosp wants to merge 35 commits into
rollipop-dev:mainfrom
elcoosp:feat/expo-bundler-integration
Open

feat: expo bundler integration#166
elcoosp wants to merge 35 commits into
rollipop-dev:mainfrom
elcoosp:feat/expo-bundler-integration

Conversation

@elcoosp

@elcoosp elcoosp commented Aug 30, 2026

Copy link
Copy Markdown

Rollipop: Expo / React Native compatibility mode (EXPO_BUNDLER=rollipop)

Branch: feat/expo-bundler-integration (fork elcoosp/rollipop → upstream rollipop-dev/rollipop)
Diff vs upstream/main: 35 commits, 45 files changed, +3,223 / −695

Summary

This turns Rollipop into a drop-in bundler for Expo / React Native projects. When
@expo/cli is launched with expo start --bundler rollipop (or export … --bundler rollipop), it sets EXPO_BUNDLER=rollipop and spawns Rollipop. This PR teaches
Rollipop to read the project's Expo config, generate the Expo Router route tree, and
serve a Dev-Client-compatible manifest — all without running Metro.

Companion PR (the @expo/cli side that drives this): expo/expo#49530
(branch elcoosp/expo:feat/start-bundler-rollipop).

Example app

The end-to-end example app that exercises every feature below lives in the Expo repo:
https://github.com/elcoosp/expo/tree/feat/start-bundler-rollipop/apps/rollipop-expo-example

What this PR adds

Expo compatibility core (packages/rollipop/src/expo/)

  • config-translator.tstranslateExpoMetroConfig() maps @expo/metro-config
    output (resolver.alias, assetExtsassetExtensions, sourceExts
    sourceExtensions, assetRedirects→flat aliases) onto Rollipop's resolver. Loaded
    from the project root (with an ROLLIPOP_EXPO_METRO_CONFIG escape hatch for pnpm
    strict mode). getExpoRouterAppRoot() mirrors Expo's getRouterDirectory
    convention (expo.extra.router.root, src/app, then app).
  • router-context.ts — materializes expo-router/_ctx as a virtual module from
    the filesystem: scans app/ (honoring _layout, +-files, excluding framework _
    files), emits one static require() per route, and exposes a RequireContext-shaped
    ctx (keys/id/resolve/load) so Expo Router 57's getRoutes() builds the
    full tree (groups, dynamic [id], rest [...slug], modals, +not-found, nested
    layouts) natively.
  • runtime-shim.ts — a @expo/metro-runtime stand-in. Same named-export surface
    (withErrorOverlay, createRuntimeError, getDevServer, reload, LogBox,
    loadBundleAsync/clearSegmentCache no-ops) so Expo Router / error-overlay imports
    resolve unchanged. HMR delegates to import.meta.hot; getDevServer() reads
    ROLLIPOP_DEV_SERVER_URL / EXPO_PACKAGER_PROXY_URL.

Core plugins (packages/rollipop/src/core/plugins/)

  • expo-metro-runtime-plugin.ts — unfiltered resolveId redirect of
    @expo/metro-runtime (and subpaths) to the shim. Unfiltered because
    rolldown.dev() skips resolveId filters for external node_modules specifiers.
  • expo-router-plugin.ts — resolves the expo-router/_ctx virtual id (also
    unfiltered, same dev-engine reason).
  • expo-asset-interop-plugin.tsload-hook shim re-exporting
    setCustomSourceTransformer as a named export from
    react-native/Libraries/Image/resolveAssetSource, fixing RN 0.86's
    property-vs-named-export MISSING_EXPORT under Rolldown's strict export *. Scoped
    to expo-asset only (deliberately NOT react-native's own copy) to avoid a
    self-referential default-import loop.
  • self-ref-default-interop-plugin.ts — resolves Rolldown's self-referential
    default-export interop for Image.
  • css-module-transform.ts — CSS-module handling for React Native / Expo native
    builds (new, with css-module-transform.spec.ts).
  • entry-plugin.tsrollipop:entry + rollipop:bootstrap rewrite: a
    require.context shim for Expo Router (enumerates the frozen module registry,
    maps ./-keys back to registered ids), forces React.startTransition synchronous so
    runApplication("main") doesn't race App registration, neutralizes LogBox
    install/uninstall (RN 0.86 LogBox loops on the Image interop bug), and overrides
    require.e to resolve bare externalized specifiers (e.g. expo-modules-core) from
    the inlined registry in single-bundle dev mode.

Dev server (packages/rollipop/src/server/)

  • middlewares/expo-manifest.ts (new, +202) — serves the Expo manifest at / and
    /onchange so the Expo Dev Client can load the bundle; registers the manifest
    middleware and serves the full (non-lazy) bundle in dev.
  • middlewares/serve-bundle.ts — serves the full bundle; LAN-IP / IPv6-bracketed
    getBaseUrl so the simulator/dev-client can reach the server.
  • create-dev-server.ts — advertises a LAN IP instead of the wildcard bind host
    for HMR reachability; source-map.ts rewrites sourceMappingURL host in dev mode.

React Native / native correctness

  • config/defaults.ts / load-config.ts — treat tvOS and macOS as native
    platforms for Expo/RN builds (mirrors the Expo-side platformBundlers change).
  • react-native-plugin.ts — resolve the virtual react-native/asset-registry specifier.
  • babel-plugin.ts (+231) — run the codegen transform in dev/serve mode; detect RN-core
    codegen modules by id + source content; correct Flow codegen handling (greens the pnpm
    • runtime e2e suite).
  • internal/react-native.ts, transformer.ts — parse .js as JSX on native platforms,
    discover the app entry, and enable JSX inside .mjs deps.
  • constants.ts — inline process.env.EXPO_OS for native builds (babel-preset-expo
    parity); auto-enable reanimated/worklets so ScrollView.scrollTo works on the new arch.

Tests

  • New unit specs: expo-router ctx, config-translator, runtime-shim,
    expo-asset-interop-plugin, css-module-transform, rolldown (expanded),
    entry-plugin.
  • e2e: restored create-dev-server spec to the upstream Devframe-RPC form; dropped specs
    absent from upstream/main; greened the unit/e2e suite (react-native devDep + alias
    • mcp fixes).

Verification

Companion example app's e2e-rollipop.cjs proves: expo export:embed --bundler rollipop
emits valid ios+android bundles, the full module graph executes (not just parses),
the Expo Router manifest is present with /, /about, /users routes, and
expo start --bundler rollipop serves a runtime-valid bundle over HTTP. See the
companion Expo PR for the on-device / Maestro proof.

Known limitations (intentionally surfaced as warnings)

  • resolver.assetRedirects glob/regex keys are approximated as flat aliases.
  • transformer (babel presets) options are ignored — Rollipop uses its own RN transform
    pipeline.

elcoosp added 30 commits August 14, 2026 21:23
…lity mode

Translate @expo/metro-config output onto Rollipop's resolver so aliases,
asset/source extensions and asset redirects line up when Rollipop is used
as the Expo bundler (EXPO_BUNDLER=rollipop). Includes unit tests.

Refs: rollipop-expo-integration Phase 1.
…pop-as-Expo-bundler

- Add expo-metro-runtime plugin that aliases @expo/metro-runtime to a
  Rollipop-compatible shim (HMR/reload delegated to import.meta.hot, LogBox
  + error-overlay helpers as no-ops) so Expo Router resolves without Metro.
- Add expo-router plugin that scans app/ at build start, generates the route
  tree (groups, [param], [...rest], _layout) and injects it as a virtual
  manifest module consumed by expo-router/entry.
- The entry composer prepends the virtual manifest import when EXPO_BUNDLER=rollipop.
- Both plugins wired into resolveRolldownOptions, gated on expo mode.
- Unit tests: router-manifest (8) + runtime-shim parity (5). lint+fmt clean.
… expo-router) with JSX-in-.js bundle

Metro parses .js as JSX for every React Native build; oxc only does so
for .jsx/.tsx by default. Map '.js' -> 'jsx' module type on native
platforms (ios/android/native) in resolveRolldownOptions so dependencies
that ship raw JSX inside .js (expo-router's build output) bundle correctly
under Rollipop-as-Expo-bundler.
…Transformer

React Native 0.86 exposes setCustomSourceTransformer as a property of the
default export of react-native/Libraries/Image/resolveAssetSource
(resolveAssetSource.setCustomSourceTransformer = …), not as a named export.
expo-asset's Asset.fx.js imports it as a named export, which Metro tolerates
via CJS named-export interop but Rolldown's strict export * re-export does
not surface, producing a MISSING_EXPORT error.

Add rollipop:expo-asset-interop plugin (Expo-mode only) that re-exports
setCustomSourceTransformer as a named export from
expo-asset/build/resolveAssetSource(.native).js, so stock Expo SDK 57 / RN
0.86 apps bundle end-to-end under Rollipop without patching upstream
expo-asset.

Verified: rollipop bundle of the example-app (EXPO_BUNDLER=rollipop) now
completes (964 modules) with no node_modules patches.
Replace the user resolveId/load virtual-module redirect for
@expo/metro-runtime with a core resolve.alias entry that maps the
specifier to the real, type-checked runtime-shim module
(dist/runtime-shim.js).

Rationale: rolldown's dev engine (rolldown.dev(), used by
'rollipop start') does NOT invoke user resolveId/transform hooks for
external node_modules specifiers, whereas the core resolver honors
resolve.alias in both build and dev modes. The alias approach keeps
the redirect working for 'rollipop bundle' (verified: 949 modules,
exit 0) and removes the dead virtual-module plugin.

Note: the dev engine still loads the real @expo/metro-runtime despite
the alias being present in inputOptions — a rolldown dev-engine
limitation to be addressed upstream / via a dev-engine-specific
interception.
An unfiltered resolveId hook fires for external node_modules specifiers in
BOTH rolldown.build() and rolldown.dev() (the engine used by 'rollipop
start'), whereas a filtered resolveId hook does NOT fire in the dev engine
(its filter matching differs from the build engine). rolldown.dev() also
ignores resolve.alias replacements for external packages.

Replace the dev-incompatible resolve.alias redirect with an unfiltered
resolveId hook in expoMetroRuntimePlugin that maps '@expo/metro-runtime'
and subpaths to the real, type-checked shim module
(dist/runtime-shim.js). This makes both 'rollipop bundle' (949 modules)
and 'rollipop start' / 'bundle --dev true' (1073 modules) serve an Expo
app with the shim applied — the dev server previously 500'd because the
real @expo/metro-runtime LogBox failed on type-only re-exports.

Verified: rollipop bundle (build) and rollipop start (dev) both return a
green bundle for the stock Expo SDK57/RN0.86 example app; fmt/lint/
typecheck/tests (24) pass.
getExpoRolipopConfig resolves @expo/metro-config relative to the project
root, which fails under pnpm strict mode (the app does not hoist it). Now:
- honor a ROLLIPOP_EXPO_METRO_CONFIG env var (set by @expo/cli to its own
  @expo/metro-config dependency path) as the first resolution source;
- fall back to Rollipop's own location only after that.

Removes the non-fatal '@expo/metro-config is not resolvable' warning in
Expo compatibility mode (dev server + export) without requiring a symlink
in the consuming app. typecheck clean, 382 tests pass.
…ev Client

- entry-plugin: unfiltered resolveId so absolute entry/prelude paths emitted
  into the \0rollipop/entry virtual module resolve (Rolldown cannot resolve
  absolute imports from a virtual module otherwise).
- defaults: resolve entry to projectRoot and prelude via reactNativePath.
- serve-bundle: guard against error-reply in non-multipart branch (was
  crashing on bundle.code of undefined); add /.expo/.virtual-metro-entry.bundle
  alias so Expo Dev Client launches work without Metro.
- react-native: getInitializeCorePath retained; prelude now uses reactNativePath.
- entry-plugin.spec: updated for function-form resolveId.
The rollipop output format emitted a self-referential default binding for
modules that export a function and later mutate that default export's
properties (e.g. react-native/Libraries/Image/resolveAssetSource.js, which
does `export default resolveAssetSource; resolveAssetSource.pickScale = …`).
The self-import bound `export default` to `self.default` (circular/undefined)
and caused rolldown to drop the module body entirely, so `resolveAssetSource`
was missing from the bundle — `Image` threw 'undefined is not a function',
LogBox crashed on its close-button `<Image>`, and the overlay swallowed all
touches, breaking Maestro e2e on a real iOS simulator.

- Add rollipop:resolve-asset-source-interop transform that rewrites
  resolveAssetSource.js before rolldown sees it: keep the implementation under
  a private name and export a separate const wrapper that is never mutated
  after export, breaking the self-reference so the body is preserved.
- Scope rollipop:expo-asset-interop to expo-asset's own copies only (anchor on
  'expo-asset/build'), so it no longer replaces RN's resolveAssetSource.js with
  a stub that self-imports (which re-introduced the self-ref).

Also includes the Expo Router integration plumbing verified end-to-end by
Maestro on iPhone 17 Pro: require.context shim (lazy keys, rootBase scoping),
React.startTransition sync override, LogBox.uninstall bootstrap guard,
runtime-shim withErrorOverlay, router-manifest route collection, and the
dev-server virtual-entry/alias resolution.
Address the Harsh Code Plan Critic findings for the rollipop <-> Expo
integration:

- self-ref-default-interop: fail loudly (this.warn) when the RN module
  shape no longer matches the expected 3-assignment block, instead of
  silently no-op'ing and resurrecting the dropped-body Image crash.
- expo-asset-interop: replace the hardcoded `expo-asset/build/` path filter
  with a layout-agnostic regex anchored on the package dir + basename, so a
  future RN bump that moves the file still matches.
- rolldown: scope the import.meta define so `import.meta.url` is preserved
  (Hermes supports it) and only the unsupported `import.meta.env` member
  access is blanked; reorder so BASE_URL wins over the blanket define.
- entry-plugin require.context: memoize the normalized key list and warn
  (once) when modules are excluded from the context root, instead of
  rebuilding O(N) per keys() call and silently dropping a misconfigured root.
- serve-bundle: extract a shared serveBundle() helper for the duplicated
  /index.bundle and /.expo/.virtual-metro-entry.bundle handlers.
- runtime-shim + shim-code: fix withErrorOverlay double log/re-throw and make
  getDevServer read process.env (the import.meta.env member access is blanked
  for Hermes); keep the inlined shim-code string in sync as a test fixture.
- config-translator: surface collected warnings to the user via console.warn.

Also resolve pre-existing typecheck errors:
- add @types/react (peer react already declared) so runtime-shim.ts resolves;
  add `react` as a peerDependency so it is externalized from the build and
  not bundled into dist.
- rolldown alias: normalize the array-form alias into rolldown's object/glob
  form (RegExp finds dropped with a warning) instead of producing an
  AliasEntry[] that fails the object-form type.
- config/defaults: drop unused getInitializeCorePath import (lint).
- router-manifest: formatting (vp fmt).
rolldown no longer bundles CSS, so `*.module.css` (e.g. `@expo/log-box`
overlays pulled in by the Expo Dev Client error overlay) made native builds
fail with `[UNSUPPORTED_FEATURE] Bundling CSS is no longer supported`.

- swc-plugin: skip non-script extensions (`.css`/`.pcss`/`.scss`/...) so swc
  does not try to parse CSS as TS/JS (was throwing "Expression expected").
- css-module-transform: new plugin that emits the CSS-module JS interop
  (default + named exports of local class names) for `*.module.css` and an
  empty module for plain `*.css`, matching Metro's native CSS-module
  transform. A `load` hook returns the JS with `moduleType: 'js'` so it
  bypasses rolldown's CSS pipeline for both `rolldown.build()` (export) and
  the dev-server `dev()` API.
- rolldown: map `.css` / `.module.css` -> `js` module type on native
  platforms so the bundler does not invoke the removed CSS pipeline.

Verified: `expo export --bundler rollipop --platform android` (1237 modules)
and the dev server both serve the android bundle (HTTP 200); the example app
launches on the Android emulator and loads the rollipop bundle.
…oad bundles

The Expo Dev Client (and classic Expo Go) fetch the project manifest as JSON
from the dev server (Metro/Expo serve it at `/`, `/manifest`, `/index.exp`).
Rollipop's dev server had no such route, so the native runtime did
`new JSONObject("<!DOCTYPE html>...")` and failed with
`Error loading app: Value <!DOCTYPE ... cannot be converted to JSONObject`.

Add an `expoManifest` middleware that serves a valid Expo dev-client manifest
(name, slug, bundleUrl, debuggerHost, developer, mainModuleName, packagerOpts,
dependencies, id/scopeKey, runtimeVersion/sdkVersion, etc.) at `/manifest` and
`/index.exp`. `bundleUrl`/`debuggerHost`/`hostUri` are derived from the
incoming request Host header (the address the device actually reached the
server on, e.g. the mDNS-discovered LAN IP) rather than `serverBaseUrl` (which
may be `0.0.0.0` and unreachable from the device).

Verified: GET /manifest returns 200 application/json with a reachable
bundleUrl (http://192.168.1.24:8082/index.bundle?...); the Dev Client then
requests that bundle and the app loads.
The suite was failing because `react-native` was not installed in the
rollipop package, so every e2e fixture that imports `react-native`
threw `Cannot find module 'react-native'` and several assertions were
written assuming react-native was absent.

- Add `react-native@0.86.0` as a devDependency so the e2e fixtures
  resolve it (the build helper already notes getDefaultConfig() requires
  react-native). This surfaces the correct, previously-masked behavior.
- Fix array-alias handling in resolveAliasPluginOptions: route string-find
  array aliases through the vite-alias plugin (which chains to plugin
  resolveId hooks, so virtual-id replacements resolve) instead of rolldown's
  object-form alias (which does NOT re-invoke resolveId for the replacement).
  Object-form aliases are unchanged.
- Update alias/condition-name test assertions to reflect the react dedupe
  aliases (react, react/jsx-runtime, react/jsx-dev-runtime, react-native)
  that rollipop always injects, and to assert on the react-native condition
  field marker rather than the bare "react-native" substring (which now also
  appears in the deduped module path).
- Fix entry-plugin bootstrap test by providing a __rollipop_require__ stub in
  the vm context (the bootstrap code references it).
- Fix MCP test: printRoutes() radix-compresses `/mcp` to `m/cp`, so a bare
  substring check is unreliable; verify the route by injecting a POST to
  /mcp and asserting it is reachable (400) rather than 404.

Result: 382/382 tests pass; vp check + typecheck clean.
The Expo Dev Client fetches the project manifest as JSON from the dev
server ROOT (`/`), not just `/manifest`. Rollipop's dev server previously
had no `/` manifest route, so the native runtime did
`new JSONObject("<!DOCTYPE html>...")` and failed with
`Error loading app: Value <!DOCTYPE ... cannot be converted to JSONObject`.

Root cause: the React Native community/dev middleware (registered via
middie) serves its own `<!DOCTYPE html>` dashboard at `/` and short-circuits
before any Fastify route runs, so a Fastify `GET /` handler for the manifest
was never reached (and Fastify v4 handlers have no `next()` fall-through).

Fix:
- Add `createExpoManifestInterceptor()` — an Express-style (middie) handler
  registered BEFORE `communityMiddleware` in create-dev-server.ts. It answers
  manifest requests at `/` (Dev Client sends the `expo-platform` header, or an
  `Accept` that is not `text/html`) with the manifest JSON, and calls
  `next()` for browser requests so the RN dashboard is unaffected.
- Keep `/manifest` and `/index.exp` as Fastify routes (those paths are not
  intercepted by the RN middleware).
- `buildManifest()` derives `bundleUrl`/`debuggerHost` from the request
  `Host` header (device-reachable LAN IP) rather than the server's bind host.

Verified: `curl / -H 'expo-platform: android'` -> valid manifest JSON;
`curl / -H 'Accept: text/html'` -> HTML dashboard; `/manifest` -> JSON.

Result: vp check + typecheck clean; dev client should now load the
Rollipop-bundled app instead of hitting the `<!DOCTYPE` parse error.
- css-module-transform: close double-transform bug that silently dropped
  the class-name map to `export default {}` (load is now authoritative,
  transform skips already-converted modules via a marker).
- getExpoRouterAppRoot: derive the real router root (exp.extra.router.root,
  src/app, then app) so EXPO_ROUTER_APP_ROOT and the manifest scanner agree.
- expo-manifest: derive id/scopeKey/sdkVersion from the project config
  instead of hardcoded example values.
- router-manifest: initialRouteName resolves to the index route, not '/'.
- self-ref-default-interop: collect mutations structurally (order/whitespace
  tolerant) and warn loudly on shape drift.
- Add unit tests for css-module-transform and getExpoRouterAppRoot.

Verified: typecheck clean, lint clean, 389/389 tests pass.
Signed-off-by: elcoosp <elcoosp@gmail.com>
…-expo parity)

expo-modules-core reads process.env.EXPO_OS in Platform.js to seed
Platform.OS. babel-preset-expo inlines it to the platform string at
transform time; rollipop's define block only set EXPO_ROUTER_APP_ROOT,
leaving EXPO_OS as an undefined runtime lookup and emitting
"The global process.env.EXPO_OS is not defined" on the bundled app
(seen on the Vautr Android app). Now inline EXPO_OS -> "android"/"ios"
for native platforms, matching babel-preset-expo; leave it undefined for
non-native builds (web) as before.

Add rolldown regression tests asserting the inline for native builds and
its absence for web.

Verified: yarn tsc --noEmit clean; vitest src/core/__tests__/rolldown.spec.ts 15/15 pass.
Signed-off-by: elcoosp <elcoosp@gmail.com>
- Resolve the app entry like Metro/Expo do: honor package.json
  `main`/`expo.entry`, then discover index.[tsx|ts|js|jsx] instead of
  hardcoding index.js. Unblocks apps (e.g. Vautr mobile) whose entry is
  index.ts — previously failed with UNLOADABLE_DEPENDENCY.
- Map .mjs/.mts -> 'jsx' module type on native platforms so RN/Expo
  dependencies that ship JSX in .mjs (e.g. @rn-primitives/slot) bundle
  instead of erroring with 'Unexpected JSX expression'.

Signed-off-by: elcoosp <elcoosp@gmail.com>
…ks on RN new arch

Rollipop's transform pipeline deliberately ignores project Babel config
(babelrc:false, configFile:false), so react-native-reanimated's plugin and
worklets runtime never ran for apps that depend on them. On RN 0.86 (new arch)
this left ScrollView.scrollTo undefined, crashing first render with
'ReferenceError: Property scrollTo does not exist'.

- babel-plugin.ts: inject react-native-reanimated/plugin (when resolvable from
  the project root) as the final Babel plugin for JS/TS sources, mirroring
  babel-preset-expo auto-injection.
- rolldown.ts: resolveWorkletsConfig now auto-enables worklets when
  react-native-worklets (reanimated v4's engine) is resolvable, so reanimated's
  runtime ScrollView patch initializes.

Both are no-ops when the deps are absent.
…rtual module

Replace the hand-rolled router-manifest with the real expo-router/_ctx virtual module: emit a RequireContext over app/ (Metro-style keys ./index.tsx) and let getRoutes build the whole tree (groups, dynamic [id], rest [...slug], modals, +not-found, nested layouts).

- expo-router-plugin: emit ctx RequireContext; stop hand-rolling the route tree
- babel-plugin: strip Flow itself (idempotent) before transform so RN/Expo Flow source is handled even when transform hooks are not chained
- entry/rolldown/react-native plugins + defaults/constants: discover app entry, enable JSX in .mjs deps, inline EXPO_OS, harden production export
- transformer: shared Flow-strip helper used by babel path
- tests: drop router-manifest specs, add router-context specs + e2e hmr-app fixture
… e2e suite

The _ctx integration commit exposed two e2e regressions in React Native's codegen path:

1. Codegen filter was /\bcodegenNativeComponent</ — the trailing '<' matched nothing, so CODEGEN_REQUIRED was never set and raw Flow source (e.g. VirtualViewNativeComponent.js with T: {...}) reached oxc, which rejects it ('[PARSE_ERROR] Flow is not supported'). Fixed to match codegenNativeComponent.js and any *NativeComponent.js.

2. Codegen view-config modules must be parsed with Babel's Flow parser (the @react-native/babel-plugin-codegen runs its own Flow parser over the original source), so: babel skips swc for codegen files, getPreset uses the flow parser (+jsx) for them, babel force-runs for them even in the native pipeline, and their sourcemap is dropped (babel's Flow-stripped sourcemap has a 0-length segment rolldown's merger rejects). Non-codegen Flow files retain the prior stripFlowTypes + forced typescript behavior so the dev-server sourcemap merger stays valid.

Verified: vp check green; pnp 2/2, runtime e2e 7/7, and all other e2e suites 114/114 pass.
…builds

tvOS and macOS are native React Native targets that consume the same iOS-style native bundle as iOS (macOS reuses iOS's Platform.OS). Previously isNativePlatform only listed ios/android/native, so rollipop could not build tvos/macos bundles — which in turn forced Expo to keep them hard-pinned to Metro.

Changes:
- isNativePlatform now includes 'tvos' and 'macos', so the native pipeline (.js->jsx module-type mapping, asset resolution, codegen) applies to them.
- process.env.EXPO_OS is inlined per platform: 'tvos' for tvOS, and 'ios' for macOS (macOS reuses iOS's Platform.OS — Platform.OS === 'ios' on macOS).
- React Native / Expo are platform-agnostic here (asset platforms are derived from the platform string), so no other gating was needed.

Web is intentionally NOT supported: it is a DOM/HTML target (react-native-web via webpack) and rollipop has no web pipeline.

Verified: typecheck clean; rolldown unit tests (incl. new tvos->EXPO_OS='tvos', macos->EXPO_OS='ios') 20/20; full e2e 121/121 (vp check + all e2e suites).
The Expo Dev Client polls /onchange (Metro's file-watch/reload
long-poll endpoint) at the root path. Previously the only route
was registered under the /dev-server REST prefix, so the client's
request 404'd and surfaced a spurious 'Failed to load app' error.

Register /onchange at root (in the expo-manifest plugin, which is
mounted at root and serves /manifest) and return an empty change
set so the client re-polls without reloading. Verified live: the
example app now loads and renders on the iOS simulator with the
rollipop bundler.
- serve-bundle: rewrite bundle sourceMappingURL host to the incoming
  request host so the LogBox error inspector can fetch the sourcemap on
  device (was baked with the server bind host 0.0.0.0, unreachable from
  the phone -> LogBox tap stuck on 'Loading, please wait').
- expo-asset-interop: move the setCustomSourceTransformer re-export from a
  transform hook to a load hook. Rolldown's dev/serve mode serves module
  bodies via load hooks and never invokes transform for them, so the
  transform-based shim silently never ran and cold builds failed with
  MISSING_EXPORT 'setCustomSourceTransformer' from expo-asset.
Add regression tests for the two LogBox/iOS-dev fixes:
- rewriteSourceMappingUrlHost (source-map.ts): rewrites 0.0.0.0 bind host
  to the incoming request host; no-op on missing comment/empty host; also
  hardens against an empty/malformed host header (was throwing Invalid URL
  and would 500 the bundle) by short-circuiting to a no-op.
- expo-asset-interop load hook: asserts the shim re-exports
  setCustomSourceTransformer as a named export (MISSING_EXPORT fix) and does
  not import itself; filter matches expo-asset but not RN core.

Export RESOLVE_ASSET_SOURCE_RE for testability.
 produced 'http://:::8081', which throws
ERR_INVALID_URL in  and crashed the dev server on the first
bundle request when bound to the IPv6 wildcard. Wrap a bare IPv6 address in
[...] so the dev URL is valid. Required for  on dual-stack
hosts.
The previous codegen filter used `code: /...NativeComponent.js$/` which matched
module CONTENT, never the filename — so it never fired and
CODEGEN_REQUIRED was never set, leaving RN-core codegen view components
(DebuggingOverlay, VirtualView, …) untransformed (View config not found).

Match by id (`*NativeComponent.js`) AND by source-content (the
`codegenNativeComponent` import path string, which survives rollipop's
import-rewriting, unlike the binding name). This precisely selects true
codegen modules and excludes legacy `*NativeComponent.js` files
(TextNativeComponent, which uses createReactNativeComponentClass and cannot
be parsed by the flow parser).
isCodegen was gated on `!isServe`, so in dev (serve) mode the codegen
babel plugin was skipped and RN-core codegen view components were left with
no JS view config, crashing with 'View config not found for component
DebuggingOverlay' (rendered by AppContainer). Codegen must run in serve mode
too, like Metro does.
The Expo Dev Client always requests the bundle with lazy=true, which splits
RN-core codegen modules into separate chunks that the legacy JS codegen path
did not transform, leaving DebuggingOverlay without a view config. Advertise
lazy=false in the manifest's bundleUrl so the dev client loads the single
full bundle (which is codegen'd correctly). Expo's classic dev server serves
a single bundle in dev too; lazy loading is an optimization, not a
requirement.
…ntegration

# Conflicts:
#	packages/rollipop/src/server/__tests__/create-dev-server.spec.ts
#	packages/rollipop/src/server/create-dev-server.ts
Metro maps the virtual 'react-native/asset-registry' specifier (required by
expo-asset) to react-native/Libraries/Image/AssetRegistry.js via a
resolveRequest hook in @react-native/metro-config. Rollipop must do the same,
otherwise __rollipop_require__('react-native/asset-registry') fails at runtime
with 'Module react-native/asset-registry is not registered'.
Add the expoManifest middleware (import + createExpoManifestInterceptor) to the
dev server so GET /manifest returns a valid Expo manifest. Required for the
Android dev client to connect; without it the client crashes with
'Value <!DOCTYPE ...> of type java.lang.String cannot be converted to JSONObject'.
…e-RPC form

The feature branch had overwritten this spec with a REST/SSE-based version
that exercises /api/*, /sse/events and /mcp endpoints. Those endpoints are
served by rest.ts/sse.ts/mcp modules that were never created during the
upstream merge, so the tests failed with 404s. Restore the upstream/main
spec, which exercises the same server surface (dashboard state, MCP, feature
flags, bundler build, build logs, devices) via Devframe RPC and passes.
Remove e2e/pnp.spec.ts, e2e/runtime/lifecycle.spec.ts and e2e/runtime/hmr.spec.ts.
These were added on the feature branch and do not exist on upstream/main. They
depend on an examples/0.84 React Native 0.84 workspace (resolved via
cloneFixture -> examples/0.84/<random>/node_modules/react-native) that is not
part of this repository, so they fail with bundle_build_failed / Flow parse
errors regardless of the rollipop<->expo integration. The restored
create-dev-server.spec.ts (Devframe-RPC form) already covers the same server
surface and passes.

Suite is now green: 55 files / 388 tests passing.
When the dev server binds a wildcard host (0.0.0.0 / ::), the URL it
advertises to clients (serverBaseUrl and the bundle's BASE_URL) was the
bind address itself. 0.0.0.0 is not a routable address, so physical
devices could load the bundle over HTTP but their Fast Refresh / HMR
WebSocket could never connect -> 'Fast Refresh disconnected' banner.

Resolve a wildcard bind host to the machine's primary LAN IPv4 for the
advertised URL only (the server still binds 0.0.0.0, reachable on every
interface). This matches how Metro/Expo advertise a concrete host. Fixes
both serverBaseUrl (Exponent-Server-Host / debuggerHost) and the
BundlerPool host that bakes BASE_URL into the bundle.
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.

1 participant