embedded-react 0.1.1 → 0.2.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/LICENSE +201 -201
- package/README.md +268 -238
- package/aot/compile.mjs +2 -2
- package/aot/screenshot-smoke.mjs +110 -110
- package/assets/emit-pack.mjs +1 -1
- package/cli.mjs +147 -0
- package/package.json +17 -7
- package/persist-transform.mjs +1 -1
- package/sim/embedded-react.js +2 -0
- package/sim/embedded-react.wasm +0 -0
- package/sim/index.html +387 -0
- package/sim-server.mjs +242 -0
- package/src/embedded-react/Animated.js +357 -352
- package/src/embedded-react/AppRegistry.js +49 -49
- package/src/embedded-react/Easing.js +39 -39
- package/src/embedded-react/LayoutAnimation.js +45 -45
- package/src/embedded-react/Platform.js +26 -26
- package/src/embedded-react/StyleSheet.js +36 -36
- package/src/embedded-react/components.js +44 -44
- package/src/embedded-react/index.js +52 -52
- package/src/embedded-react/layout-anim-config.js +91 -91
- package/src/embedded-react/split-style.js +58 -58
- package/src/embedded-react/usePersistentState.js +2 -2
- package/src/host-config.js +196 -196
- package/src/native-ui.js +24 -24
- package/src/props.js +183 -183
- package/src/renderer.js +57 -57
package/sim/index.html
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
Copyright 2026 Cory Lamming
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
5
|
+
|
|
6
|
+
WASM simulator host page. Loads embedded-react.wasm and runs a requestAnimationFrame loop:
|
|
7
|
+
er_web_pump(dt) -> read the RGBA framebuffer -> putImageData. The canvas fills the whole viewport, so in
|
|
8
|
+
"Fit to window" mode the board size IS the viewport — set the browser's device toolbar to 240x320 and the
|
|
9
|
+
app renders at exactly 240x320 (same workflow as a responsive web project). The size controls are a small
|
|
10
|
+
floating, collapsible chip so they never steal render space. You can also lock to a real panel size. It
|
|
11
|
+
runs Flow A bundles (QuickJS in WASM) with hot reload. See tools/web-sim/README.md.
|
|
12
|
+
-->
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="utf-8" />
|
|
16
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
17
|
+
<title>embedded-react — WASM simulator</title>
|
|
18
|
+
<style>
|
|
19
|
+
:root { color-scheme: dark; }
|
|
20
|
+
html, body { height: 100%; }
|
|
21
|
+
body { margin: 0; overflow: hidden; background: #0b0d10; color: #c8d2e0; font: 13px/1.4 system-ui, sans-serif; }
|
|
22
|
+
|
|
23
|
+
/* Stage fills the whole viewport. The canvas fills it in Fit mode, or sits centered at its native size
|
|
24
|
+
in a locked mode (overflow scrolls if the board is larger than the window). */
|
|
25
|
+
#stage {
|
|
26
|
+
position: fixed;
|
|
27
|
+
inset: 0;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
overflow: auto;
|
|
32
|
+
}
|
|
33
|
+
/* 1:1 — backing store equals the board size, shown at native resolution (no upscale → crisp).
|
|
34
|
+
touch-action:none — give the engine raw pointer events: the browser must not treat a drag as a scroll
|
|
35
|
+
(which would swallow pointermove and break drags) or allow double-tap-zoom on the canvas. */
|
|
36
|
+
canvas {
|
|
37
|
+
image-rendering: pixelated;
|
|
38
|
+
display: block;
|
|
39
|
+
touch-action: none;
|
|
40
|
+
user-select: none;
|
|
41
|
+
-webkit-user-select: none;
|
|
42
|
+
-webkit-touch-callout: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Optional device-frame chrome (locked sizes only): a cosmetic bezel + speaker slit + home indicator
|
|
46
|
+
around the canvas so the preview reads like the physical panel. Purely visual — no effect on render. */
|
|
47
|
+
#device.framed {
|
|
48
|
+
position: relative;
|
|
49
|
+
padding: 26px 12px 30px;
|
|
50
|
+
background: #15181d;
|
|
51
|
+
border: 1px solid #2a313c;
|
|
52
|
+
border-radius: 26px;
|
|
53
|
+
}
|
|
54
|
+
#device.framed::before {
|
|
55
|
+
content: '';
|
|
56
|
+
position: absolute; top: 11px; left: 50%; transform: translateX(-50%);
|
|
57
|
+
width: 42px; height: 5px; border-radius: 3px; background: #2a313c;
|
|
58
|
+
}
|
|
59
|
+
#device.framed::after {
|
|
60
|
+
content: '';
|
|
61
|
+
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
|
|
62
|
+
width: 60px; height: 5px; border-radius: 3px; background: #2a313c;
|
|
63
|
+
}
|
|
64
|
+
#device.framed canvas { border-radius: 4px; }
|
|
65
|
+
|
|
66
|
+
/* Floating, collapsible controls — position:fixed so they overlay the render area without taking any
|
|
67
|
+
layout space (the framebuffer always gets the full viewport). Expanded, it wraps within the viewport
|
|
68
|
+
so nothing is cut off on a small board; collapsed, it is just a square icon button. */
|
|
69
|
+
#panel {
|
|
70
|
+
position: fixed;
|
|
71
|
+
left: 8px;
|
|
72
|
+
bottom: 8px;
|
|
73
|
+
z-index: 10;
|
|
74
|
+
display: flex;
|
|
75
|
+
gap: 8px;
|
|
76
|
+
align-items: center;
|
|
77
|
+
flex-wrap: wrap;
|
|
78
|
+
box-sizing: border-box;
|
|
79
|
+
max-width: calc(100vw - 16px);
|
|
80
|
+
border-radius: 8px;
|
|
81
|
+
}
|
|
82
|
+
#panel:not(.collapsed) {
|
|
83
|
+
padding: 6px 8px;
|
|
84
|
+
background: rgba(14, 17, 22, 0.92);
|
|
85
|
+
border: 1px solid #2a313c;
|
|
86
|
+
backdrop-filter: blur(4px);
|
|
87
|
+
}
|
|
88
|
+
#panel.collapsed .controls { display: none; }
|
|
89
|
+
.controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
90
|
+
/* Square icon toggle: bordered chip when collapsed, plain icon when the dock is open. */
|
|
91
|
+
#toggle {
|
|
92
|
+
flex: 0 0 auto;
|
|
93
|
+
width: 32px;
|
|
94
|
+
height: 32px;
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
padding: 0;
|
|
99
|
+
border-radius: 8px;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
color: #c8d2e0;
|
|
102
|
+
background: transparent;
|
|
103
|
+
border: 1px solid transparent;
|
|
104
|
+
}
|
|
105
|
+
#panel.collapsed #toggle {
|
|
106
|
+
background: rgba(14, 17, 22, 0.92);
|
|
107
|
+
border-color: #2a313c;
|
|
108
|
+
backdrop-filter: blur(4px);
|
|
109
|
+
}
|
|
110
|
+
select, input:not([type='checkbox']), button.act {
|
|
111
|
+
background: #161a20;
|
|
112
|
+
color: #c8d2e0;
|
|
113
|
+
border: 1px solid #2a313c;
|
|
114
|
+
border-radius: 5px;
|
|
115
|
+
padding: 3px 7px;
|
|
116
|
+
font: inherit;
|
|
117
|
+
}
|
|
118
|
+
input[type='number'] { width: 58px; }
|
|
119
|
+
.controls .chk { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
|
|
120
|
+
button.act { cursor: pointer; }
|
|
121
|
+
.controls label { opacity: 0.65; }
|
|
122
|
+
#sizeLabel { opacity: 0.65; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<div id="stage"><div id="device"><canvas id="screen"></canvas></div></div>
|
|
127
|
+
<div id="panel" class="collapsed">
|
|
128
|
+
<button id="toggle" title="Size controls" aria-label="Size controls">
|
|
129
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
|
|
130
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
131
|
+
<circle cx="12" cy="12" r="3"></circle>
|
|
132
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
133
|
+
</svg>
|
|
134
|
+
</button>
|
|
135
|
+
<div class="controls">
|
|
136
|
+
<label for="preset">size</label>
|
|
137
|
+
<select id="preset"></select>
|
|
138
|
+
<input id="w" type="number" min="64" max="4096" step="1" />
|
|
139
|
+
<span>×</span>
|
|
140
|
+
<input id="h" type="number" min="64" max="4096" step="1" />
|
|
141
|
+
<button id="apply" class="act">Lock</button>
|
|
142
|
+
<label class="chk"><input id="frame" type="checkbox" />frame</label>
|
|
143
|
+
<span id="sizeLabel">loading…</span>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<script src="./public/embedded-react.js"></script>
|
|
148
|
+
<script>
|
|
149
|
+
(async () => {
|
|
150
|
+
const stage = document.getElementById('stage');
|
|
151
|
+
const canvas = document.getElementById('screen');
|
|
152
|
+
const ctx = canvas.getContext('2d');
|
|
153
|
+
const panel = document.getElementById('panel');
|
|
154
|
+
const toggle = document.getElementById('toggle');
|
|
155
|
+
const sizeLabel = document.getElementById('sizeLabel');
|
|
156
|
+
const presetSel = document.getElementById('preset');
|
|
157
|
+
const wIn = document.getElementById('w');
|
|
158
|
+
const hIn = document.getElementById('h');
|
|
159
|
+
const device = document.getElementById('device');
|
|
160
|
+
const frameChk = document.getElementById('frame');
|
|
161
|
+
let framed = false;
|
|
162
|
+
const fail = (msg) => { sizeLabel.textContent = msg; panel.classList.remove('collapsed'); };
|
|
163
|
+
|
|
164
|
+
// "Fit to window" (the full viewport) is the default — the browser's responsive/device toolbar drives
|
|
165
|
+
// the board directly. Presets lock to a real panel size.
|
|
166
|
+
const PRESETS = [
|
|
167
|
+
[800, 480],
|
|
168
|
+
[1024, 600],
|
|
169
|
+
[480, 320],
|
|
170
|
+
[320, 240],
|
|
171
|
+
[240, 320],
|
|
172
|
+
];
|
|
173
|
+
const fitOpt = document.createElement('option');
|
|
174
|
+
fitOpt.value = 'fit';
|
|
175
|
+
fitOpt.textContent = 'Fit to window';
|
|
176
|
+
presetSel.appendChild(fitOpt);
|
|
177
|
+
for (const [pw, ph] of PRESETS) {
|
|
178
|
+
const o = document.createElement('option');
|
|
179
|
+
o.value = `${pw}x${ph}`;
|
|
180
|
+
o.textContent = `${pw} × ${ph}`;
|
|
181
|
+
presetSel.appendChild(o);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
toggle.addEventListener('click', () => panel.classList.toggle('collapsed'));
|
|
185
|
+
|
|
186
|
+
let Module;
|
|
187
|
+
try {
|
|
188
|
+
Module = await createEmbeddedReact();
|
|
189
|
+
} catch (e) {
|
|
190
|
+
fail('load failed — run build.mjs');
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const init = Module.cwrap('er_web_init', 'number', ['number', 'number']);
|
|
195
|
+
const demoScene = Module.cwrap('er_web_demo_scene', null, []);
|
|
196
|
+
const loadSource = Module.cwrap('er_web_load_source', null, ['number', 'number']);
|
|
197
|
+
const loadPack = Module.cwrap('er_web_load_pack', 'number', ['number', 'number']);
|
|
198
|
+
const resize = Module.cwrap('er_web_resize', 'number', ['number', 'number']);
|
|
199
|
+
const pump = Module.cwrap('er_web_pump', null, ['number']);
|
|
200
|
+
const touch = Module.cwrap('er_web_touch', null, ['number', 'number', 'number']);
|
|
201
|
+
const framebuffer = Module.cwrap('er_web_framebuffer', 'number', []);
|
|
202
|
+
const fbWidth = Module.cwrap('er_web_fb_width', 'number', []);
|
|
203
|
+
const fbHeight = Module.cwrap('er_web_fb_height', 'number', []);
|
|
204
|
+
|
|
205
|
+
const clamp = (n) => Math.max(64, Math.min(4096, n | 0));
|
|
206
|
+
// The stage is inset:0, so its client size is the full viewport (the floating panel never shrinks it).
|
|
207
|
+
const stageSize = () => [clamp(stage.clientWidth), clamp(stage.clientHeight)];
|
|
208
|
+
|
|
209
|
+
// Initial size: ?screen=WxH locks to that size; otherwise fit the viewport.
|
|
210
|
+
const params = new URLSearchParams(location.search);
|
|
211
|
+
let mode = 'fit';
|
|
212
|
+
let [W, H] = stageSize();
|
|
213
|
+
const initial = params.get('screen');
|
|
214
|
+
if (initial) {
|
|
215
|
+
const [iw, ih] = initial.split('x').map((n) => parseInt(n, 10) || 0);
|
|
216
|
+
if (iw && ih) {
|
|
217
|
+
mode = 'fixed';
|
|
218
|
+
W = clamp(iw);
|
|
219
|
+
H = clamp(ih);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
presetSel.value = mode === 'fit' ? 'fit' : PRESETS.some(([a, b]) => a === W && b === H) ? `${W}x${H}` : 'fit';
|
|
223
|
+
|
|
224
|
+
if (!init(W, H)) {
|
|
225
|
+
fail('init failed');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Copy bytes into wasm memory, hand the pointer to a C function, then free.
|
|
230
|
+
const withWasmBytes = (bytes, fn) => {
|
|
231
|
+
const ptr = Module._malloc(bytes.length);
|
|
232
|
+
Module.HEAPU8.set(bytes, ptr);
|
|
233
|
+
fn(ptr, bytes.length);
|
|
234
|
+
Module._free(ptr);
|
|
235
|
+
};
|
|
236
|
+
const fetchBytes = async (url) => {
|
|
237
|
+
const res = await fetch(url, { cache: 'no-store' });
|
|
238
|
+
return res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Load the asset pack (if any) BEFORE the bundle so <Image>/custom-font nodes resolve, then run the
|
|
242
|
+
// Flow A app in the QuickJS runtime. Re-runnable: the dev server calls this on every hot reload, and
|
|
243
|
+
// er_web_load_source resets + re-evaluates (useState is preserved by the persist transform). Falls
|
|
244
|
+
// back to the built-in static scene if no app.js is served.
|
|
245
|
+
const loadApp = async () => {
|
|
246
|
+
const pack = await fetchBytes('./public/assets.pack');
|
|
247
|
+
if (pack) withWasmBytes(pack, loadPack);
|
|
248
|
+
const app = await fetchBytes('./public/app.js');
|
|
249
|
+
if (!app) {
|
|
250
|
+
console.warn('no app.js — showing the built-in demo scene');
|
|
251
|
+
demoScene();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
withWasmBytes(app, loadSource);
|
|
255
|
+
};
|
|
256
|
+
loadApp();
|
|
257
|
+
|
|
258
|
+
// Hot reload: the dev server pushes a Server-Sent "reload" on every rebuild; re-load the new pack +
|
|
259
|
+
// bundle. window.__erHot is injected only when served by the dev server, so a static export (no
|
|
260
|
+
// server) doesn't poll a nonexistent /reload endpoint.
|
|
261
|
+
if (window.__erHot) {
|
|
262
|
+
try {
|
|
263
|
+
const es = new EventSource('/reload');
|
|
264
|
+
es.onmessage = () => loadApp();
|
|
265
|
+
es.onerror = () => {};
|
|
266
|
+
} catch {
|
|
267
|
+
/* EventSource unavailable */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let w = 0;
|
|
272
|
+
let h = 0;
|
|
273
|
+
let len = 0;
|
|
274
|
+
let image = null;
|
|
275
|
+
|
|
276
|
+
const syncCanvas = () => {
|
|
277
|
+
w = fbWidth();
|
|
278
|
+
h = fbHeight();
|
|
279
|
+
len = w * h * 4;
|
|
280
|
+
canvas.width = w;
|
|
281
|
+
canvas.height = h;
|
|
282
|
+
// Fit: fill the viewport (backing already equals it, so still 1:1). Locked: native size, centered,
|
|
283
|
+
// scroll if larger than the window. The #device wrapper fills the stage in Fit mode or shrinks to
|
|
284
|
+
// the canvas (+ bezel when framed) when locked.
|
|
285
|
+
canvas.style.width = mode === 'fit' ? '100%' : w + 'px';
|
|
286
|
+
canvas.style.height = mode === 'fit' ? '100%' : h + 'px';
|
|
287
|
+
device.style.display = mode === 'fit' ? 'block' : 'inline-block';
|
|
288
|
+
device.style.width = mode === 'fit' ? '100%' : 'auto';
|
|
289
|
+
device.style.height = mode === 'fit' ? '100%' : 'auto';
|
|
290
|
+
device.classList.toggle('framed', framed && mode === 'fixed');
|
|
291
|
+
image = ctx.createImageData(w, h);
|
|
292
|
+
wIn.value = w;
|
|
293
|
+
hIn.value = h;
|
|
294
|
+
sizeLabel.textContent = `${w}×${h}${mode === 'fit' ? ' · fit' : ''}`;
|
|
295
|
+
};
|
|
296
|
+
syncCanvas();
|
|
297
|
+
|
|
298
|
+
const applySize = (nw, nh) => {
|
|
299
|
+
nw = clamp(nw);
|
|
300
|
+
nh = clamp(nh);
|
|
301
|
+
if (nw === w && nh === h) return;
|
|
302
|
+
if (resize(nw, nh)) syncCanvas();
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Debounced refit while the viewport changes (window drag, device toolbar, rotate). Driven by the
|
|
306
|
+
// window 'resize' event (fires reliably for viewport changes) with a ResizeObserver as backup, and a
|
|
307
|
+
// setTimeout debounce (runs even when the tab is backgrounded, unlike requestAnimationFrame).
|
|
308
|
+
let resizeTimer = 0;
|
|
309
|
+
const refit = () => {
|
|
310
|
+
if (mode !== 'fit') return;
|
|
311
|
+
clearTimeout(resizeTimer);
|
|
312
|
+
resizeTimer = setTimeout(() => applySize(...stageSize()), 60);
|
|
313
|
+
};
|
|
314
|
+
window.addEventListener('resize', refit);
|
|
315
|
+
new ResizeObserver(refit).observe(stage);
|
|
316
|
+
// Initial fit once layout has settled (the first synchronous measure can predate viewport layout).
|
|
317
|
+
setTimeout(() => mode === 'fit' && applySize(...stageSize()), 0);
|
|
318
|
+
|
|
319
|
+
presetSel.addEventListener('change', () => {
|
|
320
|
+
if (presetSel.value === 'fit') {
|
|
321
|
+
mode = 'fit';
|
|
322
|
+
applySize(...stageSize());
|
|
323
|
+
} else {
|
|
324
|
+
mode = 'fixed';
|
|
325
|
+
applySize(...presetSel.value.split('x').map((n) => parseInt(n, 10)));
|
|
326
|
+
}
|
|
327
|
+
syncCanvas();
|
|
328
|
+
});
|
|
329
|
+
document.getElementById('apply').addEventListener('click', () => {
|
|
330
|
+
mode = 'fixed';
|
|
331
|
+
presetSel.value = PRESETS.some(([a, b]) => a === +wIn.value && b === +hIn.value)
|
|
332
|
+
? `${+wIn.value}x${+hIn.value}`
|
|
333
|
+
: 'fit';
|
|
334
|
+
applySize(+wIn.value, +hIn.value);
|
|
335
|
+
syncCanvas();
|
|
336
|
+
});
|
|
337
|
+
// Device-frame chrome only makes sense at a locked size, so enabling it locks the current size.
|
|
338
|
+
frameChk.addEventListener('change', () => {
|
|
339
|
+
framed = frameChk.checked;
|
|
340
|
+
if (framed && mode === 'fit') {
|
|
341
|
+
mode = 'fixed';
|
|
342
|
+
presetSel.value = PRESETS.some(([a, b]) => a === w && b === h) ? `${w}x${h}` : 'fit';
|
|
343
|
+
}
|
|
344
|
+
syncCanvas();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Pointer -> touch. Map CSS pixels to framebuffer pixels (the canvas may be stretched in Fit mode).
|
|
348
|
+
let down = false;
|
|
349
|
+
const toFb = (e) => {
|
|
350
|
+
const r = canvas.getBoundingClientRect();
|
|
351
|
+
const x = Math.round(((e.clientX - r.left) / r.width) * w);
|
|
352
|
+
const y = Math.round(((e.clientY - r.top) / r.height) * h);
|
|
353
|
+
return [x, y];
|
|
354
|
+
};
|
|
355
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
down = true;
|
|
358
|
+
canvas.setPointerCapture(e.pointerId);
|
|
359
|
+
touch(0, ...toFb(e));
|
|
360
|
+
});
|
|
361
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
362
|
+
if (down) touch(1, ...toFb(e));
|
|
363
|
+
});
|
|
364
|
+
const release = (e) => {
|
|
365
|
+
if (!down) return;
|
|
366
|
+
down = false;
|
|
367
|
+
touch(2, ...toFb(e));
|
|
368
|
+
};
|
|
369
|
+
canvas.addEventListener('pointerup', release);
|
|
370
|
+
canvas.addEventListener('pointercancel', release);
|
|
371
|
+
|
|
372
|
+
let prev = performance.now();
|
|
373
|
+
const frame = (now) => {
|
|
374
|
+
const dt = Math.min(100, now - prev);
|
|
375
|
+
prev = now;
|
|
376
|
+
pump(Math.round(dt));
|
|
377
|
+
// HEAPU8 may detach when WASM memory grows, so wrap a fresh view each frame, then copy into the
|
|
378
|
+
// persistent ImageData (robust against detach during the copy).
|
|
379
|
+
image.data.set(new Uint8ClampedArray(Module.HEAPU8.buffer, framebuffer(), len));
|
|
380
|
+
ctx.putImageData(image, 0, 0);
|
|
381
|
+
requestAnimationFrame(frame);
|
|
382
|
+
};
|
|
383
|
+
requestAnimationFrame(frame);
|
|
384
|
+
})();
|
|
385
|
+
</script>
|
|
386
|
+
</body>
|
|
387
|
+
</html>
|
package/sim-server.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Cory Lamming
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// sim-server.mjs — the shared WASM-simulator bundler + dev server.
|
|
18
|
+
//
|
|
19
|
+
// esbuild-bundles a JSX app → app.js, bakes its imported images/fonts → assets.pack, and either (a) serves
|
|
20
|
+
// the prebuilt embedded-react.{js,wasm} host page with live hot reload (runDevServer), or (b) produces those
|
|
21
|
+
// files once for a static export (buildApp). useState survives a hot reload via the Babel persist transform.
|
|
22
|
+
//
|
|
23
|
+
// Used by the consumer CLI (cli.mjs → `npx embedded-react dev` / `export`) and the repo dev loop
|
|
24
|
+
// (tools/web-sim/dev.mjs). The only differences are paths, passed in here.
|
|
25
|
+
|
|
26
|
+
import { createServer } from 'node:http';
|
|
27
|
+
import { readFile } from 'node:fs/promises';
|
|
28
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
import { basename, dirname, extname, relative, resolve } from 'node:path';
|
|
32
|
+
|
|
33
|
+
const HERE = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const esbuild = require('esbuild');
|
|
36
|
+
const { bakeAssetPack } = await import(pathToFileURL(resolve(HERE, 'assets/index.mjs')).href);
|
|
37
|
+
const { transformPersist } = await import(pathToFileURL(resolve(HERE, 'persist-transform.mjs')).href);
|
|
38
|
+
|
|
39
|
+
const MIME = {
|
|
40
|
+
'.html': 'text/html; charset=utf-8',
|
|
41
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
42
|
+
'.wasm': 'application/wasm',
|
|
43
|
+
'.pack': 'application/octet-stream',
|
|
44
|
+
};
|
|
45
|
+
const assetName = (p) => basename(p).replace(/\.[^.]+$/, '');
|
|
46
|
+
const mtime = (p) => {
|
|
47
|
+
try {
|
|
48
|
+
return statSync(p).mtimeMs;
|
|
49
|
+
} catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the esbuild config + asset baker for one app bundle. Owns its own asset-discovery state, so it is
|
|
56
|
+
* safe to use for either a one-shot build or a watching context.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} o
|
|
59
|
+
* @param {string} o.entry, o.projectRoot, o.libSrc, o.outDir
|
|
60
|
+
* @param {string[]} o.nodePaths
|
|
61
|
+
* @param {boolean} [o.persist] Apply the useState→persist transform (dev hot reload). Default true.
|
|
62
|
+
* @param {() => void} [o.onRebuilt] Called after each successful build + asset bake (e.g. broadcast reload).
|
|
63
|
+
* @returns {{ options: object, bundlePath: string, packPath: string }}
|
|
64
|
+
*/
|
|
65
|
+
function createBundle({ entry, projectRoot, libSrc, nodePaths, outDir, persist = true, onRebuilt }) {
|
|
66
|
+
const bundlePath = resolve(outDir, 'app.js');
|
|
67
|
+
const packPath = resolve(outDir, 'assets.pack');
|
|
68
|
+
const projNorm = projectRoot.replace(/\\/g, '/');
|
|
69
|
+
const images = new Map();
|
|
70
|
+
const fonts = new Map();
|
|
71
|
+
let lastAssetSig = null;
|
|
72
|
+
|
|
73
|
+
async function bakePack() {
|
|
74
|
+
const discoveredSizes = [
|
|
75
|
+
...new Set(
|
|
76
|
+
[...readFileSync(bundlePath, 'utf8').matchAll(/\bfontSize\s*:\s*(\d+(?:\.\d+)?)/g)].map((m) => Math.round(Number(m[1]))),
|
|
77
|
+
),
|
|
78
|
+
].sort((a, b) => a - b);
|
|
79
|
+
|
|
80
|
+
let cfg = {};
|
|
81
|
+
const cp = resolve(projectRoot, 'assets.config.js');
|
|
82
|
+
if (existsSync(cp)) cfg = (await import(`${pathToFileURL(cp).href}?t=${mtime(cp)}`)).default || {};
|
|
83
|
+
const fontConfig = cfg.fonts || {};
|
|
84
|
+
|
|
85
|
+
const fontJobs = [...fonts.entries()].map(([family, path]) => {
|
|
86
|
+
const fc = fontConfig[family] || {};
|
|
87
|
+
const sizes = fc.sizes && fc.sizes.length ? fc.sizes : discoveredSizes.length ? discoveredSizes : [16];
|
|
88
|
+
return { path, family, sizes, bpp: fc.bpp ?? 4, glyphs: fc.glyphs ?? 'ascii' };
|
|
89
|
+
});
|
|
90
|
+
const imageJobs = [...images.entries()].map(([name, path]) => ({ path, name }));
|
|
91
|
+
|
|
92
|
+
const sig = JSON.stringify({
|
|
93
|
+
i: imageJobs.map((j) => [j.name, j.path, mtime(j.path)]).sort(),
|
|
94
|
+
f: fontJobs.map((j) => [j.family, j.path, mtime(j.path), j.sizes, j.bpp, j.glyphs]).sort(),
|
|
95
|
+
});
|
|
96
|
+
if (sig === lastAssetSig) return;
|
|
97
|
+
lastAssetSig = sig;
|
|
98
|
+
if (!imageJobs.length && !fontJobs.length) return; // built-in font only → no pack
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const s = bakeAssetPack({ images: imageJobs, fonts: fontJobs, outPath: packPath });
|
|
102
|
+
console.log(` assets → ${s.images} image(s), ${s.fonts} font size(s), ${s.bytes} B`);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(` asset bake failed: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const options = {
|
|
109
|
+
entryPoints: [entry],
|
|
110
|
+
bundle: true,
|
|
111
|
+
format: 'iife',
|
|
112
|
+
outfile: bundlePath,
|
|
113
|
+
platform: 'neutral',
|
|
114
|
+
target: 'es2020',
|
|
115
|
+
jsx: 'automatic',
|
|
116
|
+
alias: { 'embedded-react': libSrc },
|
|
117
|
+
nodePaths,
|
|
118
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
119
|
+
legalComments: 'none',
|
|
120
|
+
logLevel: 'silent',
|
|
121
|
+
plugins: [
|
|
122
|
+
{
|
|
123
|
+
name: 'embedded-react-websim',
|
|
124
|
+
setup(b) {
|
|
125
|
+
b.onStart(() => {
|
|
126
|
+
images.clear();
|
|
127
|
+
fonts.clear();
|
|
128
|
+
});
|
|
129
|
+
b.onLoad({ filter: /\.(jsx?|tsx?)$/ }, (a) => {
|
|
130
|
+
if (!persist || !a.path.replace(/\\/g, '/').startsWith(projNorm)) return undefined;
|
|
131
|
+
try {
|
|
132
|
+
return { contents: transformPersist(readFileSync(a.path, 'utf8'), relative(projectRoot, a.path).replace(/\\/g, '/')), loader: 'jsx' };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { errors: [{ text: `persist transform: ${e.message}` }] };
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
b.onLoad({ filter: /\.(png|jpe?g|webp|gif|bmp)$/i }, (a) => {
|
|
138
|
+
images.set(assetName(a.path), a.path);
|
|
139
|
+
return { contents: `module.exports = ${JSON.stringify(assetName(a.path))};`, loader: 'js' };
|
|
140
|
+
});
|
|
141
|
+
b.onLoad({ filter: /\.(ttf|otf)$/i }, (a) => {
|
|
142
|
+
fonts.set(assetName(a.path), a.path);
|
|
143
|
+
return { contents: `module.exports = ${JSON.stringify(assetName(a.path))};`, loader: 'js' };
|
|
144
|
+
});
|
|
145
|
+
b.onEnd(async (r) => {
|
|
146
|
+
if (r.errors.length) {
|
|
147
|
+
console.error(`✗ build failed (${r.errors.length} error(s))`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await bakePack();
|
|
151
|
+
if (onRebuilt) onRebuilt();
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return { options, bundlePath, packPath };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One-shot bundle + asset bake into outDir (app.js [+ assets.pack]). For the static export.
|
|
163
|
+
* Persist transform is off (a static page has no hot reload to preserve state across).
|
|
164
|
+
*/
|
|
165
|
+
export async function buildApp({ entry, projectRoot, libSrc, nodePaths, outDir }) {
|
|
166
|
+
const { options } = createBundle({ entry, projectRoot, libSrc, nodePaths, outDir, persist: false });
|
|
167
|
+
await esbuild.build(options);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Run the simulator dev server: watch + bundle + bake + serve + hot reload.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} o
|
|
174
|
+
* @param {string} o.entry, o.projectRoot, o.libSrc, o.indexHtml, o.simDir, o.outDir, o.label
|
|
175
|
+
* @param {string[]} o.nodePaths
|
|
176
|
+
* @param {number} o.port
|
|
177
|
+
*/
|
|
178
|
+
export async function runDevServer({ entry, projectRoot, libSrc, nodePaths, indexHtml, simDir, outDir, port, label }) {
|
|
179
|
+
const clients = new Set();
|
|
180
|
+
const broadcastReload = () => {
|
|
181
|
+
for (const res of clients) res.write('data: reload\n\n');
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const { options } = createBundle({ entry, projectRoot, libSrc, nodePaths, outDir, persist: true, onRebuilt: () => {
|
|
185
|
+
console.log('↻ rebuilt → reloading');
|
|
186
|
+
broadcastReload();
|
|
187
|
+
} });
|
|
188
|
+
|
|
189
|
+
const ctx = await esbuild.context(options);
|
|
190
|
+
await ctx.rebuild();
|
|
191
|
+
await ctx.watch();
|
|
192
|
+
|
|
193
|
+
// Serve the host page; embedded-react.{js,wasm} from simDir, the freshly bundled app.js/assets.pack from
|
|
194
|
+
// outDir. The page references ./public/<name>. window.__erHot is injected so the page connects to /reload.
|
|
195
|
+
const send = async (res, file) => {
|
|
196
|
+
try {
|
|
197
|
+
res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store' }).end(await readFile(file));
|
|
198
|
+
} catch {
|
|
199
|
+
res.writeHead(404).end('not found');
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const server = createServer(async (req, res) => {
|
|
203
|
+
const path = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
204
|
+
if (path === '/reload') {
|
|
205
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
206
|
+
res.write('retry: 1000\n\n');
|
|
207
|
+
clients.add(res);
|
|
208
|
+
req.on('close', () => clients.delete(res));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (path === '/' || path === '/index.html') {
|
|
212
|
+
try {
|
|
213
|
+
const html = (await readFile(indexHtml, 'utf8')).replace('</head>', '<script>window.__erHot=true</script></head>');
|
|
214
|
+
res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' }).end(html);
|
|
215
|
+
} catch {
|
|
216
|
+
res.writeHead(404).end('not found');
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (path.startsWith('/public/')) {
|
|
221
|
+
const name = basename(path); // flat filename only
|
|
222
|
+
const dir = name === 'embedded-react.js' || name === 'embedded-react.wasm' ? simDir : outDir;
|
|
223
|
+
return void send(res, resolve(dir, name));
|
|
224
|
+
}
|
|
225
|
+
res.writeHead(404).end('not found');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await new Promise((r) => server.listen(port, r));
|
|
229
|
+
console.log(`embedded-react WASM sim → http://localhost:${port}/`);
|
|
230
|
+
console.log(`watching ${label} — edit & save to hot-reload. Ctrl-C to quit.`);
|
|
231
|
+
|
|
232
|
+
const shutdown = async () => {
|
|
233
|
+
try {
|
|
234
|
+
await ctx.dispose();
|
|
235
|
+
} catch {}
|
|
236
|
+
server.close();
|
|
237
|
+
process.exit(0);
|
|
238
|
+
};
|
|
239
|
+
process.on('SIGINT', shutdown);
|
|
240
|
+
process.on('SIGTERM', shutdown);
|
|
241
|
+
return { ctx, server };
|
|
242
|
+
}
|