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/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webcanvas-wasm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Gecko WebView in WebAssembly, with a small framework-agnostic API and optional React bindings.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ibidathoillah/webcanvas-wasm.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/ibidathoillah/webcanvas-wasm/issues"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/ibidathoillah/webcanvas-wasm#readme",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/gecko/index.js",
|
|
16
|
+
"types": "./types/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./types/index.d.ts",
|
|
20
|
+
"import": "./src/gecko/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./react": {
|
|
23
|
+
"types": "./types/react.d.ts",
|
|
24
|
+
"import": "./src/gecko/react.js"
|
|
25
|
+
},
|
|
26
|
+
"./vite": {
|
|
27
|
+
"types": "./types/vite.d.ts",
|
|
28
|
+
"import": "./src/gecko/vite.js"
|
|
29
|
+
},
|
|
30
|
+
"./server": {
|
|
31
|
+
"types": "./types/server.d.ts",
|
|
32
|
+
"import": "./src/server.js"
|
|
33
|
+
},
|
|
34
|
+
"./engine/*": "./engine/*",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src/gecko",
|
|
39
|
+
"src/server.js",
|
|
40
|
+
"types",
|
|
41
|
+
"engine",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"sideEffects": false,
|
|
45
|
+
"scripts": {
|
|
46
|
+
"start": "node src/server.js",
|
|
47
|
+
"test": "node --test",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"prepack": "npm run typecheck && npm test"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@mercuryworkshop/wisp-js": "^0.4.1"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"react": ">=18"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"react": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/node": "^26.6.3",
|
|
64
|
+
"@types/react": "^19.3.0",
|
|
65
|
+
"jsdom": "^29.1.1",
|
|
66
|
+
"react": "^19.3.0",
|
|
67
|
+
"react-dom": "^19.3.0",
|
|
68
|
+
"typescript": "^7.0.2",
|
|
69
|
+
"vite": "^7.3.6"
|
|
70
|
+
},
|
|
71
|
+
"keywords": [
|
|
72
|
+
"webview",
|
|
73
|
+
"webcanvas",
|
|
74
|
+
"gecko",
|
|
75
|
+
"firefox",
|
|
76
|
+
"wasm",
|
|
77
|
+
"webassembly",
|
|
78
|
+
"browser-in-a-box",
|
|
79
|
+
"headless",
|
|
80
|
+
"react"
|
|
81
|
+
],
|
|
82
|
+
"engines": {
|
|
83
|
+
"node": ">=20"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package entry point.
|
|
3
|
+
*
|
|
4
|
+
* The library is already plain ESM, so this is a re-export barrel rather than
|
|
5
|
+
* a build artifact. There is no bundler step between the source in this
|
|
6
|
+
* repository and the files published to npm.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { createGeckoRuntime, normalizeHttpUrl, GeckoEvalError, isGeckoEvalError } from './runtime.js';
|
|
10
|
+
export { createGeckoPage } from './page.js';
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Page object over a {@link GeckoSession}.
|
|
5
|
+
*
|
|
6
|
+
* The session exposes the engine faithfully; the page object exposes the
|
|
7
|
+
* things a caller actually wants to do to a document. Every method is one
|
|
8
|
+
* round-trip, so chaining reads the same as the script it replaces.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {import('../../types/session.js').GeckoSession} GeckoSession
|
|
11
|
+
* @typedef {import('../../types/page.js').GeckoPage} GeckoPage
|
|
12
|
+
* @typedef {import('../../types/session.js').ElementSnapshot} ElementSnapshot
|
|
13
|
+
* @typedef {import('../../types/session.js').EvaluateOptions} EvaluateOptions
|
|
14
|
+
* @typedef {import('../../types/session.js').WaitForOptions} WaitForOptions
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Writes a value the way a real user would, rather than by assignment.
|
|
19
|
+
*
|
|
20
|
+
* Assigning `el.value` directly is invisible to React and to any other
|
|
21
|
+
* framework that tracks the last value it set: the DOM node changes but the
|
|
22
|
+
* component state does not, so the next render reverts it. Calling the native
|
|
23
|
+
* `value` setter and dispatching `input` makes the change observable, which is
|
|
24
|
+
* what real typing does.
|
|
25
|
+
*
|
|
26
|
+
* The snippets take `(sel, value)` as declared parameters rather than reading
|
|
27
|
+
* `arguments`: they run as arrows, where `arguments` is a ReferenceError.
|
|
28
|
+
*/
|
|
29
|
+
const FILL = `(sel,value)=>{const el=document.querySelector(sel);if(!el)throw new Error('no match for '+sel);`
|
|
30
|
+
+ `const text=value==null?'':String(value);`
|
|
31
|
+
+ `const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:`
|
|
32
|
+
+ `el.tagName==='SELECT'?HTMLSelectElement.prototype:HTMLInputElement.prototype;`
|
|
33
|
+
+ `const desc=Object.getOwnPropertyDescriptor(proto,'value');`
|
|
34
|
+
+ `if(desc&&desc.set)desc.set.call(el,text);else el.value=text;`
|
|
35
|
+
+ `el.dispatchEvent(new Event('input',{bubbles:true}));`
|
|
36
|
+
+ `el.dispatchEvent(new Event('change',{bubbles:true}));`
|
|
37
|
+
+ `el.focus();return true}`;
|
|
38
|
+
|
|
39
|
+
/** Types a value one character at a time, for sites that only respond to keys. */
|
|
40
|
+
const TYPE = `(sel,value)=>{const el=document.querySelector(sel);if(!el)throw new Error('no match for '+sel);`
|
|
41
|
+
+ `el.focus();const text=value==null?'':String(value);`
|
|
42
|
+
+ `for(const ch of text){`
|
|
43
|
+
+ `el.dispatchEvent(new KeyboardEvent('keydown',{key:ch,bubbles:true}));`
|
|
44
|
+
+ `el.dispatchEvent(new KeyboardEvent('keypress',{key:ch,bubbles:true}));`
|
|
45
|
+
+ `el.value+=ch;`
|
|
46
|
+
+ `el.dispatchEvent(new InputEvent('input',{bubbles:true,data:ch}));`
|
|
47
|
+
+ `el.dispatchEvent(new KeyboardEvent('keyup',{key:ch,bubbles:true}));}`
|
|
48
|
+
+ `el.dispatchEvent(new Event('change',{bubbles:true}));return true}`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {GeckoSession} session
|
|
52
|
+
* @returns {GeckoPage}
|
|
53
|
+
*/
|
|
54
|
+
export function createGeckoPage(session) {
|
|
55
|
+
/**
|
|
56
|
+
* @template T
|
|
57
|
+
* @param {string} code
|
|
58
|
+
* @returns {Promise<T>}
|
|
59
|
+
*/
|
|
60
|
+
function pageEval(code) {
|
|
61
|
+
return /** @type {Promise<T>} */ (session.eval(code));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} selector
|
|
66
|
+
* @returns {Promise<boolean>}
|
|
67
|
+
*/
|
|
68
|
+
function exists(selector) {
|
|
69
|
+
return pageEval(`document.querySelector(${JSON.stringify(selector)}) !== null`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} selector
|
|
74
|
+
* @returns {Promise<number>}
|
|
75
|
+
*/
|
|
76
|
+
function count(selector) {
|
|
77
|
+
return pageEval(`document.querySelectorAll(${JSON.stringify(selector)}).length`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} selector
|
|
82
|
+
* @param {string} value
|
|
83
|
+
* @returns {Promise<boolean>}
|
|
84
|
+
*/
|
|
85
|
+
function fill(selector, value) {
|
|
86
|
+
return pageEval(`(${FILL})(${JSON.stringify(selector)},${JSON.stringify(value)})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} selector
|
|
91
|
+
* @param {string} value
|
|
92
|
+
* @returns {Promise<boolean>}
|
|
93
|
+
*/
|
|
94
|
+
function type(selector, value) {
|
|
95
|
+
return pageEval(`(${TYPE})(${JSON.stringify(selector)},${JSON.stringify(value)})`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scrolls an element into view before clicking, then waits briefly for the
|
|
100
|
+
* DOM to settle so a follow-up read does not race the click handler.
|
|
101
|
+
* @param {string} selector
|
|
102
|
+
* @returns {Promise<boolean>}
|
|
103
|
+
*/
|
|
104
|
+
async function click(selector) {
|
|
105
|
+
const ok = await pageEval(
|
|
106
|
+
`(()=>{const el=document.querySelector(${JSON.stringify(selector)});if(!el)return false;`
|
|
107
|
+
+ `el.scrollIntoView({block:'center'});el.click();return true})()`
|
|
108
|
+
);
|
|
109
|
+
if (ok) await settle();
|
|
110
|
+
return ok;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Waits a short beat for the DOM to stop changing.
|
|
115
|
+
*
|
|
116
|
+
* There is no reliable "navigation finished" signal for an in-page click, so
|
|
117
|
+
* this is a deliberate small delay rather than a fake guarantee.
|
|
118
|
+
*/
|
|
119
|
+
async function settle(ms = 150) {
|
|
120
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} url
|
|
125
|
+
* @param {number} [timeoutMs]
|
|
126
|
+
* @returns {Promise<string>}
|
|
127
|
+
*/
|
|
128
|
+
function open(url, timeoutMs) {
|
|
129
|
+
return timeoutMs === undefined ? session.open(url) : session.open(url, timeoutMs);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @returns {Promise<string>} */
|
|
133
|
+
function url() {
|
|
134
|
+
return pageEval('location.href');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @returns {Promise<string>} */
|
|
138
|
+
function origin() {
|
|
139
|
+
return pageEval('location.origin');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @returns {Promise<string>} */
|
|
143
|
+
function title() {
|
|
144
|
+
return pageEval('document.title');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @template T
|
|
149
|
+
* @param {string} code
|
|
150
|
+
* @param {EvaluateOptions} [evaluateOptions]
|
|
151
|
+
* @returns {Promise<T>}
|
|
152
|
+
*/
|
|
153
|
+
function run(code, evaluateOptions) {
|
|
154
|
+
return evaluateOptions === undefined
|
|
155
|
+
? pageEval(code)
|
|
156
|
+
: /** @type {Promise<T>} */ (session.eval(code, evaluateOptions));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} predicate
|
|
161
|
+
* @param {WaitForOptions} [waitOptions]
|
|
162
|
+
* @returns {Promise<boolean>}
|
|
163
|
+
*/
|
|
164
|
+
function waitFor(predicate, waitOptions) {
|
|
165
|
+
return waitOptions === undefined ? session.waitFor(predicate) : session.waitFor(predicate, waitOptions);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @template [T=ElementSnapshot]
|
|
170
|
+
* @param {string} selector
|
|
171
|
+
* @param {WaitForOptions} [waitOptions]
|
|
172
|
+
* @returns {Promise<T | null>}
|
|
173
|
+
*/
|
|
174
|
+
async function waitForSelector(selector, waitOptions) {
|
|
175
|
+
const found = await session.waitFor(
|
|
176
|
+
`document.querySelector(${JSON.stringify(selector)}) !== null`,
|
|
177
|
+
waitOptions
|
|
178
|
+
);
|
|
179
|
+
return found ? query(selector) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @template [T=ElementSnapshot]
|
|
184
|
+
* @param {string} selector
|
|
185
|
+
* @returns {Promise<T | null>}
|
|
186
|
+
*/
|
|
187
|
+
function query(selector) {
|
|
188
|
+
return session.query(selector);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @template [T=ElementSnapshot]
|
|
193
|
+
* @param {string} selector
|
|
194
|
+
* @returns {Promise<T[]>}
|
|
195
|
+
*/
|
|
196
|
+
function queryAll(selector) {
|
|
197
|
+
return session.queryAll(selector);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} selector
|
|
202
|
+
* @returns {Promise<string>}
|
|
203
|
+
*/
|
|
204
|
+
function text(selector) {
|
|
205
|
+
return session.text(selector);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {string} name
|
|
210
|
+
* @param {string} selector
|
|
211
|
+
* @returns {Promise<string | null>}
|
|
212
|
+
*/
|
|
213
|
+
function attr(name, selector) {
|
|
214
|
+
return session.attr(name, selector);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @returns {string | null} */
|
|
218
|
+
function reload() {
|
|
219
|
+
return session.reload();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
session,
|
|
224
|
+
open,
|
|
225
|
+
url,
|
|
226
|
+
origin,
|
|
227
|
+
title,
|
|
228
|
+
eval: run,
|
|
229
|
+
waitFor,
|
|
230
|
+
waitForSelector,
|
|
231
|
+
settle,
|
|
232
|
+
exists,
|
|
233
|
+
count,
|
|
234
|
+
query,
|
|
235
|
+
queryAll,
|
|
236
|
+
text,
|
|
237
|
+
attr,
|
|
238
|
+
fill,
|
|
239
|
+
type,
|
|
240
|
+
click,
|
|
241
|
+
reload
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React bindings for the Gecko session.
|
|
5
|
+
*
|
|
6
|
+
* This is a thin adapter: the engine is owned outside React and merely observed
|
|
7
|
+
* from inside. No `useEffect` here decides *what* to evaluate — it only wires a
|
|
8
|
+
* subscription to a session that already knows how to evaluate.
|
|
9
|
+
*
|
|
10
|
+
* There is deliberately no JSX, so the package stays consumable from a plain
|
|
11
|
+
* Vite or Node project without a build step.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createContext,
|
|
16
|
+
createElement,
|
|
17
|
+
useCallback,
|
|
18
|
+
useContext,
|
|
19
|
+
useEffect,
|
|
20
|
+
useMemo,
|
|
21
|
+
useRef,
|
|
22
|
+
useState,
|
|
23
|
+
useSyncExternalStore
|
|
24
|
+
} from 'react';
|
|
25
|
+
|
|
26
|
+
import { createGeckoRuntime } from './runtime.js';
|
|
27
|
+
import { createGeckoPage } from './page.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {import('../../types/session.js').GeckoSession} GeckoSession
|
|
31
|
+
* @typedef {import('../../types/session.js').SessionState} SessionState
|
|
32
|
+
* @typedef {import('../../types/session.js').SessionOptions} SessionOptions
|
|
33
|
+
* @typedef {import('../../types/page.js').GeckoPage} GeckoPage
|
|
34
|
+
* @typedef {import('../../types/react.js').GeckoContextValue} GeckoContextValue
|
|
35
|
+
* @typedef {import('../../types/react.js').GeckoProviderProps} GeckoProviderProps
|
|
36
|
+
* @typedef {import('../../types/react.js').GeckoRunResult<unknown>} GeckoRunResult
|
|
37
|
+
* @typedef {import('react').ReactNode} ReactNode
|
|
38
|
+
* @typedef {{ session: GeckoSession | null, page: GeckoPage | null, cancel: (() => void) | null }} Holder
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** No session exists outside a provider; make the mistake obvious if ignored. */
|
|
42
|
+
const MISSING = 'useGecko() was called outside a <GeckoProvider>. Wrap the tree in one.';
|
|
43
|
+
|
|
44
|
+
const GeckoContext = /** @type {import('react').Context<GeckoContextValue | null>} */ (
|
|
45
|
+
/** @type {unknown} */ (createContext(/** @type {GeckoContextValue | null} */ (null)))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How long a session is kept after its last subscriber goes away.
|
|
50
|
+
*
|
|
51
|
+
* The deferral is an optimisation, not a correctness requirement: it lets a
|
|
52
|
+
* provider that remounts in the same task (fast refresh, a key change) keep the
|
|
53
|
+
* engine it already paid to boot. Note that React StrictMode's double-invoke
|
|
54
|
+
* does *not* need this — its cleanup runs before `init()` has produced an
|
|
55
|
+
* engine, so a synchronous teardown there would be a no-op anyway.
|
|
56
|
+
*/
|
|
57
|
+
const DESTROY_GRACE_MS = 0;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Holds the session between the strict-mode unmount and the remount that
|
|
61
|
+
* immediately follows it.
|
|
62
|
+
*
|
|
63
|
+
* @param {GeckoSession} session
|
|
64
|
+
* @param {number} [ms]
|
|
65
|
+
* @returns {() => void} a cancel function
|
|
66
|
+
*/
|
|
67
|
+
function scheduleDestroy(session, ms = DESTROY_GRACE_MS) {
|
|
68
|
+
const timer = setTimeout(() => session.destroy(), ms);
|
|
69
|
+
return () => clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Owns one session for the life of the provider.
|
|
74
|
+
*
|
|
75
|
+
* The requirement that actually bites is that the engine must not be created
|
|
76
|
+
* during render: React invokes a render twice in development, so building an
|
|
77
|
+
* engine there would boot two of them and leak the discarded one. Creation is
|
|
78
|
+
* therefore deferred to an effect, and the effect has no dependencies so it
|
|
79
|
+
* runs once for the lifetime of the holder.
|
|
80
|
+
*
|
|
81
|
+
* @param {Omit<SessionOptions, 'canvas'> & { canvasRef?: import('react').RefObject<HTMLCanvasElement | null> }} options
|
|
82
|
+
* @returns {GeckoContextValue}
|
|
83
|
+
*/
|
|
84
|
+
function useGeckoSession(options) {
|
|
85
|
+
const { canvasRef, ...sessionOptions } = options;
|
|
86
|
+
const optionsRef = useRef(sessionOptions);
|
|
87
|
+
optionsRef.current = sessionOptions;
|
|
88
|
+
|
|
89
|
+
// A holder rather than a bare session, because the session is created inside
|
|
90
|
+
// an effect (it needs the canvas) but must outlive a strict-mode cleanup.
|
|
91
|
+
const [holder] = useState(/** @returns {Holder} */ () => ({
|
|
92
|
+
session: null,
|
|
93
|
+
page: null,
|
|
94
|
+
cancel: null
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
const [state, setState] = useState(/** @type {SessionState} */ ('idle'));
|
|
98
|
+
const [error, setError] = useState(/** @type {unknown} */ (null));
|
|
99
|
+
// Bumped when the engine handle changes. `subscribe` reports `'idle'` first,
|
|
100
|
+
// which is already the current state, so React would bail out of that render
|
|
101
|
+
// and the new handle would never reach consumers without this.
|
|
102
|
+
const [generation, setGeneration] = useState(0);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
// A teardown from the previous strict-mode pass is still pending.
|
|
106
|
+
if (holder.cancel) {
|
|
107
|
+
holder.cancel();
|
|
108
|
+
holder.cancel = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A destroyed session cannot be revived, so replace it. The engine is
|
|
112
|
+
// reused on an ordinary remount; the second branch is defensive, since a
|
|
113
|
+
// fresh root always gets a fresh holder anyway.
|
|
114
|
+
if (!holder.session || holder.session.state === 'destroyed') {
|
|
115
|
+
const canvas = canvasRef ? canvasRef.current : null;
|
|
116
|
+
if (!canvas) return undefined;
|
|
117
|
+
const session = createGeckoRuntime({ ...optionsRef.current, canvas });
|
|
118
|
+
holder.session = session;
|
|
119
|
+
holder.page = createGeckoPage(session);
|
|
120
|
+
setGeneration(value => value + 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const session = holder.session;
|
|
124
|
+
if (!session) return undefined;
|
|
125
|
+
|
|
126
|
+
// `subscribe` fires immediately, so the first snapshot is free and no
|
|
127
|
+
// render can observe a state that is already out of date.
|
|
128
|
+
const unsubscribe = session.subscribe(setState);
|
|
129
|
+
let cancelled = false;
|
|
130
|
+
session.init().then(
|
|
131
|
+
() => {
|
|
132
|
+
if (!cancelled) setError(null);
|
|
133
|
+
},
|
|
134
|
+
(/** @type {unknown} */ reason) => {
|
|
135
|
+
if (!cancelled) setError(reason);
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return () => {
|
|
140
|
+
cancelled = true;
|
|
141
|
+
unsubscribe();
|
|
142
|
+
// Deferred so a same-task remount can reclaim the engine; a pending
|
|
143
|
+
// teardown from an earlier cleanup is cancelled at the top instead.
|
|
144
|
+
if (!holder.cancel) holder.cancel = scheduleDestroy(session);
|
|
145
|
+
};
|
|
146
|
+
// The engine is built once on purpose: swapping options later would mean
|
|
147
|
+
// discarding a live page, which is not a decision a render should make.
|
|
148
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
return useMemo(
|
|
152
|
+
() => ({
|
|
153
|
+
session: holder.session,
|
|
154
|
+
page: holder.page,
|
|
155
|
+
state,
|
|
156
|
+
error,
|
|
157
|
+
ready: state === 'ready',
|
|
158
|
+
// Reading `generation` is what makes the engine handle part of the value.
|
|
159
|
+
generation
|
|
160
|
+
}),
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
|
+
[state, error, generation]
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Provides one Gecko engine to the subtree below it.
|
|
168
|
+
*
|
|
169
|
+
* The engine is created on mount and torn down when the last consumer goes
|
|
170
|
+
* away. Children read it with {@link useGecko}, {@link useGeckoPage},
|
|
171
|
+
* {@link useGeckoState} or {@link useGeckoValue}.
|
|
172
|
+
*
|
|
173
|
+
* @param {GeckoProviderProps} props
|
|
174
|
+
* @returns {import('react').ReactElement}
|
|
175
|
+
*/
|
|
176
|
+
export function GeckoProvider(props) {
|
|
177
|
+
const { children, canvasRef, ...options } = props;
|
|
178
|
+
const ownCanvas = useRef(/** @type {HTMLCanvasElement | null} */ (null));
|
|
179
|
+
|
|
180
|
+
// A caller may supply their own canvas; otherwise render one and use it.
|
|
181
|
+
const resolvedRef = canvasRef || ownCanvas;
|
|
182
|
+
const value = useGeckoSession({ canvasRef: resolvedRef, ...options });
|
|
183
|
+
|
|
184
|
+
const canvas = canvasRef
|
|
185
|
+
? null
|
|
186
|
+
: createElement('canvas', {
|
|
187
|
+
ref: (/** @type {HTMLCanvasElement | null} */ node) => {
|
|
188
|
+
ownCanvas.current = node;
|
|
189
|
+
},
|
|
190
|
+
style: { display: 'block', width: '100%', height: '100%' }
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return createElement(GeckoContext.Provider, { value }, canvas, children);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The full context: the session, the page object, and the current state.
|
|
198
|
+
*
|
|
199
|
+
* @returns {GeckoContextValue}
|
|
200
|
+
*/
|
|
201
|
+
export function useGecko() {
|
|
202
|
+
const value = useContext(GeckoContext);
|
|
203
|
+
if (!value) throw new Error(MISSING);
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Just the page object, which is what most components want.
|
|
209
|
+
*
|
|
210
|
+
* @returns {GeckoPage | null}
|
|
211
|
+
*/
|
|
212
|
+
export function useGeckoPage() {
|
|
213
|
+
return useGecko().page;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Just the lifecycle state, for components that only show a spinner.
|
|
218
|
+
*
|
|
219
|
+
* @returns {SessionState}
|
|
220
|
+
*/
|
|
221
|
+
export function useGeckoState() {
|
|
222
|
+
return useGecko().state;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run an async read against the page and keep the result in state.
|
|
227
|
+
*
|
|
228
|
+
* A result is discarded if the inputs change or the component unmounts before
|
|
229
|
+
* it lands, so a slow evaluation cannot overwrite a newer one. State is *not*
|
|
230
|
+
* reset when the page navigates, because the previous value is still the best
|
|
231
|
+
* answer available until the new one arrives.
|
|
232
|
+
*
|
|
233
|
+
* @template T
|
|
234
|
+
* @param {(session: GeckoSession) => Promise<T> | T} run
|
|
235
|
+
* @param {unknown[]} [deps]
|
|
236
|
+
* @returns {import('../../types/react.js').GeckoRunResult<T>}
|
|
237
|
+
*/
|
|
238
|
+
export function useGeckoValue(run, deps = []) {
|
|
239
|
+
const { session, state, generation } = useGecko();
|
|
240
|
+
const [result, setResult] = useState(/** @type {import('../../types/react.js').GeckoRunResult<T>} */ ({
|
|
241
|
+
status: 'idle',
|
|
242
|
+
value: undefined
|
|
243
|
+
}));
|
|
244
|
+
// Held in a ref so a caller passing an inline closure does not re-run on
|
|
245
|
+
// every render; `deps` is the declared dependency list.
|
|
246
|
+
const runRef = useRef(run);
|
|
247
|
+
runRef.current = run;
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!session || state !== 'ready') return undefined;
|
|
251
|
+
let cancelled = false;
|
|
252
|
+
setResult(previous => ({ ...previous, status: 'pending' }));
|
|
253
|
+
|
|
254
|
+
Promise.resolve()
|
|
255
|
+
.then(() => runRef.current(session))
|
|
256
|
+
.then(
|
|
257
|
+
value => {
|
|
258
|
+
if (!cancelled) setResult({ status: 'ok', value });
|
|
259
|
+
},
|
|
260
|
+
error => {
|
|
261
|
+
if (!cancelled) setResult(previous => ({ status: 'error', value: previous.value, error }));
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
return () => {
|
|
266
|
+
cancelled = true;
|
|
267
|
+
};
|
|
268
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
269
|
+
}, [session, state, generation, ...deps]);
|
|
270
|
+
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Convenience wrapper around {@link useGeckoValue} for a single `eval`.
|
|
276
|
+
*
|
|
277
|
+
* @template T
|
|
278
|
+
* @param {string} code
|
|
279
|
+
* @param {{ deps?: unknown[]; evaluateOptions?: { timeoutMs?: number } }} [options]
|
|
280
|
+
* @returns {import('../../types/react.js').GeckoRunResult<T>}
|
|
281
|
+
*/
|
|
282
|
+
export function useGeckoEval(code, options = {}) {
|
|
283
|
+
const { deps = [], evaluateOptions } = options;
|
|
284
|
+
const run = useCallback(
|
|
285
|
+
(/** @type {GeckoSession} */ session) =>
|
|
286
|
+
/** @type {Promise<T>} */ (session.eval(/** @type {string} */ (code), evaluateOptions)),
|
|
287
|
+
[code, evaluateOptions]
|
|
288
|
+
);
|
|
289
|
+
return useGeckoValue(run, deps);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export { GeckoContext };
|
|
293
|
+
export default GeckoProvider;
|