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 +305 -0
- package/engine/gecko.js +1657 -0
- package/engine/gecko.wasm.zst +4 -0
- package/package.json +85 -0
- package/src/gecko/index.js +10 -0
- package/src/gecko/page.js +244 -0
- package/src/gecko/react.js +293 -0
- package/src/gecko/runtime.js +717 -0
- package/src/gecko/vite.js +171 -0
- package/src/server.js +128 -0
- package/types/engine.d.ts +132 -0
- package/types/index.d.ts +30 -0
- package/types/page.d.ts +85 -0
- package/types/react.d.ts +102 -0
- package/types/server.d.ts +20 -0
- package/types/session.d.ts +216 -0
- package/types/vite.d.ts +47 -0
- package/types/wisp.d.ts +17 -0
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.
|