mithril-lynx 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -300,10 +300,45 @@ Tests run against `@lynx-js/testing-environment`'s jsdom-backed PAPI polyfill. T
300
300
 
301
301
  **Not provided**: Fast Refresh and a devtools/inspector bundle (project plan, Phase 9 / subsystem 12) are explicitly out of scope — they're deep, compiler-driven DX features in upstream ReactLynx with no zero-compiler equivalent worth building.
302
302
 
303
- In development, `pluginMithrilLynx()` adds a small background-thread client even
304
- when the app has no `background.ts`. After a successful rebuild it reloads the
305
- bundle through Lynx Go's `ExplorerModule.openSchema()`, rather than attempting
306
- module HMR (Mithril views run in the separate main-thread/Lepus bundle). This
307
- is intentionally a **Lynx Go viewer** convenience, not a portable SDK API: a
308
- different viewer or a final native host that does not provide `ExplorerModule`
309
- will log a warning and must be reloaded manually.
303
+ In development, `pluginMithrilLynx()` reloads the running page after every
304
+ successful rebuild instead of attempting module HMR — Mithril view code runs in
305
+ the main-thread/Lepus bundle, and rspack's hot runtime can only patch modules on
306
+ the background thread, so HMR could never reach it.
307
+
308
+ The default strategy (`liveReload: true`) is an **in-bundle client**: a small
309
+ WebSocket client (`src/dev-reload-client.js`) is injected into a synthetic
310
+ background chunk and runs on the device itself. On a successful rebuild it sends
311
+ CDP `Page.reload` through `NativeModules.LynxDevToolSetModule.invokeCdp` — the
312
+ same mechanism Rspeedy's own live-reload client uses
313
+ (`@lynx-js/webpack-dev-transport/lib/client/reloadApp.js`) — with a cache-busted
314
+ URL. That reloads the existing page **in place**, so nothing is pushed onto the
315
+ viewer's back stack. Because the client is *in* the bundle, the only network hop
316
+ is the WebSocket itself: **the reload works over Wi-Fi alone — no adb, no cable**
317
+ (the initial load via QR/URL is also over Wi-Fi). Three consequences worth
318
+ knowing:
319
+
320
+ - **Why not module HMR**: real HMR would need to patch main-thread/Lepus view
321
+ code, which rspack's hot runtime can only reach from the background thread —
322
+ an architectural mismatch, not a bug to fix. The plugin forces `dev.hmr` off.
323
+ - **Why not `lynx.reload()`**: on current engines it re-executes the main-thread
324
+ bundle against the live session without re-injecting
325
+ `removeComponents`/`updatePage`, crashing with two TypeErrors (measured on a
326
+ real device). It is kept only as an in-client fallback for viewers/SDKs whose
327
+ reload path is intact.
328
+ - It is a **dev-server convenience, not a portable SDK API**: the client is only
329
+ ever imported in dev builds, and no reload code is bundled into the app in a
330
+ production build.
331
+
332
+ Two alternative strategies exist, selected via the `liveReload` option:
333
+
334
+ - **`liveReload: "devtool"`** — the 0.0.8 strategy. The reload runs from the
335
+ Node dev-server process via `@lynx-js/devtool-connector`, which reaches the
336
+ device over adb (it sets up an adb reverse tunnel to the device's DebugRouter);
337
+ with no adb connection the rebuild logs a warning and you reload by hand.
338
+ Wireless debugging (`adb connect <ip>:5555`) satisfies this with no cable, and
339
+ `ANDROID_SERIAL` picks the device when several are attached. Use this for
340
+ viewers/SDKs that don't expose the in-bundle DevTool CDP bridge module.
341
+ - **`liveReload: false`** — no live reload at all; reload the page by hand.
342
+
343
+ The full 0.0.7 → 0.0.8 → 0.0.9 history and the measured on-device evidence live
344
+ in `NETWORK_LIVE_RELOAD_INVESTIGATION.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mithril-lynx",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Mithril.js rendered through Lynx's Element PAPI \u2014 a contract-complete port of mithril/render/render.js@2.3.8 to the Lynx main thread, packaged as a reusable framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -87,6 +87,7 @@
87
87
  "test": "rstest run"
88
88
  },
89
89
  "dependencies": {
90
+ "@lynx-js/devtool-connector": "^0.13.2",
90
91
  "@lynx-js/runtime-wrapper-webpack-plugin": "^0.2.4",
91
92
  "@lynx-js/template-webpack-plugin": "^0.16.0"
92
93
  },
package/plugin.d.ts CHANGED
@@ -6,6 +6,26 @@ import type { RsbuildPlugin } from "@lynx-js/rspeedy";
6
6
  export interface PluginMithrilLynxOptions {
7
7
  /** Lynx engine target SDK version. Defaults to "3.5". */
8
8
  targetSdkVersion?: string;
9
+ /**
10
+ * Opt back into module-level HMR. Defaults to false, which the plugin forces
11
+ * because rspack's hot runtime can only patch modules on the background
12
+ * thread, while Mithril view code always runs on the main thread — so HMR
13
+ * adds hot-update error noise without ever being able to update anything.
14
+ */
15
+ hmr?: boolean;
16
+ /**
17
+ * Reload the running page after each successful dev rebuild. Defaults to
18
+ * true. `true` uses the in-bundle client (`src/dev-reload-client.js`):
19
+ * a WebSocket client injected into a synthetic background chunk that sends
20
+ * CDP `Page.reload` via `NativeModules.LynxDevToolSetModule.invokeCdp`
21
+ * (cache-busted URL) — works over Wi-Fi alone, no adb, no back-stack
22
+ * corruption. `"devtool"` uses the 0.0.8 strategy: the Node dev-server
23
+ * process sends CDP `Page.reload` through
24
+ * `@lynx-js/devtool-connector`, which reaches the device over adb — the
25
+ * fallback for viewers/SDKs without the in-bundle DevTool CDP bridge.
26
+ * `false` disables live reload.
27
+ */
28
+ liveReload?: boolean | "devtool";
9
29
  }
10
30
 
11
31
  /**
package/plugin.js CHANGED
@@ -28,36 +28,90 @@ const PLUGIN_NAME = "mithril-lynx-template-webpack";
28
28
  const BACKGROUND_CANDIDATES = ["background.ts", "background.js"];
29
29
  const STYLE_CANDIDATES = ["style.css"];
30
30
 
31
- // Live reload's client only runs where a full JS engine with Native Module
32
- // access exists: the background/JS thread. The main-thread/Lepus VM cannot
33
- // use it. An app without background.ts therefore gets a synthetic background
34
- // entry in development; see `includeBackground` below. Imported by this
35
- // resolved absolute path directly (see the entry-construction loop below),
36
- // not through a "mithril-lynx/..." bare specifier — that collides with the
37
- // package's own broader "mithril-lynx" resolve alias.
31
+ // ---------------------------------------------------------------------------
32
+ // Live reload (dev only)
33
+ //
34
+ // Two strategies, both triggered by a successful rebuild, picked by the
35
+ // `liveReload` option:
36
+ //
37
+ // - `true` (default): the "in-bundle" client. A small WebSocket client
38
+ // (src/dev-reload-client.js) is injected into a synthetic background chunk
39
+ // and runs on the device; on an `ok` message it sends a CDP `Page.reload`
40
+ // through `NativeModules.LynxDevToolSetModule.invokeCdp` — the same
41
+ // mechanism Rspeedy's own live-reload client uses
42
+ // (@lynx-js/webpack-dev-transport/lib/client/reloadApp.js) — with a
43
+ // cache-busted URL. Page.reload reloads the CURRENT page in place without
44
+ // starting a new Activity (verified in 0.0.8). This works over Wi-Fi alone
45
+ // (the WebSocket is the only network hop): no adb, no cable. Requires the
46
+ // viewer SDK to expose the DevTool CDP bridge native module (Lynx Go does).
47
+ //
48
+ // - `"devtool"`: the 0.0.8 strategy. Runs from the Node dev-server process
49
+ // via @lynx-js/devtool-connector (adb on Android) and sends CDP
50
+ // `Page.reload` to the session serving this app's bundle. Also reloads in
51
+ // place (no back-stack corruption), but needs the device on adb — so it is
52
+ // the fallback for viewers/SDKs where the in-bundle CDP bridge is missing.
53
+ //
54
+ // Why not real module HMR: mithril view code lives in the main-thread/Lepus
55
+ // chunk, and rspack's hot runtime can only patch modules in the registry it
56
+ // runs from — the background/JS thread. That is an architectural mismatch,
57
+ // not a bug to fix. dev.hmr is forced off regardless of strategy.
58
+ //
59
+ // Why not lynx.reload() (0.0.9's original mechanism): on the device's engine
60
+ // (reported 3.5) it re-executes the main-thread bundle against the live
61
+ // session but does not re-inject the runtime helpers
62
+ // removeComponents/updatePage, so the render crashes with two TypeErrors and
63
+ // the background chunk never re-runs (socket dies; later builds ignored). Kept
64
+ // only as an in-client fallback for viewers whose reload path is intact.
65
+ //
66
+ // Why not ExplorerModule.openSchema(url) (0.0.7's mechanism): it works over
67
+ // Wi-Fi, but it is a real navigation — Lynx Go starts a NEW
68
+ // LynxViewShellActivity every time and never finishes the one it replaces, so
69
+ // Back steps through one frozen snapshot per reload. Kept only as an
70
+ // in-client fallback for viewers lacking `lynx.reload`.
71
+ //
72
+ // Why not lynx.fetchBundle + lynx.loadScript: in lynx-core these are the
73
+ // lazy-bundle path (LazyBundleLoader / LoadCustomSectionScript) — they load a
74
+ // customSection of a SEPARATE lazily-fetched bundle, not the page's own
75
+ // main-thread/Lepus template, so they cannot re-render the current page.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ // The in-bundle client only runs where a full JS engine with WebSocket + the
79
+ // `lynx` global exists: the background/JS thread. The main-thread/Lepus VM
80
+ // cannot use it. An app without background.ts therefore gets a synthetic
81
+ // background entry in development; see `includeBackground` below. Imported by
82
+ // its resolved absolute path (plus the query string the client reads its
83
+ // config from) rather than through the bare "mithril-lynx/dev-reload-client"
84
+ // specifier + an alias: the package-wide "mithril-lynx" prefix alias set
85
+ // below matches that specifier first regardless of registration order
86
+ // (webpack/rspack's resolve.alias picks the first matching entry, not the
87
+ // most specific one), which broke the import entirely in 0.0.7.
38
88
  const DEV_RELOAD_CLIENT_PATH = path.join(
39
89
  path.dirname(fileURLToPath(import.meta.url)),
40
90
  "src",
41
91
  "dev-reload-client.js",
42
92
  );
43
93
 
94
+ /**
95
+ * Builds the query string the in-bundle dev-reload client reads its config
96
+ * from: the WebSocket endpoint to connect to and the bundle URL it would
97
+ * fall back to (ExplorerModule.openSchema). Mirrors what
98
+ * `@lynx-js/rsbuild-plugin` bakes into `@lynx-js/webpack-dev-transport/client`.
99
+ */
44
100
  function createDevReloadClientQuery(api, environment, entryName) {
45
101
  const config = environment.config ?? {};
46
102
  const dev = config.dev ?? {};
47
103
  const server = config.server ?? {};
48
104
  const devServer = api.context.devServer ?? {};
49
- // Rsbuild resolves this to the LAN address it advertises when server.host is
50
- // 0.0.0.0, which is the address a physical Lynx Go device can actually use.
105
+ // Rsbuild resolves this to the LAN address it advertises when server.host
106
+ // is 0.0.0.0, which is the address a physical Lynx Go device can use.
51
107
  const hostname = dev.client?.host || devServer.hostname || server.host || "";
52
108
  const port = devServer.port ?? server.port ?? "";
53
109
  const protocol = devServer.https ? "https" : "http";
54
110
  // At this point in the pipeline Rsbuild hasn't started the dev server yet,
55
- // so `dev.assetPrefix` (when it's the default, host-derived one — see
56
- // @lynx-js/rsbuild-plugin) still contains the literal, unsubstituted
57
- // "<port>" placeholder it's only resolved to a real port number later, in
58
- // its own `printUrls` callback. `hostname`/`port` above are already the
59
- // real values, though, so resolve the placeholder the same way that
60
- // callback does rather than trusting assetPrefix as pre-resolved.
111
+ // so `dev.assetPrefix` (when it's the default, host-derived one) still
112
+ // contains the literal "<port>" placeholder it's only resolved to a real
113
+ // port number later. `hostname`/`port` above are already the real values,
114
+ // so resolve the placeholder the same way rather than trusting assetPrefix.
61
115
  const assetPrefix = (typeof dev.assetPrefix === "string" ? dev.assetPrefix : "/").replaceAll(
62
116
  "<port>",
63
117
  String(port),
@@ -65,18 +119,195 @@ function createDevReloadClientQuery(api, environment, entryName) {
65
119
  const base = /^https?:\/\//.test(assetPrefix)
66
120
  ? assetPrefix
67
121
  : `${protocol}://${hostname}${port ? `:${port}` : ""}${assetPrefix}`;
68
- const bundleUrl = new URL(`${entryName}.bundle`, base.endsWith("/") ? base : `${base}/`).toString();
122
+ const clientBundleUrl = new URL(
123
+ `${entryName}.bundle`,
124
+ base.endsWith("/") ? base : `${base}/`,
125
+ ).toString();
69
126
  const params = new URLSearchParams({
70
127
  hostname,
71
128
  port: String(port),
72
129
  pathname: "/rsbuild-hmr",
73
130
  protocol: devServer.https ? "wss" : "ws",
74
- "bundle-url": bundleUrl,
131
+ "bundle-url": clientBundleUrl,
75
132
  });
76
133
  if (environment.webSocketToken) params.set("token", environment.webSocketToken);
77
134
  return params.toString();
78
135
  }
