mithril-lynx 0.0.8 → 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
@@ -303,24 +303,42 @@ Tests run against `@lynx-js/testing-environment`'s jsdom-backed PAPI polyfill. T
303
303
  In development, `pluginMithrilLynx()` reloads the running page after every
304
304
  successful rebuild instead of attempting module HMR — Mithril view code runs in
305
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. (No synthetic background
307
- entry is created for this: the reload runs from the dev-server process, not from
308
- a chunk inside the app.)
309
-
310
- The reload goes through the Lynx DevTool connector and sends CDP `Page.reload`
311
- to the session currently serving your bundle. That reloads the existing page
312
- **in place**, so nothing is pushed onto the viewer's back stack. Two consequences
313
- worth knowing:
314
-
315
- - **Live reload reaches the device over adb, not over the LAN.** The DevTool
316
- connector talks to the adb server (it sets up an adb reverse tunnel to the
317
- device's DebugRouter). `npm run dev` and the QR-scan flow are unaffected and
318
- still work over Wi-Fi — only the automatic reload needs adb. With no adb
319
- connection the rebuild logs a warning and you reload by hand. Wireless
320
- debugging (`adb connect <ip>:5555`) satisfies this with no cable, and
321
- `ANDROID_SERIAL` picks the device when several are attached.
322
- - It is a **dev-server convenience, not a portable SDK API**: the connector is
323
- imported lazily by a dev rebuild only, and no reload code is bundled into the
324
- app in any build.
325
-
326
- Turn it off with `pluginMithrilLynx({ liveReload: false })`.
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.8",
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",
package/plugin.d.ts CHANGED
@@ -15,11 +15,17 @@ export interface PluginMithrilLynxOptions {
15
15
  hmr?: boolean;
16
16
  /**
17
17
  * Reload the running page after each successful dev rebuild. Defaults to
18
- * true. Reloading goes through the Lynx DevTool connector, which reaches the
19
- * device over adb, so a device connected only over Wi-Fi must be reloaded
20
- * manually.
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.
21
27
  */
22
- liveReload?: boolean;
28
+ liveReload?: boolean | "devtool";
23
29
  }
24
30
 
25
31
  /**
package/plugin.js CHANGED
@@ -18,6 +18,7 @@
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
20
  import { createRequire } from "node:module";
21
+ import { fileURLToPath } from "node:url";
21
22
 
22
23
  import { RuntimeWrapperWebpackPlugin } from "@lynx-js/runtime-wrapper-webpack-plugin";
23
24
  import { LynxEncodePlugin, LynxTemplatePlugin } from "@lynx-js/template-webpack-plugin";
@@ -30,31 +31,115 @@ const STYLE_CANDIDATES = ["style.css"];
30
31
  // ---------------------------------------------------------------------------
31
32
  // Live reload (dev only)
32
33
  //
33
- // How a rebuild reaches the device, and why it works this way:
34
+ // Two strategies, both triggered by a successful rebuild, picked by the
35
+ // `liveReload` option:
34
36
  //
35
- // - Real module HMR cannot help here at all. Mithril view code lives in the
36
- // main-thread/Lepus chunk, and rspack's hot runtime can only patch modules in
37
- // the registry it runs from — the background/JS thread. That is an
38
- // architectural mismatch, not a bug to fix.
39
- // - A CDP command the bundle sends to *itself*
40
- // (NativeModules.LynxDevToolSetModule.invokeCdp) is a silent no-op: there is
41
- // no external DevTool session behind it.
42
- // - ExplorerModule.openSchema(url) — what 0.0.7 shipped — works, but it is a
43
- // real navigation: Lynx Go starts a NEW LynxViewShellActivity every time and
44
- // never finishes the one it replaces, so Back then steps through one frozen
45
- // snapshot per reload.
46
- // - Page.reload from an *external* DevTool session reloads the existing page in
47
- // place (the DevTools reference notes the session URL is unchanged after it),
48
- // so nothing new is pushed onto the back stack.
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).
49
47
  //
50
- // So this runs from the Node dev-server process rather than from a chunk
51
- // bundled into the app, and no synthetic background entry is needed.
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.
52
53
  //
53
- // The trade-off: the DevTool connector reaches the device through adb, so live
54
- // reload needs the device connected over adb. openSchema could work over Wi-Fi
55
- // alone, but only by corrupting the back stack.
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.
56
76
  // ---------------------------------------------------------------------------
57
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.
88
+ const DEV_RELOAD_CLIENT_PATH = path.join(
89
+ path.dirname(fileURLToPath(import.meta.url)),
90
+ "src",
91
+ "dev-reload-client.js",
92
+ );
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
+ */
100
+ function createDevReloadClientQuery(api, environment, entryName) {
101
+ const config = environment.config ?? {};
102
+ const dev = config.dev ?? {};
103
+ const server = config.server ?? {};
104
+ const devServer = api.context.devServer ?? {};
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.
107
+ const hostname = dev.client?.host || devServer.hostname || server.host || "";
108
+ const port = devServer.port ?? server.port ?? "";
109
+ const protocol = devServer.https ? "https" : "http";
110
+ // At this point in the pipeline Rsbuild hasn't started the dev server yet,
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.
115
+ const assetPrefix = (typeof dev.assetPrefix === "string" ? dev.assetPrefix : "/").replaceAll(
116
+ "<port>",
117
+ String(port),
118
+ );
119
+ const base = /^https?:\/\//.test(assetPrefix)
120
+ ? assetPrefix
121
+ : `${protocol}://${hostname}${port ? `:${port}` : ""}${assetPrefix}`;
122
+ const clientBundleUrl = new URL(
123
+ `${entryName}.bundle`,
124
+ base.endsWith("/") ? base : `${base}/`,
125
+ ).toString();
126
+ const params = new URLSearchParams({
127
+ hostname,
128
+ port: String(port),
129
+ pathname: "/rsbuild-hmr",
130
+ protocol: devServer.https ? "wss" : "ws",
131
+ "bundle-url": clientBundleUrl,
132
+ });
133
+ if (environment.webSocketToken) params.set("token", environment.webSocketToken);
134
+ return params.toString();
135
+ }
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
+
58
143
  /** Lynx Go's own shell page is a Lynx session too — never a reload target. */
59
144
  const VIEWER_SHELL_BUNDLE = "homepage.lynx.bundle";
60
145
 
@@ -256,11 +341,16 @@ function packageRootOf(resolvedFile) {
256
341
  export function pluginMithrilLynx(options = {}) {
257
342
  const targetSdkVersion = options.targetSdkVersion ?? "3.5";
258
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.
259
348
  const liveReload = options.liveReload ?? true;
349
+ const useDevtoolReload = liveReload === "devtool";
260
350
 
261
351
  // Filled in by modifyBundlerChain below with "<entry>.bundle" for every
262
352
  // configured entry, so a reload prefers the session actually serving this
263
- // app over whichever Lynx session happens to be newest.
353
+ // app over whichever Lynx session happens to be newest (devtool mode only).
264
354
  const bundleHints = new Set();
265
355
 
266
356
  return {
@@ -276,9 +366,10 @@ export function pluginMithrilLynx(options = {}) {
276
366
  // the way the real background.js bundle is, and fail native-side
277
367
  // with "ReferenceError: exports is not defined" if hot is left on.
278
368
  // Force dev.hmr off (unless the app explicitly set it) -- real HMR
279
- // can't reach this framework's app code regardless of how reload
280
- // itself is triggered (see reloadViaDevtool() above), so leaving it
281
- // on only adds that error noise for no benefit.
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.
282
373
  api.modifyRsbuildConfig({
283
374
  // Not a plain default: Rsbuild has already stamped dev.hmr:true onto
284
375
  // the config by the time ANY hook sees it (even api.getRsbuildConfig
@@ -291,12 +382,12 @@ export function pluginMithrilLynx(options = {}) {
291
382
  order: "post",
292
383
  });
293
384
 
294
- // Live reload itself: on every successful dev rebuild (skipping the
295
- // first, which is the initial build rather than an edit), find the
296
- // running Lynx session and CDP-reload it in place. See the block
297
- // above reloadViaDevtool() for why this runs from here (the Node
298
- // dev-server process) instead of from a chunk bundled into the app.
299
- if (liveReload) {
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) {
300
391
  api.onAfterDevCompile(async ({ isFirstCompile, stats }) => {
301
392
  if (isFirstCompile || stats.hasErrors()) return;
302
393
  let reloaded = false;
@@ -319,7 +410,7 @@ export function pluginMithrilLynx(options = {}) {
319
410
  api.onCloseDevServer?.(closeDevtoolTransport);
320
411
  }
321
412
 
322
- api.modifyBundlerChain((chain) => {
413
+ api.modifyBundlerChain((chain, { isDev, environment }) => {
323
414
  // mithril-lynx's own src/lynx-mithril-shim.js deep-imports mithril's
324
415
  // internal render/cachedAttrsIsStaticMap.js (and its emptyAttrs
325
416
  // singleton). If the app's own `require("mithril")` resolves to a
@@ -391,12 +482,33 @@ export function pluginMithrilLynx(options = {}) {
391
482
  const bgAsset = `.rspeedy/${name}/background.js`;
392
483
  const mtAsset = `.rspeedy/${name}/main-thread.js`;
393
484
  const hasBackground = bgSource != null;
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);
394
492
 
395
493
  // Each entry always has main-thread code and may opt into a
396
494
  // background thread by adding a sibling background.ts file.
397
- if (hasBackground) {
495
+ if (includeBackground) {
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
505
+ ? [
506
+ `${DEV_RELOAD_CLIENT_PATH}?${createDevReloadClientQuery(api, environment, name)}`,
507
+ ...(hasBackground ? [bgSource] : []),
508
+ ]
509
+ : bgSource;
398
510
  chain.entry(bgEntry).add({
399
- import: bgSource,
511
+ import: bgImports,
400
512
  filename: bgAsset,
401
513
  });
402
514
  }
@@ -411,7 +523,7 @@ export function pluginMithrilLynx(options = {}) {
411
523
  ...LynxTemplatePlugin.defaultOptions,
412
524
  filename: `${name}.bundle`,
413
525
  intermediate: `.rspeedy/${name}`,
414
- chunks: hasBackground ? [bgEntry, mtEntry] : [mtEntry],
526
+ chunks: includeBackground ? [bgEntry, mtEntry] : [mtEntry],
415
527
  dsl: "react_nodiff",
416
528
  targetSdkVersion,
417
529
  cssPlugins: [],
@@ -420,10 +532,11 @@ export function pluginMithrilLynx(options = {}) {
420
532
 
421
533
  // The bundle this entry produces is always "<name>.bundle"
422
534
  // (the filename above), and a loaded session's URL ends with
423
- // it — that is what lets a reload pick out this app's session.
535
+ // it — that is what lets a devtool-mode reload pick out this
536
+ // app's session.
424
537
  bundleHints.add(`${name}.bundle`);
425
538
 
426
- if (hasBackground) {
539
+ if (includeBackground) {
427
540
  // Background chunks run in the JavaScript thread and need the
428
541
  // Lynx runtime wrapper; main-thread chunks are encoded as lepus.
429
542
  chain.plugin(`runtime-wrapper-${name}`).use(
@@ -0,0 +1,261 @@
1
+ // Development-only client for Lynx Go.
2
+ //
3
+ // Mithril view code runs in the main-thread/Lepus bundle, while WebSocket and
4
+ // Native Module access only exist in the background JS bundle. Consequently
5
+ // normal module HMR cannot patch the view code. On a successful rebuild this
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.
42
+
43
+ function parseResourceQuery(query) {
44
+ const values = {};
45
+ if (typeof query !== "string" || !query.startsWith("?")) return values;
46
+ for (const pair of query.slice(1).split("&")) {
47
+ const index = pair.indexOf("=");
48
+ const key = index === -1 ? pair : pair.slice(0, index);
49
+ const value = index === -1 ? "" : pair.slice(index + 1);
50
+ values[key] = decodeURIComponent(value);
51
+ }
52
+ return values;
53
+ }
54
+
55
+ function socketURL(options) {
56
+ const hostname = options.hostname || "";
57
+ const port = options.port ? `:${options.port}` : "";
58
+ const pathname = options.pathname || "/rsbuild-hmr";
59
+ const token = options.token ? `?token=${encodeURIComponent(options.token)}` : "";
60
+ return `${options.protocol || "ws"}://${hostname}${port}${pathname}${token}`;
61
+ }
62
+
63
+ const options = parseResourceQuery(__resourceQuery);
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
+
84
+ let currentHash;
85
+ let initialBuild = true;
86
+ let socket;
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.
96
+ let reloading = false;
97
+
98
+ console.info(
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:",
104
+ typeof NativeModules !== "undefined" && typeof NativeModules.ExplorerModule?.openSchema,
105
+ );
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
+
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.
189
+ const openSchema =
190
+ typeof NativeModules !== "undefined" && NativeModules.ExplorerModule?.openSchema;
191
+ if (typeof openSchema !== "function") {
192
+ console.warn(
193
+ "[mithril-lynx] Live reload unavailable: neither CDP invokeCdp, lynx.reload, nor ExplorerModule.openSchema was found.",
194
+ );
195
+ return;
196
+ }
197
+ if (!bundleUrl) {
198
+ console.warn("[mithril-lynx] Live reload unavailable: the bundle URL was not configured.");
199
+ return;
200
+ }
201
+ console.info("[mithril-lynx] Reloading updated bundle via ExplorerModule.openSchema (fallback).");
202
+ reloading = true;
203
+ socket?.close();
204
+ openSchema.call(NativeModules.ExplorerModule, bundleUrl);
205
+ }
206
+
207
+ function handleMessage(rawMessage) {
208
+ let message;
209
+ try {
210
+ message = JSON.parse(rawMessage);
211
+ } catch {
212
+ console.warn("[mithril-lynx] Ignoring an invalid dev-server message.");
213
+ return;
214
+ }
215
+
216
+ switch (message.type) {
217
+ case "hash":
218
+ currentHash = message.data;
219
+ break;
220
+ case "ok":
221
+ // The server sends hash + ok immediately after connecting. Reloading
222
+ // then would loop forever, so only act after a later successful build.
223
+ if (initialBuild) initialBuild = false;
224
+ else if (currentHash) reload();
225
+ break;
226
+ case "still-ok":
227
+ console.info("[mithril-lynx] Nothing changed.");
228
+ break;
229
+ case "warnings":
230
+ console.warn("[mithril-lynx] Build completed with warnings.", message.data);
231
+ if (!message.params?.preventReloading && !initialBuild) reload();
232
+ break;
233
+ case "errors":
234
+ console.warn("[mithril-lynx] Build failed; waiting for the next successful build.", message.data);
235
+ break;
236
+ case "invalid":
237
+ console.info("[mithril-lynx] App updated. Recompiling...");
238
+ break;
239
+ case "error":
240
+ console.error("[mithril-lynx] Dev server error:", message.data);
241
+ break;
242
+ }
243
+ }
244
+
245
+ function connect(retries = 0) {
246
+ socket = new WebSocket(socketURL(options));
247
+ socket.onmessage = (event) => handleMessage(event.data);
248
+ socket.onerror = (error) => console.error("[mithril-lynx] Dev server connection error:", error);
249
+ socket.onclose = () => {
250
+ if (reloading) return; // Deliberately closed by reload() — a fresh socket is on its way already.
251
+ if (retries >= 10) {
252
+ console.error("[mithril-lynx] Unable to reconnect to the dev server.");
253
+ return;
254
+ }
255
+ const delay = 1000 * 2 ** retries + Math.random() * 100;
256
+ console.info("[mithril-lynx] Dev server disconnected; reconnecting...");
257
+ setTimeout(() => connect(retries + 1), delay);
258
+ };
259
+ }
260
+
261
+ connect();