From fab0568347d2cfa437c2b63b38dea3b9299eb3a9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 2 Sep 2026 11:57:42 +0200 Subject: [PATCH] feat(lynx): merge @rozenite/lynx-dev in and inject the device runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting Rozenite up for Lynx cost two installs and a hand-written import in the app's entry point. That import had to be `__DEV__`-guarded or the dispatcher shipped to production, and the README offered the unguarded form as an equally valid alternative. `@rozenite/lynx` now carries both halves: `.` stays the device runtime, `./rspeedy` is the plugin formerly published as `@rozenite/lynx-dev`. The plugin injects the runtime itself via `source.preEntry`, from inside a `setup` that `apply: 'serve'` already keeps out of `rspeedy build` — so the production leak is structurally impossible rather than documented against. `@rozenite/lynx-dev` stays as a deprecated re-export shim. Closes #488 --- .changeset/rozenite-lynx-merge.md | 17 + apps/playground-lynx/README.md | 11 +- apps/playground-lynx/lynx.config.ts | 2 +- apps/playground-lynx/package.json | 3 +- apps/playground-lynx/src/index.tsx | 4 - packages/lynx-dev/README.md | 136 +------- packages/lynx-dev/package.json | 12 +- packages/lynx-dev/src/index.ts | 265 +-------------- packages/lynx-dev/tsconfig.json | 2 +- packages/lynx-dev/tsconfig.lib.json | 11 +- packages/lynx-dev/vite.config.ts | 16 +- packages/lynx/README.md | 173 +++++++++- packages/lynx/package.json | 25 +- packages/lynx/src/rspeedy.ts | 311 ++++++++++++++++++ .../bridge/__tests__/close-reasons.test.ts | 0 .../__tests__/handshake-sequence.test.ts | 0 .../__tests__/translate-device-frame.test.ts | 0 .../__tests__/translate-host-message.test.ts | 0 .../src/rspeedy}/bridge/close-reasons.ts | 0 .../src => lynx/src/rspeedy}/bridge/index.ts | 0 .../rspeedy}/bridge/translate-device-frame.ts | 0 .../rspeedy}/bridge/translate-host-message.ts | 0 .../src => lynx/src/rspeedy}/bridge/types.ts | 0 .../src/rspeedy}/server/__tests__/fakes.ts | 0 .../server/__tests__/inspector-socket.test.ts | 0 .../server/__tests__/json-list.test.ts | 0 .../src => lynx/src/rspeedy}/server/index.ts | 0 .../src/rspeedy}/server/inspector-socket.ts | 0 .../src/rspeedy}/server/inspector-targets.ts | 0 .../src/rspeedy}/server/json-list.ts | 0 .../src/rspeedy}/server/logical-device-id.ts | 0 .../src/rspeedy}/server/request-origin.ts | 0 .../src/rspeedy}/transport/__tests__/fakes.ts | 0 .../transport/__tests__/index.test.ts | 0 .../src/rspeedy}/transport/connector-types.ts | 0 .../src/rspeedy}/transport/connector.ts | 0 .../src/rspeedy}/transport/index.ts | 0 .../src/rspeedy}/transport/wire.ts | 0 .../src => lynx/src/rspeedy}/types.ts | 0 packages/lynx/tsconfig.json | 6 + packages/lynx/tsconfig.lib.json | 12 +- packages/lynx/vite.config.ts | 11 + packages/lynx/vite.rspeedy.config.ts | 71 ++++ pnpm-lock.yaml | 91 +++-- website/src/docs/rozenite-for-lynx.mdx | 34 +- 45 files changed, 707 insertions(+), 506 deletions(-) create mode 100644 .changeset/rozenite-lynx-merge.md create mode 100644 packages/lynx/src/rspeedy.ts rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/__tests__/close-reasons.test.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/__tests__/handshake-sequence.test.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/__tests__/translate-device-frame.test.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/__tests__/translate-host-message.test.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/close-reasons.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/index.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/translate-device-frame.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/translate-host-message.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/bridge/types.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/__tests__/fakes.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/__tests__/inspector-socket.test.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/__tests__/json-list.test.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/index.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/inspector-socket.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/inspector-targets.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/json-list.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/logical-device-id.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/server/request-origin.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/transport/__tests__/fakes.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/transport/__tests__/index.test.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/transport/connector-types.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/transport/connector.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/transport/index.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/transport/wire.ts (100%) rename packages/{lynx-dev/src => lynx/src/rspeedy}/types.ts (100%) create mode 100644 packages/lynx/vite.rspeedy.config.ts diff --git a/.changeset/rozenite-lynx-merge.md b/.changeset/rozenite-lynx-merge.md new file mode 100644 index 00000000..9bae2733 --- /dev/null +++ b/.changeset/rozenite-lynx-merge.md @@ -0,0 +1,17 @@ +--- +'@rozenite/lynx': minor +'@rozenite/lynx-dev': minor +--- + +Rozenite for Lynx is now one package. `@rozenite/lynx` now exports both the +device runtime (`.`, unchanged) and the rspeedy/Rsbuild dev-server plugin +(`./rspeedy`, previously `@rozenite/lynx-dev`). Install just `@rozenite/lynx` +and add `rozeniteLynxPlugin` from `@rozenite/lynx/rspeedy` to your +`lynx.config.ts` — the plugin now injects the device runtime for you, only +in development, so there is nothing left to import by hand in your app's own +source (and no way to accidentally ship it to production). + +`@rozenite/lynx-dev` is deprecated and now re-exports `@rozenite/lynx/rspeedy` +for backwards compatibility; existing `import { rozeniteLynxPlugin } from +'@rozenite/lynx-dev'` code keeps working but should migrate to +`@rozenite/lynx/rspeedy`. diff --git a/apps/playground-lynx/README.md b/apps/playground-lynx/README.md index 55ef8f3f..8a7c6805 100644 --- a/apps/playground-lynx/README.md +++ b/apps/playground-lynx/README.md @@ -30,12 +30,11 @@ Edit `src/App.tsx` to see updates — the page hot-reloads as you save. ## Rozenite integration -- [`@rozenite/lynx-dev`](../../packages/lynx-dev) is added to - `lynx.config.ts`. It discovers installed plugins and bridges Lynx's - DebugRouter to the CDP dialect `@rozenite/app` speaks. -- [`@rozenite/lynx`](../../packages/lynx) is imported once in `src/index.tsx`. - It installs the device-side dispatcher that plugins talk to, and must run - before any plugin hook does. +- [`@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`. ## Plugins diff --git a/apps/playground-lynx/lynx.config.ts b/apps/playground-lynx/lynx.config.ts index 7539b424..d8e07ce9 100644 --- a/apps/playground-lynx/lynx.config.ts +++ b/apps/playground-lynx/lynx.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from '@lynx-js/rspeedy'; import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; import { pluginTypeCheck } from '@rsbuild/plugin-type-check'; -import { rozeniteLynxPlugin } from '@rozenite/lynx-dev'; +import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy'; export default defineConfig({ plugins: [ diff --git a/apps/playground-lynx/package.json b/apps/playground-lynx/package.json index ef559fe0..04df3710 100644 --- a/apps/playground-lynx/package.json +++ b/apps/playground-lynx/package.json @@ -14,7 +14,6 @@ "@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", @@ -26,7 +25,7 @@ "@lynx-js/react-rsbuild-plugin": "^0.19.1", "@lynx-js/rspeedy": "^0.16.5", "@lynx-js/types": "4.1.0", - "@rozenite/lynx-dev": "workspace:*", + "@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/index.tsx b/apps/playground-lynx/src/index.tsx index a31fea24..aed6bf3a 100644 --- a/apps/playground-lynx/src/index.tsx +++ b/apps/playground-lynx/src/index.tsx @@ -1,9 +1,5 @@ import '@lynx-js/preact-devtools'; import '@lynx-js/react/debug'; -// Installs the device-side dispatcher that Rozenite plugins talk to. Must run -// before any plugin's `useRozeniteDevToolsClient` does, so it is imported -// ahead of `App`. -import '@rozenite/lynx'; import { root } from '@lynx-js/react'; import { App } from './App.jsx'; diff --git a/packages/lynx-dev/README.md b/packages/lynx-dev/README.md index c7cba9b1..8412eeab 100644 --- a/packages/lynx-dev/README.md +++ b/packages/lynx-dev/README.md @@ -1,43 +1,17 @@ ![rozenite-banner](https://www.rozenite.dev/rozenite-banner.jpg) -### An rspeedy/Rsbuild plugin for integrating React Native DevTools plugins into your Lynx development workflow. +### Deprecated: `@rozenite/lynx-dev` has been merged into `@rozenite/lynx`. [![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-dev` brings Rozenite to [Lynx](https://lynxjs.org). It runs a small dev server on top of your rspeedy/Rsbuild dev server that speaks Metro's inspector dialect — `/json/list` and `/inspector/debug` — so the same `@rozenite/app` DevTools frontend React Native uses connects to a Lynx app 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. - -## Features - -- **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 -- **Reload-Safe Device Ids**: a device's identity survives an app reload, so a saved DevTools tab reconnects instead of going stale -- **Express Middleware**: provides the same scoped, composable middleware shape as `@rozenite/metro` - -## Installation - -Install the plugin as a development dependency: - -```bash -npm install --save-dev @rozenite/lynx-dev -``` - -Your app also needs [`@rozenite/lynx`](https://www.npmjs.com/package/@rozenite/lynx) — the small runtime that installs the device-side dispatcher Rozenite plugins talk to. Import it once at your app's entry point: - -```ts -if (__DEV__) { - require('@rozenite/lynx'); -} -``` - -## Usage - -Add the plugin to your `lynx.config.ts`: +`@rozenite/lynx-dev` and `@rozenite/lynx` are now a single package. Install +[`@rozenite/lynx`](https://www.npmjs.com/package/@rozenite/lynx) and import +the plugin from `@rozenite/lynx/rspeedy` instead: ```ts // lynx.config.ts import { defineConfig } from '@lynx-js/rspeedy'; -import { rozeniteLynxPlugin } from '@rozenite/lynx-dev'; +import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy'; export default defineConfig({ plugins: [ @@ -47,98 +21,16 @@ export default defineConfig({ }); ``` -### With Custom Options - -```ts -// lynx.config.ts -import { defineConfig } from '@lynx-js/rspeedy'; -import { rozeniteLynxPlugin } from '@rozenite/lynx-dev'; - -export default defineConfig({ - plugins: [ - rozeniteLynxPlugin({ - include: ['@my-org/my-plugin', 'another-plugin'], - exclude: ['unwanted-plugin'], - destroyOnDetachPlugins: ['@rozenite/network-activity-plugin'], - pluginDisplay: 'sidebar', - }), - ], -}); -``` - -Start your rspeedy dev server as usual, plug in a Lynx app, and Rozenite will log a DevTools URL for it in the terminal as soon as it connects. - -## Configuration - -### `RozeniteLynxOptions` - -```typescript -type RozeniteLynxOptions = { - enabled?: boolean; // Whether to enable Rozenite. Defaults to `process.env.NODE_ENV !== 'production'` - include?: string[]; // Only load these specific plugins - exclude?: string[]; // Exclude these plugins from loading - destroyOnDetachPlugins?: string[]; // Plugins that should be destroyed when switching panels - pluginDisplay?: 'sidebar' | 'tabs'; // How plugins are displayed in DevTools - deviceSerial?: string; // Restrict device discovery to one physical device serial/udid - enableAndroid?: boolean; // Discover physical Android devices over adb. Default: true - 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 -}; -``` - -**Options:** - -- `enabled` - Whether to enable Rozenite (optional, defaults to disabled in production builds) -- `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) -- `pluginDisplay` - Use `'sidebar'` (default) to show all plugin panels in one Rozenite tab, or `'tabs'` to retain a separate DevTools tab for every plugin panel -- `deviceSerial`, `enableAndroid`, `enableIOS`, `enableHarmony`, `enableDesktop` - Control which devices DebugRouter discovers - -`enableAndroid` and `enableIOS` cover **physical** devices only, over adb and usbmux. Simulators and emulators are ordinary processes on your machine, so DebugRouter inside them is reachable on `127.0.0.1:8901-8919` — which is what `enableDesktop` scans. That is why it defaults to on; turning it off is how you stop seeing simulators. - -## Plugin Discovery - -Rozenite plugins are discovered from your project's `package.json` the exact same way they are for React Native: - -1. **Scanning node_modules**: Searches all node_modules directories in the project -2. **Checking for Rozenite manifest**: Looks for the Rozenite manifest file in each package -3. **Validating plugin structure**: Ensures the plugin has the required build output -4. **Loading plugin metadata**: Extracts plugin information for integration - -## Plugin Requirements - -For a package to be recognized as a Rozenite plugin, it must: - -1. **Have a Rozenite manifest**: `dist/rozenite.json` file -2. **Be properly built**: Plugin assets must be available in the `dist` directory -3. **Follow naming conventions**: Package name should not start with `.` -4. **Be accessible**: Package must be readable from node_modules - -## Troubleshooting - -### No plugins found - -- Ensure plugins are properly installed in `node_modules` -- Check that plugins have been built and contain `dist/rozenite.json` -- Verify plugin package names are not excluded in configuration - -### No DevTools URL is logged - -- Make sure `@rozenite/lynx` is imported at your app's entry point, guarded for development builds -- 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`) - -### Device reconnects but DevTools disconnects - -- This is what the plugin's stable `logicalDeviceId` and reload-robustness handling exist to prevent — if it still happens, please open an issue with your Lynx SDK version and platform - -## Requirements +You no longer need to import `@rozenite/lynx` (or this package) anywhere in +your app's own source — `@rozenite/lynx/rspeedy` now injects the device +runtime for you, only in development. See +[`@rozenite/lynx`'s README](https://www.npmjs.com/package/@rozenite/lynx) for +the full option list and current documentation. -- Node.js >= 20.19.0 -- rspeedy / Rsbuild-based Lynx project -- Installed Rozenite plugins +This package remains available, re-exporting `@rozenite/lynx/rspeedy` +unchanged, so existing `import { rozeniteLynxPlugin } from '@rozenite/lynx-dev'` +code keeps working. It will be removed in a future major version — please +migrate when convenient. ## Made with ❤️ at Callstack diff --git a/packages/lynx-dev/package.json b/packages/lynx-dev/package.json index 787c8362..47c08409 100644 --- a/packages/lynx-dev/package.json +++ b/packages/lynx-dev/package.json @@ -1,7 +1,7 @@ { "name": "@rozenite/lynx-dev", "version": "2.3.0", - "description": "Rozenite dev-server integration for Lynx: a DebugRouter to CDP bridge and an rspeedy plugin.", + "description": "Deprecated: merged into @rozenite/lynx. Install @rozenite/lynx and import from @rozenite/lynx/rspeedy instead.", "keywords": [ "bundler", "devtools", @@ -45,17 +45,9 @@ "test": "vitest --run --passWithNoTests" }, "dependencies": { - "@lynx-js/debug-router-connector": "^0.0.15", - "@rozenite/middleware": "workspace:*", - "@rozenite/tools": "workspace:*", - "express": "^5.1.0", - "tslib": "^2.3.0", - "ws": "^8.18.3" + "@rozenite/lynx": "workspace:*" }, "devDependencies": { - "@rsbuild/core": "2.1.10", - "@types/express": "^5.0.3", - "@types/ws": "^8.18.1", "vitest": "^4.0.18" }, "engines": { diff --git a/packages/lynx-dev/src/index.ts b/packages/lynx-dev/src/index.ts index b961f22f..ef3da3cf 100644 --- a/packages/lynx-dev/src/index.ts +++ b/packages/lynx-dev/src/index.ts @@ -1,260 +1,9 @@ /** - * The public entry point: an rspeedy/Rsbuild plugin that wires - * `./transport/` (DebugRouter discovery) and `./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. - * - * `@lynx-js/rspeedy` re-exports `RsbuildPlugin` from `@rsbuild/core` - * unchanged, so this is written directly against `@rsbuild/core`'s types - * (a devDependency here) rather than depending on `@lynx-js/rspeedy` - * itself. + * @deprecated `@rozenite/lynx-dev` has been merged into `@rozenite/lynx`. + * Install `@rozenite/lynx` instead and import from `@rozenite/lynx/rspeedy`. + * This package is kept only as a re-export shim so existing + * `import { rozeniteLynxPlugin } from '@rozenite/lynx-dev'` code keeps + * working, and will be removed in a future major version — see issue #488 + * for the rationale. */ -import type { IncomingMessage } from 'node:http'; -import type { RsbuildPlugin } from '@rsbuild/core'; -import { - createScopedMiddleware, - initializeRozenite, - type RozeniteConfig, -} from '@rozenite/middleware'; -import { logger } from '@rozenite/tools'; -import { createLynxTransport } from './transport/index.js'; -import { createRozeniteLynxServer, listInspectorTargets } from './server/index.js'; - -export type { LynxClient, LynxSession, DeviceFrame, LynxTransport } from './types.js'; -export { - createLynxTransport, - type LynxTransportLogger, - type LynxTransportOptions, -} from './transport/index.js'; -export { - createRozeniteLynxServer, - listInspectorTargets, - computeLogicalDeviceId, - findClientByLogicalDeviceId, - type InspectorTarget, - type Logger, - type RozeniteLynxServer, - type RozeniteLynxServerOptions, -} from './server/index.js'; - -export type RozeniteLynxOptions = Omit & { - /** - * Whether to enable Rozenite. - * - * @default `process.env.NODE_ENV !== 'production'` - */ - enabled?: boolean; - /** Restrict device discovery to one physical device serial/udid. */ - deviceSerial?: string; - /** Platform toggles for DebugRouter discovery. Default: Android + iOS on. */ - enableAndroid?: boolean; - enableIOS?: boolean; - enableHarmony?: boolean; - enableDesktop?: boolean; -}; - -const PLUGIN_NAME = 'rozenite-lynx'; - -/** - * Builds the URL a developer opens to get Rozenite's DevTools for one - * inspector target — the standalone `@rozenite/app`, told which device and - * page to connect to via the same `?ws=&appId=` convention Fusebox's own - * frontend uses (see `packages/app/src/connection/target-from-url.ts`). - */ -const buildDevToolsUrl = ( - host: string, - port: number, - target: { logicalDeviceId: string; sessionId: number; appId: string }, -): string => { - const wsPath = `/inspector/debug?device=${target.logicalDeviceId}&page=${target.sessionId}`; - const query = new URLSearchParams({ ws: wsPath, appId: target.appId }); - return `http://${host}:${port}/rozenite/app?${query.toString()}`; -}; - -/** - * Rsbuild's default `server.host` is `0.0.0.0` (and it may be `::`), which - * is a valid bind address but not something a browser can usefully open. - * The printed URL has to be clickable, so a wildcard bind is reported as - * `localhost`; an explicitly configured host is left alone. - */ -const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]', '']); - -const toBrowsableHost = (host: string): string => (WILDCARD_HOSTS.has(host) ? 'localhost' : host); - -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', - setup: async (api) => { - // 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'; - if (!enabled) { - return; - } - - const rozenite = await initializeRozenite( - { - projectRoot: api.context.rootPath, - integration: 'lynx', - include: options.include, - exclude: options.exclude, - destroyOnDetachPlugins: options.destroyOnDetachPlugins, - projectType: options.projectType, - logLevel: options.logLevel, - pluginDisplay: options.pluginDisplay, - }, - // `@rozenite/runtime` is a React Native runtime shim - // (`packages/runtime`) and is not — and should not become — a - // dependency of this package: Lynx apps import `@rozenite/lynx` - // instead (see this package's README). `runtimeVersion` is only - // used to report a version alongside React Native plugin - // manifests, which does not apply here. - undefined, - ); - - const transport = await createLynxTransport({ - deviceSerial: options.deviceSerial, - enableAndroid: options.enableAndroid, - enableIOS: options.enableIOS, - enableHarmony: options.enableHarmony, - enableDesktop: options.enableDesktop, - logger, - }); - - const server = createRozeniteLynxServer({ transport, logger }); - - api.modifyRsbuildConfig((config) => { - config.dev ??= {}; - const previous = config.dev.setupMiddlewares - ? Array.isArray(config.dev.setupMiddlewares) - ? config.dev.setupMiddlewares - : [config.dev.setupMiddlewares] - : []; - - config.dev.setupMiddlewares = [ - ...previous, - (middlewares) => { - middlewares.push(server.middleware); - middlewares.push(createScopedMiddleware('/rozenite', rozenite.middleware)); - }, - ]; - }); - - // --- WebSocket upgrade wiring ------------------------------------- - // - // `SetupMiddlewaresContext` (the second argument the function above - // receives) only exposes `sockWrite` and `environments` — no way to - // reach the underlying `http.Server` from there. The documented, - // supported way turns out to be the `onBeforeStartDevServer` hook: - // its payload's `server` (an `RsbuildDevServer`) already carries - // `httpServer` by the time this hook fires — `createDevServer` in - // `@rsbuild/core` assigns it before calling this hook, well before - // `devServer.listen()` (and therefore before Rsbuild attaches its - // own HMR `'upgrade'` listener). Confirmed by reading - // `@rsbuild/core`'s compiled source in `node_modules`, not just its - // `.d.ts` files. This replaces an earlier plan to capture the - // server off the socket of the first request that reaches the - // middleware above (`(req.socket as { server }).server`) — keep - // that trick in mind only if a future Rsbuild version stops handing - // out `httpServer` here. - const attachedHttpServers = new WeakSet(); - - api.onBeforeStartDevServer(({ server: devServer }) => { - const httpServer = devServer.httpServer; - if (!httpServer) { - // `server.middlewareMode` is enabled: there is no `http.Server` - // for this plugin to attach to, so Lynx devices will not be - // able to reach `/inspector/debug`. Everything else (plugin - // discovery, `/json/list`) still works. - logger.warn( - '[Rozenite Lynx] No http.Server available to attach the inspector ' + - 'WebSocket route to (is server.middlewareMode enabled?). ' + - 'Lynx devices will not be able to connect.', - ); - return; - } - if (attachedHttpServers.has(httpServer)) { - return; - } - attachedHttpServers.add(httpServer); - - httpServer.on('upgrade', (request: IncomingMessage, socket, head: Buffer) => { - // Never destroy a socket this route doesn't claim: Rsbuild's - // own HMR WebSocket listens on this same `'upgrade'` event, and - // `handleUpgrade` already returns `false` (leaving the socket - // untouched) for any path it doesn't recognise. - server.handleUpgrade(request, socket, head); - }); - }); - - // --- "DevTools URL available" logging ------------------------------ - // - // Logged once per inspector target (a device, or one of its cards) - // the first time it appears, using the actual host/port the dev - // server ended up bound to — not requested config, since Rsbuild - // may have incremented the port if the requested one was taken. - let devServerAddress: { host: string; port: number } | undefined; - let knownTargetKeys = new Set(); - - const logNewTargets = (): void => { - if (!devServerAddress) { - return; - } - const { host, port } = devServerAddress; - const currentKeys = new Set(); - - for (const target of listInspectorTargets(transport)) { - const key = `${target.logicalDeviceId}:${target.sessionId}`; - currentKeys.add(key); - if (knownTargetKeys.has(key)) { - continue; - } - - const url = buildDevToolsUrl(host, port, { - logicalDeviceId: target.logicalDeviceId, - sessionId: target.sessionId, - appId: target.client.appName, - }); - logger.info( - `[Rozenite Lynx] DevTools available for "${target.client.appName}" on ` + - `${target.client.deviceName}: ${url}`, - ); - } - - knownTargetKeys = currentKeys; - }; - - const unsubscribeTopologyLogging = transport.onTopologyChanged(logNewTargets); - - api.onAfterStartDevServer(({ port }) => { - devServerAddress = { host: toBrowsableHost(api.getNormalizedConfig().server.host), port }; - // Devices discovered before the server finished starting (USB - // discovery in `createLynxTransport` begins immediately, on its - // own schedule) would otherwise never get logged: their - // `onTopologyChanged` event already fired while `devServerAddress` - // was still unset. - logNewTargets(); - }); - - api.onCloseDevServer(async () => { - unsubscribeTopologyLogging(); - await server.dispose(); - await transport.dispose(); - await rozenite.dispose(); - }); - }, - }; -}; +export * from '@rozenite/lynx/rspeedy'; diff --git a/packages/lynx-dev/tsconfig.json b/packages/lynx-dev/tsconfig.json index 9d90281e..62499943 100644 --- a/packages/lynx-dev/tsconfig.json +++ b/packages/lynx-dev/tsconfig.json @@ -4,7 +4,7 @@ "include": [], "references": [ { - "path": "../middleware" + "path": "../lynx" }, { "path": "./tsconfig.lib.json" diff --git a/packages/lynx-dev/tsconfig.lib.json b/packages/lynx-dev/tsconfig.lib.json index e0de7fc2..fde3515a 100644 --- a/packages/lynx-dev/tsconfig.lib.json +++ b/packages/lynx-dev/tsconfig.lib.json @@ -7,17 +7,14 @@ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", "emitDeclarationOnly": true, "forceConsistentCasingInFileNames": true, - "types": ["node", "vite/client"], - "esModuleInterop": true, - "resolveJsonModule": true + "types": ["vite/client"], + "module": "esnext", + "moduleResolution": "bundler" }, "include": ["src/**/*.ts"], "references": [ { - "path": "../tools/tsconfig.lib.json" - }, - { - "path": "../middleware/tsconfig.lib.json" + "path": "../lynx/tsconfig.lib.json" } ] } diff --git a/packages/lynx-dev/vite.config.ts b/packages/lynx-dev/vite.config.ts index 23f584d4..c9be88b1 100644 --- a/packages/lynx-dev/vite.config.ts +++ b/packages/lynx-dev/vite.config.ts @@ -4,7 +4,21 @@ import path, { resolve } from 'node:path'; import dts from 'vite-plugin-dts'; import packageJson from './package.json' with { type: 'json' }; +// This package is now a thin deprecated re-export shim over +// `@rozenite/lynx/rspeedy` (see `src/index.ts`) — `@rozenite/lynx` is +// externalised, not bundled, same as any other dependency. +// +// Rollup's `external` array only matches an import specifier *exactly* +// against this list — `dependencies` here is `['@rozenite/lynx']`, and +// `import ... from '@rozenite/lynx/rspeedy'` (a different specifier, the +// package's subpath export) would not match it, silently bundling the +// entire rspeedy plugin into this shim (confirmed empirically: without +// this, the build ballooned to ~277 kB instead of staying a thin +// re-export). A predicate also externalises anything under a dependency's +// own subpath. const dependencies = Object.keys(packageJson.dependencies || {}); +const isExternal = (id: string): boolean => + dependencies.some((dep) => id === dep || id.startsWith(`${dep}/`)); export default defineConfig({ root: __dirname, @@ -24,7 +38,7 @@ export default defineConfig({ formats: ['es' as const, 'cjs' as const], }, rollupOptions: { - external: dependencies, + external: isExternal, }, }, test: { diff --git a/packages/lynx/README.md b/packages/lynx/README.md index 1abe5673..cc409088 100644 --- a/packages/lynx/README.md +++ b/packages/lynx/README.md @@ -1,26 +1,107 @@ ![rozenite-banner](https://www.rozenite.dev/rozenite-banner.jpg) -### Rozenite's device-side runtime for Lynx. +### Rozenite for Lynx: one package for 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` is the small runtime a [Lynx](https://lynxjs.org) app imports once at its entry point. Importing it installs the global that [`@rozenite/plugin-bridge`](https://www.npmjs.com/package/@rozenite/plugin-bridge) talks to on the device, so Rozenite DevTools plugins can exchange messages with the host over Lynx's own devtool channel. +`@rozenite/lynx` brings Rozenite to [Lynx](https://lynxjs.org). It has two +entry points: + +- **`@rozenite/lynx`** (this package's default export) — the small + device-side runtime that installs the global + [`@rozenite/plugin-bridge`](https://www.npmjs.com/package/@rozenite/plugin-bridge) + talks to on the device. +- **`@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 + `@rozenite/app` DevTools frontend React Native uses connects to a Lynx app + 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. ## Features -- **Zero configuration**: a single import at the app's entry point is all that's needed -- **Zero runtime dependencies**: nothing beyond `tslib` -- **Background-runtime aware**: only installs where `lynx.getDevtool()` actually exists, so it's inert (and side-effect free) everywhere else +- **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 +- **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 +- **Reload-Safe Device Ids**: a device's identity survives an app reload, so a saved DevTools tab reconnects instead of going stale +- **Express Middleware**: provides the same scoped, composable middleware shape as `@rozenite/metro` ## Installation ```bash -npm install @rozenite/lynx +npm install --save-dev @rozenite/lynx ``` ## Usage -Import `@rozenite/lynx` once, as early as possible in your app's entry point, guarded for development builds. It must run before any plugin's `useRozeniteDevToolsClient` runs, so plugin device code always finds the dispatcher already installed. +Add the plugin to your `lynx.config.ts`: + +```ts +// lynx.config.ts +import { defineConfig } from '@lynx-js/rspeedy'; +import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy'; + +export default defineConfig({ + plugins: [ + // ...your other rspeedy plugins + rozeniteLynxPlugin(), + ], +}); +``` + +That's the whole setup — no changes to `src/index.tsx` or any other app +source are needed. + +### With Custom Options + +```ts +// lynx.config.ts +import { defineConfig } from '@lynx-js/rspeedy'; +import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy'; + +export default defineConfig({ + plugins: [ + rozeniteLynxPlugin({ + include: ['@my-org/my-plugin', 'another-plugin'], + exclude: ['unwanted-plugin'], + destroyOnDetachPlugins: ['@rozenite/network-activity-plugin'], + pluginDisplay: 'sidebar', + }), + ], +}); +``` + +Start your rspeedy dev server as usual, plug in a Lynx app, and Rozenite will log a DevTools URL for it in the terminal as soon as it connects. + +## How the runtime gets into your app + +`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. + +If you need the device runtime outside of that automatic wiring — for +example, a non-rspeedy build pipeline — you can still import it directly, +guarded for development: ```ts if (__DEV__) { @@ -28,13 +109,81 @@ if (__DEV__) { } ``` -If your entry point already uses ES module imports guarded by a dev check, a static import works the same way as long as it executes before your plugins do: - -```ts -import '@rozenite/lynx'; +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. + +## Configuration + +### `RozeniteLynxOptions` + +```typescript +type RozeniteLynxOptions = { + enabled?: boolean; // Whether to enable Rozenite. Defaults to `process.env.NODE_ENV !== 'production'` + include?: string[]; // Only load these specific plugins + exclude?: string[]; // Exclude these plugins from loading + destroyOnDetachPlugins?: string[]; // Plugins that should be destroyed when switching panels + pluginDisplay?: 'sidebar' | 'tabs'; // How plugins are displayed in DevTools + deviceSerial?: string; // Restrict device discovery to one physical device serial/udid + enableAndroid?: boolean; // Discover physical Android devices over adb. Default: true + 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 +}; ``` -That's it — no further setup is required. Rozenite DevTools plugins used in your app will now be able to connect to the host. +**Options:** + +- `enabled` - Whether to enable Rozenite (optional, defaults to disabled in production builds) +- `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) +- `pluginDisplay` - Use `'sidebar'` (default) to show all plugin panels in one Rozenite tab, or `'tabs'` to retain a separate DevTools tab for every plugin panel +- `deviceSerial`, `enableAndroid`, `enableIOS`, `enableHarmony`, `enableDesktop` - Control which devices DebugRouter discovers + +`enableAndroid` and `enableIOS` cover **physical** devices only, over adb and usbmux. Simulators and emulators are ordinary processes on your machine, so DebugRouter inside them is reachable on `127.0.0.1:8901-8919` — which is what `enableDesktop` scans. That is why it defaults to on; turning it off is how you stop seeing simulators. + +## Plugin Discovery + +Rozenite plugins are discovered from your project's `package.json` the exact same way they are for React Native: + +1. **Scanning node_modules**: Searches all node_modules directories in the project +2. **Checking for Rozenite manifest**: Looks for the Rozenite manifest file in each package +3. **Validating plugin structure**: Ensures the plugin has the required build output +4. **Loading plugin metadata**: Extracts plugin information for integration + +## Plugin Requirements + +For a package to be recognized as a Rozenite plugin, it must: + +1. **Have a Rozenite manifest**: `dist/rozenite.json` file +2. **Be properly built**: Plugin assets must be available in the `dist` directory +3. **Follow naming conventions**: Package name should not start with `.` +4. **Be accessible**: Package must be readable from node_modules + +## Troubleshooting + +### No plugins found + +- Ensure plugins are properly installed in `node_modules` +- Check that plugins have been built and contain `dist/rozenite.json` +- Verify plugin package names are not excluded in configuration + +### 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`) + +### Device reconnects but DevTools disconnects + +- This is what the plugin's stable `logicalDeviceId` and reload-robustness handling exist to prevent — if it still happens, please open an issue with your Lynx SDK version and platform + +## Requirements + +- Node.js >= 20.19.0 +- rspeedy / Rsbuild-based Lynx project +- Installed Rozenite plugins ## Made with ❤️ at Callstack diff --git a/packages/lynx/package.json b/packages/lynx/package.json index 4bd252a0..e0ccbed0 100644 --- a/packages/lynx/package.json +++ b/packages/lynx/package.json @@ -1,12 +1,14 @@ { "name": "@rozenite/lynx", "version": "2.3.0", - "description": "Rozenite device runtime for Lynx.", + "description": "Rozenite for Lynx: the device-side runtime, plus an rspeedy/Rsbuild plugin (`@rozenite/lynx/rspeedy`) that wires it in automatically.", "keywords": [ + "bundler", "devtools", "lynx", "plugin", - "rozenite" + "rozenite", + "rspeedy" ], "homepage": "https://github.com/callstackincubator/rozenite#readme", "bugs": { @@ -31,24 +33,37 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./rspeedy": { + "types": "./dist/rspeedy.d.ts", + "import": "./dist/rspeedy.js", + "require": "./dist/rspeedy.cjs" } }, "publishConfig": { "access": "public" }, "scripts": { - "build": "vite build", + "build": "vite build && vite build --config vite.rspeedy.config.ts", "typecheck": "tsc -p tsconfig.lib.json --noEmit", "lint": "eslint .", "test": "vitest --run --passWithNoTests" }, "dependencies": { - "tslib": "^2.3.0" + "@lynx-js/debug-router-connector": "^0.0.15", + "@rozenite/middleware": "workspace:*", + "@rozenite/tools": "workspace:*", + "express": "^5.1.0", + "tslib": "^2.3.0", + "ws": "^8.18.3" }, "devDependencies": { + "@rsbuild/core": "2.1.10", + "@types/express": "^5.0.3", + "@types/ws": "^8.18.1", "vitest": "^4.0.18" }, "engines": { - "node": ">=20" + "node": ">=20.19.0" } } diff --git a/packages/lynx/src/rspeedy.ts b/packages/lynx/src/rspeedy.ts new file mode 100644 index 00000000..9e0ea2f0 --- /dev/null +++ b/packages/lynx/src/rspeedy.ts @@ -0,0 +1,311 @@ +/** + * The public entry point (`@rozenite/lynx/rspeedy`): an rspeedy/Rsbuild + * plugin that wires `./rspeedy/transport/` (DebugRouter discovery) and + * `./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. + * + * `@lynx-js/rspeedy` re-exports `RsbuildPlugin` from `@rsbuild/core` + * unchanged, so this is written directly against `@rsbuild/core`'s types + * (a devDependency here) rather than depending on `@lynx-js/rspeedy` + * itself. + */ +import type { IncomingMessage } from 'node:http'; +import { createRequire } from 'node:module'; +import type { RsbuildPlugin } from '@rsbuild/core'; +import { + createScopedMiddleware, + initializeRozenite, + type RozeniteConfig, +} from '@rozenite/middleware'; +import { logger } from '@rozenite/tools'; +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. + * + * This is a package *self-reference*: this module lives inside + * `@rozenite/lynx`, and Node resolves the specifier through this package's + * own `exports` map by matching the `name` in the nearest `package.json`. + * It therefore needs no `node_modules` lookup and behaves identically + * whether the package is symlinked (pnpm workspace) or installed normally. + * + * `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 + * this one form works from either. The same pattern is used in + * `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. + */ +const require = createRequire(import.meta.url); +const RUNTIME_ENTRY = require.resolve('@rozenite/lynx'); + +export type { LynxClient, LynxSession, DeviceFrame, LynxTransport } from './rspeedy/types.js'; +export { + createLynxTransport, + type LynxTransportLogger, + type LynxTransportOptions, +} from './rspeedy/transport/index.js'; +export { + createRozeniteLynxServer, + listInspectorTargets, + computeLogicalDeviceId, + findClientByLogicalDeviceId, + type InspectorTarget, + type Logger, + type RozeniteLynxServer, + type RozeniteLynxServerOptions, +} from './rspeedy/server/index.js'; + +export type RozeniteLynxOptions = Omit & { + /** + * Whether to enable Rozenite. + * + * @default `process.env.NODE_ENV !== 'production'` + */ + enabled?: boolean; + /** Restrict device discovery to one physical device serial/udid. */ + deviceSerial?: string; + /** Platform toggles for DebugRouter discovery. Default: Android + iOS on. */ + enableAndroid?: boolean; + enableIOS?: boolean; + enableHarmony?: boolean; + enableDesktop?: boolean; +}; + +const PLUGIN_NAME = 'rozenite-lynx'; + +/** + * Builds the URL a developer opens to get Rozenite's DevTools for one + * inspector target — the standalone `@rozenite/app`, told which device and + * page to connect to via the same `?ws=&appId=` convention Fusebox's own + * frontend uses (see `packages/app/src/connection/target-from-url.ts`). + */ +const buildDevToolsUrl = ( + host: string, + port: number, + target: { logicalDeviceId: string; sessionId: number; appId: string }, +): string => { + const wsPath = `/inspector/debug?device=${target.logicalDeviceId}&page=${target.sessionId}`; + const query = new URLSearchParams({ ws: wsPath, appId: target.appId }); + return `http://${host}:${port}/rozenite/app?${query.toString()}`; +}; + +/** + * Rsbuild's default `server.host` is `0.0.0.0` (and it may be `::`), which + * is a valid bind address but not something a browser can usefully open. + * The printed URL has to be clickable, so a wildcard bind is reported as + * `localhost`; an explicitly configured host is left alone. + */ +const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]', '']); + +const toBrowsableHost = (host: string): string => (WILDCARD_HOSTS.has(host) ? 'localhost' : host); + +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', + setup: async (api) => { + // 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'; + if (!enabled) { + return; + } + + const rozenite = await initializeRozenite( + { + projectRoot: api.context.rootPath, + integration: 'lynx', + include: options.include, + exclude: options.exclude, + destroyOnDetachPlugins: options.destroyOnDetachPlugins, + projectType: options.projectType, + logLevel: options.logLevel, + pluginDisplay: options.pluginDisplay, + }, + // `@rozenite/runtime` is a React Native runtime shim + // (`packages/runtime`) and is not — and should not become — a + // dependency of this package: Lynx apps import `@rozenite/lynx` + // instead (see this package's README). `runtimeVersion` is only + // used to report a version alongside React Native plugin + // manifests, which does not apply here. + undefined, + ); + + const transport = await createLynxTransport({ + deviceSerial: options.deviceSerial, + enableAndroid: options.enableAndroid, + enableIOS: options.enableIOS, + enableHarmony: options.enableHarmony, + enableDesktop: options.enableDesktop, + logger, + }); + + const server = createRozeniteLynxServer({ transport, logger }); + + api.modifyRsbuildConfig((config) => { + config.dev ??= {}; + const previous = config.dev.setupMiddlewares + ? Array.isArray(config.dev.setupMiddlewares) + ? config.dev.setupMiddlewares + : [config.dev.setupMiddlewares] + : []; + + config.dev.setupMiddlewares = [ + ...previous, + (middlewares) => { + middlewares.push(server.middleware); + middlewares.push(createScopedMiddleware('/rozenite', rozenite.middleware)); + }, + ]; + + // Inject the device runtime instead of asking the user to import it + // by hand (issue #488). `source.preEntry` modules are added before + // the app's own entry, which is exactly the ordering the runtime + // 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 + // removes the app's entry point from the equation entirely. + config.source ??= {}; + const prevPreEntry = config.source.preEntry ?? []; + config.source.preEntry = [ + ...(Array.isArray(prevPreEntry) ? prevPreEntry : [prevPreEntry]), + RUNTIME_ENTRY, + ]; + }); + + // --- WebSocket upgrade wiring ------------------------------------- + // + // `SetupMiddlewaresContext` (the second argument the function above + // receives) only exposes `sockWrite` and `environments` — no way to + // reach the underlying `http.Server` from there. The documented, + // supported way turns out to be the `onBeforeStartDevServer` hook: + // its payload's `server` (an `RsbuildDevServer`) already carries + // `httpServer` by the time this hook fires — `createDevServer` in + // `@rsbuild/core` assigns it before calling this hook, well before + // `devServer.listen()` (and therefore before Rsbuild attaches its + // own HMR `'upgrade'` listener). Confirmed by reading + // `@rsbuild/core`'s compiled source in `node_modules`, not just its + // `.d.ts` files. This replaces an earlier plan to capture the + // server off the socket of the first request that reaches the + // middleware above (`(req.socket as { server }).server`) — keep + // that trick in mind only if a future Rsbuild version stops handing + // out `httpServer` here. + const attachedHttpServers = new WeakSet(); + + api.onBeforeStartDevServer(({ server: devServer }) => { + const httpServer = devServer.httpServer; + if (!httpServer) { + // `server.middlewareMode` is enabled: there is no `http.Server` + // for this plugin to attach to, so Lynx devices will not be + // able to reach `/inspector/debug`. Everything else (plugin + // discovery, `/json/list`) still works. + logger.warn( + '[Rozenite Lynx] No http.Server available to attach the inspector ' + + 'WebSocket route to (is server.middlewareMode enabled?). ' + + 'Lynx devices will not be able to connect.', + ); + return; + } + if (attachedHttpServers.has(httpServer)) { + return; + } + attachedHttpServers.add(httpServer); + + httpServer.on('upgrade', (request: IncomingMessage, socket, head: Buffer) => { + // Never destroy a socket this route doesn't claim: Rsbuild's + // own HMR WebSocket listens on this same `'upgrade'` event, and + // `handleUpgrade` already returns `false` (leaving the socket + // untouched) for any path it doesn't recognise. + server.handleUpgrade(request, socket, head); + }); + }); + + // --- "DevTools URL available" logging ------------------------------ + // + // Logged once per inspector target (a device, or one of its cards) + // the first time it appears, using the actual host/port the dev + // server ended up bound to — not requested config, since Rsbuild + // may have incremented the port if the requested one was taken. + let devServerAddress: { host: string; port: number } | undefined; + let knownTargetKeys = new Set(); + + const logNewTargets = (): void => { + if (!devServerAddress) { + return; + } + const { host, port } = devServerAddress; + const currentKeys = new Set(); + + for (const target of listInspectorTargets(transport)) { + const key = `${target.logicalDeviceId}:${target.sessionId}`; + currentKeys.add(key); + if (knownTargetKeys.has(key)) { + continue; + } + + const url = buildDevToolsUrl(host, port, { + logicalDeviceId: target.logicalDeviceId, + sessionId: target.sessionId, + appId: target.client.appName, + }); + logger.info( + `[Rozenite Lynx] DevTools available for "${target.client.appName}" on ` + + `${target.client.deviceName}: ${url}`, + ); + } + + knownTargetKeys = currentKeys; + }; + + const unsubscribeTopologyLogging = transport.onTopologyChanged(logNewTargets); + + api.onAfterStartDevServer(({ port }) => { + devServerAddress = { host: toBrowsableHost(api.getNormalizedConfig().server.host), port }; + // Devices discovered before the server finished starting (USB + // discovery in `createLynxTransport` begins immediately, on its + // own schedule) would otherwise never get logged: their + // `onTopologyChanged` event already fired while `devServerAddress` + // was still unset. + logNewTargets(); + }); + + api.onCloseDevServer(async () => { + unsubscribeTopologyLogging(); + await server.dispose(); + await transport.dispose(); + await rozenite.dispose(); + }); + }, + }; +}; diff --git a/packages/lynx-dev/src/bridge/__tests__/close-reasons.test.ts b/packages/lynx/src/rspeedy/bridge/__tests__/close-reasons.test.ts similarity index 100% rename from packages/lynx-dev/src/bridge/__tests__/close-reasons.test.ts rename to packages/lynx/src/rspeedy/bridge/__tests__/close-reasons.test.ts diff --git a/packages/lynx-dev/src/bridge/__tests__/handshake-sequence.test.ts b/packages/lynx/src/rspeedy/bridge/__tests__/handshake-sequence.test.ts similarity index 100% rename from packages/lynx-dev/src/bridge/__tests__/handshake-sequence.test.ts rename to packages/lynx/src/rspeedy/bridge/__tests__/handshake-sequence.test.ts diff --git a/packages/lynx-dev/src/bridge/__tests__/translate-device-frame.test.ts b/packages/lynx/src/rspeedy/bridge/__tests__/translate-device-frame.test.ts similarity index 100% rename from packages/lynx-dev/src/bridge/__tests__/translate-device-frame.test.ts rename to packages/lynx/src/rspeedy/bridge/__tests__/translate-device-frame.test.ts diff --git a/packages/lynx-dev/src/bridge/__tests__/translate-host-message.test.ts b/packages/lynx/src/rspeedy/bridge/__tests__/translate-host-message.test.ts similarity index 100% rename from packages/lynx-dev/src/bridge/__tests__/translate-host-message.test.ts rename to packages/lynx/src/rspeedy/bridge/__tests__/translate-host-message.test.ts diff --git a/packages/lynx-dev/src/bridge/close-reasons.ts b/packages/lynx/src/rspeedy/bridge/close-reasons.ts similarity index 100% rename from packages/lynx-dev/src/bridge/close-reasons.ts rename to packages/lynx/src/rspeedy/bridge/close-reasons.ts diff --git a/packages/lynx-dev/src/bridge/index.ts b/packages/lynx/src/rspeedy/bridge/index.ts similarity index 100% rename from packages/lynx-dev/src/bridge/index.ts rename to packages/lynx/src/rspeedy/bridge/index.ts diff --git a/packages/lynx-dev/src/bridge/translate-device-frame.ts b/packages/lynx/src/rspeedy/bridge/translate-device-frame.ts similarity index 100% rename from packages/lynx-dev/src/bridge/translate-device-frame.ts rename to packages/lynx/src/rspeedy/bridge/translate-device-frame.ts diff --git a/packages/lynx-dev/src/bridge/translate-host-message.ts b/packages/lynx/src/rspeedy/bridge/translate-host-message.ts similarity index 100% rename from packages/lynx-dev/src/bridge/translate-host-message.ts rename to packages/lynx/src/rspeedy/bridge/translate-host-message.ts diff --git a/packages/lynx-dev/src/bridge/types.ts b/packages/lynx/src/rspeedy/bridge/types.ts similarity index 100% rename from packages/lynx-dev/src/bridge/types.ts rename to packages/lynx/src/rspeedy/bridge/types.ts diff --git a/packages/lynx-dev/src/server/__tests__/fakes.ts b/packages/lynx/src/rspeedy/server/__tests__/fakes.ts similarity index 100% rename from packages/lynx-dev/src/server/__tests__/fakes.ts rename to packages/lynx/src/rspeedy/server/__tests__/fakes.ts diff --git a/packages/lynx-dev/src/server/__tests__/inspector-socket.test.ts b/packages/lynx/src/rspeedy/server/__tests__/inspector-socket.test.ts similarity index 100% rename from packages/lynx-dev/src/server/__tests__/inspector-socket.test.ts rename to packages/lynx/src/rspeedy/server/__tests__/inspector-socket.test.ts diff --git a/packages/lynx-dev/src/server/__tests__/json-list.test.ts b/packages/lynx/src/rspeedy/server/__tests__/json-list.test.ts similarity index 100% rename from packages/lynx-dev/src/server/__tests__/json-list.test.ts rename to packages/lynx/src/rspeedy/server/__tests__/json-list.test.ts diff --git a/packages/lynx-dev/src/server/index.ts b/packages/lynx/src/rspeedy/server/index.ts similarity index 100% rename from packages/lynx-dev/src/server/index.ts rename to packages/lynx/src/rspeedy/server/index.ts diff --git a/packages/lynx-dev/src/server/inspector-socket.ts b/packages/lynx/src/rspeedy/server/inspector-socket.ts similarity index 100% rename from packages/lynx-dev/src/server/inspector-socket.ts rename to packages/lynx/src/rspeedy/server/inspector-socket.ts diff --git a/packages/lynx-dev/src/server/inspector-targets.ts b/packages/lynx/src/rspeedy/server/inspector-targets.ts similarity index 100% rename from packages/lynx-dev/src/server/inspector-targets.ts rename to packages/lynx/src/rspeedy/server/inspector-targets.ts diff --git a/packages/lynx-dev/src/server/json-list.ts b/packages/lynx/src/rspeedy/server/json-list.ts similarity index 100% rename from packages/lynx-dev/src/server/json-list.ts rename to packages/lynx/src/rspeedy/server/json-list.ts diff --git a/packages/lynx-dev/src/server/logical-device-id.ts b/packages/lynx/src/rspeedy/server/logical-device-id.ts similarity index 100% rename from packages/lynx-dev/src/server/logical-device-id.ts rename to packages/lynx/src/rspeedy/server/logical-device-id.ts diff --git a/packages/lynx-dev/src/server/request-origin.ts b/packages/lynx/src/rspeedy/server/request-origin.ts similarity index 100% rename from packages/lynx-dev/src/server/request-origin.ts rename to packages/lynx/src/rspeedy/server/request-origin.ts diff --git a/packages/lynx-dev/src/transport/__tests__/fakes.ts b/packages/lynx/src/rspeedy/transport/__tests__/fakes.ts similarity index 100% rename from packages/lynx-dev/src/transport/__tests__/fakes.ts rename to packages/lynx/src/rspeedy/transport/__tests__/fakes.ts diff --git a/packages/lynx-dev/src/transport/__tests__/index.test.ts b/packages/lynx/src/rspeedy/transport/__tests__/index.test.ts similarity index 100% rename from packages/lynx-dev/src/transport/__tests__/index.test.ts rename to packages/lynx/src/rspeedy/transport/__tests__/index.test.ts diff --git a/packages/lynx-dev/src/transport/connector-types.ts b/packages/lynx/src/rspeedy/transport/connector-types.ts similarity index 100% rename from packages/lynx-dev/src/transport/connector-types.ts rename to packages/lynx/src/rspeedy/transport/connector-types.ts diff --git a/packages/lynx-dev/src/transport/connector.ts b/packages/lynx/src/rspeedy/transport/connector.ts similarity index 100% rename from packages/lynx-dev/src/transport/connector.ts rename to packages/lynx/src/rspeedy/transport/connector.ts diff --git a/packages/lynx-dev/src/transport/index.ts b/packages/lynx/src/rspeedy/transport/index.ts similarity index 100% rename from packages/lynx-dev/src/transport/index.ts rename to packages/lynx/src/rspeedy/transport/index.ts diff --git a/packages/lynx-dev/src/transport/wire.ts b/packages/lynx/src/rspeedy/transport/wire.ts similarity index 100% rename from packages/lynx-dev/src/transport/wire.ts rename to packages/lynx/src/rspeedy/transport/wire.ts diff --git a/packages/lynx-dev/src/types.ts b/packages/lynx/src/rspeedy/types.ts similarity index 100% rename from packages/lynx-dev/src/types.ts rename to packages/lynx/src/rspeedy/types.ts diff --git a/packages/lynx/tsconfig.json b/packages/lynx/tsconfig.json index c23e61c8..e7bc60b6 100644 --- a/packages/lynx/tsconfig.json +++ b/packages/lynx/tsconfig.json @@ -3,6 +3,12 @@ "files": [], "include": [], "references": [ + { + "path": "../middleware" + }, + { + "path": "../tools" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/lynx/tsconfig.lib.json b/packages/lynx/tsconfig.lib.json index 15cda648..a970e695 100644 --- a/packages/lynx/tsconfig.lib.json +++ b/packages/lynx/tsconfig.lib.json @@ -7,9 +7,15 @@ "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", "emitDeclarationOnly": true, "forceConsistentCasingInFileNames": true, - "types": ["vite/client"], + "types": ["node", "vite/client"], "module": "esnext", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "esModuleInterop": true, + "resolveJsonModule": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "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 0544c2c7..bdce9ab4 100644 --- a/packages/lynx/vite.config.ts +++ b/packages/lynx/vite.config.ts @@ -3,6 +3,16 @@ 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. +// +// `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`). export default defineConfig({ root: __dirname, cacheDir: '../../node_modules/.vite/lynx', @@ -10,6 +20,7 @@ export default defineConfig({ plugins: [ dts({ tsconfigPath: './tsconfig.lib.json', + exclude: ['src/rspeedy.ts', 'src/rspeedy/**'], }), ], build: { diff --git a/packages/lynx/vite.rspeedy.config.ts b/packages/lynx/vite.rspeedy.config.ts new file mode 100644 index 00000000..480cc396 --- /dev/null +++ b/packages/lynx/vite.rspeedy.config.ts @@ -0,0 +1,71 @@ +/// +import { defineConfig } from 'vite'; +import path, { resolve } from 'node:path'; +import dts from 'vite-plugin-dts'; +import packageJson from './package.json' with { type: 'json' }; + +// Builds the rspeedy/Rsbuild plugin entry (`./rspeedy` — `src/rspeedy.ts` +// plus its `src/rspeedy/**` support modules, formerly the whole of +// `@rozenite/lynx-dev`). Unlike `vite.config.ts` (the device-runtime +// build), this is `ssr: true` with every package dependency externalised: +// the plugin runs in the developer's Node process as part of their +// `lynx.config.ts`, not in the app bundle, so it should behave like any +// other Node package build rather than being inlined — and inlining +// `express`/`ws`/`@lynx-js/debug-router-connector` would be actively wrong +// for a couple of them (see `./src/rspeedy/transport/connector.ts` on why +// `@lynx-js/debug-router-connector` in particular must stay a real +// `require()` at call time). +// +// `emptyOutDir: false` is load-bearing: this build runs second (see this +// package's `build` script), and the default `emptyOutDir: true` would +// delete the device-runtime output `vite.config.ts` just produced. +const dependencies = Object.keys(packageJson.dependencies || {}); + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/lynx-rspeedy', + base: './', + plugins: [ + dts({ + // NOT `rollupTypes: true` here, unlike the original standalone + // `@rozenite/lynx-dev` config this was adapted from. Verified + // empirically that it cannot work for a *second* entry sharing one + // `package.json`: vite-plugin-dts's rollup step picks its single + // bundled output path from `package.json`'s top-level `types` field + // (falling back through `typings`/`exports.types`/`exports["."].types`), + // never from this build's own `lib.entry` — so it targeted + // `dist/index.d.ts` (this package's `types` field, correct for the + // *device-runtime* entry) and, finding that file already written by + // `vite.config.ts`, skipped emitting anything for this entry at all. + // Emitting un-rolled, per-module declarations (mirroring `src/`, the + // same shape the device-runtime build above already produces) + // sidesteps that entirely: `entryRoot: 'src'` naturally lands this + // entry's output at `dist/rspeedy.d.ts` (importing the rest of + // `dist/rspeedy/**/*.d.ts` relatively), matching `./rspeedy`'s + // `dist/rspeedy.js`/`dist/rspeedy.cjs` with no special-casing needed. + entryRoot: 'src', + // `tsconfig.lib.json`'s `include` covers the whole of `src/**/*.ts` + // (both entries share one tsconfig), so this needs its own filter or + // vite-plugin-dts also walks the device-runtime files, which sit + // outside this entry's dependency graph but not outside `entryRoot` + // now that it is `src`. + include: ['src/rspeedy.ts', 'src/rspeedy/**/*.ts'], + tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'), + }), + ], + build: { + emptyOutDir: false, + ssr: true, + lib: { + entry: resolve(__dirname, 'src/rspeedy.ts'), + fileName: 'rspeedy', + formats: ['es' as const, 'cjs' as const], + }, + rollupOptions: { + external: dependencies, + }, + }, + test: { + passWithNoTests: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a86eadb0..e7b91874 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,9 +383,6 @@ 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 +411,9 @@ importers: '@lynx-js/types': specifier: 4.1.0 version: 4.1.0 - '@rozenite/lynx-dev': + '@rozenite/lynx': specifier: workspace:* - version: link:../../packages/lynx-dev + 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) @@ -897,16 +894,6 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@18.16.9)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0(supports-color@8.1.1))(lightningcss@1.32.0)(supports-color@8.1.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1) packages/lynx: - dependencies: - tslib: - specifier: ^2.3.0 - version: 2.8.1 - devDependencies: - vitest: - specifier: ^4.0.18 - version: 4.1.0(@types/node@18.16.9)(@vitest/ui@3.2.4(vitest@3.2.4))(jsdom@22.1.0(supports-color@8.1.1))(vite@7.3.5(@types/node@18.16.9)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1)) - - packages/lynx-dev: dependencies: '@lynx-js/debug-router-connector': specifier: ^0.0.15 @@ -940,6 +927,16 @@ importers: specifier: ^4.0.18 version: 4.1.0(@types/node@18.16.9)(@vitest/ui@3.2.4(vitest@3.2.4))(jsdom@22.1.0(supports-color@8.1.1))(vite@7.3.5(@types/node@18.16.9)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1)) + packages/lynx-dev: + dependencies: + '@rozenite/lynx': + specifier: workspace:* + version: link:../lynx + devDependencies: + vitest: + specifier: ^4.0.18 + version: 4.1.0(@types/node@18.16.9)(@vitest/ui@3.2.4(vitest@3.2.4))(jsdom@22.1.0(supports-color@8.1.1))(vite@7.3.5(@types/node@18.16.9)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1)) + packages/metro: dependencies: '@rozenite/middleware': @@ -9214,10 +9211,6 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -19035,26 +19028,26 @@ snapshots: '@internationalized/date@3.12.0': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 '@internationalized/message@3.1.8': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 intl-messageformat: 10.7.18 '@internationalized/number@3.6.5': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 '@internationalized/string@3.2.7': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -19715,8 +19708,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.1 optional: true @@ -20453,7 +20446,7 @@ snapshots: '@react-types/autocomplete': 3.0.0-alpha.38(react@19.2.3) '@react-types/button': 3.15.1(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -20517,7 +20510,7 @@ snapshots: '@react-aria/ssr': 3.9.10(react@19.2.3) '@react-aria/utils': 3.33.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) @@ -20618,7 +20611,7 @@ snapshots: '@react-stately/dnd': 3.7.4(react@19.2.3) '@react-types/button': 3.15.1(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -20627,7 +20620,7 @@ snapshots: '@react-aria/interactions': 3.27.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-aria/utils': 3.33.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 clsx: 2.1.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -20694,7 +20687,7 @@ snapshots: '@react-aria/utils': 3.33.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-stately/flags': 3.1.2 '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -20741,7 +20734,7 @@ snapshots: '@react-aria/live-announcer@3.4.4': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 '@react-aria/menu@3.21.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -20801,7 +20794,7 @@ snapshots: '@react-types/button': 3.15.1(react@19.2.3) '@react-types/overlays': 3.9.4(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -20980,7 +20973,7 @@ snapshots: '@react-stately/utils': 3.11.0(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) '@react-types/textfield': 3.12.8(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -21014,7 +21007,7 @@ snapshots: '@react-aria/i18n': 3.12.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-aria/utils': 3.33.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -21060,7 +21053,7 @@ snapshots: '@react-aria/utils': 3.33.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-stately/virtualizer': 4.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -21540,7 +21533,7 @@ snapshots: '@react-stately/autocomplete@3.0.0-beta.4(react@19.2.3)': dependencies: '@react-stately/utils': 3.11.0(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 '@react-stately/calendar@3.9.3(react@19.2.3)': @@ -21627,7 +21620,7 @@ snapshots: '@react-stately/flags@3.1.2': dependencies: - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 '@react-stately/form@3.2.4(react@19.2.3)': dependencies: @@ -21652,7 +21645,7 @@ snapshots: '@react-types/grid': 3.3.8(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) '@react-types/table': 3.13.6(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -21721,7 +21714,7 @@ snapshots: '@react-stately/collections': 3.12.10(react@19.2.3) '@react-stately/utils': 3.11.0(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 '@react-stately/slider@3.7.5(react@19.2.3)': @@ -21742,7 +21735,7 @@ snapshots: '@react-types/grid': 3.3.8(react@19.2.3) '@react-types/shared': 3.33.1(react@19.2.3) '@react-types/table': 3.13.6(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 '@react-stately/tabs@3.8.9(react@19.2.3)': @@ -21791,7 +21784,7 @@ snapshots: '@react-stately/virtualizer@4.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@react-types/shared': 3.33.1(react@19.2.3) - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.23 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -25980,8 +25973,6 @@ snapshots: cookie-signature@1.2.2: {} - cookie@0.7.1: {} - cookie@0.7.2: {} cookie@1.1.1: {} @@ -26803,7 +26794,7 @@ snapshots: es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -27621,7 +27612,7 @@ snapshots: body-parser: 1.20.3(supports-color@8.1.1) content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.7.1 + cookie: 0.7.2 cookie-signature: 1.0.6 debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 @@ -27656,7 +27647,7 @@ snapshots: body-parser: 2.2.0(supports-color@8.1.1) content-disposition: 1.0.0 content-type: 1.0.5 - cookie: 0.7.1 + cookie: 0.7.2 cookie-signature: 1.2.2 debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -28595,7 +28586,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.4 side-channel: 1.1.0 internmap@2.0.3: {} @@ -33259,7 +33250,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 string.prototype.matchall@4.0.12: dependencies: @@ -34562,7 +34553,7 @@ snapshots: dependencies: ansi-styles: 6.2.1 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 wrappy@1.0.2: {} diff --git a/website/src/docs/rozenite-for-lynx.mdx b/website/src/docs/rozenite-for-lynx.mdx index 2a994126..2d932421 100644 --- a/website/src/docs/rozenite-for-lynx.mdx +++ b/website/src/docs/rozenite-for-lynx.mdx @@ -8,7 +8,7 @@ Rozenite for Lynx is experimental. It has been verified against LynxExplorer on Rozenite for Lynx lets you debug a [Lynx](https://lynxjs.org) app with the same DevTools panels, the same plugin catalogue and the same `rozenite` CLI you use for React Native. -You need three things: `@rozenite/lynx-dev` in your dev server, `@rozenite/lynx` in your app, and **Lynx DevTool turned on in the app itself** — that last one is off by default and nothing works without it. Start there. +You need two things: `@rozenite/lynx` in your dev server, and **Lynx DevTool turned on in the app itself** — that last one is off by default and nothing works without it. Start there. ## Turn on Lynx DevTool @@ -18,11 +18,11 @@ Lynx ships its DevTool component switched off. With it off, your app never regis **In your own app**, enable it where you initialise the Lynx environment — `LynxEnv.inst().enableDevtool(true)` on Android, `devtoolEnabled = YES` on iOS. On iOS you also need `enableAllSessions` on `LynxServiceDevToolProtocol`, or your cards won't be offered for debugging. Lynx's [Integrate Lynx DevTool](https://lynxjs.org/guide/start/integrate-lynx-devtool.html) guide has the full setup for each platform. -## Add @rozenite/lynx-dev to your dev server +## Add @rozenite/lynx to your dev server ### Installation - + ### Configuration @@ -31,35 +31,21 @@ Add the plugin to your `lynx.config.ts`: ```typescript import { defineConfig } from '@lynx-js/rspeedy'; import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; -import { rozeniteLynxPlugin } from '@rozenite/lynx-dev'; +import { rozeniteLynxPlugin } from '@rozenite/lynx/rspeedy'; export default defineConfig({ plugins: [pluginReactLynx(), rozeniteLynxPlugin()], }); ``` -The plugin is inert in production builds. Pass `enabled` explicitly if you want to control that yourself. +That's it — there is nothing to add to your app's own source. The plugin +injects the device runtime for you ahead of your app's entry point, and only +in development: it is inert in production builds (pass `enabled` explicitly +if you want to control that yourself), and it never runs at all during +`rspeedy build`. Rozenite plugins are discovered from your project's `package.json` exactly as they are for React Native — install a plugin as a dependency and it shows up. -## Add @rozenite/lynx to your app - -### Installation - - - -### Entry point - -Import it once, as early as possible in your app's entry point. It has to run before any plugin's `useRozeniteDevToolsClient` does. - -```typescript -if (__DEV__) { - require('@rozenite/lynx'); -} -``` - -It installs itself only in Lynx's background runtime and is a no-op everywhere else. - ## Open DevTools 1. Connect your device over USB, or start your simulator, and open your app. @@ -85,7 +71,7 @@ Note that `enableDesktop` is an iOS Simulator concern only. An Android emulator **Your target list is empty.** Nine times out of ten, Lynx DevTool is off — see [Turn on Lynx DevTool](#turn-on-lynx-devtool), and remember it needs an app restart. Otherwise: check the app is a development build, and that a card is actually open (an app sitting on a native screen has no cards to debug). On Android, `adb devices` should list the phone or emulator — if it doesn't, Rozenite can't see it either. On the iOS Simulator, check you haven't turned `enableDesktop` off. -**Your card shows up but panels stay disconnected.** The app is reachable but Rozenite isn't running inside it. Check `@rozenite/lynx` is imported at the entry point and that the import actually runs in your development build. +**Your card shows up but panels stay disconnected.** The app is reachable but Rozenite isn't running inside it. `rozeniteLynxPlugin()` only injects the device runtime for `rspeedy dev` — never for `rspeedy build`, and not for `rspeedy preview` either, which runs with `NODE_ENV=production`. Check you're on the dev server, and that `enabled` hasn't been explicitly turned off in your `lynx.config.ts`. **A panel connects but never receives anything.** Most likely the plugin's device half is written against React Native APIs — see [What doesn't work yet](#what-doesnt-work-yet).