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-banner](https://www.rozenite.dev/rozenite-banner.jpg) -### 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)