mithril-lynx 0.0.6 → 0.0.7

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,12 @@ 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()` adds a small background-thread client even
304
+ when the app has no `background.ts`. After a successful rebuild it reloads the
305
+ bundle through Lynx Go's `ExplorerModule.openSchema()`, rather than attempting
306
+ module HMR (Mithril views run in the separate main-thread/Lepus bundle). This
307
+ is intentionally a **Lynx Go viewer** convenience, not a portable SDK API: a
308
+ different viewer or a final native host that does not provide `ExplorerModule`
309
+ will log a warning and must be reloaded manually.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mithril-lynx",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
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.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";
@@ -27,6 +28,55 @@ const PLUGIN_NAME = "mithril-lynx-template-webpack";
27
28
  const BACKGROUND_CANDIDATES = ["background.ts", "background.js"];
28
29
  const STYLE_CANDIDATES = ["style.css"];
29
30
 
31
+ // Live reload's client only runs where a full JS engine with Native Module
32
+ // access exists: the background/JS thread. The main-thread/Lepus VM cannot
33
+ // use it. An app without background.ts therefore gets a synthetic background
34
+ // entry in development; see `includeBackground` below. Imported by this
35
+ // resolved absolute path directly (see the entry-construction loop below),
36
+ // not through a "mithril-lynx/..." bare specifier — that collides with the
37
+ // package's own broader "mithril-lynx" resolve alias.
38
+ const DEV_RELOAD_CLIENT_PATH = path.join(
39
+ path.dirname(fileURLToPath(import.meta.url)),
40
+ "src",
41
+ "dev-reload-client.js",
42
+ );
43
+
44
+ function createDevReloadClientQuery(api, environment, entryName) {
45
+ const config = environment.config ?? {};
46
+ const dev = config.dev ?? {};
47
+ const server = config.server ?? {};
48
+ const devServer = api.context.devServer ?? {};
49
+ // Rsbuild resolves this to the LAN address it advertises when server.host is
50
+ // 0.0.0.0, which is the address a physical Lynx Go device can actually use.
51
+ const hostname = dev.client?.host || devServer.hostname || server.host || "";
52
+ const port = devServer.port ?? server.port ?? "";
53
+ const protocol = devServer.https ? "https" : "http";
54
+ // At this point in the pipeline Rsbuild hasn't started the dev server yet,
55
+ // so `dev.assetPrefix` (when it's the default, host-derived one — see
56
+ // @lynx-js/rsbuild-plugin) still contains the literal, unsubstituted
57
+ // "<port>" placeholder it's only resolved to a real port number later, in
58
+ // its own `printUrls` callback. `hostname`/`port` above are already the
59
+ // real values, though, so resolve the placeholder the same way that
60
+ // callback does rather than trusting assetPrefix as pre-resolved.
61
+ const assetPrefix = (typeof dev.assetPrefix === "string" ? dev.assetPrefix : "/").replaceAll(
62
+ "<port>",
63
+ String(port),
64
+ );
65
+ const base = /^https?:\/\//.test(assetPrefix)
66
+ ? assetPrefix
67
+ : `${protocol}://${hostname}${port ? `:${port}` : ""}${assetPrefix}`;
68
+ const bundleUrl = new URL(`${entryName}.bundle`, base.endsWith("/") ? base : `${base}/`).toString();
69
+ const params = new URLSearchParams({
70
+ hostname,
71
+ port: String(port),
72
+ pathname: "/rsbuild-hmr",
73
+ protocol: devServer.https ? "wss" : "ws",
74
+ "bundle-url": bundleUrl,
75
+ });
76
+ if (environment.webSocketToken) params.set("token", environment.webSocketToken);
77
+ return params.toString();
78
+ }
79
+
30
80
  function findSibling(dir, candidates) {
31
81
  for (const name of candidates) {
32
82
  const candidate = path.join(dir, name);
@@ -59,13 +109,37 @@ function packageRootOf(resolvedFile) {
59
109
 
60
110
  export function pluginMithrilLynx(options = {}) {
61
111
  const targetSdkVersion = options.targetSdkVersion ?? "3.5";
112
+ const hmr = options.hmr ?? false;
62
113
 
63
114
  return {
64
115
  name: PLUGIN_NAME,
65
116
  setup(api) {
66
117
  // Keep the template plugin discoverable by Rspeedy's Lynx internals.
67
118
  api.expose(Symbol.for("LynxTemplatePlugin"), { LynxTemplatePlugin });
68
- api.modifyBundlerChain((chain) => {
119
+
120
+ // setupApp()'s render model has no per-module "accept and patch"
121
+ // story (rendering is driven by native __RenderPage/__UpdatePage
122
+ // events, not by re-executing a hot-swapped module) -- module-level
123
+ // HMR's eval'd *.hot-update.js chunks also aren't runtime-wrapped
124
+ // the way the real background.js bundle is, and fail native-side
125
+ // with "ReferenceError: exports is not defined" if hot is left on.
126
+ // Force dev.hmr off (unless the app explicitly set it) so the dev
127
+ // client's ok() handler takes its other branch -- a full native
128
+ // Page.reload -- which is the one live-reload path this framework
129
+ // actually supports cleanly. dev.liveReload stays at its default.
130
+ api.modifyRsbuildConfig({
131
+ // Not a plain default: Rsbuild has already stamped dev.hmr:true onto
132
+ // the config by the time ANY hook sees it (even api.getRsbuildConfig
133
+ // ("original")), so there's no reliable way to tell "the app asked for
134
+ // hot module replacement" apart from "Rsbuild defaulted it" -- this
135
+ // always wins, with an explicit opt-out via pluginMithrilLynx({ hmr })
136
+ // for anyone who's fixed up their own app-level accept() story and the
137
+ // RuntimeWrapperWebpackPlugin gap noted below.
138
+ handler: (config, { mergeRsbuildConfig }) => mergeRsbuildConfig(config, { dev: { hmr } }),
139
+ order: "post",
140
+ });
141
+
142
+ api.modifyBundlerChain((chain, { isDev, environment }) => {
69
143
  // mithril-lynx's own src/lynx-mithril-shim.js deep-imports mithril's
70
144
  // internal render/cachedAttrsIsStaticMap.js (and its emptyAttrs
71
145
  // singleton). If the app's own `require("mithril")` resolves to a
@@ -137,12 +211,30 @@ export function pluginMithrilLynx(options = {}) {
137
211
  const bgAsset = `.rspeedy/${name}/background.js`;
138
212
  const mtAsset = `.rspeedy/${name}/main-thread.js`;
139
213
  const hasBackground = bgSource != null;
214
+ // In dev, always materialize a background chunk — even for an app
215
+ // with no background.ts of its own — so live reload's client has
216
+ // somewhere to run. In production, keep the original behavior
217
+ // exactly (no background chunk at all when the app doesn't use one).
218
+ const includeBackground = hasBackground || isDev;
140
219
 
141
220
  // Each entry always has main-thread code and may opt into a
142
221
  // background thread by adding a sibling background.ts file.
143
- if (hasBackground) {
222
+ if (includeBackground) {
223
+ // Imported by its resolved absolute path (plus the query string
224
+ // the client reads its config from) rather than through the bare
225
+ // "mithril-lynx/dev-reload-client" specifier + an alias: the
226
+ // package-wide "mithril-lynx" prefix alias set above matches that
227
+ // specifier first regardless of registration order (webpack/
228
+ // rspack's resolve.alias picks the first matching entry, not the
229
+ // most specific one), which broke the import entirely.
230
+ const bgImports = isDev
231
+ ? [
232
+ `${DEV_RELOAD_CLIENT_PATH}?${createDevReloadClientQuery(api, environment, name)}`,
233
+ ...(hasBackground ? [bgSource] : []),
234
+ ]
235
+ : bgSource;
144
236
  chain.entry(bgEntry).add({
145
- import: bgSource,
237
+ import: bgImports,
146
238
  filename: bgAsset,
147
239
  });
148
240
  }
@@ -157,14 +249,14 @@ export function pluginMithrilLynx(options = {}) {
157
249
  ...LynxTemplatePlugin.defaultOptions,
158
250
  filename: `${name}.bundle`,
159
251
  intermediate: `.rspeedy/${name}`,
160
- chunks: hasBackground ? [bgEntry, mtEntry] : [mtEntry],
252
+ chunks: includeBackground ? [bgEntry, mtEntry] : [mtEntry],
161
253
  dsl: "react_nodiff",
162
254
  targetSdkVersion,
163
255
  cssPlugins: [],
164
256
  },
165
257
  ]);
166
258
 
167
- if (hasBackground) {
259
+ if (includeBackground) {
168
260
  // Background chunks run in the JavaScript thread and need the
169
261
  // Lynx runtime wrapper; main-thread chunks are encoded as lepus.
170
262
  chain.plugin(`runtime-wrapper-${name}`).use(
@@ -0,0 +1,122 @@
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 asks Lynx Go's ExplorerModule to load the same bundle URL again.
7
+
8
+ function parseResourceQuery(query) {
9
+ const values = {};
10
+ if (typeof query !== "string" || !query.startsWith("?")) return values;
11
+ for (const pair of query.slice(1).split("&")) {
12
+ const index = pair.indexOf("=");
13
+ const key = index === -1 ? pair : pair.slice(0, index);
14
+ const value = index === -1 ? "" : pair.slice(index + 1);
15
+ values[key] = decodeURIComponent(value);
16
+ }
17
+ return values;
18
+ }
19
+
20
+ function socketURL(options) {
21
+ const hostname = options.hostname || "";
22
+ const port = options.port ? `:${options.port}` : "";
23
+ const pathname = options.pathname || "/rsbuild-hmr";
24
+ const token = options.token ? `?token=${encodeURIComponent(options.token)}` : "";
25
+ return `${options.protocol || "ws"}://${hostname}${port}${pathname}${token}`;
26
+ }
27
+
28
+ const options = parseResourceQuery(__resourceQuery);
29
+ const bundleUrl = options["bundle-url"];
30
+ let currentHash;
31
+ let initialBuild = true;
32
+ let socket;
33
+ // ExplorerModule.openSchema(url) makes Lynx Go fully reload — including
34
+ // re-executing this very module from scratch. That would naturally replace
35
+ // this stale instance's socket with a fresh one, except the OLD connection
36
+ // is otherwise left open: confirmed on-device that every earlier reload's
37
+ // socket stays alive and keeps receiving server broadcasts, so a build a few
38
+ // reloads in fires openSchema several times at once (one call per still-open
39
+ // stale socket), and the resulting navigations race and clobber each other —
40
+ // only the first reload after a fresh QR/URL load (a single open socket)
41
+ // reliably lands. Closing this socket ourselves right before reloading, and
42
+ // suppressing the reconnect-on-close it would otherwise trigger, keeps
43
+ // exactly one socket alive at a time.
44
+ let reloading = false;
45
+
46
+ console.info(
47
+ "[mithril-lynx] ExplorerModule.openSchema:",
48
+ typeof NativeModules !== "undefined" && typeof NativeModules.ExplorerModule?.openSchema,
49
+ );
50
+
51
+ function reload() {
52
+ const openSchema =
53
+ typeof NativeModules !== "undefined" && NativeModules.ExplorerModule?.openSchema;
54
+ if (typeof openSchema !== "function") {
55
+ console.warn("[mithril-lynx] Live reload unavailable: ExplorerModule.openSchema was not found.");
56
+ return;
57
+ }
58
+ if (!bundleUrl) {
59
+ console.warn("[mithril-lynx] Live reload unavailable: the bundle URL was not configured.");
60
+ return;
61
+ }
62
+ console.info("[mithril-lynx] Reloading updated bundle.");
63
+ reloading = true;
64
+ socket?.close();
65
+ openSchema.call(NativeModules.ExplorerModule, bundleUrl);
66
+ }
67
+
68
+ function handleMessage(rawMessage) {
69
+ let message;
70
+ try {
71
+ message = JSON.parse(rawMessage);
72
+ } catch {
73
+ console.warn("[mithril-lynx] Ignoring an invalid dev-server message.");
74
+ return;
75
+ }
76
+
77
+ switch (message.type) {
78
+ case "hash":
79
+ currentHash = message.data;
80
+ break;
81
+ case "ok":
82
+ // The server sends hash + ok immediately after connecting. Reloading
83
+ // then would loop forever, so only act after a later successful build.
84
+ if (initialBuild) initialBuild = false;
85
+ else if (currentHash) reload();
86
+ break;
87
+ case "still-ok":
88
+ console.info("[mithril-lynx] Nothing changed.");
89
+ break;
90
+ case "warnings":
91
+ console.warn("[mithril-lynx] Build completed with warnings.", message.data);
92
+ if (!message.params?.preventReloading && !initialBuild) reload();
93
+ break;
94
+ case "errors":
95
+ console.warn("[mithril-lynx] Build failed; waiting for the next successful build.", message.data);
96
+ break;
97
+ case "invalid":
98
+ console.info("[mithril-lynx] App updated. Recompiling...");
99
+ break;
100
+ case "error":
101
+ console.error("[mithril-lynx] Dev server error:", message.data);
102
+ break;
103
+ }
104
+ }
105
+
106
+ function connect(retries = 0) {
107
+ socket = new WebSocket(socketURL(options));
108
+ socket.onmessage = (event) => handleMessage(event.data);
109
+ socket.onerror = (error) => console.error("[mithril-lynx] Dev server connection error:", error);
110
+ socket.onclose = () => {
111
+ if (reloading) return; // Deliberately closed by reload() — a fresh socket is on its way already.
112
+ if (retries >= 10) {
113
+ console.error("[mithril-lynx] Unable to reconnect to the dev server.");
114
+ return;
115
+ }
116
+ const delay = 1000 * 2 ** retries + Math.random() * 100;
117
+ console.info("[mithril-lynx] Dev server disconnected; reconnecting...");
118
+ setTimeout(() => connect(retries + 1), delay);
119
+ };
120
+ }
121
+
122
+ connect();