79
136
 
137
+ // --- Devtool-connector strategy (liveReload: "devtool") --------------------
138
+ // Everything below through closeDevtoolTransport()/describeReloadFailure() is
139
+ // the 0.0.8 adb/CDP path, used only when the in-bundle client can't run (e.g.
140
+ // a viewer whose SDK predates the DevTool CDP bridge native module). It is
141
+ // otherwise dormant.
142
+
143
+ /** Lynx Go's own shell page is a Lynx session too — never a reload target. */
144
+ const VIEWER_SHELL_BUNDLE = "homepage.lynx.bundle";
145
+
146
+ let connectorPromise;
147
+ let devtoolTransport;
148
+
149
+ /**
150
+ * Lazily loads the DevTool connector. Kept lazy so `@lynx-js/devtool-connector`
151
+ * is only ever loaded by a dev rebuild, never by a production build.
152
+ */
153
+ function getDevtoolConnector() {
154
+ if (!connectorPromise) {
155
+ connectorPromise = Promise.all([
156
+ import("@lynx-js/devtool-connector"),
157
+ import("@lynx-js/devtool-connector/transport"),
158
+ ])
159
+ .then(([{ Connector }, { AndroidTransport }]) => {
160
+ devtoolTransport = new AndroidTransport();
161
+ return new Connector([devtoolTransport]);
162
+ })
163
+ .catch((error) => {
164
+ // Don't cache a rejection: the usual cause is the package not
165
+ // being installed yet, and the dev server outlives an
166
+ // `npm install`.
167
+ connectorPromise = undefined;
168
+ throw error;
169
+ });
170
+ }
171
+ return connectorPromise;
172
+ }
173
+
174
+ /** Last path segment of a URL, ignoring any query string or fragment. */
175
+ function bundleBasename(url) {
176
+ if (typeof url !== "string") return "";
177
+ const withoutQuery = url.split("?")[0].split("#")[0];
178
+ const segments = withoutQuery.split("/");
179
+ return segments[segments.length - 1] || withoutQuery;
180
+ }
181
+
182
+ function isViewerShell(url) {
183
+ return bundleBasename(url) === VIEWER_SHELL_BUNDLE;
184
+ }
185
+
186
+ /**
187
+ * Adds a unique query parameter to a bundle URL.
188
+ *
189
+ * `Page.reload` on its own is not enough to pick up a rebuild: measured
190
+ * on-device, a reload without this re-fetched and re-ran the PREVIOUS bundle
191
+ * (the loaded template stayed byte-for-byte the old one, and the old text
192
+ * stayed on screen) even with `ignoreCache: true`. Both the HTTP layer and
193
+ * Lynx's own bytecode cache are keyed by URL, so changing the URL is what
194
+ * actually invalidates them. The session's own URL is unaffected — the DevTools
195
+ * reference notes it does not change after a reload, and that was confirmed
196
+ * here too.
197
+ *
198
+ * Returns undefined for anything that isn't an http(s) URL, in which case the
199
+ * caller lets Page.reload use the URL it already has (it rejects anything else).
200
+ */
201
+ export function cacheBustedUrl(url, now = Date.now()) {
202
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return undefined;
203
+ const [base, query = ""] = url.split("?");
204
+ const params = new URLSearchParams(query);
205
+ params.set("t", String(now));
206
+ return `${base}?${params.toString()}`;
207
+ }
208
+
209
+ function matchesHint(url, hints) {
210
+ if (typeof url !== "string" || hints.length === 0) return false;
211
+ return hints.some((hint) => url.includes(hint));
212
+ }
213
+
214
+ /**
215
+ * Chooses which client/session to reload: `targets` is `[{ client, sessions }]`,
216
+ * returns `{ clientId, sessionId, url }` or null.
217
+ *
218
+ * Pure and exported so it can be tested without a device attached.
219
+ */
220
+ export function pickReloadTarget(targets, { bundleHints = [] } = {}) {
221
+ const candidates = [];
222
+ for (const { client, sessions } of targets) {
223
+ for (const session of sessions ?? []) {
224
+ if (session?.type !== "lynx") continue;
225
+ if (isViewerShell(session.url)) continue;
226
+ candidates.push({ clientId: client.id, session });
227
+ }
228
+ }
229
+ if (candidates.length === 0) return null;
230
+
231
+ // Prefer the session actually serving one of this app's bundles over
232
+ // "whatever was opened most recently": with a second Lynx app, or a second
233
+ // attached device, the newest session need not be ours.
234
+ const preferred = candidates.filter((candidate) => matchesHint(candidate.session.url, bundleHints));
235
+ const pool = preferred.length > 0 ? preferred : candidates;
236
+
237
+ const latest = pool.reduce((a, b) => (b.session.session_id > a.session.session_id ? b : a));
238
+ return {
239
+ clientId: latest.clientId,
240
+ sessionId: latest.session.session_id,
241
+ url: latest.session.url,
242
+ };
243
+ }
244
+
245
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
246
+
247
+ async function listClientSessions(connector) {
248
+ const clients = await connector.listClients();
249
+ const targets = [];
250
+ for (const client of clients) {
251
+ try {
252
+ targets.push({ client, sessions: await connector.sendListSessionMessage(client.id) });
253
+ } catch {
254
+ // This client doesn't support session listing, or isn't ready yet.
255
+ }
256
+ }
257
+ return targets;
258
+ }
259
+
260
+ /**
261
+ * Finds a session worth reloading, retrying briefly: on the very first rebuild
262
+ * the DevTool client may not have registered with the device yet.
263
+ */
264
+ async function findReloadTarget(bundleHints, { attempts = 3, delayMs = 400 } = {}) {
265
+ const connector = await getDevtoolConnector();
266
+ for (let attempt = 0; attempt < attempts; attempt++) {
267
+ const target = pickReloadTarget(await listClientSessions(connector), { bundleHints });
268
+ if (target) return { connector, target };
269
+ if (attempt < attempts - 1) await sleep(delayMs);
270
+ }
271
+ return { connector, target: null };
272
+ }
273
+
274
+ /**
275
+ * Reloads the running page in place. Returns true when a session was reloaded,
276
+ * false when none was found (the caller logs that).
277
+ */
278
+ async function reloadViaDevtool(bundleHints) {
279
+ const { connector, target } = await findReloadTarget(bundleHints);
280
+ if (!target) return false;
281
+
282
+ const params = { ignoreCache: true };
283
+ // Without a changed URL the device replays its cached copy of the previous
284
+ // bundle — see cacheBustedUrl(). The session URL itself does not change.
285
+ const url = cacheBustedUrl(target.url);
286
+ if (url != null) params.url = url;
287
+
288
+ await connector.sendCDPMessage(target.clientId, target.sessionId, "Page.reload", params);
289
+ console.info(
290
+ `[mithril-lynx] Reloaded ${target.url || "(url unknown)"} (session ${target.sessionId}${url != null ? "" : ", no cache-busting: non-http url"}).`,
291
+ );
292
+ return true;
293
+ }
294
+
295
+ /** Releases the adb connection when the dev server goes away. */
296
+ async function closeDevtoolTransport() {
297
+ const transport = devtoolTransport;
298
+ devtoolTransport = undefined;
299
+ connectorPromise = undefined;
300
+ await transport?.close?.();
301
+ }
302
+
303
+ /** Turns a connector failure into an actionable one-liner. */
304
+ function describeReloadFailure(error) {
305
+ if (error?.code === "ERR_MODULE_NOT_FOUND" || /devtool-connector/.test(error?.message ?? "")) {
306
+ return "Live reload is off: @lynx-js/devtool-connector is not installed. Run `npm install @lynx-js/devtool-connector`, then restart the dev server.";
307
+ }
308
+ return `Live reload unavailable: ${error instanceof Error ? error.message : String(error)}`;
309
+ }
310
+
80
311
  function findSibling(dir, candidates) {
81
312
  for (const name of candidates) {
82
313
  const candidate = path.join(dir, name);
@@ -110,6 +341,17 @@ function packageRootOf(resolvedFile) {
110
341
  export function pluginMithrilLynx(options = {}) {
111
342
  const targetSdkVersion = options.targetSdkVersion ?? "3.5";
112
343
  const hmr = options.hmr ?? false;
344
+ // `true` (default): in-bundle client over Wi-Fi via CDP Page.reload (see
345
+ // src/dev-reload-client.js) — no adb. `"devtool"`: 0.0.8's adb/CDP
346
+ // Page.reload path (fallback for viewers without the in-bundle CDP bridge).
347
+ // `false`: off.
348
+ const liveReload = options.liveReload ?? true;
349
+ const useDevtoolReload = liveReload === "devtool";
350
+
351
+ // Filled in by modifyBundlerChain below with "<entry>.bundle" for every
352
+ // configured entry, so a reload prefers the session actually serving this
353
+ // app over whichever Lynx session happens to be newest (devtool mode only).
354
+ const bundleHints = new Set();
113
355
 
114
356
  return {
115
357
  name: PLUGIN_NAME,
@@ -123,10 +365,11 @@ export function pluginMithrilLynx(options = {}) {
123
365
  // HMR's eval'd *.hot-update.js chunks also aren't runtime-wrapped
124
366
  // the way the real background.js bundle is, and fail native-side
125
367
  // with "ReferenceError: exports is not defined" if hot is left on.
126
- // Force dev.hmr off (unless the app explicitly set it) so the dev
127
- // client's ok() handler takes its other branch -- a full native
128
- // Page.reload -- which is the one live-reload path this framework
129
- // actually supports cleanly. dev.liveReload stays at its default.
368
+ // Force dev.hmr off (unless the app explicitly set it) -- real HMR
369
+ // can't reach this framework's app code regardless of whether the
370
+ // reload strategy is in-bundle (CDP Page.reload) or devtool (Node
371
+ // process, CDP Page.reload), so leaving it on only adds error noise
372
+ // for no benefit.
130
373
  api.modifyRsbuildConfig({
131
374
  // Not a plain default: Rsbuild has already stamped dev.hmr:true onto
132
375
  // the config by the time ANY hook sees it (even api.getRsbuildConfig
@@ -139,6 +382,34 @@ export function pluginMithrilLynx(options = {}) {
139
382
  order: "post",
140
383
  });
141
384
 
385
+ // Live reload (devtool strategy only): on every successful dev rebuild
386
+ // (skipping the first), find the running Lynx session over adb and
387
+ // CDP-reload it in place. The default in-bundle strategy needs none of
388
+ // this — its client (injected below into a synthetic background chunk)
389
+ // drives the reload itself via CDP Page.reload.
390
+ if (useDevtoolReload) {
391
+ api.onAfterDevCompile(async ({ isFirstCompile, stats }) => {
392
+ if (isFirstCompile || stats.hasErrors()) return;
393
+ let reloaded = false;
394
+ try {
395
+ reloaded = await reloadViaDevtool([...bundleHints]);
396
+ } catch (error) {
397
+ console.warn(`[mithril-lynx] ${describeReloadFailure(error)}`);
398
+ return;
399
+ }
400
+ if (!reloaded) {
401
+ console.warn(
402
+ "[mithril-lynx] Live reload unavailable: no Lynx session found for this app. " +
403
+ "Is the device connected over adb with the page open in Lynx Go? Reload manually.",
404
+ );
405
+ }
406
+ });
407
+
408
+ // The transport owns adb port-forwards; don't let them outlive
409
+ // the dev server.
410
+ api.onCloseDevServer?.(closeDevtoolTransport);
411
+ }
412
+
142
413
  api.modifyBundlerChain((chain, { isDev, environment }) => {
143
414
  // mithril-lynx's own src/lynx-mithril-shim.js deep-imports mithril's
144
415
  // internal render/cachedAttrsIsStaticMap.js (and its emptyAttrs
@@ -211,27 +482,30 @@ export function pluginMithrilLynx(options = {}) {
211
482
  const bgAsset = `.rspeedy/${name}/background.js`;
212
483
  const mtAsset = `.rspeedy/${name}/main-thread.js`;
213
484
  const hasBackground = bgSource != null;
214
- // In dev, always materialize a background chunk — even for an app
215
- // with no background.ts of its own — so live reload's client has
216
- // somewhere to run. In production, keep the original behavior
217
- // exactly (no background chunk at all when the app doesn't use one).
218
- const includeBackground = hasBackground || isDev;
485
+ // In dev with the in-bundle reload client, always materialize a
486
+ // background chunk — even for an app with no background.ts of its
487
+ // own — so the dev-reload client has somewhere to run. In
488
+ // production, or when the reload is driven from the Node process
489
+ // (devtool mode), keep the original behavior exactly (no
490
+ // background chunk at all when the app doesn't use one).
491
+ const includeBackground = hasBackground || (isDev && liveReload === true);
219
492
 
220
493
  // Each entry always has main-thread code and may opt into a
221
494
  // background thread by adding a sibling background.ts file.
222
495
  if (includeBackground) {
223
- // Imported by its resolved absolute path (plus the query string
224
- // the client reads its config from) rather than through the bare
225
- // "mithril-lynx/dev-reload-client" specifier + an alias: the
226
- // package-wide "mithril-lynx" prefix alias set above matches that
227
- // specifier first regardless of registration order (webpack/
228
- // rspack's resolve.alias picks the first matching entry, not the
229
- // most specific one), which broke the import entirely.
230
- const bgImports = isDev
496
+ // The dev-reload client is imported by its resolved absolute path
497
+ // (plus the query string it reads its config from) rather than
498
+ // through the bare "mithril-lynx/dev-reload-client" specifier + an
499
+ // alias: the package-wide "mithril-lynx" prefix alias set above
500
+ // matches that specifier first regardless of registration order
501
+ // (webpack/rspack's resolve.alias picks the first matching entry,
502
+ // not the most specific one), which broke the import entirely in
503
+ // 0.0.7.
504
+ const bgImports = isDev && liveReload === true
231
505
  ? [
232
506
  `${DEV_RELOAD_CLIENT_PATH}?${createDevReloadClientQuery(api, environment, name)}`,
233
507
  ...(hasBackground ? [bgSource] : []),
234
- ]
508
+ ]
235
509
  : bgSource;
236
510
  chain.entry(bgEntry).add({
237
511
  import: bgImports,
@@ -256,6 +530,12 @@ export function pluginMithrilLynx(options = {}) {
256
530
  },
257
531
  ]);
258
532
 
533
+ // The bundle this entry produces is always "<name>.bundle"
534
+ // (the filename above), and a loaded session's URL ends with
535
+ // it — that is what lets a devtool-mode reload pick out this
536
+ // app's session.
537
+ bundleHints.add(`${name}.bundle`);
538
+
259
539
  if (includeBackground) {
260
540
  // Background chunks run in the JavaScript thread and need the
261
541
  // Lynx runtime wrapper; main-thread chunks are encoded as lepus.
@@ -3,7 +3,42 @@
3
3
  // Mithril view code runs in the main-thread/Lepus bundle, while WebSocket and
4
4
  // Native Module access only exist in the background JS bundle. Consequently
5
5
  // normal module HMR cannot patch the view code. On a successful rebuild this
6
- // client asks Lynx Go's ExplorerModule to load the same bundle URL again.
6
+ // client triggers an in-place reload of the current page.
7
+ //
8
+ // Mechanism: CDP `Page.reload` via the DevTool native module
9
+ // (`NativeModules.LynxDevToolSetModule.invokeCdp`) — the same mechanism
10
+ // Rspeedy's own official live-reload client uses
11
+ // (`@lynx-js/webpack-dev-transport/lib/client/reloadApp.js`). It reloads the
12
+ // CURRENT page in place (no new Activity on the back stack) and it is invoked
13
+ // from inside the bundle itself, so the only network hop is the WebSocket the
14
+ // client runs on: Wi-Fi only, no adb, no cable.
15
+ //
16
+ // Why not the alternatives (each verified on-device):
17
+ // - `lynx.reload({})` (v0.0.9's original mechanism): on the device's engine it
18
+ // re-executes the main-thread bundle against the live session, but the
19
+ // runtime helpers `removeComponents`/`updatePage` are not re-injected, so
20
+ // the render crashes with two TypeErrors (captured via lynx-devtool) and the
21
+ // background chunk never re-runs — the socket dies and later builds are
22
+ // silently ignored.
23
+ // - `ExplorerModule.openSchema(url)` (0.0.7's mechanism): real navigation —
24
+ // Lynx Go starts a NEW LynxViewShellActivity every time and never finishes
25
+ // the one it replaces, so Back steps through one frozen snapshot per reload.
26
+ // - `lynx.fetchBundle(url)` + `lynx.loadScript(sectionName, {bundleName})`:
27
+ // in lynx-core these are the lazy-bundle path (`LazyBundleLoader` /
28
+ // `LoadCustomSectionScript`) — they load a customSection of a SEPARATE
29
+ // lazily-fetched bundle, not the page's own main-thread/Lepus template, so
30
+ // they cannot re-render the current page.
31
+ //
32
+ // The reload passes a cache-busted `url` (`?t=<ts>`) because Lynx keys both
33
+ // its HTTP layer and its bytecode cache by URL: measured on-device in 0.0.8,
34
+ // a `Page.reload` without a changed URL re-runs the PREVIOUS bundle
35
+ // byte-for-byte even with `ignoreCache:true` (see `cacheBustedUrl()` in
36
+ // plugin.js).
37
+ //
38
+ // This client runs on the background/JS thread (where WebSocket + the
39
+ // `lynx`/`NativeModules` globals are available), is only ever imported in dev
40
+ // builds (see plugin.js's synthetic background chunk), and reads its config
41
+ // from a resource query string injected at build time.
7
42
 
8
43
  function parseResourceQuery(query) {
9
44
  const values = {};
@@ -27,39 +62,143 @@ function socketURL(options) {
27
62
 
28
63
  const options = parseResourceQuery(__resourceQuery);
29
64
  const bundleUrl = options["bundle-url"];
65
+
66
+ /**
67
+ * Appends a unique query parameter to a bundle URL. Lynx keys both the HTTP
68
+ * layer and its bytecode cache by URL, so `Page.reload` with the SAME URL
69
+ * re-runs the previous bundle byte-for-byte even with `ignoreCache: true`
70
+ * (measured on-device in 0.0.8). Changing the URL is what actually forces a
71
+ * fresh fetch — same reasoning as `cacheBustedUrl()` in plugin.js.
72
+ *
73
+ * Returns undefined for anything that isn't an http(s) URL, so the caller can
74
+ * fall back to letting Page.reload use its existing URL.
75
+ */
76
+ function cacheBustedUrl(url, now = Date.now()) {
77
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return undefined;
78
+ const [base, query = ""] = url.split("?");
79
+ const params = new URLSearchParams(query);
80
+ params.set("t", String(now));
81
+ return `${base}?${params.toString()}`;
82
+ }
83
+
30
84
  let currentHash;
31
85
  let initialBuild = true;
32
86
  let socket;
33
- // ExplorerModule.openSchema(url) makes Lynx Go fully reload — including
34
- // re-executing this very module from scratch. That would naturally replace
35
- // this stale instance's socket with a fresh one, except the OLD connection
36
- // is otherwise left open: confirmed on-device that every earlier reload's
37
- // socket stays alive and keeps receiving server broadcasts, so a build a few
38
- // reloads in fires openSchema several times at once (one call per still-open
39
- // stale socket), and the resulting navigations race and clobber each other —
40
- // only the first reload after a fresh QR/URL load (a single open socket)
41
- // reliably lands. Closing this socket ourselves right before reloading, and
42
- // suppressing the reconnect-on-close it would otherwise trigger, keeps
43
- // exactly one socket alive at a time.
87
+ // Reloading the current page — including re-executing this very module from
88
+ // scratch would naturally replace this stale instance's socket with a fresh
89
+ // one, except the OLD connection is otherwise left open: confirmed on-device
90
+ // (0.0.7) that every earlier reload's socket stays alive and keeps receiving
91
+ // server broadcasts, so a build a few reloads in fires the reload several
92
+ // times at once (one call per still-open stale socket), and the resulting
93
+ // reloads race. Closing this socket ourselves right before reloading, and
94
+ // suppressing the reconnect-on-close it would otherwise trigger, keeps exactly
95
+ // one socket alive at a time. Same lifecycle bug as 0.0.7, identical fix.
44
96
  let reloading = false;
45
97
 
46
98
  console.info(
47
- "[mithril-lynx] ExplorerModule.openSchema:",
99
+ "[mithril-lynx] LynxDevToolSetModule.invokeCdp:",
100
+ typeof NativeModules !== "undefined" && typeof NativeModules.LynxDevToolSetModule?.invokeCdp,
101
+ "| lynx.reload:",
102
+ typeof lynx !== "undefined" && typeof lynx?.reload,
103
+ "| ExplorerModule.openSchema:",
48
104
  typeof NativeModules !== "undefined" && typeof NativeModules.ExplorerModule?.openSchema,
49
105
  );
50
106
 
107
+ function invokeCdpReload() {
108
+ // Mirrors @lynx-js/webpack-dev-transport/lib/client/reloadApp.js:
109
+ // Lynx Go exposes the DevTool CDP bridge under two module casings;
110
+ // either one accepts the Page.reload CDP message. Note the older
111
+ // casing binds "Page.reload" as a leading argument, like the official
112
+ // client does.
113
+ const upperCase = typeof NativeModules !== "undefined" ? NativeModules.LynxDevToolSetModule : undefined;
114
+ const lowerCase = typeof NativeModules !== "undefined" ? NativeModules.LynxDevtoolSetModule : undefined;
115
+ const invokeCdp =
116
+ (upperCase?.invokeCdp?.bind(upperCase)) ??
117
+ (typeof lowerCase?.invokeCdp === "function" ? lowerCase.invokeCdp.bind(lowerCase, "Page.reload") : undefined);
118
+ if (typeof invokeCdp !== "function") return false;
119
+
120
+ // Page.reload with the SAME URL re-runs the previous bundle (URL-keyed
121
+ // bytecode cache — measured in 0.0.8), so pass the cache-busted URL. If
122
+ // the URL is unusable for some reason, fall back to the bare CDP reload
123
+ // rather than skipping the reload entirely.
124
+ const params = { ignoreCache: true };
125
+ const url = cacheBustedUrl(bundleUrl);
126
+ if (url != null) params.url = url;
127
+
128
+ invokeCdp(
129
+ JSON.stringify({ method: "Page.reload", params }),
130
+ (data) => {
131
+ if (!data) return;
132
+ try {
133
+ const { error } = JSON.parse(data);
134
+ if (error) console.error("[mithril-lynx] Page.reload failed:", error.message);
135
+ } catch {
136
+ // response is not JSON — ignore
137
+ }
138
+ },
139
+ );
140
+ return true;
141
+ }
142
+
51
143
  function reload() {
144
+ // Preferred: CDP Page.reload through the DevTool native module — the
145
+ // same mechanism Rspeedy's own live-reload client uses. Page.reload
146
+ // reloads the current page in place (no new Activity, no back-stack
147
+ // corruption — verified on-device in 0.0.8), and because it is invoked
148
+ // from inside the bundle it works over Wi-Fi alone.
149
+ const cdpModule =
150
+ (typeof NativeModules !== "undefined" && NativeModules.LynxDevToolSetModule?.invokeCdp) ||
151
+ (typeof NativeModules !== "undefined" && NativeModules.LynxDevtoolSetModule?.invokeCdp);
152
+ if (typeof cdpModule === "function") {
153
+ console.info("[mithril-lynx] Reloading in place via CDP Page.reload.");
154
+ reloading = true;
155
+ socket?.close();
156
+ invokeCdpReload();
157
+ return;
158
+ }
159
+
160
+ // Fallback: lynx.reload() reloads the current page in place — no new
161
+ // Activity, no back-stack corruption. `value` is the new init data for the
162
+ // page; pass {} to keep the same (no new initData), mirroring openSchema's
163
+ // full reload. The callback fires when the reload completes. NOTE: on the
164
+ // engine this project targets (reported 3.5 on-device), lynx.reload
165
+ // re-executes the main-thread bundle against the live session without
166
+ // re-injecting the runtime helpers removeComponents/updatePage, crashing
167
+ // with two TypeErrors and never re-running the background chunk — that is
168
+ // why CDP is preferred and lynx.reload stays only as a fallback for
169
+ // viewers/SDKs whose reload path is intact.
170
+ const reloadFn = typeof lynx !== "undefined" && typeof lynx?.reload === "function" ? lynx.reload : null;
171
+ if (reloadFn) {
172
+ console.info("[mithril-lynx] Reloading in place via lynx.reload().");
173
+ reloading = true;
174
+ socket?.close();
175
+ try {
176
+ reloadFn.call(lynx, {}, () => {
177
+ console.info("[mithril-lynx] Reload complete.");
178
+ });
179
+ } catch (error) {
180
+ console.error("[mithril-lynx] lynx.reload threw:", error);
181
+ }
182
+ return;
183
+ }
184
+
185
+ // Last resort: ExplorerModule.openSchema(url) — the 0.0.7 mechanism. Works
186
+ // over Wi-Fi too, but it is a real navigation and stacks a new
187
+ // LynxViewShellActivity per reload (Back then walks frozen snapshots). Kept
188
+ // only for viewers/SDKs where lynx.reload is unavailable.
52
189
  const openSchema =
53
190
  typeof NativeModules !== "undefined" && NativeModules.ExplorerModule?.openSchema;
54
191
  if (typeof openSchema !== "function") {
55
- console.warn("[mithril-lynx] Live reload unavailable: ExplorerModule.openSchema was not found.");
192
+ console.warn(
193
+ "[mithril-lynx] Live reload unavailable: neither CDP invokeCdp, lynx.reload, nor ExplorerModule.openSchema was found.",
194
+ );
56
195
  return;
57
196
  }
58
197
  if (!bundleUrl) {
59
198
  console.warn("[mithril-lynx] Live reload unavailable: the bundle URL was not configured.");
60
199
  return;
61
200
  }
62
- console.info("[mithril-lynx] Reloading updated bundle.");
201
+ console.info("[mithril-lynx] Reloading updated bundle via ExplorerModule.openSchema (fallback).");
63
202
  reloading = true;
64
203
  socket?.close();
65
204
  openSchema.call(NativeModules.ExplorerModule, bundleUrl);