mithril-lynx 0.0.6 → 0.0.8

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
@@ -298,4 +298,29 @@ bun run test
298
298
 
299
299
  Tests run against `@lynx-js/testing-environment`'s jsdom-backed PAPI polyfill. The polyfill itself is published as `mithril-lynx/testing`'s `installTestingPolyfills()` (see `test/setup.ts` for the one-line setup) — consuming apps can use the exact same polyfill for their own tests instead of maintaining a duplicate copy; see `mithril-app/test/setup.ts` for a worked example.
300
300
 
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. `rspeedy dev`'s existing full-reload-on-file-change loop is the honest substitute.
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
+
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. (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 })`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mithril-lynx",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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,20 @@ 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. 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.
21
+ */
22
+ liveReload?: boolean;
9
23
  }
10
24
 
11
25
  /**
package/plugin.js CHANGED
@@ -27,6 +27,202 @@ const PLUGIN_NAME = "mithril-lynx-template-webpack";
27
27
  const BACKGROUND_CANDIDATES = ["background.ts", "background.js"];
28
28
  const STYLE_CANDIDATES = ["style.css"];
29
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Live reload (dev only)
32
+ //
33
+ // How a rebuild reaches the device, and why it works this way:
34
+ //
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.
49
+ //
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.
52
+ //
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.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** Lynx Go's own shell page is a Lynx session too — never a reload target. */
59
+ const VIEWER_SHELL_BUNDLE = "homepage.lynx.bundle";
60
+
61
+ let connectorPromise;
62
+ let devtoolTransport;
63
+
64
+ /**
65
+ * Lazily loads the DevTool connector. Kept lazy so `@lynx-js/devtool-connector`
66
+ * is only ever loaded by a dev rebuild, never by a production build.
67
+ */
68
+ function getDevtoolConnector() {
69
+ if (!connectorPromise) {
70
+ connectorPromise = Promise.all([
71
+ import("@lynx-js/devtool-connector"),
72
+ import("@lynx-js/devtool-connector/transport"),
73
+ ])
74
+ .then(([{ Connector }, { AndroidTransport }]) => {
75
+ devtoolTransport = new AndroidTransport();
76
+ return new Connector([devtoolTransport]);
77
+ })
78
+ .catch((error) => {
79
+ // Don't cache a rejection: the usual cause is the package not
80
+ // being installed yet, and the dev server outlives an
81
+ // `npm install`.
82
+ connectorPromise = undefined;
83
+ throw error;
84
+ });
85
+ }
86
+ return connectorPromise;
87
+ }
88
+
89
+ /** Last path segment of a URL, ignoring any query string or fragment. */
90
+ function bundleBasename(url) {
91
+ if (typeof url !== "string") return "";
92
+ const withoutQuery = url.split("?")[0].split("#")[0];
93
+ const segments = withoutQuery.split("/");
94
+ return segments[segments.length - 1] || withoutQuery;
95
+ }
96
+
97
+ function isViewerShell(url) {
98
+ return bundleBasename(url) === VIEWER_SHELL_BUNDLE;
99
+ }
100
+
101
+ /**
102
+ * Adds a unique query parameter to a bundle URL.
103
+ *
104
+ * `Page.reload` on its own is not enough to pick up a rebuild: measured
105
+ * on-device, a reload without this re-fetched and re-ran the PREVIOUS bundle
106
+ * (the loaded template stayed byte-for-byte the old one, and the old text
107
+ * stayed on screen) even with `ignoreCache: true`. Both the HTTP layer and
108
+ * Lynx's own bytecode cache are keyed by URL, so changing the URL is what
109
+ * actually invalidates them. The session's own URL is unaffected — the DevTools
110
+ * reference notes it does not change after a reload, and that was confirmed
111
+ * here too.
112
+ *
113
+ * Returns undefined for anything that isn't an http(s) URL, in which case the
114
+ * caller lets Page.reload use the URL it already has (it rejects anything else).
115
+ */
116
+ export function cacheBustedUrl(url, now = Date.now()) {
117
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return undefined;
118
+ const [base, query = ""] = url.split("?");
119
+ const params = new URLSearchParams(query);
120
+ params.set("t", String(now));
121
+ return `${base}?${params.toString()}`;
122
+ }
123
+
124
+ function matchesHint(url, hints) {
125
+ if (typeof url !== "string" || hints.length === 0) return false;
126
+ return hints.some((hint) => url.includes(hint));
127
+ }
128
+
129
+ /**
130
+ * Chooses which client/session to reload: `targets` is `[{ client, sessions }]`,
131
+ * returns `{ clientId, sessionId, url }` or null.
132
+ *
133
+ * Pure and exported so it can be tested without a device attached.
134
+ */
135
+ export function pickReloadTarget(targets, { bundleHints = [] } = {}) {
136
+ const candidates = [];
137
+ for (const { client, sessions } of targets) {
138
+ for (const session of sessions ?? []) {
139
+ if (session?.type !== "lynx") continue;
140
+ if (isViewerShell(session.url)) continue;
141
+ candidates.push({ clientId: client.id, session });
142
+ }
143
+ }
144
+ if (candidates.length === 0) return null;
145
+
146
+ // Prefer the session actually serving one of this app's bundles over
147
+ // "whatever was opened most recently": with a second Lynx app, or a second
148
+ // attached device, the newest session need not be ours.
149
+ const preferred = candidates.filter((candidate) => matchesHint(candidate.session.url, bundleHints));
150
+ const pool = preferred.length > 0 ? preferred : candidates;
151
+
152
+ const latest = pool.reduce((a, b) => (b.session.session_id > a.session.session_id ? b : a));
153
+ return {
154
+ clientId: latest.clientId,
155
+ sessionId: latest.session.session_id,
156
+ url: latest.session.url,
157
+ };
158
+ }
159
+
160
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
161
+
162
+ async function listClientSessions(connector) {
163
+ const clients = await connector.listClients();
164
+ const targets = [];
165
+ for (const client of clients) {
166
+ try {
167
+ targets.push({ client, sessions: await connector.sendListSessionMessage(client.id) });
168
+ } catch {
169
+ // This client doesn't support session listing, or isn't ready yet.
170
+ }
171
+ }
172
+ return targets;
173
+ }
174
+
175
+ /**
176
+ * Finds a session worth reloading, retrying briefly: on the very first rebuild
177
+ * the DevTool client may not have registered with the device yet.
178
+ */
179
+ async function findReloadTarget(bundleHints, { attempts = 3, delayMs = 400 } = {}) {
180
+ const connector = await getDevtoolConnector();
181
+ for (let attempt = 0; attempt < attempts; attempt++) {
182
+ const target = pickReloadTarget(await listClientSessions(connector), { bundleHints });
183
+ if (target) return { connector, target };
184
+ if (attempt < attempts - 1) await sleep(delayMs);
185
+ }
186
+ return { connector, target: null };
187
+ }
188
+
189
+ /**
190
+ * Reloads the running page in place. Returns true when a session was reloaded,
191
+ * false when none was found (the caller logs that).
192
+ */
193
+ async function reloadViaDevtool(bundleHints) {
194
+ const { connector, target } = await findReloadTarget(bundleHints);
195
+ if (!target) return false;
196
+
197
+ const params = { ignoreCache: true };
198
+ // Without a changed URL the device replays its cached copy of the previous
199
+ // bundle — see cacheBustedUrl(). The session URL itself does not change.
200
+ const url = cacheBustedUrl(target.url);
201
+ if (url != null) params.url = url;
202
+
203
+ await connector.sendCDPMessage(target.clientId, target.sessionId, "Page.reload", params);
204
+ console.info(
205
+ `[mithril-lynx] Reloaded ${target.url || "(url unknown)"} (session ${target.sessionId}${url != null ? "" : ", no cache-busting: non-http url"}).`,
206
+ );
207
+ return true;
208
+ }
209
+
210
+ /** Releases the adb connection when the dev server goes away. */
211
+ async function closeDevtoolTransport() {
212
+ const transport = devtoolTransport;
213
+ devtoolTransport = undefined;
214
+ connectorPromise = undefined;
215
+ await transport?.close?.();
216
+ }
217
+
218
+ /** Turns a connector failure into an actionable one-liner. */
219
+ function describeReloadFailure(error) {
220
+ if (error?.code === "ERR_MODULE_NOT_FOUND" || /devtool-connector/.test(error?.message ?? "")) {
221
+ return "Live reload is off: @lynx-js/devtool-connector is not installed. Run `npm install @lynx-js/devtool-connector`, then restart the dev server.";
222
+ }
223
+ return `Live reload unavailable: ${error instanceof Error ? error.message : String(error)}`;
224
+ }
225
+
30
226
  function findSibling(dir, candidates) {
31
227
  for (const name of candidates) {
32
228
  const candidate = path.join(dir, name);
@@ -59,12 +255,70 @@ function packageRootOf(resolvedFile) {
59
255
 
60
256
  export function pluginMithrilLynx(options = {}) {
61
257
  const targetSdkVersion = options.targetSdkVersion ?? "3.5";
258
+ const hmr = options.hmr ?? false;
259
+ const liveReload = options.liveReload ?? true;
260
+
261
+ // Filled in by modifyBundlerChain below with "<entry>.bundle" for every
262
+ // configured entry, so a reload prefers the session actually serving this
263
+ // app over whichever Lynx session happens to be newest.
264
+ const bundleHints = new Set();
62
265
 
63
266
  return {
64
267
  name: PLUGIN_NAME,
65
268
  setup(api) {
66
269
  // Keep the template plugin discoverable by Rspeedy's Lynx internals.
67
270
  api.expose(Symbol.for("LynxTemplatePlugin"), { LynxTemplatePlugin });
271
+
272
+ // setupApp()'s render model has no per-module "accept and patch"
273
+ // story (rendering is driven by native __RenderPage/__UpdatePage
274
+ // events, not by re-executing a hot-swapped module) -- module-level
275
+ // HMR's eval'd *.hot-update.js chunks also aren't runtime-wrapped
276
+ // the way the real background.js bundle is, and fail native-side
277
+ // with "ReferenceError: exports is not defined" if hot is left on.
278
+ // 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.
282
+ api.modifyRsbuildConfig({
283
+ // Not a plain default: Rsbuild has already stamped dev.hmr:true onto
284
+ // the config by the time ANY hook sees it (even api.getRsbuildConfig
285
+ // ("original")), so there's no reliable way to tell "the app asked for
286
+ // hot module replacement" apart from "Rsbuild defaulted it" -- this
287
+ // always wins, with an explicit opt-out via pluginMithrilLynx({ hmr })
288
+ // for anyone who's fixed up their own app-level accept() story and the
289
+ // RuntimeWrapperWebpackPlugin gap noted below.
290
+ handler: (config, { mergeRsbuildConfig }) => mergeRsbuildConfig(config, { dev: { hmr } }),
291
+ order: "post",
292
+ });
293
+
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) {
300
+ api.onAfterDevCompile(async ({ isFirstCompile, stats }) => {
301
+ if (isFirstCompile || stats.hasErrors()) return;
302
+ let reloaded = false;
303
+ try {
304
+ reloaded = await reloadViaDevtool([...bundleHints]);
305
+ } catch (error) {
306
+ console.warn(`[mithril-lynx] ${describeReloadFailure(error)}`);
307
+ return;
308
+ }
309
+ if (!reloaded) {
310
+ console.warn(
311
+ "[mithril-lynx] Live reload unavailable: no Lynx session found for this app. " +
312
+ "Is the device connected over adb with the page open in Lynx Go? Reload manually.",
313
+ );
314
+ }
315
+ });
316
+
317
+ // The transport owns adb port-forwards; don't let them outlive
318
+ // the dev server.
319
+ api.onCloseDevServer?.(closeDevtoolTransport);
320
+ }
321
+
68
322
  api.modifyBundlerChain((chain) => {
69
323
  // mithril-lynx's own src/lynx-mithril-shim.js deep-imports mithril's
70
324
  // internal render/cachedAttrsIsStaticMap.js (and its emptyAttrs
@@ -164,6 +418,11 @@ export function pluginMithrilLynx(options = {}) {
164
418
  },
165
419
  ]);
166
420
 
421
+ // The bundle this entry produces is always "<name>.bundle"
422
+ // (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.
424
+ bundleHints.add(`${name}.bundle`);
425
+
167
426
  if (hasBackground) {
168
427
  // Background chunks run in the JavaScript thread and need the
169
428
  // Lynx runtime wrapper; main-thread chunks are encoded as lepus.