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 +42 -7
- package/package.json +2 -1
- package/plugin.d.ts +20 -0
- package/plugin.js +315 -35
- package/src/dev-reload-client.js +154 -15
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()`
|
|
304
|
-
|
|
305
|
-
bundle
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
50
|
-
// 0.0.0.0, which is the address a physical Lynx Go device can
|
|
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
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
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":
|
|
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)
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
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
|
|
215
|
-
// with no background.ts of its
|
|
216
|
-
// somewhere to run. In
|
|
217
|
-
//
|
|
218
|
-
|
|
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
|
-
//
|
|
224
|
-
// the
|
|
225
|
-
// "mithril-lynx/dev-reload-client" specifier + an
|
|
226
|
-
// package-wide "mithril-lynx" prefix alias set above
|
|
227
|
-
// specifier first regardless of registration order
|
|
228
|
-
// rspack's resolve.alias picks the first matching entry,
|
|
229
|
-
// most specific one), which broke the import entirely
|
|
230
|
-
|
|
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.
|
package/src/dev-reload-client.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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]
|
|
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(
|
|
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);
|