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 +39 -21
- package/package.json +1 -1
- package/plugin.d.ts +10 -4
- package/plugin.js +149 -36
- package/src/dev-reload-client.js +261 -0
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.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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.
|
|
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.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
//
|
|
34
|
+
// Two strategies, both triggered by a successful rebuild, picked by the
|
|
35
|
+
// `liveReload` option:
|
|
34
36
|
//
|
|
35
|
-
// -
|
|
36
|
-
//
|
|
37
|
-
// the
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// (
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
-
//
|
|
51
|
-
//
|
|
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
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
|
280
|
-
//
|
|
281
|
-
// on only adds
|
|
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
|
|
295
|
-
// first,
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
if (
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
|
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 (
|
|
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();
|