webcanvas-wasm 0.1.0

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 ADDED
@@ -0,0 +1,305 @@
1
+ # webcanvas-wasm
2
+
3
+ <img width="1512" height="843" alt="Screenshot 2026-09-26 at 23 04 42" src="https://github.com/user-attachments/assets/33b0ad01-56a2-48eb-a51f-7f6fab35712a" />
4
+
5
+
6
+
7
+ Minimal standalone Gecko WebView running in WebAssembly.
8
+
9
+ The engine runs unmodified in a `<canvas>` and exposes a typed session, a page object, optional React bindings, and a Vite plugin. Everything is plain ES modules and shipped as source — there is no build step between the files in this repository and the files a consumer imports.
10
+
11
+ - one persistent `<canvas>` used by Gecko
12
+ - Gecko WASM boot with `SharedArrayBuffer`
13
+ - same-origin Wisp networking at `/wisp/`
14
+ - COOP/COEP headers required for cross-origin isolation
15
+ - no database, auth, worker, or application-specific code
16
+
17
+ ## Install
18
+
19
+ The engine artifacts are large, so they are fetched with the package rather than bundled into your build output.
20
+
21
+ ```bash
22
+ npm install webcanvas-wasm
23
+ ```
24
+
25
+ Node.js 20 or newer. The Vite plugin additionally needs the Node version Vite itself supports (`^20.19.0 || >=22.12.0`).
26
+
27
+ ## Run
28
+
29
+ Requires Node.js 20+ and a modern Chromium-based host browser. `npm start` serves the `demo/` directory through `createWebviewServer()`, so the Wisp endpoint and the isolation headers are already wired.
30
+
31
+ ```bash
32
+ npm install
33
+ npm start
34
+ ```
35
+
36
+ Open:
37
+
38
+ ```text
39
+ http://127.0.0.1:8080
40
+ ```
41
+
42
+ That address is for the *host page*. A remote deployment should use HTTPS so the browser can provide the secure, cross-origin-isolated environment needed by `SharedArrayBuffer`. Note that pages you navigate the WebView to are a separate matter: loopback is not reachable from inside it, see [Networking](#networking).
43
+
44
+ To listen on another address or port:
45
+
46
+ ```bash
47
+ HOST=0.0.0.0 PORT=8080 npm start
48
+ ```
49
+
50
+ ## How it works
51
+
52
+ ```text
53
+ Host browser
54
+ └─ index.html
55
+ └─ persistent canvas
56
+ └─ Gecko JS/WASM
57
+ └─ same-origin WebSocket /wisp/
58
+ └─ target website network
59
+ ```
60
+
61
+ The boot path stops at `Gecko.init()`. It does **not** perform a synthetic `about:blank` navigation as a readiness check. Navigation is dispatched after the engine is ready and is intentionally not awaited by the UI, because a Gecko navigation can already be running even while its command promise remains pending.
62
+
63
+ ## Networking
64
+
65
+ Gecko does not use the host page's `fetch`. It tunnels all of its HTTP through a [Wisp](https://github.com/MercuryWorkshop/Wisp) server reached at `/wisp/` on the **host page's own origin**, so the engine inherits the host's isolation and cookies rather than being cross-origin by accident.
66
+
67
+ This is a hard requirement, not a default: without a working `/wisp/` endpoint the engine still boots, but no page will ever load. `createWebviewServer()` sets one up for you. The Vite plugin does **not** — it only serves the two engine files and sets headers, so a Vite app has to provide the endpoint itself (for example with `configureServer` middleware that routes `/wisp/` upgrades to `wisp-js/server`).
68
+
69
+ Two consequences are worth knowing up front:
70
+
71
+ - **Loopback is not reachable from inside the WebView.** Wisp refuses to open a stream to a loopback address, as an SSRF guard. `http://127.0.0.1:…`, `http://[::1]:…` and `http://localhost:…` are all refused, while public HTTPS pages load normally. Serving your app from `127.0.0.1` is fine; browsing to it is not.
72
+ - **A refused navigation reports a misleading error.** If the navigation did not take, the engine cannot evaluate afterwards and the next `eval()` fails with `EvalError: call to eval() blocked by CSP` from `@embed-chrome`. That message means "there is no loaded document", not "a content security policy is misconfigured". The engine recovers on the next successful navigation.
73
+
74
+ ## Typed API
75
+
76
+ `createGeckoRuntime()` returns a `GeckoSession` that closes the failure modes the raw engine leaves open. Types live in `types/`, and type checking is type-only — nothing is emitted, so the app still loads as plain ES modules.
77
+
78
+ ```js
79
+ import { createGeckoRuntime, isGeckoEvalError } from 'webcanvas-wasm';
80
+
81
+ const session = createGeckoRuntime({ canvas, onLog, onError });
82
+
83
+ await session.init(); // resolves once the engine is READY
84
+ await session.open('example.com'); // returns once the new document is scriptable
85
+
86
+ const title = await session.eval('document.title');
87
+ console.log(title); // "Example Domain"
88
+ ```
89
+
90
+ | Call | Returns |
91
+ | --- | --- |
92
+ | `init()` | the live engine instance |
93
+ | `navigate(value)` | normalised URL, dispatch not awaited |
94
+ | `open(value, ms?)` | `Promise<string>`, resolved after the new document has loaded |
95
+ | `reload()` | URL, or `null` if nothing has been navigated |
96
+ | `resize(w?, h?)` | `Promise<void>`, defaults to the host viewport |
97
+ | `state` | `'idle' \| 'booting' \| 'ready' \| 'loading' \| 'destroyed'` |
98
+ | `currentUrl` | `string \| null` |
99
+ | `raw` | underlying engine, or `null` |
100
+ | `eval(code, opts?)` / `evaluate(code, opts?)` | `Promise<T>` |
101
+ | `evalJson(code, guard)` | `Promise<T>`, throws `shape` on a mismatch |
102
+ | `waitFor(predicate, opts?)` | `Promise<boolean>` |
103
+ | `query` / `queryAll` | `Promise<ElementSnapshot \| null>` / `Promise<ElementSnapshot[]>` |
104
+ | `text` / `attr` / `click` | `Promise<string>` / `Promise<string \| null>` / `Promise<boolean>` |
105
+ | `attempt(fn)` | `Promise<EvalResult<T>>` for callers who prefer a union |
106
+ | `destroy()` | stops loops, detaches input |
107
+
108
+ ### Errors are thrown, not returned
109
+
110
+ The engine's `evalChrome()` returns `''` when a snippet throws, when the snippet legitimately returns nothing, and when no page has been loaded yet. `eval()` wraps the snippet in an envelope so those three cases stay distinguishable, and reports the ones that are failures by throwing `GeckoEvalError`:
111
+
112
+ ```js
113
+ await session.eval('40+2');
114
+ // 42
115
+
116
+ await session.eval('nope.missing.prop');
117
+ // throws GeckoEvalError { code: 'throw' }
118
+ // "ReferenceError: nope is not defined\n@embed-chrome line 1 ..."
119
+ ```
120
+
121
+ `code` is a closed union, so a caller can branch on the cause instead of matching message text:
122
+
123
+ | `code` | Meaning |
124
+ | --- | --- |
125
+ | `'not-ready'` | the engine is not up yet |
126
+ | `'no-page'` | no document has been navigated yet |
127
+ | `'no-result'` | the snippet produced no reply at all |
128
+ | `'throw'` | the snippet itself threw |
129
+ | `'shape'` | `evalJson` guard rejected the value |
130
+ | `'timeout'` | the snippet or a poll ran out of time |
131
+
132
+ Because it is a real class exported from the entry point, `instanceof` narrows normally:
133
+
134
+ ```js
135
+ import { GeckoEvalError, isGeckoEvalError } from 'webcanvas-wasm';
136
+
137
+ try {
138
+ await session.eval('throw new Error("boom")');
139
+ } catch (error) {
140
+ if (isGeckoEvalError(error) && error.code === 'throw') report(error.message);
141
+ }
142
+ ```
143
+
144
+ ### Promises are awaited automatically
145
+
146
+ The command bridge stringifies its completion value synchronously, so a bare `fetch(...).then(...)` would come back as the string `"[object Promise]"`. `eval()` resolves that first, so the common case needs no ceremony:
147
+
148
+ ```js
149
+ const html = await session.eval('fetch(location.href).then(r => r.text())');
150
+ ```
151
+
152
+ `evalAsync(code, ms?)` is kept as a deprecated alias for call sites that still name it explicitly.
153
+
154
+ ### Scope limits
155
+
156
+ `eval()` runs in the **content window** of the loaded page, not in chrome. `Services`, `Cc`, `Ci` and `ChromeUtils` are all `undefined` there, so there is no privileged API surface. Full DOM read/write, `location`, first-party `document.cookie`, `localStorage`, `fetch`, XHR, WebSocket and `crypto.subtle` are available. There is no screenshot API and no cookie store, so `query()` returns a plain-data `ElementSnapshot` rather than a live element.
157
+
158
+ ## Page object
159
+
160
+ `createGeckoPage()` puts the common document operations behind readable names. Every method is one round-trip, so it reads like the script it replaces.
161
+
162
+ ```js
163
+ import { createGeckoPage, createGeckoRuntime } from 'webcanvas-wasm';
164
+
165
+ const session = createGeckoRuntime({ canvas });
166
+ await session.init();
167
+ const page = createGeckoPage(session);
168
+
169
+ await page.open('https://example.com/login');
170
+ await page.fill('#email', 'someone@example.com'); // through the native value setter
171
+ await page.click('button[type=submit]');
172
+ await page.waitForSelector('h1');
173
+ ```
174
+
175
+ `fill()` and `type()` write through the prototype `value` accessor and dispatch `input`/`change`, so a framework that tracks the last value it set still notices the edit. `type()` sends real key events one character at a time for sites that only respond to keys. A selector that matches nothing throws rather than reporting a silent success.
176
+
177
+ ## React
178
+
179
+ Optional, and not part of the core bundle. It needs React 18+ as a peer.
180
+
181
+ ```js
182
+ import { GeckoProvider, useGeckoPage } from 'webcanvas-wasm/react';
183
+
184
+ <GeckoProvider canvas={canvasRef}>
185
+ <App />
186
+ </GeckoProvider>;
187
+ ```
188
+
189
+ `useGecko()` returns the session, `useGeckoPage()` a page object over it, `useGeckoEval()` re-runs a snippet when its dependencies change, and `useGeckoState()` / `useGeckoValue()` subscribe to the session state.
190
+
191
+ ## Vite
192
+
193
+ `webcanvas-wasm/vite` serves the engine during `vite dev` and copies it on `vite build`, so there is no separate asset step to remember.
194
+
195
+ ```js
196
+ // vite.config.js
197
+ import { defineConfig } from 'vite';
198
+ import { geckoWebView } from 'webcanvas-wasm/vite';
199
+
200
+ export default defineConfig({
201
+ plugins: [geckoWebView({ engineDir: 'node_modules/webcanvas-wasm/engine' })]
202
+ });
203
+ ```
204
+
205
+ The plugin serves `gecko.js` and `gecko.wasm.zst`, and sets the `COOP`, `COEP` and `CORP` headers the engine needs. It fails loudly when the engine assets are missing. Pass `copyOnBuild: false` to keep the files out of the output.
206
+
207
+ It does not provide Wisp, so you still need a `/wisp/` endpoint on the dev server or the engine will boot without ever loading a page — see [Networking](#networking).
208
+
209
+ ## Standalone server
210
+
211
+ `webcanvas-wasm/server` is a Node-only entry point — it imports `node:http`, so it is deliberately absent from the browser entry point.
212
+
213
+ ```js
214
+ import { createWebviewServer } from 'webcanvas-wasm/server';
215
+
216
+ const app = createWebviewServer({ publicDir: 'demo' });
217
+ await app.listen(8080, '127.0.0.1');
218
+ ```
219
+
220
+ It serves static files, sets the isolation headers, and routes `/wisp/` WebSocket upgrades to the Wisp server that gives the engine its networking.
221
+
222
+ ## Entry points
223
+
224
+ | Specifier | Contents |
225
+ | --- | --- |
226
+ | `webcanvas-wasm` | `createGeckoRuntime`, `createGeckoPage`, `GeckoEvalError`, `isGeckoEvalError`, `normalizeHttpUrl` |
227
+ | `webcanvas-wasm/react` | `GeckoProvider`, `useGecko`, `useGeckoPage`, `useGeckoEval`, `useGeckoState`, `useGeckoValue`, `GeckoContext` |
228
+ | `webcanvas-wasm/vite` | `geckoWebView`, `setIsolationHeaders` |
229
+ | `webcanvas-wasm/server` | `createWebviewServer` (Node only) |
230
+ | `webcanvas-wasm/engine/*` | the raw engine artifacts |
231
+
232
+
233
+ ## Files
234
+
235
+ ```text
236
+ engine/gecko.js Gecko JS runtime
237
+ engine/gecko.wasm.zst compressed Gecko WASM
238
+ src/gecko/index.js browser entry point
239
+ src/gecko/runtime.js typed lifecycle, navigation and evaluation
240
+ src/gecko/page.js page object over a session
241
+ src/gecko/react.js optional React bindings
242
+ src/gecko/vite.js Vite plugin and isolation headers
243
+ src/server.js static server + COOP/COEP + Wisp (Node only)
244
+ types/ public type declarations, checked but never emitted
245
+ demo/ minimal WebView UI
246
+ test/ unit, integration and consumer typecheck tests
247
+ tsconfig.json type-only checking, no emit
248
+ ```
249
+
250
+ ## Engine artifacts
251
+
252
+ The engine artifacts in this repository were copied from the existing working Gecko WASM build used for this experiment.
253
+
254
+ The build is small enough to ship as it is, so it is committed here and no recompilation is needed to run this repository:
255
+
256
+ ```text
257
+ engine/gecko.js 3.9 MiB Gecko JS runtime
258
+ engine/gecko.wasm.zst 32.6 MiB compressed Gecko WASM, 132 MiB raw
259
+ 36.5 MiB total, downloadable from this repository
260
+ ```
261
+
262
+ SHA-256:
263
+
264
+ ```text
265
+ 1af4b1521986aba8fdc4b8b02c8450f4e1f2d2bb283f78bd973c3eb7ece5a813 engine/gecko.js
266
+ 2565cf3e95c3b53d7aae41fbfaf36dfffbb99c6fefa1171051b2c05ba6f18dee engine/gecko.wasm.zst
267
+ ```
268
+
269
+ This repository does not make a new licensing claim over third-party engine artifacts. Preserve and comply with the licenses applicable to the Gecko build and third-party dependencies you use. `@mercuryworkshop/wisp-js` declares AGPL-3.0 in its package metadata.
270
+
271
+ ## Test
272
+
273
+ ```bash
274
+ npm test
275
+ npm run typecheck
276
+ ```
277
+
278
+ The tests verify:
279
+
280
+ - URL normalization only accepts HTTP(S)
281
+ - Gecko readiness ends at `init()` with no hidden navigation
282
+ - navigation dispatch does not block on a hanging `load()` promise
283
+ - `open()` returns the *new* document, not the one it was leaving behind
284
+ - COOP/COEP and engine resource headers are present
285
+ - only `/wisp/` WebSocket upgrades are routed to Wisp
286
+ - the Vite plugin serves and copies exactly the two engine files
287
+ - `eval()` reports a typed error before any page is loaded, instead of `''`
288
+ - `eval()` unwraps the envelope and preserves strings, numbers, objects and booleans
289
+ - `eval()` awaits a promise-returning snippet instead of stringifying it
290
+ - `eval()` reports a thrown snippet as a `GeckoEvalError` with the real message
291
+ - `eval()` degrades circular values to text rather than reporting a crash
292
+ - `evalJson()` rejects values that do not match the expected shape
293
+ - `waitFor()` polls until the predicate holds
294
+ - `fill()` and `type()` really run, use the native value setter, and fail loudly
295
+ - `query()` returns a snapshot or `null`, and `queryAll()` never returns `null`
296
+ - session `state` transitions across init, navigate, settle and destroy
297
+ - the entry points export exactly what they claim, and a consumer without
298
+ `allowJs` can still narrow `instanceof` against `GeckoEvalError`
299
+
300
+ The session and page tests run the real bridge envelope and the real page
301
+ snippets inside a `node:vm` context against a fake engine that reproduces the
302
+ engine's failure modes, so the evaluation logic is covered without booting WASM.
303
+ The engine surface itself was verified separately against a live build in Chrome:
304
+ boot, navigation, `eval`, promise auto-await, `evalJson`, `fill`, `type`,
305
+ `waitForSelector`, `attr`, and the `GeckoEvalError` codes.