mithril-lynx 0.0.1

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/plugin.js ADDED
@@ -0,0 +1,185 @@
1
+ // plugin.js
2
+ //
3
+ // Generalized version of lynx-examples/examples/vanilla/plugin.ts's
4
+ // dual-bundle build pattern, published as a reusable Rsbuild/Rspeedy plugin
5
+ // instead of being copy-pasted per app.
6
+ //
7
+ // Convention: for each configured entry, the entry's import path names a
8
+ // "main-thread" file. If a sibling "background.ts"/"background.js" exists
9
+ // next to it, it is picked up automatically and compiled as a second Lynx
10
+ // bundle chunk (the background/JS-thread bundle), while the main-thread file
11
+ // is always compiled and encoded as lepus (main-thread/Lepus VM chunk).
12
+ //
13
+ // This is pure multi-entry bundling, no AST transform of user code: apps
14
+ // author two plain files (main-thread.ts + optional background.ts) and this
15
+ // plugin wires them into the two Lynx bundle slots. See mithril-lynx's
16
+ // project plan, Phase 2.
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { createRequire } from "node:module";
21
+
22
+ import { RuntimeWrapperWebpackPlugin } from "@lynx-js/runtime-wrapper-webpack-plugin";
23
+ import { LynxEncodePlugin, LynxTemplatePlugin } from "@lynx-js/template-webpack-plugin";
24
+
25
+ const PLUGIN_NAME = "mithril-lynx-template-webpack";
26
+
27
+ const BACKGROUND_CANDIDATES = ["background.ts", "background.js"];
28
+ const STYLE_CANDIDATES = ["style.css"];
29
+
30
+ function findSibling(dir, candidates) {
31
+ for (const name of candidates) {
32
+ const candidate = path.join(dir, name);
33
+ if (fs.existsSync(candidate)) return candidate;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ export function pluginMithrilLynx(options = {}) {
39
+ const targetSdkVersion = options.targetSdkVersion ?? "3.5";
40
+
41
+ return {
42
+ name: PLUGIN_NAME,
43
+ setup(api) {
44
+ // Keep the template plugin discoverable by Rspeedy's Lynx internals.
45
+ api.expose(Symbol.for("LynxTemplatePlugin"), { LynxTemplatePlugin });
46
+ api.modifyBundlerChain((chain) => {
47
+ // mithril-lynx's own src/lynx-mithril-shim.js deep-imports mithril's
48
+ // internal render/cachedAttrsIsStaticMap.js (and its emptyAttrs
49
+ // singleton). If the app's own `require("mithril")` resolves to a
50
+ // DIFFERENT physical copy of the package than the one mithril-lynx
51
+ // itself was installed/linked with — the norm for a `file:`-linked
52
+ // local package, whose own node_modules (built for ITS OWN tests)
53
+ // shadows Node's normal directory-walk resolution once linked — the
54
+ // two copies' emptyAttrs singletons differ. The shim then can't
55
+ // recognize the app's legitimately-reused empty-attrs object as
56
+ // such, and Mithril's own updateAttrs() misfires its "Don't reuse
57
+ // attrs object" warning on every plain `m(tag, null, ...)` element,
58
+ // every redraw. Force a single resolution by aliasing "mithril" to
59
+ // whatever copy the app itself resolves from its own project root.
60
+ try {
61
+ const appRequire = createRequire(path.join(process.cwd(), "package.json"));
62
+ // Resolve the PACKAGE DIRECTORY (not mithril's own main entry
63
+ // file) — a prefix alias needs "mithril/render/x" to rewrite to
64
+ // "<dir>/render/x", which only works aliased to a directory.
65
+ const mithrilDir = path.dirname(appRequire.resolve("mithril/package.json"));
66
+ chain.resolve.alias.set("mithril", mithrilDir);
67
+ } catch {
68
+ // App has no local "mithril" resolvable from its own root —
69
+ // leave resolution as-is rather than guessing.
70
+ }
71
+
72
+ const rawEntries = Object.entries(chain.entryPoints.entries() ?? {});
73
+ chain.entryPoints.clear();
74
+
75
+ for (const [name, entry] of rawEntries) {
76
+ const value = entry.values()?.[0];
77
+ const imports = typeof value === "string" || Array.isArray(value) ? value : value?.import;
78
+ const mtSource = Array.isArray(imports) ? imports[0] : imports;
79
+ if (typeof mtSource !== "string") continue;
80
+
81
+ const dir = path.dirname(mtSource);
82
+ const bgSource = findSibling(dir, BACKGROUND_CANDIDATES);
83
+ const cssSource = findSibling(dir, STYLE_CANDIDATES);
84
+
85
+ const bgEntry = `${name}__background`;
86
+ const mtEntry = `${name}__main-thread`;
87
+ const bgAsset = `.rspeedy/${name}/background.js`;
88
+ const mtAsset = `.rspeedy/${name}/main-thread.js`;
89
+ const hasBackground = bgSource != null;
90
+
91
+ // Each entry always has main-thread code and may opt into a
92
+ // background thread by adding a sibling background.ts file.
93
+ if (hasBackground) {
94
+ chain.entry(bgEntry).add({
95
+ import: bgSource,
96
+ filename: bgAsset,
97
+ });
98
+ }
99
+
100
+ chain.entry(mtEntry).add({
101
+ import: cssSource != null ? [mtSource, cssSource] : [mtSource],
102
+ filename: mtAsset,
103
+ });
104
+
105
+ chain.plugin(`template-${name}`).use(LynxTemplatePlugin, [
106
+ {
107
+ ...LynxTemplatePlugin.defaultOptions,
108
+ filename: `${name}.bundle`,
109
+ intermediate: `.rspeedy/${name}`,
110
+ chunks: hasBackground ? [bgEntry, mtEntry] : [mtEntry],
111
+ dsl: "react_nodiff",
112
+ targetSdkVersion,
113
+ cssPlugins: [],
114
+ },
115
+ ]);
116
+
117
+ if (hasBackground) {
118
+ // Background chunks run in the JavaScript thread and need the
119
+ // Lynx runtime wrapper; main-thread chunks are encoded as lepus.
120
+ chain.plugin(`runtime-wrapper-${name}`).use(
121
+ RuntimeWrapperWebpackPlugin,
122
+ [
123
+ {
124
+ targetSdkVersion,
125
+ test: new RegExp(`${name}/background\\.js$`),
126
+ },
127
+ ],
128
+ );
129
+ }
130
+ }
131
+
132
+ chain.plugin("encode").use(LynxEncodePlugin, []);
133
+
134
+ chain.plugin("before-encode").use({
135
+ apply(compiler) {
136
+ compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
137
+ // The default grouping only routes a chunk to lepus (main thread)
138
+ // when its asset carries `lynx:main-thread`. These hand-built
139
+ // entries don't, so main-thread JS lands in `manifest` and lepus
140
+ // stays empty. Re-map it here: background JS to manifest, the
141
+ // main-thread chunk to lepus. CSS is already grouped correctly.
142
+ const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
143
+ hooks.beforeEncode.tap(PLUGIN_NAME, (args) => {
144
+ const pageName = args.intermediate ? path.basename(args.intermediate) : "";
145
+ if (!pageName) return args;
146
+
147
+ const bgAsset = `.rspeedy/${pageName}/background.js`;
148
+ const mtAsset = `.rspeedy/${pageName}/main-thread.js`;
149
+
150
+ const backgroundAsset = compilation.getAsset(bgAsset);
151
+ const mainThreadAsset = compilation.getAsset(mtAsset);
152
+
153
+ if (!mainThreadAsset) return args;
154
+
155
+ args.encodeData.compilerOptions.targetSdkVersion = targetSdkVersion;
156
+ args.encodeData.compilerOptions.enableEventRefactor = true;
157
+
158
+ // Route tap/gesture events through the refactored main-thread
159
+ // path so `__AddEventListener` handlers fire. This page-config
160
+ // flag was dropped from `@lynx-js/config-rsbuild-plugin` 0.2.0's
161
+ // schema, so set it on the page config directly.
162
+ args.encodeData.sourceContent.config.enableEventHandleRefactor = true;
163
+
164
+ args.encodeData.manifest = backgroundAsset
165
+ ? {
166
+ [backgroundAsset.name]: backgroundAsset.source
167
+ .source()
168
+ .toString(),
169
+ }
170
+ : {};
171
+ args.encodeData.lepusCode = {
172
+ root: mainThreadAsset,
173
+ chunks: [],
174
+ filename: mainThreadAsset.name,
175
+ };
176
+
177
+ return args;
178
+ });
179
+ });
180
+ },
181
+ });
182
+ });
183
+ },
184
+ };
185
+ }
@@ -0,0 +1,21 @@
1
+ // Ambient declaration for the ESM renderer/background.js (the file itself is
2
+ // not type-checked; this describes its runtime export shape for TS consumers).
3
+
4
+ export interface RenderAppOptions {
5
+ /** Called exactly once to build the root Mithril vnode against the virtual tree. */
6
+ root(): unknown;
7
+ }
8
+
9
+ /**
10
+ * Renders `root()` against a virtual (op-log-recording) tree and sends the
11
+ * resulting patch to the main thread. Call once, at background.ts's top
12
+ * level. See the project plan, Phase 4 ("renderer mode").
13
+ */
14
+ export function renderApp(options: RenderAppOptions): void;
15
+
16
+ /**
17
+ * Re-invokes the app's view() (via the shim's own redraw()) and flushes the
18
+ * resulting patch to the main thread. Call this instead of importing the
19
+ * shim's redraw() directly in renderer-mode event handlers.
20
+ */
21
+ export function redraw(): void;
@@ -0,0 +1,84 @@
1
+ // renderer/background.js
2
+ //
3
+ // "Renderer mode" (project plan, Phase 4): runs Mithril's UNMODIFIED diff
4
+ // algorithm on the background thread against a VirtualNodeWrapper tree
5
+ // (internal/virtual-node.js) instead of a real Element PAPI tree. Every
6
+ // mutation the diff makes is recorded as a serializable op; ops accumulated
7
+ // during one render/redraw pass are flushed as a single batch to the main
8
+ // thread, which replays them against a real LynxNodeWrapper tree (see
9
+ // renderer/main-thread.js's applyPatch()).
10
+ //
11
+ // This reuses the shim's OWN render()/redraw() convenience API unmodified —
12
+ // the algorithm never knows it's talking to a virtual tree, because it only
13
+ // ever calls generic dom.* methods (see ../CONTRACT.md). Note: render(), not
14
+ // renderToPage() — renderToPage() expects a RAW native page handle and calls
15
+ // __GetElementUniqueID() on it internally; render() takes an
16
+ // already-constructed wrapper directly, which is what the virtual root is.
17
+
18
+ import shim from "../src/lynx-mithril-shim.js";
19
+ import { createVirtualDocument } from "../internal/virtual-node.js";
20
+ import { rendererEventEventName, rendererPatchEventName } from "../internal/constants.js";
21
+
22
+ // A real app calls renderApp() exactly once, establishing this state for the
23
+ // lifetime of the app; redraw() operates on whatever the latest renderApp()
24
+ // call set up. Kept in module scope (rather than passed around) only because
25
+ // redraw() is a separate export with no other way to reach it — NOT shared
26
+ // across independent virtual trees the way a naively module-level
27
+ // styleProxies/vid-counter would be (createVirtualDocument() is called AFRESH
28
+ // inside renderApp(), so each call gets its own independent document, vid
29
+ // counter, and style-proxy registry).
30
+ let opLog = [];
31
+ let flushStyleProxies = () => {};
32
+
33
+ /**
34
+ * Renders `root()` once against the virtual tree and sends the initial
35
+ * patch. Call exactly once, at background.ts's top level (mirrors
36
+ * setupApp()'s root() contract in data-channel mode: subsequent updates
37
+ * flow through redraw(), which re-invokes the component's view(), not
38
+ * root() again).
39
+ */
40
+ export function renderApp(options) {
41
+ const { root } = options;
42
+ opLog = [];
43
+ // vid -> VirtualNodeWrapper, so forwarded main-thread events (addressed by
44
+ // vid) can be dispatched to the right node's listener.
45
+ const nodesByVid = new Map();
46
+
47
+ const virtualDocument = createVirtualDocument(
48
+ (op) => opLog.push(op),
49
+ (wrapper) => nodesByVid.set(wrapper._vid, wrapper),
50
+ );
51
+ flushStyleProxies = virtualDocument.flushStyleProxies;
52
+
53
+ const rootWrapper = virtualDocument.createRootWrapper();
54
+
55
+ shim.render(rootWrapper, root());
56
+ flush();
57
+
58
+ lynx.getCoreContext().addEventListener(rendererEventEventName, (event) => {
59
+ const { vid, type, payload } = event.data;
60
+ const node = nodesByVid.get(vid);
61
+ if (node == null) return;
62
+ node.dispatchEvent({
63
+ type,
64
+ currentTarget: node,
65
+ preventDefault() {},
66
+ stopPropagation() {},
67
+ ...payload,
68
+ });
69
+ });
70
+ }
71
+
72
+ function flush() {
73
+ flushStyleProxies();
74
+ if (opLog.length === 0) return;
75
+ const ops = opLog;
76
+ opLog = [];
77
+ lynx.getCoreContext().dispatchEvent({ type: rendererPatchEventName, data: ops });
78
+ }
79
+
80
+ /** Re-invokes the app's view() and flushes the resulting patch. Call this — not shim.redraw() directly — after mutating state in an event handler. */
81
+ export function redraw() {
82
+ shim.redraw();
83
+ flush();
84
+ }
@@ -0,0 +1,12 @@
1
+ // Ambient declaration for the ESM renderer/main-thread.js (the file itself
2
+ // is not type-checked; this describes its runtime export shape for TS
3
+ // consumers).
4
+
5
+ /**
6
+ * Wires the Lynx engine's page lifecycle and the renderer-mode patch/event
7
+ * channel: replays op-log patches from the background thread's virtual tree
8
+ * against a real Element PAPI tree, and forwards real PAPI events back to
9
+ * the background thread by vid. Call once, at main-thread.ts's top level.
10
+ * See the project plan, Phase 4 ("renderer mode").
11
+ */
12
+ export function setupRenderer(): void;
@@ -0,0 +1,175 @@
1
+ // renderer/main-thread.js
2
+ //
3
+ // "Renderer mode" (project plan, Phase 4) main-thread half: replays the
4
+ // op-log produced by renderer/background.js's virtual tree against a real
5
+ // LynxNodeWrapper tree, and forwards real Element PAPI events back to the
6
+ // background thread by vid (see renderer/background.js's header for the
7
+ // full picture).
8
+ //
9
+ // applyPatch() deliberately does NOT reimplement any PAPI-mapping logic —
10
+ // every op is replayed by calling the EXACT SAME method (setAttribute,
11
+ // className =, style.setProperty, ...) a normal main-thread-owned Mithril
12
+ // app would call on a real LynxNodeWrapper, so it inherits that (already
13
+ // tested) code's correctness for free. The one exception is style: see the
14
+ // setStyleProps case below for why it calls __SetInlineStyles directly.
15
+
16
+ import shim from "../src/lynx-mithril-shim.js";
17
+ import {
18
+ destroyLifetimeEventName,
19
+ renderPageEventName,
20
+ rendererEventEventName,
21
+ rendererPatchEventName,
22
+ } from "../internal/constants.js";
23
+
24
+ // The native engine unconditionally invokes a global `processData(initData)`
25
+ // hook on every __RenderPage, regardless of framework or rendering mode —
26
+ // found missing here via real-device testing (main-thread.js, data-channel
27
+ // mode, already had this fix from Phase 1; this file was never given it).
28
+ Object.assign(globalThis, {
29
+ processData: (data) => data,
30
+ });
31
+
32
+ /**
33
+ * Waits for __RenderPage to create the real page (matching data-channel
34
+ * mode's timing, in case native requires it before the tree can be built),
35
+ * then wires the patch/event channel. The background thread's own initial
36
+ * renderApp() runs independently and may finish before or after
37
+ * __RenderPage fires, so patches arriving early are buffered and replayed
38
+ * in order once the page exists. Call once, at main-thread.ts's top level.
39
+ */
40
+ export function setupRenderer() {
41
+ // vid -> LynxNodeWrapper. Scoped to this setupRenderer() call (a real app
42
+ // calls it exactly once) rather than module-level, so it can never hold
43
+ // stale entries from a previous, unrelated render.
44
+ const vidMap = new Map();
45
+ // (vid + ":" + type) -> the forwarding function passed to addEventListener,
46
+ // so a later removeEvent op can pass the SAME reference to
47
+ // removeEventListener (LynxNodeWrapper matches listeners by identity).
48
+ const forwarders = new Map();
49
+
50
+ function applyOp(op) {
51
+ switch (op.op) {
52
+ case "createElement": {
53
+ const wrapper = vidMap.get(0).ownerDocument.createElement(op.tag);
54
+ vidMap.set(op.vid, wrapper);
55
+ break;
56
+ }
57
+ case "createText": {
58
+ const wrapper = vidMap.get(0).ownerDocument.createTextNode(op.value);
59
+ vidMap.set(op.vid, wrapper);
60
+ break;
61
+ }
62
+ case "appendChild":
63
+ vidMap.get(op.parentVid).appendChild(vidMap.get(op.childVid));
64
+ break;
65
+ case "insertBefore":
66
+ vidMap.get(op.parentVid).insertBefore(
67
+ vidMap.get(op.childVid),
68
+ op.refVid != null ? vidMap.get(op.refVid) : null,
69
+ );
70
+ break;
71
+ case "removeChild":
72
+ vidMap.get(op.parentVid).removeChild(vidMap.get(op.childVid));
73
+ vidMap.delete(op.childVid);
74
+ break;
75
+ case "setProp":
76
+ vidMap.get(op.vid)[op.key] = op.value;
77
+ break;
78
+ case "setAttribute":
79
+ vidMap.get(op.vid).setAttribute(op.key, op.value);
80
+ break;
81
+ case "removeAttribute":
82
+ vidMap.get(op.vid).removeAttribute(op.key);
83
+ break;
84
+ case "setStyleProps": {
85
+ // The virtual side's styles object is already the COMPLETE, current
86
+ // style set (VirtualStyleProxy._flush recomputes it from scratch
87
+ // every time, exactly like the real LynxStyleProxy._flush does).
88
+ // Calling __SetInlineStyles directly with that complete object —
89
+ // rather than replaying individual `wrapper.style[key] = value`
90
+ // assignments through the real LynxStyleProxy — is what correctly
91
+ // propagates REMOVALS: the real proxy only accumulates/overwrites via
92
+ // setProperty, so a key absent from this op (because it was removed
93
+ // on the virtual side) would otherwise never get cleared.
94
+ //
95
+ // Every node gets a style proxy flushed unconditionally (matching
96
+ // the real shim's own flushTree(), which does the same for EVERY
97
+ // LynxStyleProxy ever created) — including raw text nodes, which
98
+ // don't support inline styles at all. The real shim's flushTree()
99
+ // silently swallows that failure (`try { ... } catch {}`) since it
100
+ // runs the flush and the PAPI call in one step, same-thread; here
101
+ // the two are split across the wire, so the try/catch has to live
102
+ // here, on the replay side, instead.
103
+ try {
104
+ __SetInlineStyles(vidMap.get(op.vid)._handle, op.styles);
105
+ } catch (e) {
106
+ /* ignore, e.g. raw text nodes have no .style */
107
+ }
108
+ break;
109
+ }
110
+ case "setText":
111
+ vidMap.get(op.vid).nodeValue = op.value;
112
+ break;
113
+ case "addEvent": {
114
+ const wrapper = vidMap.get(op.vid);
115
+ const forward = (ev) => {
116
+ const { currentTarget, preventDefault, stopPropagation, ...payload } = ev;
117
+ lynx.getJSContext().dispatchEvent({
118
+ type: rendererEventEventName,
119
+ data: { vid: op.vid, type: ev.type, payload },
120
+ });
121
+ };
122
+ forwarders.set(op.vid + ":" + op.type, forward);
123
+ wrapper.addEventListener(op.type, forward, {});
124
+ break;
125
+ }
126
+ case "removeEvent": {
127
+ const key = op.vid + ":" + op.type;
128
+ const forward = forwarders.get(key);
129
+ if (forward != null) {
130
+ vidMap.get(op.vid).removeEventListener(op.type, forward, {});
131
+ forwarders.delete(key);
132
+ }
133
+ break;
134
+ }
135
+ default:
136
+ throw new Error(`renderer/main-thread.js: unknown op "${op.op}"`);
137
+ }
138
+ }
139
+
140
+ function applyPatch(ops) {
141
+ for (const op of ops) applyOp(op);
142
+ __FlushElementTree();
143
+ }
144
+
145
+ const engine = lynx.getEngine();
146
+ const background = lynx.getJSContext();
147
+ let pageReady = false;
148
+ let pendingPatches = [];
149
+
150
+ const onPatch = (event) => {
151
+ if (!pageReady) {
152
+ pendingPatches.push(event.data);
153
+ return;
154
+ }
155
+ applyPatch(event.data);
156
+ };
157
+ background.addEventListener(rendererPatchEventName, onPatch);
158
+
159
+ const onRenderPage = () => {
160
+ const page = __CreatePage("0", 0);
161
+ vidMap.set(0, shim.createPageWrapper(page));
162
+ pageReady = true;
163
+ for (const ops of pendingPatches) applyPatch(ops);
164
+ pendingPatches = [];
165
+ };
166
+ engine.addEventListener(renderPageEventName, onRenderPage);
167
+
168
+ const onDestroyLifetime = () => {
169
+ background.dispatchEvent({ type: destroyLifetimeEventName, data: undefined });
170
+ background.removeEventListener(rendererPatchEventName, onPatch);
171
+ engine.removeEventListener(renderPageEventName, onRenderPage);
172
+ engine.removeEventListener(destroyLifetimeEventName, onDestroyLifetime);
173
+ };
174
+ engine.addEventListener(destroyLifetimeEventName, onDestroyLifetime);
175
+ }
@@ -0,0 +1,16 @@
1
+ // Ambient declaration for the CJS lynx-mithril-shim.js (the file itself is
2
+ // not type-checked; this describes its runtime export shape for main-thread.ts).
3
+
4
+ declare const shim: {
5
+ (dom: unknown, vnodes: unknown, redraw?: () => void): void;
6
+ render(rootWrapper: unknown, vnode: unknown): void;
7
+ redraw(): void;
8
+ createPageWrapper(pageElement: unknown): unknown;
9
+ renderToPage(pageElement: unknown, vnode: unknown): unknown;
10
+ createLynxWindow(pageElement: unknown): unknown;
11
+ LynxNodeWrapper: unknown;
12
+ LynxStyleProxy: unknown;
13
+ normalizeEvent(rawEv: unknown, node: unknown): unknown;
14
+ };
15
+
16
+ export default shim;