diff --git a/.changeset/lynx-production-plugin-guarantee.md b/.changeset/lynx-production-plugin-guarantee.md
new file mode 100644
index 00000000..fc3de50d
--- /dev/null
+++ b/.changeset/lynx-production-plugin-guarantee.md
@@ -0,0 +1,34 @@
+---
+'@rozenite/lynx': minor
+'@rozenite/middleware': minor
+'@rozenite/tools': minor
+---
+
+Extend the [production guarantee](https://github.com/callstackincubator/rozenite/issues/492) landed
+for Metro and Re.Pack to Lynx: a `rspeedy build` now fails, naming the importing file, if it resolves
+into a Rozenite plugin package through anything other than that plugin's declared production entry.
+
+`@rozenite/lynx` gets the same app-side seam React Native has. Render `` (this package's
+`.` export) once, unconditionally, at your app root, and move plugin wiring into a `rozenite.dev.tsx`
+next to your `lynx.config.ts`. `rozeniteLynxPlugin()` redirects the seam to that file in development
+and to a shipped noop in production, using the same `RozeniteResolverPlugin` (from `@rozenite/middleware`)
+that Re.Pack installs — Metro, Re.Pack and Lynx now share one implementation of both the dev-entry
+redirect and the production guard.
+
+The guard also checks that a resolved plugin declares Lynx support in its manifest's `integrations`
+field: a plugin published only for React Native resolving into a Lynx bundle now fails the same way,
+naming the integrations it does declare.
+
+**Breaking:** `rozeniteLynxPlugin`'s device runtime moved from `@rozenite/lynx`'s root export to
+`@rozenite/lynx/runtime`. The root export is now the seam (``) instead, which must be
+side-effect-free so it can be rendered unconditionally in production. If you previously followed the
+manual fallback (`if (__DEV__) { require('@rozenite/lynx'); }`) for a non-rspeedy build pipeline,
+change it to `require('@rozenite/lynx/runtime')`. Apps that only ever used `rozeniteLynxPlugin()`'s
+automatic injection are unaffected.
+
+**Breaking:** `rozeniteLynxPlugin` no longer declares `apply: 'serve'`, so its resolver guard now runs
+during `rspeedy build` as well as `rspeedy dev` — this is the point of the change, but it means a
+plugin import that previously shipped silently into a Lynx release bundle now fails the build. Move
+plugin wiring into `rozenite.dev.tsx`, declare a `productionEntries` entry in the plugin's
+`rozenite.config.ts`, or pass `allowInProduction: ['some-plugin']` (logged loudly on every build) as an
+escape hatch.
diff --git a/apps/playground-lynx/README.md b/apps/playground-lynx/README.md
index 8a7c6805..0deae817 100644
--- a/apps/playground-lynx/README.md
+++ b/apps/playground-lynx/README.md
@@ -32,15 +32,22 @@ Edit `src/App.tsx` to see updates — the page hot-reloads as you save.
- [`@rozenite/lynx/rspeedy`](../../packages/lynx) is added to
`lynx.config.ts`. It discovers installed plugins, bridges Lynx's
- DebugRouter to the CDP dialect `@rozenite/app` speaks, and injects the
- device-side dispatcher plugins talk to — there is nothing to import in
- `src/index.tsx`.
+ DebugRouter to the CDP dialect `@rozenite/app` speaks, injects the
+ device-side dispatcher plugins talk to, and guards every build --
+ `rspeedy build` included -- against plugin code reaching a production
+ bundle.
+- `src/App.tsx` renders `` (`@rozenite/lynx`'s default export)
+ once, unconditionally, exactly where the plugin playgrounds below used to
+ render directly. In development this redirects to
+ [`rozenite.dev/`](./rozenite.dev); in production it resolves to a shipped
+ noop.
## Plugins
Every official plugin that declares `lynx` in its `rozenite.config.ts`
`integrations` is installed here, with a minimal playground under
-`src/plugins/`:
+[`rozenite.dev/`](./rozenite.dev) -- never in app source, so the resolver
+guard above can prove none of it reaches a release build:
| Plugin | Playground |
| ---------------------------------- | -------------------------------------------------------------- |
diff --git a/apps/playground-lynx/package.json b/apps/playground-lynx/package.json
index 04df3710..531c0782 100644
--- a/apps/playground-lynx/package.json
+++ b/apps/playground-lynx/package.json
@@ -14,6 +14,7 @@
"@lynx-js/react": "^0.125.0",
"@rozenite/controls-plugin": "workspace:*",
"@rozenite/feature-flags-plugin": "workspace:*",
+ "@rozenite/lynx": "workspace:*",
"@rozenite/rhf-plugin": "workspace:*",
"@rozenite/tanstack-query-plugin": "workspace:*",
"@tanstack/react-query": "^5.81.5",
@@ -25,7 +26,6 @@
"@lynx-js/react-rsbuild-plugin": "^0.19.1",
"@lynx-js/rspeedy": "^0.16.5",
"@lynx-js/types": "4.1.0",
- "@rozenite/lynx": "workspace:*",
"@rsbuild/plugin-type-check": "1.6.0",
"@types/react": "^19.2.18",
"eslint": "^9.25.0",
diff --git a/apps/playground-lynx/src/plugins/ControlsPlayground.tsx b/apps/playground-lynx/rozenite.dev/ControlsPlayground.tsx
similarity index 97%
rename from apps/playground-lynx/src/plugins/ControlsPlayground.tsx
rename to apps/playground-lynx/rozenite.dev/ControlsPlayground.tsx
index 22c3d980..ba610c0e 100644
--- a/apps/playground-lynx/src/plugins/ControlsPlayground.tsx
+++ b/apps/playground-lynx/rozenite.dev/ControlsPlayground.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from '@lynx-js/react';
import { createSection, useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
-import { Button, Group, Row } from '../ui.jsx';
+import { Button, Group, Row } from '../src/ui.jsx';
/**
* Minimal Controls playground: one section the DevTools panel can read and
diff --git a/apps/playground-lynx/src/plugins/FeatureFlagsPlayground.tsx b/apps/playground-lynx/rozenite.dev/FeatureFlagsPlayground.tsx
similarity index 97%
rename from apps/playground-lynx/src/plugins/FeatureFlagsPlayground.tsx
rename to apps/playground-lynx/rozenite.dev/FeatureFlagsPlayground.tsx
index 84c1310d..59861342 100644
--- a/apps/playground-lynx/src/plugins/FeatureFlagsPlayground.tsx
+++ b/apps/playground-lynx/rozenite.dev/FeatureFlagsPlayground.tsx
@@ -6,7 +6,7 @@ import {
type FeatureFlagInput,
} from '@rozenite/feature-flags-plugin';
-import { Group, Row } from '../ui.jsx';
+import { Group, Row } from '../src/ui.jsx';
const declarations: FeatureFlagInput[] = [
{ key: 'new-splash', value: true, type: 'boolean' },
diff --git a/apps/playground-lynx/src/plugins/RhfPlayground.tsx b/apps/playground-lynx/rozenite.dev/RhfPlayground.tsx
similarity index 97%
rename from apps/playground-lynx/src/plugins/RhfPlayground.tsx
rename to apps/playground-lynx/rozenite.dev/RhfPlayground.tsx
index 2d5fb0a3..80cfbcea 100644
--- a/apps/playground-lynx/src/plugins/RhfPlayground.tsx
+++ b/apps/playground-lynx/rozenite.dev/RhfPlayground.tsx
@@ -1,7 +1,7 @@
import { useController, useForm } from 'react-hook-form';
import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin';
-import { Group, Row } from '../ui.jsx';
+import { Group, Row } from '../src/ui.jsx';
type DemoForm = {
email: string;
diff --git a/apps/playground-lynx/src/plugins/TanStackQueryPlayground.tsx b/apps/playground-lynx/rozenite.dev/TanStackQueryPlayground.tsx
similarity index 95%
rename from apps/playground-lynx/src/plugins/TanStackQueryPlayground.tsx
rename to apps/playground-lynx/rozenite.dev/TanStackQueryPlayground.tsx
index 32856c72..34747035 100644
--- a/apps/playground-lynx/src/plugins/TanStackQueryPlayground.tsx
+++ b/apps/playground-lynx/rozenite.dev/TanStackQueryPlayground.tsx
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
-import { Button, Group, Row } from '../ui.jsx';
+import { Button, Group, Row } from '../src/ui.jsx';
const queryClient = new QueryClient();
diff --git a/apps/playground-lynx/rozenite.dev/index.tsx b/apps/playground-lynx/rozenite.dev/index.tsx
new file mode 100644
index 00000000..ce6cb628
--- /dev/null
+++ b/apps/playground-lynx/rozenite.dev/index.tsx
@@ -0,0 +1,33 @@
+import { ControlsPlayground } from './ControlsPlayground.jsx';
+import { FeatureFlagsPlayground } from './FeatureFlagsPlayground.jsx';
+import { RhfPlayground } from './RhfPlayground.jsx';
+import { TanStackQueryPlayground } from './TanStackQueryPlayground.jsx';
+
+/**
+ * The Lynx dev entry. `rozeniteLynxPlugin()` redirects
+ * `@rozenite/lynx`'s `` here in development; none of this is
+ * reachable in a production bundle.
+ *
+ * Every playground panel below both wires a plugin's DevTools hook and
+ * renders the on-device UI that shows a remote change taking effect, so the
+ * whole showcase lives under this `rozenite.dev/` directory rather than
+ * only the hook calls -- `` sits in `../src/App.tsx` exactly
+ * where these panels used to render directly. Keeping every panel as a
+ * sibling file *inside* `rozenite.dev/`, rather than importing them from
+ * `../src/plugins/`, matters beyond organisation: `RozeniteResolverPlugin`
+ * only skips its "move this into rozenite.dev.tsx" dev-time advisory for an
+ * importer whose own path has a `rozenite.dev` segment (see
+ * `isDevEntryOrigin` in `@rozenite/middleware`'s `production-guard.ts`) --
+ * an importer one directory outside it would warn on every plugin hook
+ * call below, even though production is unaffected either way.
+ */
+export default function RozeniteDevEntry() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/apps/playground-lynx/src/App.tsx b/apps/playground-lynx/src/App.tsx
index a2971327..7971a4cf 100644
--- a/apps/playground-lynx/src/App.tsx
+++ b/apps/playground-lynx/src/App.tsx
@@ -1,9 +1,6 @@
import './App.css';
+import Rozenite from '@rozenite/lynx';
import { RozeniteLogo } from './RozeniteLogo.jsx';
-import { ControlsPlayground } from './plugins/ControlsPlayground.jsx';
-import { FeatureFlagsPlayground } from './plugins/FeatureFlagsPlayground.jsx';
-import { RhfPlayground } from './plugins/RhfPlayground.jsx';
-import { TanStackQueryPlayground } from './plugins/TanStackQueryPlayground.jsx';
export function App() {
return (
@@ -19,10 +16,7 @@ export function App() {
-
-
-
-
+
);
}
diff --git a/apps/playground-lynx/src/tsconfig.json b/apps/playground-lynx/src/tsconfig.json
index f4c79579..395d3aca 100644
--- a/apps/playground-lynx/src/tsconfig.json
+++ b/apps/playground-lynx/src/tsconfig.json
@@ -11,5 +11,5 @@
"noEmit": true
},
- "include": ["./**/*.ts", "./**/*.tsx"]
+ "include": ["./**/*.ts", "./**/*.tsx", "../rozenite.dev/**/*.ts", "../rozenite.dev/**/*.tsx"]
}
diff --git a/docs/adr/0002-lynx-plugins-never-enter-production-bundles.md b/docs/adr/0002-lynx-plugins-never-enter-production-bundles.md
new file mode 100644
index 00000000..d573d55e
--- /dev/null
+++ b/docs/adr/0002-lynx-plugins-never-enter-production-bundles.md
@@ -0,0 +1,176 @@
+# 0002 — Rozenite plugins never enter Lynx production bundles
+
+**Status:** Accepted
+
+**Related:** [callstackincubator/rozenite#492](https://github.com/callstackincubator/rozenite/issues/492),
+[callstackincubator/rozenite#415](https://github.com/callstackincubator/rozenite/issues/415),
+[0001](./0001-plugins-never-enter-production-bundles.md)
+
+## Context
+
+ADR 0001 establishes, for React Native, that nothing from a Rozenite plugin
+reaches a production bundle unless its author declared it: apps mount
+`` from `@rozenite/react-native` once, all plugin wiring lives in
+`rozenite.dev.tsx`, the bundler resolver redirects the seam to that file in
+development and to a shipped noop in production, and a production build that
+resolves into a plugin package through anything other than a declared
+`productionEntries` subpath fails, naming the importing file. The rspack
+implementation of that resolver (`RozeniteResolverPlugin`) lives in
+`@rozenite/middleware` so that more than one bundler integration can install
+it.
+
+Lynx has none of this. Plugin device halves are imported straight from app
+code (`apps/playground-lynx/src/plugins/*` import `useRozeniteControlsPlugin`,
+`useTanStackQueryDevTools`, …), and the only thing keeping that code out of a
+release is each plugin's hand-written `react-native.ts` shim folding on
+`__DEV__` — inclusion is survivable, not impossible, and a third-party plugin
+exporting a hook from its package index defeats it entirely.
+
+The current rspeedy integration cannot close that gap:
+
+- `rozeniteLynxPlugin` (`packages/lynx/src/rspeedy.ts`) is `apply: 'serve'`
+ and additionally gated on `NODE_ENV`, so it never runs during
+ `rspeedy build`. That is correct for the dev server, the DebugRouter
+ transport and the runtime injection, but it means nothing observes a
+ production build at all.
+- The root export of `@rozenite/lynx` *is* the injected device runtime and
+ calls `setupRozenite()` at import time. The plugin injects it through
+ `source.preEntry`; the app never imports it.
+- `packages/test-utils` drives Metro only. Nothing can prove a Lynx release
+ bundle is clean.
+
+Two facts shape what the Lynx design can look like:
+
+- **Some plugins mount components at the root.** A bundler-injected entry
+ can register hooks, but it has no React tree to mount into. The seam
+ component is therefore required on Lynx as well, not just a React Native
+ workaround.
+- **ReactLynx runs effects on the background thread only.** Hooks inside a
+ dev entry rendered from the ReactLynx root are naturally background-only.
+ The main-thread inertness the runtime needs is already handled by the
+ `__BACKGROUND__` gate in `packages/lynx/src/install.ts` and does not need
+ to be repeated in a seam.
+
+## Decision
+
+Lynx gets the same DX and the same enforcement as React Native, through the
+same shared resolver plugin, with one package-shape change.
+
+### `` is exported from `@rozenite/lynx`
+
+`@rozenite/lynx` splits into side-effect-free-by-construction entries:
+
+| Entry | Contents |
+|---|---|
+| `@rozenite/lynx` | The seam: `` rendering a statically imported `./dev-entry.js` noop, mirroring `@rozenite/react-native`. React (via ReactLynx) is its only peer. Importing it does nothing. |
+| `@rozenite/lynx/runtime` | The injected device runtime (today's root export). `setupRozenite()` and the `__BACKGROUND__` gate live here. `rozeniteLynxPlugin` points `source.preEntry` at this subpath. |
+| `@rozenite/lynx/rspeedy` | Unchanged. |
+
+The seam cannot share the root entry with the runtime: an app imports the
+seam unconditionally, so a side-effectful root would install the dispatcher
+in every production build — the exact leak this ADR exists to prevent.
+
+The README's manual fallback for non-rspeedy pipelines
+(`if (__DEV__) require('@rozenite/lynx')`) moves to the `/runtime` subpath.
+This is the one user-visible break and gets its own changeset entry.
+
+### The rspeedy plugin installs the guard in both modes
+
+`rozeniteLynxPlugin` drops `apply: 'serve'`. Inside `setup`:
+
+- The dev server, middleware, DebugRouter transport and `preEntry` runtime
+ injection stay serve-only and `enabled`-gated, exactly as today.
+- The resolver guard is installed unconditionally — in `serve` and in
+ `build` — via `api.modifyRspackConfig`, by appending the shared
+ `RozeniteResolverPlugin` from `@rozenite/middleware`. `isDev` derives from
+ the Rsbuild mode, not `NODE_ENV`. `installDevEntryRedirect` is true only
+ when Rozenite is enabled.
+
+Semantics match Metro and Re.Pack: `enabled: false` means "no dev server,
+guard still active"; a production build that resolves into a Rozenite plugin
+package through anything but a declared `productionEntries` subpath fails,
+naming the importing file; the same mistake warns in development;
+`allowInProduction` is the escape hatch and is logged loudly.
+
+No new rspack mechanics are needed. `beforeResolve` for the dev-entry
+redirect and `afterResolve` plus `compilation.errors.push(new
+WebpackError(...))` for the guard were verified against rspack for Re.Pack,
+and Rsbuild leaves `normalModuleFactory` hooks intact.
+
+### `rozenite.dev.tsx` is identical
+
+Project root, resolved through `resolve.extensions`, flat file or
+`rozenite.dev/` directory.
+
+**Deferred:** `rozenite init` scaffolding this file and printing the mount
+snippet for Lynx projects, as this section originally promised, has not
+landed. `packages/cli`'s `init-command.ts` is entirely React-Native-shaped
+today -- Lynx-project detection, an rspeedy config wrapper, and a
+Lynx-flavoured mount snippet are all new work, not a small addition to the
+existing flow, and tracked separately rather than folded into this change.
+Everything else in this ADR -- the seam, the resolver guard, and the
+integration check -- does not depend on it: a Lynx project can adopt
+`rozeniteLynxPlugin()` and `rozenite.dev.tsx` today by hand, following this
+package's README, exactly as a React Native project could before `rozenite
+init` supported it.
+
+### Integration gating rides the same resolver
+
+`dist/rozenite.json` now carries `integrations`. The Lynx guard also refuses
+a plugin that does not declare `lynx` (or `lynx-web` for web targets):
+warning in development, error in production, same message shape as the
+production guard. A React Native-only plugin resolving into a Lynx bundle is
+a mistake the resolver can name just as well.
+
+### A rspeedy release-bundle bench
+
+`@rozenite/test-utils` gains a rspeedy counterpart to `bundleForRelease()`
+that builds a throwaway ReactLynx app in production mode and reports
+`rozeniteModules` / `panelModules` from emitted module paths, so the Lynx
+guard gets the same non-vacuous tests `docs/agents/release-bundle-testing.md`
+requires: a deliberate plugin import fails naming the file, a declared
+production entry succeeds, `enabled: false` still guards, and a clean app
+with `rozenite.dev.tsx` ships zero `rozenite.dev` modules and zero plugin
+`src/**`.
+
+## Consequences
+
+- App authors get one convention across React Native and Lynx: mount
+ `` once, wire plugins in `rozenite.dev.tsx`, never write a
+ `__DEV__` guard.
+- Plugin authors get nothing new to do. `productionEntries`,
+ `allowInProduction` and the manifest are shared, and `integrations` is
+ already populated.
+- `rozeniteLynxPlugin` runs (minimally) during `rspeedy build`. A clean
+ production build pays one memoized `package.json` walk per resolved
+ module; a dirty one fails instead of shipping.
+- The `@rozenite/lynx` root export changes meaning. Anyone who followed the
+ manual `require('@rozenite/lynx')` fallback must move to
+ `@rozenite/lynx/runtime`.
+- Verified during implementation: `@lynx-js/react` does not depend on
+ `react` at all -- it is its own implementation of the React runtime, not
+ an alias for it -- so the apparent `react` resolution in the playground
+ came from elsewhere in the workspace, not from `@lynx-js/react`. The Lynx
+ seam is therefore built against `@lynx-js/react`'s own `jsx-runtime`
+ (`jsxImportSource: '@lynx-js/react'` at build time, `@lynx-js/react`
+ external in `packages/lynx/vite.seam.config.ts`), not shared source with
+ `@rozenite/react-native`'s seam -- as anticipated above, this does not
+ change the decision, only which JSX runtime the built seam imports.
+
+## Alternatives considered
+
+- **Keep `rozeniteLynxPlugin` serve-only and ship a separate guard plugin.**
+ Rejected. A guard users can forget to install is the same weak link the
+ seam removes on the app side. The guard has value only if it is present in
+ the build the user did not think about.
+- **Inject `rozenite.dev.tsx` through `preEntry` instead of a seam.** Rejected.
+ It cannot mount root components, and it would give Lynx a different
+ convention from React Native, where injection is impossible (Metro has no
+ way to add artificial dependencies to an entry point; run-before-main-module
+ only reorders modules already in the graph).
+- **Export the seam from the existing root entry next to the runtime.**
+ Rejected: the root entry has import-time side effects, so the seam would
+ ship the dispatcher install to production.
+- **A `__DEV__`-folded seam instead of a resolver redirect.** Rejected for
+ the same reason as in 0001: a bare `require` inside a strict ES module is
+ fatal under rspack, and folding rests on transform order nothing pins.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index fdc4ac84..f565f856 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -22,3 +22,4 @@ Status values:
|---|---|---|
| [0000](./0000-single-target-discovery-endpoint.md) | One Rozenite endpoint for debug-target discovery | Accepted |
| [0001](./0001-plugins-never-enter-production-bundles.md) | Plugins never enter production bundles | Accepted |
+| [0002](./0002-lynx-plugins-never-enter-production-bundles.md) | Rozenite plugins never enter Lynx production bundles | Accepted |
diff --git a/docs/agents/release-bundle-testing.md b/docs/agents/release-bundle-testing.md
index 5844171e..ca361648 100644
--- a/docs/agents/release-bundle-testing.md
+++ b/docs/agents/release-bundle-testing.md
@@ -56,6 +56,55 @@ it(
- `files` replaces the fixture's sources when a case needs the app to
import something.
+## The rspeedy bench
+
+`bundleLynxForRelease()` is the same idea for Lynx: it creates a throwaway
+Lynx app in a temp directory, bundles it through rspeedy's JavaScript API
+(`createRspeedy` + `.build()`) with `mode: 'production'`, and reports what
+ended up inside, from rspack's own module graph rather than emitted source:
+
+```ts
+import { bundleLynxForRelease, RELEASE_BUNDLE_TIMEOUT } from '@rozenite/test-utils';
+import { rozeniteLynxPlugin } from '../rspeedy.js';
+
+it(
+ 'fails when an app imports a Rozenite plugin directly',
+ async () => {
+ await expect(
+ bundleLynxForRelease({
+ resolveFrom: packageRoot,
+ files: {
+ 'src/index.js': "require('./App.js');\n",
+ 'src/App.js':
+ "import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';\nuseRozeniteControlsPlugin;\n",
+ },
+ plugins: [rozeniteLynxPlugin()],
+ }),
+ ).rejects.toThrow(/src.App\.js/);
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+);
+```
+
+- `plugins` is the app's full rspeedy plugin list, in order (typically
+ `[...pluginReactLynx(), rozeniteLynxPlugin(options)]`) -- there is no
+ `configureRspeedy`-style wrapper the way `withRozenite` wraps a Metro
+ config, because `rozeniteLynxPlugin()` is itself an ordinary
+ `RsbuildPlugin`.
+- `resolveFrom` means the same thing as in `bundleForRelease`, but matters
+ more here: under this monorepository's `nodeLinker: hoisted`, a workspace
+ package is symlinked into each of its *consumers'* `node_modules`, not
+ hoisted to the repository root, so a plugin used only to exercise the
+ guard (not a real dependency of `@rozenite/lynx` itself) resolves from the
+ fixture only because `resolveFrom` adds it to rspack's `resolve.modules`.
+- `.build()` rejects with a generic `Error('Rspack build failed.')` rather
+ than the actual message -- the bench captures the real one itself (via
+ `onAfterBuild`, whose `stats` carries it regardless of success) and
+ re-throws with it, so `.rejects.toThrow(/pattern/)` still works against
+ `RozeniteResolverPlugin`'s real error text.
+- See `packages/lynx/src/__tests__/release-bundle.test.ts` for the full
+ suite this pattern comes from.
+
## The two result fields
- `rozeniteModules` -- every module in the bundle that belongs to Rozenite:
diff --git a/packages/lynx/README.md b/packages/lynx/README.md
index cc409088..72cb5223 100644
--- a/packages/lynx/README.md
+++ b/packages/lynx/README.md
@@ -1,16 +1,22 @@

-### Rozenite for Lynx: one package for the device runtime and the rspeedy/Rsbuild dev-server plugin.
+### Rozenite for Lynx: one package for the app-side seam, the device runtime, and the rspeedy/Rsbuild dev-server plugin.
[![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![Chat][chat-badge]][chat] [![PRs Welcome][prs-welcome-badge]][prs-welcome]
-`@rozenite/lynx` brings Rozenite to [Lynx](https://lynxjs.org). It has two
+`@rozenite/lynx` brings Rozenite to [Lynx](https://lynxjs.org). It has three
entry points:
-- **`@rozenite/lynx`** (this package's default export) — the small
- device-side runtime that installs the global
+- **`@rozenite/lynx`** (this package's default export) — the app-side seam:
+ a `` component you render once, unconditionally, from your app
+ root. It ships a noop in production and no plugin code is ever included in
+ your bundle.
+- **`@rozenite/lynx/runtime`** — the device-side runtime that installs the
+ global
[`@rozenite/plugin-bridge`](https://www.npmjs.com/package/@rozenite/plugin-bridge)
- talks to on the device.
+ talks to on the device. You do not import this by hand; the plugin injects
+ it for you (see [How the runtime gets into your app](#how-the-runtime-gets-into-your-app)
+ below).
- **`@rozenite/lynx/rspeedy`** — an rspeedy/Rsbuild plugin that runs a small
dev server on top of your rspeedy/Rsbuild dev server, speaking Metro's
inspector dialect (`/json/list` and `/inspector/debug`) so the same
@@ -18,16 +24,14 @@ entry points:
unmodified. Underneath, it discovers Lynx apps over
[DebugRouter](https://github.com/lynx-family/lynx/tree/main/devtool) and
bridges DebugRouter's wire protocol to Chrome DevTools Protocol (CDP) on
- the fly.
-
-You only ever install this one package. **The plugin installs the device
-runtime for you** — see [How the runtime gets into your app](#how-the-runtime-gets-into-your-app)
-below — so there is nothing to import by hand in your app's own source.
+ the fly. It also guards every build — `rspeedy build` included — against
+ Rozenite plugin code reaching a production bundle.
## Features
- **One package, one install**: no separate dev/device split to keep in sync
-- **Zero manual wiring**: the plugin injects the device runtime for you, only in development — there is nothing to import, and so nothing to get wrong
+- **A production guarantee, not just a convention**: `rspeedy build` fails if it resolves into a Rozenite plugin package through anything other than that plugin's declared production entry — the same guarantee `@rozenite/metro` and `@rozenite/repack` give React Native
+- **Zero manual wiring for the runtime**: the plugin injects the device runtime for you, only in development — there is nothing to import, and so nothing to get wrong
- **Automatic Plugin Discovery**: discovers installed Rozenite plugins from your project's `package.json`, exactly as `@rozenite/metro` does for React Native
- **Metro-Compatible Dev Server**: serves `/json/list` and `/inspector/debug` so `@rozenite/app` needs no Lynx-specific code
- **DebugRouter Bridge**: discovers Lynx apps over USB and translates DebugRouter frames to and from CDP
@@ -57,8 +61,41 @@ export default defineConfig({
});
```
-That's the whole setup — no changes to `src/index.tsx` or any other app
-source are needed.
+Then mount the seam once, unconditionally, at your app root:
+
+```tsx
+// src/App.tsx
+import Rozenite from '@rozenite/lynx';
+
+export function App() {
+ return (
+
+
+ {/* ...the rest of your app */}
+
+ );
+}
+```
+
+Wire your plugins in a `rozenite.dev.tsx` file at your project root — never
+in app source:
+
+```tsx
+// rozenite.dev.tsx
+import { useRozeniteTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
+
+export default function RozeniteDevEntry() {
+ useRozeniteTanStackQueryDevTools(/* ... */);
+ return null;
+}
+```
+
+In development, `rozeniteLynxPlugin()` redirects ``'s internal
+import to `rozenite.dev.tsx`. In production it resolves to a shipped noop
+instead, and `rspeedy build` fails outright if any plugin import escaped
+into app source some other way — see
+[How the production guarantee works](#how-the-production-guarantee-works)
+below.
### With Custom Options
@@ -85,19 +122,17 @@ Start your rspeedy dev server as usual, plug in a Lynx app, and Rozenite will lo
`rozeniteLynxPlugin()` appends its own device runtime to Rsbuild's
[`source.preEntry`](https://rsbuild.rs/config/source/pre-entry) — the same
-runtime this package publishes at its `.` export — so it is bundled ahead of
-your app's own entry point automatically, satisfying the one ordering rule
-that matters: it must install `__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__` before
-any plugin's `useRozeniteDevToolsClient` runs.
-
-This only happens in development. The plugin's `setup` — where the injection
-runs — never executes during `rspeedy build` (Rsbuild only calls a plugin
-whose `apply` matches the current action, and this plugin declares
-`apply: 'serve'`), and is additionally gated by an `enabled` option that
-defaults to off whenever `NODE_ENV === 'production'`. Both guards would have
-to be defeated at once for the runtime to reach a production bundle, and
-neither is something your app's code can accidentally get wrong — there is
-no import for you to place correctly or forget.
+runtime this package publishes at its `./runtime` export — so it is bundled
+ahead of your app's own entry point automatically, satisfying the one
+ordering rule that matters: it must install
+`__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__` before any plugin's
+`useRozeniteDevToolsClient` runs.
+
+This only happens when the plugin is `enabled` (the default whenever
+`NODE_ENV !== 'production'`) *and* the current build is not `rspeedy build`.
+Both conditions are read once, up front, and gate every piece of dev-only
+wiring this plugin does — there is no import for you to place correctly or
+forget.
If you need the device runtime outside of that automatic wiring — for
example, a non-rspeedy build pipeline — you can still import it directly,
@@ -105,14 +140,33 @@ guarded for development:
```ts
if (__DEV__) {
- require('@rozenite/lynx');
+ require('@rozenite/lynx/runtime');
}
```
-Do **not** import `@rozenite/lynx` unguarded at your app's entry point. An
-unguarded `import '@rozenite/lynx'` ships the dispatcher (and the code that
-installs it) into your production bundle, since nothing about a static,
-side-effectful import can be stripped by the bundler on its own.
+Do **not** import `@rozenite/lynx/runtime` unguarded at your app's entry
+point. An unguarded `import '@rozenite/lynx/runtime'` ships the dispatcher
+(and the code that installs it) into your production bundle, since nothing
+about a static, side-effectful import can be stripped by the bundler on its
+own. `@rozenite/lynx`'s root export (``, the seam described
+above) is the one import that is always safe to leave in app source
+unconditionally.
+
+## How the production guarantee works
+
+`rozeniteLynxPlugin()` installs a resolver guard (`RozeniteResolverPlugin`,
+shared with `@rozenite/repack` via `@rozenite/middleware`) on every build,
+`rspeedy build` included — not just when the dev server runs. If a
+production build resolves into a Rozenite plugin package through anything
+other than that plugin's declared production entry point, the build fails,
+naming the file that imported it. The same mistake only warns in
+development. `allowInProduction` in `RozeniteLynxOptions` is the escape
+hatch, and every package listed there is logged loudly once per build.
+
+The guard also checks that a plugin declares Lynx support at all: a plugin
+built only for React Native (or one that has not declared any target)
+resolving into a Lynx bundle fails the same way, with a message naming the
+integrations the plugin *does* declare.
## Configuration
@@ -130,12 +184,14 @@ type RozeniteLynxOptions = {
enableIOS?: boolean; // Discover physical iOS devices over usbmux. Default: true
enableHarmony?: boolean; // Discover physical HarmonyOS devices. Default: false
enableDesktop?: boolean; // Discover targets on localhost, including simulators. Default: true
+ allowInProduction?: string[]; // Rozenite plugin packages allowed to bypass the production guard
};
```
**Options:**
- `enabled` - Whether to enable Rozenite (optional, defaults to disabled in production builds)
+- `allowInProduction` - Plugin package names allowed to bypass the production guard described in [How the production guarantee works](#how-the-production-guarantee-works) (optional; prefer declaring `productionEntries` in the plugin's `rozenite.config.ts` instead)
- `include` - Array of package names to explicitly include (optional)
- `exclude` - Array of package names to exclude from loading (optional)
- `destroyOnDetachPlugins` - Array of package names that should be destroyed when switching panels instead of maintaining their state (optional, by default all plugins persist their state)
@@ -173,7 +229,7 @@ For a package to be recognized as a Rozenite plugin, it must:
### No DevTools URL is logged
- Make sure the Lynx app is actually connected over USB, and DebugRouter can see it
-- Check that `enabled` was not explicitly set to `false`, and that you are not running a production build (`rozeniteLynxPlugin` never runs during `rspeedy build`)
+- Check that `enabled` was not explicitly set to `false`, and that you are not running a production build (the dev server and device runtime injection never run during `rspeedy build`, though the production guard still does)
### Device reconnects but DevTools disconnects
diff --git a/packages/lynx/package.json b/packages/lynx/package.json
index 261523b1..4dcdb39e 100644
--- a/packages/lynx/package.json
+++ b/packages/lynx/package.json
@@ -34,6 +34,11 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
+ "./runtime": {
+ "types": "./dist/runtime.d.ts",
+ "import": "./dist/runtime.js",
+ "require": "./dist/runtime.cjs"
+ },
"./rspeedy": {
"types": "./dist/rspeedy.d.ts",
"import": "./dist/rspeedy.js",
@@ -44,7 +49,7 @@
"access": "public"
},
"scripts": {
- "build": "vite build && vite build --config vite.rspeedy.config.ts",
+ "build": "vite build --config vite.seam.config.ts && vite build --config vite.dev-entry.config.ts && vite build && vite build --config vite.rspeedy.config.ts",
"typecheck": "tsc -p tsconfig.lib.json --noEmit",
"lint": "eslint .",
"test": "vitest --run --passWithNoTests"
@@ -58,11 +63,21 @@
"ws": "^8.18.3"
},
"devDependencies": {
+ "@lynx-js/react": "^0.125.0",
+ "@lynx-js/react-rsbuild-plugin": "^0.19.1",
+ "@lynx-js/rspeedy": "^0.16.5",
+ "@rozenite/controls-plugin": "workspace:*",
+ "@rozenite/feature-flags-plugin": "workspace:*",
+ "@rozenite/storage-plugin": "workspace:*",
+ "@rozenite/test-utils": "workspace:*",
"@rsbuild/core": "2.1.10",
"@types/express": "^5.0.3",
"@types/ws": "^8.18.1",
"vitest": "^4.0.18"
},
+ "peerDependencies": {
+ "@lynx-js/react": "*"
+ },
"engines": {
"node": ">=20.19.0"
}
diff --git a/packages/lynx/src/__tests__/dispatcher.test.ts b/packages/lynx/src/__tests__/dispatcher.test.ts
index 6954c51a..c6e48717 100644
--- a/packages/lynx/src/__tests__/dispatcher.test.ts
+++ b/packages/lynx/src/__tests__/dispatcher.test.ts
@@ -120,7 +120,7 @@ describe('setupRozenite (module install)', () => {
vi.stubGlobal('__BACKGROUND__', false);
stubLynxWithDevtool();
- await import('../index.js');
+ await import('../runtime.js');
expect((globalThis as Record)[GLOBAL_KEY]).toBeUndefined();
});
diff --git a/packages/lynx/src/__tests__/release-bundle.test.ts b/packages/lynx/src/__tests__/release-bundle.test.ts
new file mode 100644
index 00000000..6116c90e
--- /dev/null
+++ b/packages/lynx/src/__tests__/release-bundle.test.ts
@@ -0,0 +1,200 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { bundleLynxForRelease, RELEASE_BUNDLE_TIMEOUT } from '@rozenite/test-utils';
+import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin';
+import { describe, expect, it } from 'vitest';
+import { rozeniteLynxPlugin, type RozeniteLynxOptions } from '../rspeedy.js';
+
+const packageRoot = path.resolve(fileURLToPath(import.meta.url), '../../..');
+
+// The rspeedy/rspack equivalent of `packages/metro/src/__tests__/release-bundle.test.ts`,
+// per docs/agents/release-bundle-testing.md: the resolver's own decision
+// table, exercised through a real rspeedy release build rather than through
+// unit tests of `RozeniteResolverPlugin` alone. Its shared logic already
+// has unit coverage in `@rozenite/middleware`'s `production-guard.test.ts`;
+// what belongs here is proof that `rozeniteLynxPlugin` actually wires that
+// logic into a real `rspeedy build`.
+//
+// `enableDesktop`/`enableAndroid`/`enableIOS` are turned off in every case
+// below: the guard runs unconditionally regardless of `enabled`, but
+// leaving device discovery on would make every bundle in this suite spend
+// time scanning for USB/localhost DebugRouter targets it will never find.
+const bundle = (files: Record, options?: RozeniteLynxOptions) =>
+ bundleLynxForRelease({
+ files,
+ resolveFrom: packageRoot,
+ plugins: [
+ ...pluginReactLynx(),
+ rozeniteLynxPlugin({
+ enableAndroid: false,
+ enableIOS: false,
+ enableDesktop: false,
+ ...options,
+ }),
+ ],
+ });
+
+describe('rozeniteLynxPlugin in a release bundle', () => {
+ it(
+ 'fails when an app imports a Rozenite plugin directly, naming the importing file',
+ async () => {
+ const importingFile = path.join('src', 'App.js');
+
+ await expect(
+ bundle({
+ 'src/index.js': "require('./App.js');\n",
+ [importingFile]:
+ "import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';\nuseRozeniteControlsPlugin;\n",
+ }),
+ ).rejects.toThrow(new RegExp(importingFile.replace(/[/\\]/g, '.')));
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'fails when an app imports a plugin that does not declare Lynx support, naming the integrations it does declare',
+ async () => {
+ const importingFile = path.join('src', 'App.js');
+
+ // `@rozenite/storage-plugin` declares `integrations: ['react-native']`
+ // only (see `packages/storage-plugin/rozenite.config.ts`) -- a real
+ // React-Native-only plugin, not a fixture stand-in.
+ await expect(
+ bundle({
+ 'src/index.js': "require('./App.js');\n",
+ [importingFile]:
+ "import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';\nuseRozeniteStoragePlugin;\n",
+ }),
+ ).rejects.toThrow(/does not declare "lynx" support/);
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'succeeds when an app imports a declared production entry',
+ async () => {
+ const result = await bundle({
+ 'src/index.js':
+ "require('@rozenite/feature-flags-plugin/register');\nconsole.log('rozenite release bundle fixture');\n",
+ });
+
+ // Non-vacuous: the declared entry really did get bundled, and
+ // nothing beyond it -- no panel code -- came along with it.
+ expect(
+ result.rozeniteModules.some((modulePath) =>
+ /feature-flags-plugin\/dist\/react-native\/(cjs\/)?register\.js$/.test(modulePath),
+ ),
+ ).toBe(true);
+ expect(result.panelModules).toEqual([]);
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'still fails the violating import when rozeniteLynxPlugin is disabled',
+ async () => {
+ const importingFile = path.join('src', 'App.js');
+
+ await expect(
+ bundle(
+ {
+ 'src/index.js': "require('./App.js');\n",
+ [importingFile]:
+ "import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';\nuseRozeniteControlsPlugin;\n",
+ },
+ { enabled: false },
+ ),
+ ).rejects.toThrow(new RegExp(importingFile.replace(/[/\\]/g, '.')));
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'lets an undeclared import through when the plugin is listed in allowInProduction',
+ async () => {
+ const importingFile = path.join('src', 'App.js');
+
+ const result = await bundle(
+ {
+ 'src/index.js': "require('./App.js');\n",
+ [importingFile]:
+ "import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';\nuseRozeniteControlsPlugin;\n",
+ },
+ { allowInProduction: ['@rozenite/controls-plugin'] },
+ );
+
+ expect(result.rozeniteModules.length).toBeGreaterThan(0);
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'ships zero Rozenite modules for a clean app with no plugin imports',
+ async () => {
+ const result = await bundle({
+ 'src/index.js': "console.log('rozenite release bundle fixture');\n",
+ });
+
+ expect(result.rozeniteModules).toEqual([]);
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'ships zero rozenite.dev modules and zero plugin code for an app that renders with a real rozenite.dev.tsx present',
+ async () => {
+ // The one behaviour unique to the seam itself, not already covered by
+ // the guard-behaviour cases above (which are mirrored from Metro's
+ // suite and would pass even if `` unconditionally
+ // rendered its dev entry): a production build never installs the
+ // dev-entry redirect, so `` must resolve to the shipped
+ // noop and never even attempt to reach `rozenite.dev.tsx` -- even
+ // though that file exists right next to the fixture's entry and
+ // imports a real plugin hook.
+ //
+ // `enabled: true` is passed explicitly, the strongest way a caller
+ // could try to force the redirect on for a build. `bundle()`'s
+ // default already computes `false` for a `.build()` call on its own
+ // (`NODE_ENV==='production'`, set by rsbuild itself before this even
+ // runs -- see `build_build` in `@rsbuild/core`'s bundled source), so
+ // this specifically proves the redirect stays off even when that
+ // default is overridden -- defense in depth between `rspeedy.ts`'s
+ // own `enabled` computation and `RozeniteResolverPlugin`'s separate
+ // `isDev` check (from the compilation's actual mode, not `enabled`).
+ // `@rozenite/lynx` is not resolvable as a bare specifier from the
+ // fixture -- this package is not its own dependency (there's no
+ // `packages/lynx/node_modules/@rozenite/lynx` self-link) -- so the
+ // fixture requires its built seam by an absolute path instead,
+ // mirroring the pattern `docs/agents/release-bundle-testing.md`
+ // documents for "integrations that reference files by path".
+ const result = await bundle(
+ {
+ 'src/index.js': "require('./App.js');\n",
+ 'src/App.js':
+ `const Rozenite = require(${JSON.stringify(path.join(packageRoot, 'dist', 'index.cjs'))}).default;\n` +
+ 'module.exports = function App() { return Rozenite(); };\n',
+ 'rozenite.dev.tsx':
+ "import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';\n\n" +
+ 'export default function RozeniteDevEntry() {\n' +
+ ' useRozeniteControlsPlugin({ sections: [] });\n' +
+ ' return null;\n' +
+ '}\n',
+ },
+ { enabled: true },
+ );
+
+ // `@rozenite/lynx` itself (the seam) is expected in the bundle -- the
+ // app imports it on purpose, and the ADR's guarantee is about plugin
+ // code and `rozenite.dev`, not about the seam. `panelModules` (a
+ // stricter subset of `rozeniteModules`) is the right assertion, plus
+ // an explicit check that neither `rozenite.dev` nor the plugin the
+ // fixture's `rozenite.dev.tsx` imports made it into the bundle.
+ expect(result.panelModules).toEqual([]);
+ expect(result.modules.some((modulePath) => modulePath.includes('rozenite.dev'))).toBe(false);
+ expect(result.modules.some((modulePath) => modulePath.includes('controls-plugin'))).toBe(
+ false,
+ );
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+});
diff --git a/packages/lynx/src/__tests__/seam-cjs-interop.test.ts b/packages/lynx/src/__tests__/seam-cjs-interop.test.ts
new file mode 100644
index 00000000..ef803ac4
--- /dev/null
+++ b/packages/lynx/src/__tests__/seam-cjs-interop.test.ts
@@ -0,0 +1,78 @@
+import { mkdtempSync, rmSync, copyFileSync, writeFileSync } from 'node:fs';
+import Module from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// Regression test for a real bug an adversarial review caught and this
+// suite's `release-bundle.test.ts` did not: `bundleLynxForRelease` always
+// builds in production mode, where `enabled` (and therefore
+// `installDevEntryRedirect`) is always false, so it never exercises the
+// redirect this test targets.
+//
+// The built seam's CJS output (`dist/index.cjs`) must correctly unwrap
+// whichever of two different shapes `require('./dev-entry.cjs')` resolves
+// to, since `RozeniteResolverPlugin` (`@rozenite/middleware`) can rewrite
+// that exact request to a different module at resolve time:
+//
+// 1. This package's own shipped noop (built by `vite.dev-entry.config.ts`,
+// `module.exports = fn` -- no `.default`, no `__esModule` marker).
+// 2. The app's own `rozenite.dev.tsx`, once redirected there in
+// development -- compiled independently by rspack/webpack, whose CJS
+// interop wraps a default export as `{ default: fn, __esModule: true }`.
+//
+// This exercises the actual built `dist/index.cjs` (not the source
+// `index.tsx`) through Node's real CommonJS loader: the bug lives entirely
+// in what Rollup emits for the cross-module reference, invisible from the
+// TypeScript source, which is identical either way. A real sibling
+// `dev-entry.cjs` is written into a throwaway copy of `dist/` for each
+// case, rather than mocking Node's module resolution, so `require()`
+// behaves exactly as it would for a real consumer.
+const distDir = path.resolve(fileURLToPath(import.meta.url), '../../../dist');
+const seamCjsPath = path.join(distDir, 'index.cjs');
+
+type RozeniteSeam = () => { type: () => string };
+
+const renderWithDevEntryShape = (devEntrySource: string): string => {
+ // Scratch directory nested under `dist/`, not the OS temp dir: Node's
+ // module resolution for `@lynx-js/react/jsx-runtime` (a real, installed
+ // peer dependency, not something this test mocks) needs to walk up
+ // through this package's own `node_modules`, which an OS-temp-dir
+ // location would not be a descendant of.
+ const scratchDir = mkdtempSync(path.join(distDir, '.seam-cjs-interop-test-'));
+
+ try {
+ const scratchSeamPath = path.join(scratchDir, 'index.cjs');
+ copyFileSync(seamCjsPath, scratchSeamPath);
+ writeFileSync(path.join(scratchDir, 'dev-entry.cjs'), devEntrySource);
+
+ // `@lynx-js/react/jsx-runtime` resolves normally (it's a real,
+ // installed package) -- only `./dev-entry.cjs`, relative to the
+ // scratch copy of the seam, needs to be a fresh module each call.
+ delete (Module as unknown as { _cache: Record })._cache[scratchSeamPath];
+
+ const Rozenite = require(scratchSeamPath) as RozeniteSeam;
+ const element = Rozenite();
+
+ return element.type();
+ } finally {
+ rmSync(scratchDir, { recursive: true, force: true });
+ }
+};
+
+describe('the built seam (dist/index.cjs) unwraps its dev entry correctly', () => {
+ it("renders the component when require() resolves this package's own noop shape", () => {
+ const rendered = renderWithDevEntryShape('module.exports = () => "own-noop";');
+
+ expect(rendered).toBe('own-noop');
+ });
+
+ it('renders the component when require() resolves a redirected ES-module-interop shape', () => {
+ const rendered = renderWithDevEntryShape(
+ 'Object.defineProperty(exports, "__esModule", { value: true });\n' +
+ 'exports.default = () => "redirected-rozenite-dev";',
+ );
+
+ expect(rendered).toBe('redirected-rozenite-dev');
+ });
+});
diff --git a/packages/lynx/src/dev-entry.tsx b/packages/lynx/src/dev-entry.tsx
new file mode 100644
index 00000000..f7c1edce
--- /dev/null
+++ b/packages/lynx/src/dev-entry.tsx
@@ -0,0 +1,34 @@
+/**
+ * The shipped noop. This is what `` renders when nothing
+ * redirects the `./dev-entry.js` request made from `./index.tsx`.
+ *
+ * In development, `@rozenite/lynx/rspeedy`'s `rozeniteLynxPlugin` (via the
+ * shared `RozeniteResolverPlugin` from `@rozenite/middleware`) redirects
+ * that request to the app's `rozenite.dev` file. In production nothing
+ * redirects it, so this module is what ships -- which is why it must stay a
+ * plain `() => null` after `process.env.NODE_ENV` is folded, with no hooks,
+ * no imports and no plugin code behind it. Mirrors
+ * `@rozenite/react-native`'s `src/dev-entry.tsx` exactly, so the two seams
+ * cannot drift.
+ */
+
+let hasWarned = false;
+
+const RozeniteDevEntry = () => {
+ // Warning from the render body rather than an effect keeps the whole
+ // block foldable: in a production bundle this collapses to `() => null`,
+ // with no hook call left behind. `hasWarned` keeps a double render from
+ // logging twice.
+ if (process.env.NODE_ENV !== 'production' && !hasWarned) {
+ hasWarned = true;
+ console.warn(
+ '[Rozenite] rendered but no dev entry was found, so nothing was loaded.\n' +
+ ' Check that rozeniteLynxPlugin() is in your lynx.config.ts plugins, and\n' +
+ ' that rozenite.dev.tsx exists next to it.',
+ );
+ }
+
+ return null;
+};
+
+export default RozeniteDevEntry;
diff --git a/packages/lynx/src/index.ts b/packages/lynx/src/index.ts
deleted file mode 100644
index 9c85e8c3..00000000
--- a/packages/lynx/src/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * Rozenite's device-side runtime for Lynx. Importing this module installs
- * the `__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__` global that
- * `@rozenite/plugin-bridge` talks to on the device.
- *
- * ```ts
- * import '@rozenite/lynx';
- * ```
- *
- * Import it once, at the app's entry point, before any plugin's
- * `useRozeniteDevToolsClient` runs.
- */
-import { setupRozenite } from './install.js';
-
-export { setupRozenite } from './install.js';
-export type { FuseboxReactDevToolsDispatcher } from './dispatcher.js';
-
-setupRozenite();
diff --git a/packages/lynx/src/index.tsx b/packages/lynx/src/index.tsx
new file mode 100644
index 00000000..8f70481a
--- /dev/null
+++ b/packages/lynx/src/index.tsx
@@ -0,0 +1,40 @@
+import type { ReactElement } from '@lynx-js/react';
+import DevEntry from './dev-entry.js';
+
+/**
+ * The Rozenite app-side seam for Lynx. Render it unconditionally from your
+ * app root:
+ *
+ * ```tsx
+ * import Rozenite from '@rozenite/lynx';
+ *
+ *
+ * ```
+ *
+ * In development, `rozeniteLynxPlugin()` redirects the import below to your
+ * project's `rozenite.dev` file. In production it resolves to a shipped
+ * noop, and no plugin code is ever included in the bundle. Mirrors
+ * `@rozenite/react-native`'s `src/index.tsx` exactly, so the two seams
+ * cannot drift; see `@rozenite/lynx/runtime` for the device-side runtime
+ * this package also ships.
+ *
+ * `./dev-entry.js` is marked `external` in `vite.seam.config.ts` (built
+ * separately by `vite.dev-entry.config.ts`), rather than being a same-build
+ * Rollup entry this one statically imports. That is load-bearing, not
+ * incidental: `RozeniteResolverPlugin` (`@rozenite/middleware`) rewrites
+ * this exact request to a *different* module -- the app's own
+ * `rozenite.dev.tsx` -- at resolve time, and Rollup has no way to know
+ * that while bundling. Left as a same-build reference, Rollup's CJS output
+ * statically inlines a direct, un-interop'd access to whatever shape it
+ * knows *this build's own* `dev-entry.tsx` has (a bare `module.exports =
+ * fn`, since Rollup controls both sides) -- which is wrong once the
+ * request is redirected to a real ES module, compiled independently by
+ * rspack/webpack, whose CJS interop wraps a default export as `{ default:
+ * fn, __esModule: true }`. `external` makes Rollup treat the reference the
+ * way it treats any dependency it does not control: with a real runtime
+ * `__esModule` check before deciding whether to unwrap `.default`, which
+ * handles both shapes correctly.
+ */
+const Rozenite = (): ReactElement => ;
+
+export default Rozenite;
diff --git a/packages/lynx/src/rspeedy.ts b/packages/lynx/src/rspeedy.ts
index 9e0ea2f0..f34c8c2a 100644
--- a/packages/lynx/src/rspeedy.ts
+++ b/packages/lynx/src/rspeedy.ts
@@ -4,9 +4,8 @@
* `./rspeedy/server/` (the transport-agnostic `/json/list` +
* `/inspector/debug` HTTP/WS half) into a Lynx dev server — the same shape
* `@rozenite/metro`'s `withRozenite` gives Metro, and `@rozenite/repack`'s
- * `withRozenite` gives Re.Pack. It also injects `@rozenite/lynx`'s own `.`
- * export (the device runtime) into the app's bundle — see `RUNTIME_ENTRY`
- * below.
+ * `withRozenite` gives Re.Pack. It also injects `@rozenite/lynx/runtime`
+ * (the device runtime) into the app's bundle — see `RUNTIME_ENTRY` below.
*
* `@lynx-js/rspeedy` re-exports `RsbuildPlugin` from `@rsbuild/core`
* unchanged, so this is written directly against `@rsbuild/core`'s types
@@ -19,6 +18,7 @@ import type { RsbuildPlugin } from '@rsbuild/core';
import {
createScopedMiddleware,
initializeRozenite,
+ RozeniteResolverPlugin,
type RozeniteConfig,
} from '@rozenite/middleware';
import { logger } from '@rozenite/tools';
@@ -26,9 +26,9 @@ import { createLynxTransport } from './rspeedy/transport/index.js';
import { createRozeniteLynxServer, listInspectorTargets } from './rspeedy/server/index.js';
/**
- * The device runtime's own entry point (`@rozenite/lynx`'s `.` export —
- * see `packages/lynx/src/index.ts`), injected into the app's bundle below
- * via `source.preEntry` instead of asking the user to import it by hand.
+ * The device runtime's own entry point (`@rozenite/lynx/runtime` — see
+ * `packages/lynx/src/runtime.ts`), injected into the app's bundle below via
+ * `source.preEntry` instead of asking the user to import it by hand.
*
* This is a package *self-reference*: this module lives inside
* `@rozenite/lynx`, and Node resolves the specifier through this package's
@@ -36,6 +36,11 @@ import { createRozeniteLynxServer, listInspectorTargets } from './rspeedy/server
* It therefore needs no `node_modules` lookup and behaves identically
* whether the package is symlinked (pnpm workspace) or installed normally.
*
+ * The `/runtime` subpath, not the package root: the root export is
+ * ``, the app-side seam (`packages/lynx/src/index.tsx`), which
+ * must be side-effect-free so it can be rendered unconditionally in
+ * production. Only `/runtime` may install the dispatcher on import.
+ *
* `createRequire(import.meta.url)` rather than a bare `require.resolve`:
* `require` does not exist in an ESM module, and this package ships both
* ESM and CJS. Rollup shims `import.meta.url` in the `.cjs` output, so
@@ -43,13 +48,14 @@ import { createRozeniteLynxServer, listInspectorTargets } from './rspeedy/server
* `packages/repack/src/version-check.ts` and
* `./rspeedy/transport/connector.ts`.
*
- * Note this resolves the `require` condition, so it yields `dist/index.cjs`
- * rather than `dist/index.js`. That is fine — the value is only ever handed
- * to Rspack as an entry path, and it bundles either form. What matters is
- * that it is the same physical package the app would have imported itself.
+ * Note this resolves the `require` condition, so it yields
+ * `dist/runtime.cjs` rather than `dist/runtime.js`. That is fine — the
+ * value is only ever handed to Rspack as an entry path, and it bundles
+ * either form. What matters is that it is the same physical package the
+ * app would have imported itself.
*/
const require = createRequire(import.meta.url);
-const RUNTIME_ENTRY = require.resolve('@rozenite/lynx');
+const RUNTIME_ENTRY = require.resolve('@rozenite/lynx/runtime');
export type { LynxClient, LynxSession, DeviceFrame, LynxTransport } from './rspeedy/types.js';
export {
@@ -82,6 +88,19 @@ export type RozeniteLynxOptions = Omit (WILDCARD_HOSTS.has(host) ? 'l
export const rozeniteLynxPlugin = (options: RozeniteLynxOptions = {}): RsbuildPlugin => {
return {
name: PLUGIN_NAME,
- // This plugin only ever adds dev-server middleware and a WebSocket
- // route — neither exists during a production bundle (`rspeedy build`).
- // Restricting it to `serve` is a second, structural guard on top of
- // the `enabled` default below: even a caller who flips `enabled` on
- // unconditionally for every action still never runs this plugin's
- // `setup` during a production build.
- apply: 'serve',
+ // No `apply: 'serve'` here (unlike earlier versions of this plugin):
+ // the resolver guard below must run during `rspeedy build` too, or a
+ // production build is never observed at all (issue #492 / ADR 0002).
+ // `setup` therefore runs for every action; production safety comes
+ // from the two checks inside it, not from Rsbuild skipping the plugin.
setup: async (api) => {
+ const allowInProduction = options.allowInProduction ?? [];
+
+ if (allowInProduction.length > 0) {
+ logger.warn(
+ `allowInProduction is set for: ${allowInProduction.join(', ')}. ` +
+ 'Code from these Rozenite plugin package(s) may reach your production bundle -- ' +
+ 'this defeats the production guarantee for them. Prefer declaring productionEntries ' +
+ "in the plugin's rozenite.config.ts instead.",
+ );
+ }
+
// Mirrors `@rozenite/metro`'s direction of travel (see
- // `packages/metro/src/index.ts`), but landed here from the start
- // rather than as a follow-up: Rozenite must never turn itself on by
- // default in a production build. `apply: 'serve'` above already
- // rules out `rspeedy build`; this additionally covers a `dev`
- // server that a caller explicitly wants disabled outside local
- // development (e.g. a shared/staging Lynx dev server).
- const enabled = options.enabled ?? process.env.NODE_ENV !== 'production';
+ // `packages/metro/src/index.ts`): Rozenite must never turn itself on
+ // by default in a production build. `action === 'build'` covers
+ // `rspeedy build` even when a caller flips `enabled` on
+ // unconditionally for every action; the `enabled` half covers a
+ // `dev` server that a caller explicitly wants disabled outside local
+ // development (e.g. a shared/staging Lynx dev server). Read once so
+ // the whole plugin body sees one consistent answer regardless of
+ // when `api.context.action` happens to settle.
+ const enabled =
+ (options.enabled ?? process.env.NODE_ENV !== 'production') &&
+ api.context.action !== 'build';
+
+ // The guard is installed unconditionally -- in `build` as much as in
+ // `dev` -- because a production bundle must be checked even when
+ // nothing above wired a dev server into it. `enabled: false` (or a
+ // plain `rspeedy build`) means "no dev server, guard still active",
+ // exactly like `@rozenite/repack`'s `withRozenite`. `isDev` comes
+ // from Rsbuild's own resolved mode for this compilation, not
+ // `process.env.NODE_ENV`, since `rspeedy build` does not reliably
+ // set it before this config is resolved.
+ api.modifyRspackConfig((config, { isDev, environment }) => {
+ config.plugins.push(
+ new RozeniteResolverPlugin({
+ projectRoot: api.context.rootPath,
+ allowInProduction,
+ isDev,
+ installDevEntryRedirect: enabled,
+ // `environment.name` is this compilation's Rsbuild environment
+ // name -- `'lynx'` by default, or ending in `-web` for a web
+ // target (`@lynx-js/react-rsbuild-plugin` itself checks
+ // `environment.name.startsWith('lynx-')` for the same
+ // distinction). Falling back to `'lynx'` for anything else
+ // keeps this a pure addition: every environment name this
+ // plugin has ever been exercised against resolves the same way
+ // it did before this check existed.
+ targetIntegration: environment.name.endsWith('-web') ? 'lynx-web' : 'lynx',
+ setupFunctionName: 'rozeniteLynxPlugin()',
+ }),
+ );
+ });
+
if (!enabled) {
return;
}
@@ -188,13 +250,12 @@ export const rozeniteLynxPlugin = (options: RozeniteLynxOptions = {}): RsbuildPl
// needs: it must install `__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__`
// before any plugin's `useRozeniteDevToolsClient` can run.
//
- // This is structurally impossible to leak into production, not
- // just guarded against it: this whole `setup` callback only runs
- // when Rsbuild's plugin initializer resolves `apply` to `'serve'`
- // (see the comment on `apply: 'serve'` above), which never happens
- // for `rspeedy build`. There is no code path from here to a
- // production bundle — the previous approach asked every app to get
- // a `__DEV__` guard right by hand at its own entry point; this one
+ // This whole `modifyRsbuildConfig` callback sits behind the
+ // `enabled` check above, which is `false` for a plain `rspeedy
+ // build` regardless of what a caller passes — see that check's
+ // comment. There is no code path from here to a production
+ // bundle — the previous approach asked every app to get a
+ // `__DEV__` guard right by hand at its own entry point; this one
// removes the app's entry point from the equation entirely.
config.source ??= {};
const prevPreEntry = config.source.preEntry ?? [];
diff --git a/packages/lynx/src/runtime.ts b/packages/lynx/src/runtime.ts
new file mode 100644
index 00000000..bbf44c92
--- /dev/null
+++ b/packages/lynx/src/runtime.ts
@@ -0,0 +1,26 @@
+/**
+ * Rozenite's device-side runtime for Lynx. Importing this module installs
+ * the `__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__` global that
+ * `@rozenite/plugin-bridge` talks to on the device.
+ *
+ * ```ts
+ * import '@rozenite/lynx/runtime';
+ * ```
+ *
+ * `rozeniteLynxPlugin()` injects this module into the app's bundle via
+ * `source.preEntry` automatically -- most apps never import it by hand. It
+ * lives at this subpath, not `@rozenite/lynx`'s root, precisely so it can
+ * have this import-time side effect: the root export is ``, the
+ * app-side seam (`./index.tsx`), which must do nothing on its own so it can
+ * be rendered unconditionally, even in a production build.
+ *
+ * Import it once, at the app's entry point, before any plugin's
+ * `useRozeniteDevToolsClient` runs, if you are not using
+ * `rozeniteLynxPlugin()`'s automatic injection.
+ */
+import { setupRozenite } from './install.js';
+
+export { setupRozenite } from './install.js';
+export type { FuseboxReactDevToolsDispatcher } from './dispatcher.js';
+
+setupRozenite();
diff --git a/packages/lynx/tsconfig.lib.json b/packages/lynx/tsconfig.lib.json
index a970e695..1c944053 100644
--- a/packages/lynx/tsconfig.lib.json
+++ b/packages/lynx/tsconfig.lib.json
@@ -11,9 +11,11 @@
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
- "resolveJsonModule": true
+ "resolveJsonModule": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "@lynx-js/react"
},
- "include": ["src/**/*.ts"],
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
"references": [
{ "path": "../tools/tsconfig.lib.json" },
{ "path": "../middleware/tsconfig.lib.json" }
diff --git a/packages/lynx/vite.config.ts b/packages/lynx/vite.config.ts
index bdce9ab4..e4c87622 100644
--- a/packages/lynx/vite.config.ts
+++ b/packages/lynx/vite.config.ts
@@ -3,16 +3,23 @@ import { defineConfig } from 'vite';
import { resolve } from 'node:path';
import dts from 'vite-plugin-dts';
-// Builds the device-runtime entry (`.` — `src/index.ts` and friends). This
-// is a plain bundled build with no externals: the whole point of `.` is
-// that an app can `import '@rozenite/lynx'` with nothing more to install.
+// Builds the device-runtime entry (`./runtime` — `src/runtime.ts` and
+// friends: `install.ts`, `dispatcher.ts`, `lynx-devtool.ts`). This is a
+// plain bundled build with no externals: the whole point of this entry is
+// that an app can `import '@rozenite/lynx/runtime'` with nothing more to
+// install.
+//
+// It is NOT the `.` export -- that is the seam (`src/index.tsx`), built
+// separately by `vite.seam.config.ts` and run first (see this package's
+// `build` script) precisely because its default `emptyOutDir: true` must
+// not wipe out what this config and `vite.rspeedy.config.ts` produce.
+// `emptyOutDir: false` here is load-bearing for the same reason.
//
// `src/rspeedy/**` (the rspeedy/Rsbuild plugin, exported as `./rspeedy`) is
// built separately by `vite.rspeedy.config.ts` — see that file for why it
// needs a different build shape — and is excluded from the `.d.ts` output
// here so this config's declaration files stay limited to the runtime it
-// actually builds. Both configs are invoked from this package's single
-// `build` script (`vite build && vite build --config vite.rspeedy.config.ts`).
+// actually builds.
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/lynx',
@@ -20,13 +27,28 @@ export default defineConfig({
plugins: [
dts({
tsconfigPath: './tsconfig.lib.json',
- exclude: ['src/rspeedy.ts', 'src/rspeedy/**'],
+ // `src/__tests__/release-bundle.test.ts` imports `../rspeedy.js`
+ // (it exercises `rozeniteLynxPlugin` through a real rspeedy build),
+ // which crosses into the excluded `rspeedy.ts`/`rspeedy/**` island
+ // below -- vite-plugin-dts's program then refuses to resolve that
+ // import, since the file it points to was excluded from this
+ // build's file list. Excluding the test file alongside them keeps
+ // this build's declarations limited to the device runtime it
+ // actually builds, same as the other two exclusions.
+ exclude: [
+ 'src/index.tsx',
+ 'src/dev-entry.tsx',
+ 'src/rspeedy.ts',
+ 'src/rspeedy/**',
+ 'src/__tests__/release-bundle.test.ts',
+ ],
}),
],
build: {
+ emptyOutDir: false,
lib: {
- entry: resolve(__dirname, 'src/index.ts'),
- fileName: 'index',
+ entry: resolve(__dirname, 'src/runtime.ts'),
+ fileName: 'runtime',
formats: ['es', 'cjs'],
},
},
diff --git a/packages/lynx/vite.dev-entry.config.ts b/packages/lynx/vite.dev-entry.config.ts
new file mode 100644
index 00000000..2ddea7b8
--- /dev/null
+++ b/packages/lynx/vite.dev-entry.config.ts
@@ -0,0 +1,41 @@
+///
+import { defineConfig } from 'vite';
+import { resolve } from 'node:path';
+import dts from 'vite-plugin-dts';
+
+// Builds the shipped noop (`src/dev-entry.tsx`) as its own, genuinely
+// separate Rollup output -- deliberately NOT a second entry in
+// `vite.seam.config.ts`'s build, even though that would also produce a
+// `dist/dev-entry.js`. See `src/index.tsx`'s comment on its `./dev-entry.js`
+// import for why: `vite.seam.config.ts` marks that import `external`
+// specifically so Rollup treats it as a dependency it does not control
+// (real interop, checked at runtime) rather than a same-build reference it
+// can statically optimize away (wrong once `RozeniteResolverPlugin`
+// redirects the request elsewhere). That only works if this file is a
+// separate build Rollup has no visibility into while bundling the seam.
+//
+// `emptyOutDir: false`: this runs after `vite.seam.config.ts` in this
+// package's `build` script, and must not wipe out `dist/index.js`.
+export default defineConfig({
+ root: __dirname,
+ cacheDir: '../../node_modules/.vite/lynx-dev-entry',
+ base: './',
+ plugins: [
+ dts({
+ entryRoot: 'src',
+ include: ['src/dev-entry.tsx'],
+ tsconfigPath: './tsconfig.lib.json',
+ }),
+ ],
+ build: {
+ emptyOutDir: false,
+ lib: {
+ entry: resolve(__dirname, 'src/dev-entry.tsx'),
+ fileName: 'dev-entry',
+ formats: ['es', 'cjs'],
+ },
+ },
+ test: {
+ passWithNoTests: true,
+ },
+});
diff --git a/packages/lynx/vite.seam.config.ts b/packages/lynx/vite.seam.config.ts
new file mode 100644
index 00000000..20f3ddbc
--- /dev/null
+++ b/packages/lynx/vite.seam.config.ts
@@ -0,0 +1,151 @@
+///
+import { defineConfig } from 'vite';
+import { resolve } from 'node:path';
+import dts from 'vite-plugin-dts';
+
+// Builds the app-side seam (`.` — `src/index.tsx`), mirroring
+// `@rozenite/react-native`'s tsc-built seam but through this package's
+// existing Vite/Rollup pipeline instead of a fourth build tool.
+//
+// Three things make this build shape different from `vite.config.ts` (the
+// device-runtime build) and `vite.rspeedy.config.ts` (the Node-side
+// plugin):
+//
+// - JSX is compiled against `@lynx-js/react`, not `react`: ReactLynx is its
+// own implementation of the React runtime (it does not depend on the
+// `react` package at all), so a JSX pragma built for plain `react` would
+// emit an import that has nothing to do with what actually renders a
+// Lynx app. `apps/playground-lynx/src/tsconfig.json` sets the same
+// `jsx: 'react-jsx'` / `jsxImportSource: '@lynx-js/react'` pair for
+// hand-written app source; this build has to bake the same choice in at
+// publish time, because the seam ships prebuilt and no bundler transform
+// runs over `node_modules` to do it later.
+// - `@lynx-js/react` is external (a peer dependency, declared in
+// `package.json`), not bundled: the emitted `@lynx-js/react/jsx-runtime`
+// import must resolve to the *app's* copy so the seam's ``
+// is a real element in the app's own React tree, not a second, unrelated
+// instance of the runtime.
+// - `./dev-entry.js` is ALSO external, deliberately not a second entry
+// sharing this build (that was tried and reverted -- see below). It is
+// built separately by `vite.dev-entry.config.ts` and referenced here
+// only as a plain, externally-resolved specifier.
+//
+// Why not a second entry in this same config, the more obvious way to
+// get a real, separately-resolvable `dist/dev-entry.js`: `src/index.tsx`
+// imports it, and `RozeniteResolverPlugin` (`@rozenite/middleware`)
+// rewrites that exact `./dev-entry.js` request to a *different* module --
+// the app's own `rozenite.dev.tsx` -- at resolve time, which Rollup has
+// no way to know while bundling. Two same-build-entry shapes were tried
+// and both broke the redirect:
+// 1. `import DevEntry from './dev-entry.js'` (a same-build default
+// import): Rollup's CJS output statically inlines a direct,
+// un-interop'd reference to whatever shape it knows *this build's
+// own* `dev-entry.tsx` has (a bare `module.exports = fn`, since it
+// controls both sides). That reference is wrong once resolved
+// elsewhere: a real ES module compiled independently by
+// rspack/webpack wraps a default export as
+// `{ default: fn, __esModule: true }`, and the CJS build ends up
+// rendering that namespace object as the component instead of the
+// function inside it.
+// 2. `import * as DevEntryModule from './dev-entry.js'` plus a
+// hand-written runtime unwrap: this defeats Rollup's static
+// optimization, but a namespace import of a same-build entry makes
+// Rollup hoist the entry's contents into a *third*, hash-named
+// shared chunk (`dev-entry-.js`) that both `dist/index.js`
+// and `dist/dev-entry.js` import from -- so `dist/index.js` no
+// longer requests the literal `./dev-entry.js` string
+// `isSeamDevEntryRequest` (`@rozenite/middleware`'s
+// `production-guard.ts`) matches on at all, and the redirect never
+// fires, in development or production.
+// Marking it `external` sidesteps both: Rollup treats the reference the
+// way it treats any dependency it does not control, with a real runtime
+// `__esModule` check before deciding whether to unwrap `.default` (correct
+// for either shape), and the import specifier is left untouched in every
+// output format -- verified empirically: the CJS build's `require()` call
+// uses the literal `./dev-entry.js` string, not a `.cjs`-rewritten one,
+// because external requests are never extension-rewritten the way a
+// same-build chunk reference is.
+//
+// Runs first in this package's `build` script (`vite build --config
+// vite.seam.config.ts && vite build --config vite.dev-entry.config.ts &&
+// vite build && vite build --config vite.rspeedy.config.ts`), before the
+// three `emptyOutDir: false` builds that produce `dist/dev-entry.*`,
+// `dist/runtime.*` and `dist/rspeedy.*`.
+export default defineConfig({
+ root: __dirname,
+ cacheDir: '../../node_modules/.vite/lynx-seam',
+ base: './',
+ esbuild: {
+ jsx: 'automatic',
+ jsxImportSource: '@lynx-js/react',
+ },
+ plugins: [
+ dts({
+ entryRoot: 'src',
+ // `dev-entry.tsx` is included for type resolution only -- `index.tsx`
+ // imports it (for its type), even though `rollupOptions.external`
+ // below excludes it from bundling. `rollupTypes`/`include` here
+ // governs the TS program vite-plugin-dts builds, a separate concern
+ // from what Rollup bundles; without it, resolving `./dev-entry.js`'s
+ // type hits the same "file not listed in the program" error `vite.
+ // config.ts`'s `exclude` list works around from the other direction.
+ include: ['src/index.tsx', 'src/dev-entry.tsx'],
+ tsconfigPath: './tsconfig.lib.json',
+ }),
+ ],
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/index.tsx'),
+ },
+ rollupOptions: {
+ external: [/^@lynx-js\/react/, './dev-entry.js'],
+ // An explicit per-format `output` array (Vite ignores `lib.formats`
+ // once this is an array) rather than `lib.fileName`/`lib.formats`:
+ // this package's other builds put both formats' output in one flat
+ // `dist/` directory, distinguished only by extension (`.js` for ESM,
+ // `.cjs` for CJS) rather than by separate `esm/`/`cjs/` directories
+ // with their own `package.json` `"type"` marker the way
+ // `@rozenite/react-native`'s tsc build does. That means an external
+ // request emitted verbatim (Rollup's default for `external`) is
+ // wrong for the CJS output specifically: `require('./dev-entry.js')`
+ // would load `dist/dev-entry.js`, which is genuine ESM syntax (`export
+ // default`) and cannot be `require()`d. `paths` below remaps the
+ // external `./dev-entry.js` id to `./dev-entry.cjs` in the CJS
+ // output only, so each format's `require`/`import` points at the
+ // sibling file that is actually loadable as that format.
+ output: [
+ { format: 'es', entryFileNames: 'index.js' },
+ {
+ format: 'cjs',
+ entryFileNames: 'index.cjs',
+ exports: 'default',
+ // `id` here is the *resolved* external id (an absolute path, not
+ // the literal `'./dev-entry.js'` written in source) -- verified
+ // empirically: comparing against the literal specifier never
+ // matched, and the returned path is used as the `require()`
+ // string as-is, so it must be the bare relative form, not a
+ // full path.
+ paths: (id) => (id.endsWith('/dev-entry.js') ? './dev-entry.cjs' : id),
+ // Rollup's default `interop` ("default") assumes an external
+ // `require()`'s result *is* the default export and accesses it
+ // directly, with no `.default` unwrap at all -- verified
+ // empirically (the emitted code used the required value as-is).
+ // That is wrong here for the same reason a plain default import
+ // was wrong for a same-build reference: whichever module
+ // `./dev-entry.cjs` actually resolves to at runtime may be this
+ // package's own noop (a bare `module.exports = fn`, no
+ // `.default`) or the app's `rozenite.dev.tsx`, redirected there
+ // by `RozeniteResolverPlugin` and compiled independently by
+ // rspack/webpack (`{ default: fn, __esModule: true }`). `'auto'`
+ // is the one interop mode that checks `__esModule` at runtime
+ // and unwraps `.default` only when it is actually set, which is
+ // correct for both shapes.
+ interop: 'auto',
+ },
+ ],
+ },
+ },
+ test: {
+ passWithNoTests: true,
+ },
+});
diff --git a/packages/middleware/src/__tests__/production-guard.test.ts b/packages/middleware/src/__tests__/production-guard.test.ts
index 3657599b..55a378d8 100644
--- a/packages/middleware/src/__tests__/production-guard.test.ts
+++ b/packages/middleware/src/__tests__/production-guard.test.ts
@@ -8,6 +8,8 @@ import {
isSeamDevEntryRequest,
formatProductionGuardError,
formatDevAdvisory,
+ formatIntegrationMismatchError,
+ formatIntegrationMismatchAdvisory,
warnOnceForImport,
getDevEntrySpecifier,
} from '../production-guard.js';
@@ -133,6 +135,42 @@ describe('findRozenitePluginForFile', () => {
expect(plugin?.productionEntries).toEqual([]);
});
+ it('reads integrations out of the manifest', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/lynx-only', {
+ hasManifest: true,
+ manifestContents: { integrations: ['lynx', 'lynx-web'] },
+ });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+ const plugin = findRozenitePluginForFile(filePath);
+
+ expect(plugin?.integrations).toEqual(['lynx', 'lynx-web']);
+ });
+
+ it('defaults integrations to react-native when the manifest declares none', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/unlabeled', { hasManifest: true });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+ const plugin = findRozenitePluginForFile(filePath);
+
+ expect(plugin?.integrations).toEqual(['react-native']);
+ });
+
+ it('drops unrecognised integration ids and falls back to react-native if none survive', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/bogus-integrations', {
+ hasManifest: true,
+ manifestContents: { integrations: ['not-a-real-integration', 42] },
+ });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+ const plugin = findRozenitePluginForFile(filePath);
+
+ expect(plugin?.integrations).toEqual(['react-native']);
+ });
+
it('memoizes per directory while the manifest is unchanged', () => {
const packageRoot = createTempDir();
createPackage(packageRoot, '@acme/memoized', { hasManifest: true });
@@ -232,6 +270,31 @@ describe('isSeamDevEntryRequest', () => {
expect(isSeamDevEntryRequest(originModulePath, './something-else.js')).toBe(false);
});
+ it('matches the dev-entry specifier requested from inside the Lynx seam package', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@rozenite/lynx');
+
+ const originModulePath = path.join(packageRoot, 'dist', 'index.js');
+
+ expect(isSeamDevEntryRequest(originModulePath, './dev-entry.js')).toBe(true);
+ expect(isSeamDevEntryRequest(originModulePath, './dev-entry')).toBe(true);
+ });
+
+ // Regression: `@rozenite/lynx`'s Rollup-bundled seam rewrites its CJS
+ // chunk's `require()` to the sibling chunk's actual extension
+ // (`./dev-entry.cjs`), unlike `@rozenite/react-native`'s unbundled tsc
+ // build, which keeps the literal `./dev-entry.js` from source in both its
+ // ESM and CJS output. Missing this form silently drops the dev-entry
+ // redirect for every CJS consumer of the Lynx seam.
+ it('matches the .cjs dev-entry specifier a bundled CJS seam build emits', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@rozenite/lynx');
+
+ const originModulePath = path.join(packageRoot, 'dist', 'index.cjs');
+
+ expect(isSeamDevEntryRequest(originModulePath, './dev-entry.cjs')).toBe(true);
+ });
+
it('does not match when the seam package is not installed (origin outside it)', () => {
const packageRoot = createTempDir();
createPackage(packageRoot, '@acme/some-other-package');
@@ -249,6 +312,7 @@ describe('formatProductionGuardError', () => {
name: '@acme/some-plugin',
root: '/node_modules/@acme/some-plugin',
productionEntries: [],
+ integrations: ['react-native'],
},
importedFrom: '/project/src/screens/Settings.tsx',
projectRoot: '/project',
@@ -269,6 +333,7 @@ describe('formatProductionGuardError', () => {
name: '@acme/some-plugin',
root: '/node_modules/@acme/some-plugin',
productionEntries: [],
+ integrations: ['react-native'],
},
importedFrom: '/elsewhere/Settings.tsx',
projectRoot: '/project',
@@ -285,6 +350,7 @@ describe('formatDevAdvisory', () => {
name: '@rozenite/mmkv-plugin',
root: '/node_modules/@rozenite/mmkv-plugin',
productionEntries: [],
+ integrations: ['react-native'],
},
importedFrom: '/project/src/screens/Settings.tsx',
projectRoot: '/project',
@@ -297,6 +363,70 @@ describe('formatDevAdvisory', () => {
});
});
+describe('formatIntegrationMismatchError', () => {
+ it('matches the documented shape', () => {
+ const message = formatIntegrationMismatchError({
+ plugin: {
+ name: '@acme/rn-only-plugin',
+ root: '/node_modules/@acme/rn-only-plugin',
+ productionEntries: [],
+ integrations: ['react-native'],
+ },
+ importedFrom: '/project/rozenite.dev.tsx',
+ projectRoot: '/project',
+ targetIntegration: 'lynx',
+ });
+
+ const lines = message.split('\n');
+ expect(lines[0]).toBe(
+ '@acme/rn-only-plugin is a Rozenite plugin that does not declare "lynx" support.',
+ );
+ expect(lines[1]).toBe('Declared integrations: react-native.');
+ expect(lines[2]).toBe('Imported from: rozenite.dev.tsx');
+ // Names the escape hatch, defaulting to the Metro/Re.Pack spelling.
+ expect(lines[3]).toMatch(/allowInProduction/);
+ expect(lines[3]).toMatch(/withRozenite\(\)/);
+ });
+
+ it('names the caller-supplied setup function instead of the withRozenite() default', () => {
+ const message = formatIntegrationMismatchError({
+ plugin: {
+ name: '@acme/rn-only-plugin',
+ root: '/node_modules/@acme/rn-only-plugin',
+ productionEntries: [],
+ integrations: ['react-native'],
+ },
+ importedFrom: '/project/rozenite.dev.tsx',
+ projectRoot: '/project',
+ targetIntegration: 'lynx',
+ setupFunctionName: 'rozeniteLynxPlugin()',
+ });
+
+ expect(message.split('\n')[3]).toMatch(/rozeniteLynxPlugin\(\)/);
+ });
+});
+
+describe('formatIntegrationMismatchAdvisory', () => {
+ it('matches the documented shape', () => {
+ const message = formatIntegrationMismatchAdvisory({
+ plugin: {
+ name: '@acme/rn-only-plugin',
+ root: '/node_modules/@acme/rn-only-plugin',
+ productionEntries: [],
+ integrations: ['react-native'],
+ },
+ importedFrom: '/project/src/screens/Settings.tsx',
+ projectRoot: '/project',
+ targetIntegration: 'lynx',
+ });
+
+ expect(message).toBe(
+ 'warning: @acme/rn-only-plugin imported from src/screens/Settings.tsx, but it does not declare "lynx" support.\n' +
+ ' Declared integrations: react-native. This will fail your production build.',
+ );
+ });
+});
+
describe('warnOnceForImport', () => {
it('warns only once per key', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
diff --git a/packages/middleware/src/production-guard.ts b/packages/middleware/src/production-guard.ts
index 48994fe5..028be5e7 100644
--- a/packages/middleware/src/production-guard.ts
+++ b/packages/middleware/src/production-guard.ts
@@ -1,5 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
+import {
+ DEFAULT_PLUGIN_INTEGRATIONS,
+ isRozeniteIntegration,
+ type RozeniteIntegration,
+} from '@rozenite/tools';
import { ROZENITE_MANIFEST } from './constants.js';
import { logger } from './logger.js';
@@ -14,6 +19,13 @@ export type RozenitePluginPackage = {
root: string;
/** `productionEntries` as declared in `dist/rozenite.json`; `[]` when absent. */
productionEntries: string[];
+ /**
+ * `integrations` as declared in `dist/rozenite.json`; falls back to
+ * {@link DEFAULT_PLUGIN_INTEGRATIONS} (`['react-native']`) for a plugin
+ * published before this field existed, or one whose manifest doesn't
+ * carry a valid value.
+ */
+ integrations: RozeniteIntegration[];
};
type PluginLookupResult = RozenitePluginPackage | null;
@@ -74,6 +86,24 @@ const readProductionEntries = (manifestPath: string): string[] => {
);
};
+const readIntegrations = (manifestPath: string): RozeniteIntegration[] => {
+ const manifest = readJsonSafe(manifestPath);
+
+ if (
+ manifest === null ||
+ typeof manifest !== 'object' ||
+ !Array.isArray((manifest as Record).integrations)
+ ) {
+ return [...DEFAULT_PLUGIN_INTEGRATIONS];
+ }
+
+ const integrations = (manifest as { integrations: unknown[] }).integrations.filter(
+ isRozeniteIntegration,
+ );
+
+ return integrations.length > 0 ? integrations : [...DEFAULT_PLUGIN_INTEGRATIONS];
+};
+
const readPackageNameOrNull = (packageJsonPath: string): string | null => {
const packageJson = readJsonSafe(packageJsonPath);
const name =
@@ -128,6 +158,7 @@ const readPluginAtPackageRoot = (packageRoot: string): PluginLookupResult => {
name,
root: realpathSafe(packageRoot),
productionEntries: readProductionEntries(manifestPath),
+ integrations: readIntegrations(manifestPath),
};
};
@@ -197,13 +228,21 @@ const formatImportedFrom = (importedFrom: string, projectRoot: string): string =
return isInsideProjectRoot ? relative : importedFrom;
};
-/** The two user-facing messages, so Metro and Re.Pack cannot drift. */
+/** The two user-facing messages, so Metro, Re.Pack and Lynx cannot drift. */
export const formatProductionGuardError = (args: {
plugin: RozenitePluginPackage;
importedFrom: string;
projectRoot: string;
+ /**
+ * The function whose `allowInProduction` option bypasses this check --
+ * `withRozenite()` for Metro and Re.Pack, `rozeniteLynxPlugin()` for
+ * Lynx. Defaults to `withRozenite()`, the Metro/Re.Pack spelling, so
+ * existing callers that predate this option keep the exact message they
+ * already have tests and docs pinned to.
+ */
+ setupFunctionName?: string;
}): string => {
- const { plugin, importedFrom, projectRoot } = args;
+ const { plugin, importedFrom, projectRoot, setupFunctionName = 'withRozenite()' } = args;
const declaration =
plugin.productionEntries.length === 0
? 'declares no production entry points'
@@ -212,7 +251,45 @@ export const formatProductionGuardError = (args: {
return [
`${plugin.name} is a Rozenite plugin and ${declaration}.`,
`Imported from: ${formatImportedFrom(importedFrom, projectRoot)}`,
- `Move plugin wiring into rozenite.dev.tsx, or declare this file in productionEntries in rozenite.config.ts. To bypass this check for ${plugin.name} only, pass allowInProduction: ['${plugin.name}'] to withRozenite().`,
+ `Move plugin wiring into rozenite.dev.tsx, or declare this file in productionEntries in rozenite.config.ts. To bypass this check for ${plugin.name} only, pass allowInProduction: ['${plugin.name}'] to ${setupFunctionName}.`,
+ ].join('\n');
+};
+
+/** The two integration-mismatch messages, mirroring the production-guard pair above. */
+export const formatIntegrationMismatchError = (args: {
+ plugin: RozenitePluginPackage;
+ importedFrom: string;
+ projectRoot: string;
+ targetIntegration: RozeniteIntegration;
+ setupFunctionName?: string;
+}): string => {
+ const {
+ plugin,
+ importedFrom,
+ projectRoot,
+ targetIntegration,
+ setupFunctionName = 'withRozenite()',
+ } = args;
+
+ return [
+ `${plugin.name} is a Rozenite plugin that does not declare "${targetIntegration}" support.`,
+ `Declared integrations: ${plugin.integrations.join(', ')}.`,
+ `Imported from: ${formatImportedFrom(importedFrom, projectRoot)}`,
+ `This plugin was not built for this target and must not be bundled with it. If this is a false positive -- the plugin works fine here but has not declared it -- pass allowInProduction: ['${plugin.name}'] to ${setupFunctionName}.`,
+ ].join('\n');
+};
+
+export const formatIntegrationMismatchAdvisory = (args: {
+ plugin: RozenitePluginPackage;
+ importedFrom: string;
+ projectRoot: string;
+ targetIntegration: RozeniteIntegration;
+}): string => {
+ const { plugin, importedFrom, projectRoot, targetIntegration } = args;
+
+ return [
+ `warning: ${plugin.name} imported from ${formatImportedFrom(importedFrom, projectRoot)}, but it does not declare "${targetIntegration}" support.`,
+ ` Declared integrations: ${plugin.integrations.join(', ')}. This will fail your production build.`,
].join('\n');
};
@@ -248,12 +325,19 @@ export const getDevEntrySpecifier = (projectRoot: string): string => {
return path.join(projectRoot, 'rozenite.dev');
};
-const SEAM_PACKAGE_NAME = '@rozenite/react-native';
+// The app-side seam packages: `@rozenite/react-native` for React Native,
+// `@rozenite/lynx` for Lynx (its `.` export -- see `packages/lynx/src/index.tsx`).
+// Both ship the identical dev-entry-redirect shape, so one set covers them.
+const SEAM_PACKAGE_NAMES = new Set(['@rozenite/react-native', '@rozenite/lynx']);
-// The relative specifier `@rozenite/react-native`'s `src/index.tsx` emits for
-// its dev-entry seam (`import DevEntry from './dev-entry.js'`). Matched with
-// and without the extension since the CJS/ESM emit may differ.
-const SEAM_DEV_ENTRY_REQUESTS = new Set(['./dev-entry.js', './dev-entry']);
+// The relative specifier a seam's `index.tsx` emits for its dev-entry seam
+// (`import DevEntry from './dev-entry.js'`). Matched with and without the
+// extension since the CJS/ESM emit may differ -- `@rozenite/react-native`'s
+// unbundled tsc build keeps the literal `./dev-entry.js` from source in
+// both its ESM and CJS output, while `@rozenite/lynx`'s Rollup-bundled
+// build rewrites its CJS chunk's `require()` to the sibling chunk's actual
+// extension, `./dev-entry.cjs`.
+const SEAM_DEV_ENTRY_REQUESTS = new Set(['./dev-entry.js', './dev-entry', './dev-entry.cjs']);
// Separate cache from `pluginCache`: this walk answers "what package is this
// file inside", not "is this file inside a Rozenite plugin", and the seam
@@ -286,17 +370,19 @@ const findPackageNameForDirectory = (dir: string): string | null => {
};
/**
- * True when this request is the seam package (`@rozenite/react-native`)
- * asking for its shipped noop -- i.e. `originModulePath` resolves (by
- * walking up to its nearest package.json) to that package, and `request` is
- * its dev-entry specifier. "Seam not installed" (no such package.json found)
- * is "no match", never an error -- an app that does not use ``
- * must still build.
+ * True when this request is one of the seam packages (`@rozenite/react-native`,
+ * `@rozenite/lynx`) asking for its shipped noop -- i.e. `originModulePath`
+ * resolves (by walking up to its nearest package.json) to that package, and
+ * `request` is its dev-entry specifier. "Seam not installed" (no such
+ * package.json found) is "no match", never an error -- an app that does not
+ * use `` must still build.
*/
export const isSeamDevEntryRequest = (originModulePath: string, request: string): boolean => {
if (!SEAM_DEV_ENTRY_REQUESTS.has(request)) {
return false;
}
- return findPackageNameForDirectory(path.dirname(originModulePath)) === SEAM_PACKAGE_NAME;
+ const packageName = findPackageNameForDirectory(path.dirname(originModulePath));
+
+ return packageName !== null && SEAM_PACKAGE_NAMES.has(packageName);
};
diff --git a/packages/middleware/src/rspack-resolver-plugin.ts b/packages/middleware/src/rspack-resolver-plugin.ts
index 3d49c0d3..62a22d30 100644
--- a/packages/middleware/src/rspack-resolver-plugin.ts
+++ b/packages/middleware/src/rspack-resolver-plugin.ts
@@ -6,11 +6,13 @@ import {
isSeamDevEntryRequest,
formatProductionGuardError,
formatDevAdvisory,
+ formatIntegrationMismatchError,
+ formatIntegrationMismatchAdvisory,
warnOnceForImport,
getDevEntrySpecifier,
type RozenitePluginPackage,
} from './production-guard.js';
-import { logger } from '@rozenite/tools';
+import { logger, type RozeniteIntegration } from '@rozenite/tools';
// We intentionally do NOT import types (or values) from `@rspack/core` here.
// It's an optional peer dependency of `@callstack/repack` -- not guaranteed
@@ -130,6 +132,22 @@ export type RozeniteResolverPluginOptions = {
* (`enabled === false`).
*/
installDevEntryRedirect: boolean;
+ /**
+ * When set, a plugin resolved into this bundle must declare this
+ * integration in its manifest's `integrations` (see
+ * `@rozenite/tools/integration`), or the guard fails it the same way it
+ * fails an undeclared production entry. Omitted entirely (as `@rozenite/repack`
+ * does today) means no integration is checked -- this is additive, not a
+ * behaviour change for existing callers.
+ */
+ targetIntegration?: RozeniteIntegration;
+ /**
+ * The function whose `allowInProduction` option bypasses the production
+ * guard, named in its error message. `withRozenite()` for
+ * `@rozenite/repack`; `@rozenite/lynx` passes `rozeniteLynxPlugin()`.
+ * @default 'withRozenite()'
+ */
+ setupFunctionName?: string;
};
/**
@@ -369,6 +387,39 @@ export class RozeniteResolverPlugin {
return;
}
+ const { targetIntegration } = this.options;
+
+ if (targetIntegration && !plugin.integrations.includes(targetIntegration)) {
+ if (!this.options.isDev) {
+ const WebpackError = getWebpackErrorConstructor(compiler);
+ compilation.errors.push(
+ new WebpackError(
+ formatIntegrationMismatchError({
+ plugin,
+ importedFrom: originModulePath,
+ projectRoot: this.options.projectRoot,
+ targetIntegration,
+ setupFunctionName: this.options.setupFunctionName,
+ }),
+ ),
+ );
+ return;
+ }
+
+ if (!isDevEntryOrigin(originModulePath)) {
+ warnOnceForImport(
+ `${originModulePath}\0${plugin.name}\0integration`,
+ formatIntegrationMismatchAdvisory({
+ plugin,
+ importedFrom: originModulePath,
+ projectRoot: this.options.projectRoot,
+ targetIntegration,
+ }),
+ );
+ }
+ return;
+ }
+
let declaredEntryPaths: Set;
try {
@@ -396,6 +447,7 @@ export class RozeniteResolverPlugin {
plugin,
importedFrom: originModulePath,
projectRoot: this.options.projectRoot,
+ setupFunctionName: this.options.setupFunctionName,
}),
),
);
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index 7131aacb..0b68c30a 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -17,6 +17,7 @@
"test": "vitest --run --passWithNoTests"
},
"devDependencies": {
+ "@lynx-js/rspeedy": "^0.16.5",
"@react-native/metro-config": "~0.86.0",
"expo": "^57.0.7",
"metro": "0.84.4",
diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts
index c8b1faa0..4745966b 100644
--- a/packages/test-utils/src/index.ts
+++ b/packages/test-utils/src/index.ts
@@ -12,9 +12,16 @@ export {
isPanelModule,
getPanelModules,
} from './metro/rozenite-modules.js';
+export { bundleForRelease as bundleLynxForRelease } from './rspeedy/bundle-for-release.js';
+export type {
+ RspeedyReleaseBundle,
+ RspeedyReleaseBundleOptions,
+} from './rspeedy/bundle-for-release.js';
/**
* A cold Metro release build takes a few seconds; give suites using
* {@link bundleForRelease} a timeout that survives a loaded CI runner.
+ * `bundleLynxForRelease` (rspeedy) is comparably slow and uses the same
+ * budget.
*/
export const RELEASE_BUNDLE_TIMEOUT = 120_000;
diff --git a/packages/test-utils/src/rspeedy/bundle-for-release.ts b/packages/test-utils/src/rspeedy/bundle-for-release.ts
new file mode 100644
index 00000000..dcb4f017
--- /dev/null
+++ b/packages/test-utils/src/rspeedy/bundle-for-release.ts
@@ -0,0 +1,164 @@
+import path from 'node:path';
+import type { Config as RspeedyConfig, RsbuildPlugin } from '@lynx-js/rspeedy';
+import { monorepoRoot } from '../metro/paths.js';
+import { getPanelModules, getRozeniteModules } from '../metro/rozenite-modules.js';
+import { createRspeedyFixture } from './fixture.js';
+import { CollectModulesPlugin } from './collect-modules-plugin.js';
+
+export type RspeedyReleaseBundleOptions = {
+ /**
+ * Files written into the throwaway Lynx app, keyed by path relative to
+ * its root.
+ * @default a single `src/index.js` logging a line
+ */
+ files?: Record;
+ /**
+ * The app's plugins, applied in this order -- typically
+ * `[...pluginReactLynx(), rozeniteLynxPlugin(options)]`. This bench
+ * builds the `lynx` environment only (`environment: ['lynx']`, matching
+ * `createRspeedy`'s option of the same name), so a web-target plugin
+ * (`pluginReactLynx({ target: 'web' })`, `@rozenite/lynx-web`) has
+ * nothing to build here.
+ */
+ plugins: RsbuildPlugin[];
+ /**
+ * Directory whose `node_modules` is added to the fixture's resolution
+ * paths, mirroring `../metro/bundle-for-release.ts`'s option of the same
+ * name. Pass the root of the package under test (e.g. `packages/lynx`)
+ * so a workspace dependency it declares -- a Rozenite plugin used only
+ * to exercise the guard, say -- resolves from the fixture: under this
+ * monorepository's `nodeLinker: hoisted`, a workspace package is
+ * symlinked into each of its *consumers'* own `node_modules`, not
+ * hoisted to the repository root the way a third-party dependency is,
+ * so the fixture's symlink to the repository root alone
+ * (`createRspeedyFixture`) does not see it.
+ * @default process.cwd()
+ */
+ resolveFrom?: string;
+};
+
+export type RspeedyReleaseBundle = {
+ /** Absolute paths of every real module rspack put in the compilation. */
+ modules: string[];
+ /**
+ * The subset of `modules` that belongs to Rozenite, relative to the
+ * monorepository root. A release bundle must leave this empty.
+ */
+ rozeniteModules: string[];
+ /**
+ * The subset of `rozeniteModules` that belongs to a plugin's DevTools
+ * panel. This must be empty even when an app deliberately imports a
+ * plugin's device-side code.
+ */
+ panelModules: string[];
+};
+
+const DEFAULT_FILES = {
+ 'src/index.js': "console.log('rozenite release bundle fixture');\n",
+};
+
+type StatsErrorLike = { message?: string };
+
+/**
+ * Bundles a throwaway Lynx app in release mode through rspeedy's JavaScript
+ * API (`createRspeedy` + `.build()`) and reports what ended up inside --
+ * the rspeedy counterpart to `../metro/bundle-for-release.ts`'s
+ * `bundleForRelease`, needed for the same reason
+ * `docs/agents/release-bundle-testing.md` requires one per bundler
+ * integration: `result.modules` (and the `rozeniteModules`/`panelModules`
+ * derived from it) are read from rspack's own module graph, not grepped out
+ * of emitted source, so an injected leak that carries no `@rozenite` string
+ * in the bundle text still gets caught.
+ *
+ * `.build()` rejects with a generic `Error('Rspack build failed.')` on a
+ * compile error -- rsbuild logs the real message rather than attaching it
+ * to the rejection (verified against `@rsbuild/core`'s `build_build`). This
+ * function captures the real message itself, via `onAfterBuild` (which
+ * fires with populated `stats.errors` regardless of whether the build
+ * succeeded), and re-throws with it, so a caller can assert on the message
+ * `RozeniteResolverPlugin` actually produced -- e.g. the importing file's
+ * path -- exactly as the Metro bench's callers do.
+ */
+export const bundleForRelease = async ({
+ files = DEFAULT_FILES,
+ plugins,
+ resolveFrom = process.cwd(),
+}: RspeedyReleaseBundleOptions): Promise => {
+ const fixture = createRspeedyFixture(files);
+
+ try {
+ const modulePaths = new Set();
+ let capturedErrors: StatsErrorLike[] = [];
+
+ const collectorPlugin: RsbuildPlugin = {
+ name: 'rozenite-test-utils-collect-modules',
+ setup: (api) => {
+ api.modifyRspackConfig((config) => {
+ config.plugins.push(new CollectModulesPlugin(modulePaths));
+ config.resolve.modules = [
+ path.join(fixture.root, 'node_modules'),
+ path.join(resolveFrom, 'node_modules'),
+ path.join(monorepoRoot, 'node_modules'),
+ 'node_modules',
+ ];
+ });
+ api.onAfterBuild(({ stats }) => {
+ // `stats` is rspack's own `Stats | MultiStats`, not the trimmed
+ // `RsbuildStats` shape (`Pick`)
+ // -- it carries the actual per-error `message` only via
+ // `.toJson()`, not as a plain property.
+ const json = stats?.toJson({ all: false, errors: true }) as
+ | { errors?: StatsErrorLike[] }
+ | undefined;
+ capturedErrors = json?.errors ?? [];
+ });
+ },
+ };
+
+ const rspeedyConfig: RspeedyConfig = {
+ mode: 'production',
+ plugins: [...plugins, collectorPlugin],
+ // Silence rsbuild's own progress/summary output, and don't ask the
+ // fixture's throwaway package.json for browser targets it doesn't
+ // declare.
+ performance: { printFileSize: false },
+ };
+
+ const { createRspeedy } = await import('@lynx-js/rspeedy');
+
+ const rspeedy = await createRspeedy({
+ cwd: fixture.root,
+ rspeedyConfig,
+ environment: ['lynx'],
+ loadEnv: false,
+ });
+
+ try {
+ await rspeedy.build();
+ } catch (error) {
+ const detail = capturedErrors
+ .map((e) => e.message)
+ .filter((message): message is string => typeof message === 'string')
+ .join('\n');
+
+ throw detail.length > 0 ? new Error(detail) : error;
+ }
+
+ const modules = [...modulePaths]
+ // rspack reports loader-prefixed resources (`!`) for
+ // some virtual/generated modules; keep only real, absolute file
+ // paths so `getRozeniteModules`/`getPanelModules` (which do a plain
+ // `path.relative` against the monorepository root) never choke on a
+ // loader-request string that happens to contain `@rozenite`.
+ .filter((modulePath) => path.isAbsolute(modulePath) && !modulePath.includes('!'))
+ .sort();
+
+ return {
+ modules,
+ rozeniteModules: getRozeniteModules(modules),
+ panelModules: getPanelModules(modules),
+ };
+ } finally {
+ fixture.cleanup();
+ }
+};
diff --git a/packages/test-utils/src/rspeedy/collect-modules-plugin.ts b/packages/test-utils/src/rspeedy/collect-modules-plugin.ts
new file mode 100644
index 00000000..fccdd9fb
--- /dev/null
+++ b/packages/test-utils/src/rspeedy/collect-modules-plugin.ts
@@ -0,0 +1,45 @@
+// Structural types for the slice of the rspack `Compiler`/`Compilation`
+// surface this collector needs, mirroring the same approach (and the same
+// reasoning) as `@rozenite/middleware`'s `rspack-resolver-plugin.ts`: no
+// `@rspack/core` import, since this package doesn't otherwise depend on it
+// and the shapes below are stable across rspack versions.
+type Module = { resource?: string };
+
+type Compilation = { modules: Iterable };
+
+type Compiler = {
+ hooks: {
+ afterCompile: {
+ tap: (name: string, fn: (compilation: Compilation) => void) => void;
+ };
+ };
+};
+
+const PLUGIN_NAME = 'RozeniteCollectModulesPlugin';
+
+/**
+ * Collects the absolute resource path of every module rspack put in the
+ * compilation, the rspeedy/rspack equivalent of Metro's
+ * `serializer.processModuleFilter` instrumentation in
+ * `../metro/bundle-for-release.ts`. `afterCompile` (not `emit` or `done`)
+ * fires once per compilation with `compilation.modules` fully populated,
+ * before assets are written -- module identity here, not the emitted asset
+ * bytes, is what `bundleForRelease` reports.
+ */
+export class CollectModulesPlugin {
+ private readonly modulePaths: Set;
+
+ constructor(modulePaths: Set) {
+ this.modulePaths = modulePaths;
+ }
+
+ apply(compiler: Compiler): void {
+ compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => {
+ for (const module of compilation.modules) {
+ if (module.resource) {
+ this.modulePaths.add(module.resource);
+ }
+ }
+ });
+ }
+}
diff --git a/packages/test-utils/src/rspeedy/fixture.ts b/packages/test-utils/src/rspeedy/fixture.ts
new file mode 100644
index 00000000..66338633
--- /dev/null
+++ b/packages/test-utils/src/rspeedy/fixture.ts
@@ -0,0 +1,48 @@
+import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { monorepoRoot } from '../metro/paths.js';
+
+export type RspeedyFixture = {
+ /** Absolute, symlink-free path to the throwaway Lynx app. */
+ root: string;
+ cleanup: () => void;
+};
+
+const FIXTURE_PACKAGE_NAME = 'rozenite-release-bundle-fixture-lynx';
+
+/**
+ * Creates a throwaway Lynx app on disk, mirroring `../metro/fixture.ts`'s
+ * `createFixture` for rspeedy instead of Metro: `node_modules` is symlinked
+ * to the monorepository's hoisted `node_modules` so `@lynx-js/rspeedy`,
+ * `@lynx-js/react-rsbuild-plugin` and the Rozenite packages under test
+ * resolve from the fixture exactly as they would in a real app.
+ * `realpathSync` is required for the same reason as the Metro fixture:
+ * macOS hands out `/var/...` temp paths that are symlinks to
+ * `/private/var/...`, and rspack's watcher/resolver can disagree about
+ * which one a file lives under.
+ */
+export const createRspeedyFixture = (files: Record): RspeedyFixture => {
+ const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'rozenite-release-bundle-lynx-')));
+
+ writeFileSync(
+ path.join(root, 'package.json'),
+ JSON.stringify(
+ { name: FIXTURE_PACKAGE_NAME, version: '0.0.0', private: true, type: 'module' },
+ null,
+ 2,
+ ),
+ );
+ symlinkSync(path.join(monorepoRoot, 'node_modules'), path.join(root, 'node_modules'), 'dir');
+
+ for (const [relativePath, contents] of Object.entries(files)) {
+ const filePath = path.join(root, relativePath);
+ mkdirSync(path.dirname(filePath), { recursive: true });
+ writeFileSync(filePath, contents);
+ }
+
+ return {
+ root,
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
+ };
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dad3bb2c..5169d7e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -386,6 +386,9 @@ importers:
'@rozenite/feature-flags-plugin':
specifier: workspace:*
version: link:../../packages/feature-flags-plugin
+ '@rozenite/lynx':
+ specifier: workspace:*
+ version: link:../../packages/lynx
'@rozenite/rhf-plugin':
specifier: workspace:*
version: link:../../packages/rhf-plugin
@@ -414,9 +417,6 @@ importers:
'@lynx-js/types':
specifier: 4.1.0
version: 4.1.0
- '@rozenite/lynx':
- specifier: workspace:*
- version: link:../../packages/lynx
'@rsbuild/plugin-type-check':
specifier: 1.6.0
version: 1.6.0(@rsbuild/core@2.1.10)(@rspack/core@2.2.1(@swc/helpers@0.5.23))(typescript@6.0.3)
@@ -920,6 +920,27 @@ importers:
specifier: ^8.18.3
version: 8.21.2
devDependencies:
+ '@lynx-js/react':
+ specifier: ^0.125.0
+ version: 0.125.0(@lynx-js/types@4.1.0)(@types/react@19.2.18)
+ '@lynx-js/react-rsbuild-plugin':
+ specifier: ^0.19.1
+ version: 0.19.1(@lynx-js/react@0.125.0(@lynx-js/types@4.1.0)(@types/react@19.2.18))(@rsbuild/core@2.1.10)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.27.3)(lightningcss@1.32.0)(tslib@2.8.1)(webpack@5.105.4)
+ '@lynx-js/rspeedy':
+ specifier: ^0.16.5
+ version: 0.16.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rspack/core@2.2.1(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.27.3)(lightningcss@1.32.0)(supports-color@8.1.1)(typescript@5.8.3)(webpack@5.105.4)
+ '@rozenite/controls-plugin':
+ specifier: workspace:*
+ version: link:../controls-plugin
+ '@rozenite/feature-flags-plugin':
+ specifier: workspace:*
+ version: link:../feature-flags-plugin
+ '@rozenite/storage-plugin':
+ specifier: workspace:*
+ version: link:../storage-plugin
+ '@rozenite/test-utils':
+ specifier: workspace:*
+ version: link:../test-utils
'@rsbuild/core':
specifier: 2.1.10
version: 2.1.10
@@ -1823,6 +1844,9 @@ importers:
packages/test-utils:
devDependencies:
+ '@lynx-js/rspeedy':
+ specifier: ^0.16.5
+ version: 0.16.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rspack/core@2.2.1(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.27.3)(lightningcss@1.32.0)(supports-color@8.1.1)(typescript@5.8.3)(webpack@5.105.4)
'@react-native/metro-config':
specifier: ~0.86.0
version: 0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)
@@ -19564,6 +19588,30 @@ snapshots:
- lightningcss
- webpack
+ '@lynx-js/rspeedy@0.16.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rspack/core@2.2.1(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.27.3)(lightningcss@1.32.0)(supports-color@8.1.1)(typescript@5.8.3)(webpack@5.105.4)':
+ dependencies:
+ '@lynx-js/rsbuild-plugin': 0.0.3(@rsbuild/core@2.1.10)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.105.4)
+ '@rsbuild/core': 2.1.10
+ '@rsdoctor/rspack-plugin': 1.6.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rsbuild/core@2.1.10)(@rspack/core@2.2.1(@swc/helpers@0.5.23))(supports-color@8.1.1)(webpack@5.105.4)
+ optionalDependencies:
+ typescript: 5.8.3
+ transitivePeerDependencies:
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@module-federation/runtime-tools'
+ - '@parcel/css'
+ - '@rspack/core'
+ - '@swc/css'
+ - bufferutil
+ - clean-css
+ - core-js
+ - csso
+ - esbuild
+ - lightningcss
+ - supports-color
+ - utf-8-validate
+ - webpack
+
'@lynx-js/rspeedy@0.16.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@rspack/core@2.2.1(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.27.3)(lightningcss@1.32.0)(supports-color@8.1.1)(typescript@6.0.3)(webpack@5.105.4)':
dependencies:
'@lynx-js/rsbuild-plugin': 0.0.3(@rsbuild/core@2.1.10)(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.105.4)