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.
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: package/engine/gecko.wasm.zst
3
+ size: 34136993 bytes
4
+ sha256: 2565cf3e95c3b53d7aae41fbfaf36dfffbb99c6fefa1171051b2c05ba6f18dee
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;