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
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed wrapper over the vendored Gecko WASM engine.
|
|
5
|
+
*
|
|
6
|
+
* The engine class is intentionally thin and leaky: `evalChrome` swallows
|
|
7
|
+
* exceptions and returns `''`, it cannot await promises, and it has no way to
|
|
8
|
+
* report whether a page exists yet. This module keeps the escape hatch (the
|
|
9
|
+
* raw `run()` command queue stays reachable on `raw`) but presents a surface
|
|
10
|
+
* where every fallible call returns a discriminated result instead of a value
|
|
11
|
+
* that might silently be an error.
|
|
12
|
+
*
|
|
13
|
+
* Types live in `types/engine.d.ts` and `types/session.d.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import('../../types/engine.js').Gecko} Gecko
|
|
18
|
+
* @typedef {import('../../types/engine.js').GeckoEnv} GeckoEnv
|
|
19
|
+
* @typedef {import('../../types/engine.js').GeckoOptions} GeckoOptions
|
|
20
|
+
* @typedef {import('../../types/session.js').SessionOptions} SessionOptions
|
|
21
|
+
* @typedef {import('../../types/session.js').GeckoSession} GeckoSession
|
|
22
|
+
* @typedef {import('../../types/session.js').SessionState} SessionState
|
|
23
|
+
* @typedef {import('../../types/session.js').ElementSnapshot} ElementSnapshot
|
|
24
|
+
* @typedef {import('../../types/session.js').WaitForOptions} WaitForOptions
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Restated locally because a JSDoc `@typedef` that imports a generic alias
|
|
29
|
+
* collapses it to a non-generic type. The public shape is still enforced by
|
|
30
|
+
* `GeckoSession`, which the factory's `@returns` tag is checked against.
|
|
31
|
+
*
|
|
32
|
+
* @template T
|
|
33
|
+
* @typedef {{ readonly ok: true, readonly value: T }
|
|
34
|
+
* | { readonly ok: false, readonly error: string }} EvalResult
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @template T
|
|
39
|
+
* @typedef {(value: unknown) => value is T} TypeGuard
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {{ timeoutMs?: number }} EvaluateOptions
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** `o` field of the bridge envelope: promise has not settled yet. */
|
|
47
|
+
const PENDING = 0;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Raised when a snippet throws, the page cannot be reached, or a shape guard
|
|
51
|
+
* rejects. Kept distinct from a generic `Error` so callers can distinguish a
|
|
52
|
+
* WebView failure from a bug in their own code.
|
|
53
|
+
*/
|
|
54
|
+
export class GeckoEvalError extends Error {
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} message
|
|
57
|
+
* @param {'not-ready' | 'no-page' | 'no-result' | 'throw' | 'shape' | 'timeout'} code
|
|
58
|
+
*/
|
|
59
|
+
constructor(message, code) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'GeckoEvalError';
|
|
62
|
+
this.code = code;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {unknown} value @returns {value is GeckoEvalError} */
|
|
67
|
+
export function isGeckoEvalError(value) {
|
|
68
|
+
return value instanceof GeckoEvalError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_READY_TIMEOUT_MS = 20_000;
|
|
72
|
+
const DEFAULT_POLL_INTERVAL_MS = 100;
|
|
73
|
+
const DEFAULT_EVAL_TIMEOUT_MS = 15_000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Injected into the page to turn an arbitrary completion value into something
|
|
77
|
+
* safely transferable. `evalChrome` stringifies its result, and most values
|
|
78
|
+
* are not strings, so each value gets its own `JSON.stringify` attempt with a
|
|
79
|
+
* `String()` fallback. That keeps the failure modes distinguishable: a thrown
|
|
80
|
+
* snippet reports a failure, a circular value degrades to its text form instead
|
|
81
|
+
* of masquerading as a crash.
|
|
82
|
+
*/
|
|
83
|
+
const SERIALIZE = 'let s;try{s=JSON.stringify(v)}catch(e){s=JSON.stringify(String(v))}';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Page-side error formatter, injected into the page.
|
|
87
|
+
*
|
|
88
|
+
* `String(error)` keeps the message but drops the type, while `error.stack` in
|
|
89
|
+
* the embed-chrome sandbox is a bare wrapper trace with no message in it at all.
|
|
90
|
+
* Combining them keeps failures diagnosable instead of returning a trace that
|
|
91
|
+
* only mentions `@embed-chrome`.
|
|
92
|
+
*/
|
|
93
|
+
const DESCRIBE_ERROR =
|
|
94
|
+
'e=>{const head=(e&&e.name?e.name+": ":"")+String((e&&e.message)||e);const st=String((e&&e.stack)||"");return st&&st.indexOf(head)<0?head+"\\n"+st:head}';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds the snippet that runs `code` in the page.
|
|
98
|
+
*
|
|
99
|
+
* Synchronous values answer immediately. A thenable is parked on `window[key]`
|
|
100
|
+
* and reported as `o:3`, which is the only case that costs a poll, so the fast
|
|
101
|
+
* path stays free of round-trips.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} code
|
|
104
|
+
* @param {string} key
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function envelopeSource(code, key) {
|
|
108
|
+
return (
|
|
109
|
+
'(()=>{const d=' +
|
|
110
|
+
DESCRIBE_ERROR +
|
|
111
|
+
';const k=' +
|
|
112
|
+
JSON.stringify(key) +
|
|
113
|
+
';' +
|
|
114
|
+
'const done=v=>{try{let s=JSON.stringify(v);window[k]={o:1,s}}catch(e){window[k]={o:1,s:JSON.stringify(String(v))}}};' +
|
|
115
|
+
'try{const v=eval(' +
|
|
116
|
+
JSON.stringify(code) +
|
|
117
|
+
');' +
|
|
118
|
+
'if(v&&typeof v.then==="function"){window[k]={o:0};Promise.resolve(v).then(done,e=>{window[k]={o:2,e:d(e)}});return JSON.stringify({o:3})}' +
|
|
119
|
+
SERIALIZE +
|
|
120
|
+
'return JSON.stringify({o:1,s})}' +
|
|
121
|
+
'catch(e){return JSON.stringify({o:0,e:d(e)})}})()'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `o:3` marks a snippet whose completion value was a promise. */
|
|
126
|
+
const PENDING_PROMISE = 3;
|
|
127
|
+
|
|
128
|
+
/** Monotonic source for the private global a pending promise is parked on. */
|
|
129
|
+
let promiseKeySequence = 0;
|
|
130
|
+
const nextPromiseKey = () => `__webcanvasWasm${(promiseKeySequence += 1).toString(36)}`;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} source
|
|
134
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, error: string } | null}
|
|
135
|
+
*/
|
|
136
|
+
function decodeEnvelope(source) {
|
|
137
|
+
if (typeof source !== 'string' || !source) return null;
|
|
138
|
+
let payload;
|
|
139
|
+
try {
|
|
140
|
+
payload = JSON.parse(source);
|
|
141
|
+
} catch {
|
|
142
|
+
// The page returned something that is not our envelope, which means the
|
|
143
|
+
// snippet bypassed `eval` (for example it returned a raw object literal
|
|
144
|
+
// evaluated in another realm). Surface it verbatim rather than guessing.
|
|
145
|
+
return { ok: true, value: source };
|
|
146
|
+
}
|
|
147
|
+
if (!payload || typeof payload !== 'object') return { ok: true, value: payload };
|
|
148
|
+
const envelope = /** @type {{ o?: number, s?: string, e?: string }} */ (
|
|
149
|
+
/** @type {unknown} */ (payload)
|
|
150
|
+
);
|
|
151
|
+
if (envelope.o === 0) return { ok: false, error: envelope.e || 'snippet threw' };
|
|
152
|
+
if (envelope.o === PENDING_PROMISE) {
|
|
153
|
+
// The snippet returned a promise, so its value is parked on a private
|
|
154
|
+
// global. Pass the marker through so the caller knows to poll; decoding it
|
|
155
|
+
// as a normal value here would silently return `null` for every promise.
|
|
156
|
+
return { ok: true, value: payload };
|
|
157
|
+
}
|
|
158
|
+
if (typeof envelope.s !== 'string') return { ok: true, value: null };
|
|
159
|
+
try {
|
|
160
|
+
return { ok: true, value: JSON.parse(envelope.s) };
|
|
161
|
+
} catch {
|
|
162
|
+
return { ok: true, value: envelope.s };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sleep = (/** @type {number} */ ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Flattens a page element into plain data.
|
|
170
|
+
*
|
|
171
|
+
* `query` cannot hand back a live `Element`: the bridge is string-based, so
|
|
172
|
+
* anything structured would arrive mangled. Projecting to a snapshot keeps the
|
|
173
|
+
* promise honest about what crosses the boundary.
|
|
174
|
+
*/
|
|
175
|
+
const SNAPSHOT = `el=>{const r=el.getBoundingClientRect();return{tag:el.tagName.toLowerCase(),id:el.id||null,className:el.getAttribute('class'),text:(el.textContent||'').trim().slice(0,2000),html:(el.outerHTML||'').slice(0,2000),attrs:Object.fromEntries([...el.attributes].map(a=>[a.name,a.value])),visible:r.width>0&&r.height>0,rect:{x:r.x,y:r.y,width:r.width,height:r.height}}}`;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {Partial<SessionOptions>} options
|
|
179
|
+
* @returns {GeckoSession}
|
|
180
|
+
*/
|
|
181
|
+
export function createGeckoRuntime(options = {}) {
|
|
182
|
+
const host = options.host || globalThis.window;
|
|
183
|
+
const canvas = /** @type {HTMLCanvasElement | undefined} */ (options.canvas);
|
|
184
|
+
const loadModule = options.loadModule || (async url => import(url));
|
|
185
|
+
const bundleUrl = options.bundleUrl || '/engine/gecko.js';
|
|
186
|
+
const wasmUrl = options.wasmUrl || '/engine/gecko.wasm.zst';
|
|
187
|
+
const profile = options.profile || 'webcanvas-wasm';
|
|
188
|
+
const onLog = options.onLog || (() => {});
|
|
189
|
+
const onError = options.onError || (() => {});
|
|
190
|
+
|
|
191
|
+
/** @type {Gecko | null} */
|
|
192
|
+
let instance = null;
|
|
193
|
+
/** @type {Promise<Gecko> | null} */
|
|
194
|
+
let initPromise = null;
|
|
195
|
+
/** @type {string | null} */
|
|
196
|
+
let currentUrl = null;
|
|
197
|
+
/** @type {SessionState} */
|
|
198
|
+
let state = 'idle';
|
|
199
|
+
/** Bumped on every navigation so a stale watcher cannot win a race. */
|
|
200
|
+
let navigationGeneration = 0;
|
|
201
|
+
let hasNavigated = false;
|
|
202
|
+
let navigationMark = '';
|
|
203
|
+
|
|
204
|
+
/** @param {string} reason @returns {EvalResult<never>} */
|
|
205
|
+
const unavailable = reason => ({ ok: false, error: reason });
|
|
206
|
+
|
|
207
|
+
/** @type {Set<(state: SessionState) => void>} */
|
|
208
|
+
const listeners = new Set();
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Notifies subscribers that observable session state changed. Safe to call
|
|
212
|
+
* after teardown: the set is only read.
|
|
213
|
+
*/
|
|
214
|
+
function emit() {
|
|
215
|
+
for (const listener of listeners) {
|
|
216
|
+
try {
|
|
217
|
+
listener(state);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
onError(error);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {SessionState} next
|
|
226
|
+
*/
|
|
227
|
+
function setState(next) {
|
|
228
|
+
if (state === next) return;
|
|
229
|
+
state = next;
|
|
230
|
+
emit();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Subscribe to session state changes. The listener fires immediately with the
|
|
235
|
+
* current state, which keeps `useSyncExternalStore` happy.
|
|
236
|
+
*
|
|
237
|
+
* @param {(state: SessionState) => void} listener
|
|
238
|
+
* @returns {() => void} unsubscribe
|
|
239
|
+
*/
|
|
240
|
+
function subscribe(listener) {
|
|
241
|
+
listeners.add(listener);
|
|
242
|
+
listener(state);
|
|
243
|
+
return () => {
|
|
244
|
+
listeners.delete(listener);
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function wispUrl() {
|
|
249
|
+
const protocol = host.location?.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
250
|
+
return `${protocol}//${host.location?.host || '127.0.0.1:8080'}/wisp/`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Issues a snippet and decodes the reply. Resolves to `null` when the engine
|
|
255
|
+
* produced no reply at all, which is how a missing page shows up.
|
|
256
|
+
* @param {string} source
|
|
257
|
+
* @returns {Promise<{ ok: true, value: unknown } | { ok: false, error: string } | null>}
|
|
258
|
+
*/
|
|
259
|
+
async function send(source) {
|
|
260
|
+
if (!instance) return { ok: false, error: 'Gecko is not ready' };
|
|
261
|
+
if (typeof instance.evalChrome !== 'function') {
|
|
262
|
+
return { ok: false, error: 'engine build does not expose evalChrome' };
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
return decodeEnvelope(await instance.evalChrome(source));
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return { ok: false, error: String((error && /** @type {Error} */ (error).message) || error) };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Issues a snippet and returns the reply untouched.
|
|
273
|
+
*
|
|
274
|
+
* Internal probes already define their own reply format, so they must bypass
|
|
275
|
+
* {@link decodeEnvelope} rather than being misread as a wrapped value.
|
|
276
|
+
* @param {string} source
|
|
277
|
+
* @returns {Promise<string | null>}
|
|
278
|
+
*/
|
|
279
|
+
async function sendRaw(source) {
|
|
280
|
+
if (!instance) return null;
|
|
281
|
+
if (typeof instance.evalChrome !== 'function') return null;
|
|
282
|
+
try {
|
|
283
|
+
const value = await instance.evalChrome(source);
|
|
284
|
+
return typeof value === 'string' ? value : null;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Marks the document currently on screen, so a later navigation can be told
|
|
292
|
+
* apart from it.
|
|
293
|
+
*
|
|
294
|
+
* `readyState` on its own cannot tell "the new page has loaded" from "the old
|
|
295
|
+
* page is still loaded": both report something other than `loading`, so a
|
|
296
|
+
* caller waiting on it can resume against the document it was trying to
|
|
297
|
+
* leave. Stamping the outgoing document and waiting for the mark to vanish is
|
|
298
|
+
* redirect-agnostic, unlike comparing URLs.
|
|
299
|
+
*
|
|
300
|
+
* @returns {string} the mark to wait for the disappearance of
|
|
301
|
+
*/
|
|
302
|
+
function stampCurrentDocument() {
|
|
303
|
+
const mark = `g${++navigationGeneration}`;
|
|
304
|
+
try {
|
|
305
|
+
// Best effort: a document that cannot be stamped simply degrades the
|
|
306
|
+
// later wait to the readyState check on its own.
|
|
307
|
+
void Promise.resolve(
|
|
308
|
+
instance?.evalChrome(
|
|
309
|
+
`document.documentElement&&document.documentElement.setAttribute("data-gecko-nav",${JSON.stringify(mark)})`
|
|
310
|
+
)
|
|
311
|
+
).catch(() => {});
|
|
312
|
+
} catch {
|
|
313
|
+
// Same: never let bookkeeping fail a navigation.
|
|
314
|
+
}
|
|
315
|
+
return mark;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Predicate that holds only once the marked document has been replaced *and*
|
|
320
|
+
* its replacement has finished loading.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} mark
|
|
323
|
+
* @returns {string}
|
|
324
|
+
*/
|
|
325
|
+
function navigatedAwayFrom(mark) {
|
|
326
|
+
return (
|
|
327
|
+
`((${JSON.stringify(mark)})!==` +
|
|
328
|
+
`((document.documentElement&&document.documentElement.getAttribute("data-gecko-nav"))||null)` +
|
|
329
|
+
`&&document.readyState!=="loading")`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Polls the page until the document we navigated away from is gone and the
|
|
335
|
+
* new one has settled. Navigation is dispatched without awaiting it, so this
|
|
336
|
+
* is the only signal that the new document is actually scriptable.
|
|
337
|
+
* @param {number} generation
|
|
338
|
+
* @param {string} mark
|
|
339
|
+
*/
|
|
340
|
+
function watchForPageReady(generation, mark) {
|
|
341
|
+
const deadline = Date.now() + DEFAULT_READY_TIMEOUT_MS;
|
|
342
|
+
const poll = async () => {
|
|
343
|
+
while (state !== 'destroyed' && generation === navigationGeneration) {
|
|
344
|
+
if (Date.now() > deadline) {
|
|
345
|
+
// The engine is healthy even if the document never settles, so
|
|
346
|
+
// report ready rather than stranding the caller in 'loading'.
|
|
347
|
+
if (state === 'loading') setState('ready');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const swapped = await sendRaw(navigatedAwayFrom(mark));
|
|
351
|
+
// `sendRaw` stringifies, so a pending answer arrives as 'false'.
|
|
352
|
+
if (swapped && swapped !== 'false') {
|
|
353
|
+
setState('ready');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
void poll().catch(error => onError(error));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function init() {
|
|
363
|
+
if (instance) return instance;
|
|
364
|
+
if (initPromise) return initPromise;
|
|
365
|
+
if (!canvas) throw new Error('Canvas is required');
|
|
366
|
+
if (!host?.crossOriginIsolated) throw new Error('Cross-origin isolation is required');
|
|
367
|
+
setState('booting');
|
|
368
|
+
initPromise = (async () => {
|
|
369
|
+
const module = await loadModule(bundleUrl);
|
|
370
|
+
const GeckoCtor = module.Gecko;
|
|
371
|
+
/** @type {import('../../types/engine.js').GeckoOptions} */
|
|
372
|
+
const engineOptions = {
|
|
373
|
+
canvas,
|
|
374
|
+
width: Math.max(1024, Math.round(Number(host.innerWidth) || 1280)),
|
|
375
|
+
height: Math.max(720, Math.round(Number(host.innerHeight) || 720)),
|
|
376
|
+
profile,
|
|
377
|
+
wasm: { url: wasmUrl, compressed: true },
|
|
378
|
+
wispUrl: wispUrl(),
|
|
379
|
+
env: /** @type {GeckoEnv} */ ({
|
|
380
|
+
// Heavy pages exhaust the engine's 64 MB stack under the Wasm JIT.
|
|
381
|
+
GECKO_NOWASMJIT: '1',
|
|
382
|
+
...options.env
|
|
383
|
+
}),
|
|
384
|
+
print: value => onLog(String(value)),
|
|
385
|
+
printErr: value => onLog(String(value))
|
|
386
|
+
};
|
|
387
|
+
const gecko = new GeckoCtor(engineOptions);
|
|
388
|
+
await gecko.init();
|
|
389
|
+
instance = gecko;
|
|
390
|
+
setState('ready');
|
|
391
|
+
return gecko;
|
|
392
|
+
})().catch(error => {
|
|
393
|
+
initPromise = null;
|
|
394
|
+
instance = null;
|
|
395
|
+
setState('idle');
|
|
396
|
+
throw error;
|
|
397
|
+
});
|
|
398
|
+
return initPromise;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @param {string} value
|
|
403
|
+
* @returns {string}
|
|
404
|
+
*/
|
|
405
|
+
function navigate(value) {
|
|
406
|
+
if (!instance) throw new Error('Gecko is not ready');
|
|
407
|
+
const url = normalizeHttpUrl(value);
|
|
408
|
+
navigationMark = stampCurrentDocument();
|
|
409
|
+
currentUrl = url;
|
|
410
|
+
emit();
|
|
411
|
+
hasNavigated = true;
|
|
412
|
+
setState('loading');
|
|
413
|
+
navigationGeneration += 1;
|
|
414
|
+
Promise.resolve(instance.load(url)).catch(error => onError(error));
|
|
415
|
+
watchForPageReady(navigationGeneration, navigationMark);
|
|
416
|
+
return url;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** @returns {string | null} */
|
|
420
|
+
function reload() {
|
|
421
|
+
if (!currentUrl) return null;
|
|
422
|
+
return navigate(currentUrl);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {number} [width]
|
|
427
|
+
* @param {number} [height]
|
|
428
|
+
* @returns {Promise<void>}
|
|
429
|
+
*/
|
|
430
|
+
function resize(width, height) {
|
|
431
|
+
if (!instance?.resize) return Promise.resolve();
|
|
432
|
+
const w = Math.max(1024, Math.round(Number(width ?? host.innerWidth) || 1280));
|
|
433
|
+
const h = Math.max(720, Math.round(Number(height ?? host.innerHeight) || 720));
|
|
434
|
+
// `app.js` calls this from a resize listener without awaiting, so a
|
|
435
|
+
// rejection must be routed rather than left unhandled.
|
|
436
|
+
return Promise.resolve(instance.resize(w, h)).then(() => undefined, error => {
|
|
437
|
+
onError(error);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Waits for a parked promise value and decodes the slot.
|
|
443
|
+
* @param {string} key
|
|
444
|
+
* @param {number} timeoutMs
|
|
445
|
+
* @returns {Promise<unknown>}
|
|
446
|
+
*/
|
|
447
|
+
async function drainPromise(key, timeoutMs) {
|
|
448
|
+
const deadline = Date.now() + timeoutMs;
|
|
449
|
+
while (Date.now() <= deadline) {
|
|
450
|
+
const reply = await sendRaw(`(()=>{const r=window[${JSON.stringify(key)}];return r?JSON.stringify(r):null})()`);
|
|
451
|
+
if (typeof reply === 'string') {
|
|
452
|
+
let parsed = null;
|
|
453
|
+
try {
|
|
454
|
+
parsed = JSON.parse(reply);
|
|
455
|
+
} catch {
|
|
456
|
+
parsed = null;
|
|
457
|
+
}
|
|
458
|
+
const slot = parsed && typeof parsed === 'object' ? parsed : null;
|
|
459
|
+
const status = slot ? Number(slot.o) || PENDING : PENDING;
|
|
460
|
+
if (status !== PENDING) {
|
|
461
|
+
void sendRaw(`(()=>{try{delete window[${JSON.stringify(key)}]}catch(e){};return true})()`);
|
|
462
|
+
if (status === 2) {
|
|
463
|
+
throw new GeckoEvalError(String(slot.e || 'promise rejected'), 'throw');
|
|
464
|
+
}
|
|
465
|
+
const raw = slot.s;
|
|
466
|
+
if (typeof raw !== 'string') return null;
|
|
467
|
+
try {
|
|
468
|
+
return JSON.parse(raw);
|
|
469
|
+
} catch {
|
|
470
|
+
return raw;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
475
|
+
}
|
|
476
|
+
void sendRaw(`(()=>{try{delete window[${JSON.stringify(key)}]}catch(e){};return true})()`);
|
|
477
|
+
throw new GeckoEvalError(`promise did not settle within ${timeoutMs}ms`, 'timeout');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Evaluate a snippet in the loaded page and return its value.
|
|
482
|
+
*
|
|
483
|
+
* Throws {@link GeckoEvalError} on any failure, which is the ergonomic
|
|
484
|
+
* default: `await session.eval('document.title')` just gives you the title.
|
|
485
|
+
* Wrap a call in {@link attempt} when you want a value instead of a throw.
|
|
486
|
+
*
|
|
487
|
+
* A promise-returning snippet is awaited automatically, so there is no
|
|
488
|
+
* separate async entry point to remember.
|
|
489
|
+
*
|
|
490
|
+
* @template T
|
|
491
|
+
* @param {string} code
|
|
492
|
+
* @param {EvaluateOptions} [evaluateOptions]
|
|
493
|
+
* @returns {Promise<T>}
|
|
494
|
+
*/
|
|
495
|
+
async function evaluate(code, evaluateOptions = {}) {
|
|
496
|
+
const timeoutMs = evaluateOptions.timeoutMs ?? DEFAULT_EVAL_TIMEOUT_MS;
|
|
497
|
+
if (!instance) throw new GeckoEvalError('Gecko is not ready', 'not-ready');
|
|
498
|
+
if (!hasNavigated) throw new GeckoEvalError('no page has been loaded yet', 'no-page');
|
|
499
|
+
|
|
500
|
+
const key = nextPromiseKey();
|
|
501
|
+
const result = await send(envelopeSource(code, key));
|
|
502
|
+
if (result === null) throw new GeckoEvalError('page did not return a result', 'no-result');
|
|
503
|
+
if (!result.ok) throw new GeckoEvalError(result.error, 'throw');
|
|
504
|
+
|
|
505
|
+
const value = result.value;
|
|
506
|
+
if (value && typeof value === 'object' && (/** @type {{o?: number}} */ (value).o) === PENDING_PROMISE) {
|
|
507
|
+
return /** @type {T} */ (await drainPromise(key, timeoutMs));
|
|
508
|
+
}
|
|
509
|
+
return /** @type {T} */ (value);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Runs a call and converts a thrown {@link GeckoEvalError} into a result
|
|
514
|
+
* union, so the common case can stay throw-based without losing the
|
|
515
|
+
* non-throwing form where it is genuinely useful.
|
|
516
|
+
*
|
|
517
|
+
* @template T
|
|
518
|
+
* @param {() => Promise<T> | T} fn
|
|
519
|
+
* @returns {Promise<EvalResult<T>>}
|
|
520
|
+
*/
|
|
521
|
+
async function attempt(fn) {
|
|
522
|
+
try {
|
|
523
|
+
return { ok: true, value: await fn() };
|
|
524
|
+
} catch (error) {
|
|
525
|
+
const message =
|
|
526
|
+
error instanceof GeckoEvalError
|
|
527
|
+
? error.message
|
|
528
|
+
: String((error && /** @type {Error} */ (error).message) || error);
|
|
529
|
+
return { ok: false, error: message };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* @template T
|
|
535
|
+
* @param {string} code
|
|
536
|
+
* @param {TypeGuard<T>} guard
|
|
537
|
+
* @returns {Promise<T>}
|
|
538
|
+
*/
|
|
539
|
+
async function evalJson(code, guard) {
|
|
540
|
+
const value = await evaluate(code);
|
|
541
|
+
if (!guard(value)) {
|
|
542
|
+
throw new GeckoEvalError(
|
|
543
|
+
`page value did not match the expected shape (got ${describe(value)})`,
|
|
544
|
+
'shape'
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Navigate and wait until the new document is scriptable.
|
|
552
|
+
*
|
|
553
|
+
* This is the call most consumers want: `navigate()` only dispatches, so
|
|
554
|
+
* evaluating immediately afterwards races the load. `open()` closes that gap.
|
|
555
|
+
*
|
|
556
|
+
* @param {string} value
|
|
557
|
+
* @param {number} [timeoutMs]
|
|
558
|
+
* @returns {Promise<string>} the normalised URL
|
|
559
|
+
*/
|
|
560
|
+
async function open(value, timeoutMs = DEFAULT_READY_TIMEOUT_MS * 3) {
|
|
561
|
+
const url = navigate(value);
|
|
562
|
+
await waitFor(navigatedAwayFrom(navigationMark), {
|
|
563
|
+
timeoutMs,
|
|
564
|
+
intervalMs: 250
|
|
565
|
+
});
|
|
566
|
+
return url;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* @deprecated Promises are awaited automatically by `eval`. Kept as an alias
|
|
571
|
+
* so existing call sites keep working.
|
|
572
|
+
* @template T
|
|
573
|
+
* @param {string} code
|
|
574
|
+
* @param {number} [timeoutMs]
|
|
575
|
+
* @returns {Promise<T>}
|
|
576
|
+
*/
|
|
577
|
+
function evalAsync(code, timeoutMs) {
|
|
578
|
+
return evaluate(code, timeoutMs === undefined ? undefined : { timeoutMs });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* @param {string} predicate
|
|
583
|
+
* @param {WaitForOptions} [waitOptions]
|
|
584
|
+
* @returns {Promise<boolean>}
|
|
585
|
+
*/
|
|
586
|
+
async function waitFor(predicate, waitOptions = {}) {
|
|
587
|
+
const timeoutMs = waitOptions.timeoutMs ?? 15_000;
|
|
588
|
+
const intervalMs = waitOptions.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
589
|
+
const deadline = Date.now() + timeoutMs;
|
|
590
|
+
while (Date.now() <= deadline) {
|
|
591
|
+
const result = await attempt(() => evaluate(predicate));
|
|
592
|
+
if (result.ok && result.value) return true;
|
|
593
|
+
await sleep(intervalMs);
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* @template T
|
|
600
|
+
* @param {string} selector
|
|
601
|
+
* @returns {Promise<T | null>}
|
|
602
|
+
*/
|
|
603
|
+
async function query(selector) {
|
|
604
|
+
return evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});return el?(${SNAPSHOT})(el):null})()`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* @template T
|
|
609
|
+
* @param {string} selector
|
|
610
|
+
* @returns {Promise<T[]>}
|
|
611
|
+
*/
|
|
612
|
+
async function queryAll(selector) {
|
|
613
|
+
return evaluate(`[...document.querySelectorAll(${JSON.stringify(selector)})].map(el=>(${SNAPSHOT})(el))`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* @param {string} selector
|
|
618
|
+
* @returns {Promise<string>}
|
|
619
|
+
*/
|
|
620
|
+
async function text(selector) {
|
|
621
|
+
return evaluate(
|
|
622
|
+
`(()=>{const el=document.querySelector(${JSON.stringify(selector)});return el?(el.textContent||'').trim():''})()`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* @param {string} name
|
|
628
|
+
* @param {string} selector
|
|
629
|
+
* @returns {Promise<string | null>}
|
|
630
|
+
*/
|
|
631
|
+
async function attr(name, selector) {
|
|
632
|
+
return evaluate(
|
|
633
|
+
`(()=>{const el=document.querySelector(${JSON.stringify(selector)});return el?el.getAttribute(${JSON.stringify(name)}):null})()`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @param {string} selector
|
|
639
|
+
* @returns {Promise<boolean>}
|
|
640
|
+
*/
|
|
641
|
+
async function click(selector) {
|
|
642
|
+
return evaluate(
|
|
643
|
+
`(()=>{const el=document.querySelector(${JSON.stringify(selector)});if(!el)return false;el.click();return true})()`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function destroy() {
|
|
648
|
+
if (!instance) return;
|
|
649
|
+
navigationGeneration += 1;
|
|
650
|
+
try {
|
|
651
|
+
instance.destroy();
|
|
652
|
+
} catch (error) {
|
|
653
|
+
onError(error);
|
|
654
|
+
}
|
|
655
|
+
instance = null;
|
|
656
|
+
initPromise = null;
|
|
657
|
+
setState('destroyed');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
init,
|
|
662
|
+
navigate,
|
|
663
|
+
open,
|
|
664
|
+
reload,
|
|
665
|
+
resize,
|
|
666
|
+
evaluate,
|
|
667
|
+
eval: evaluate,
|
|
668
|
+
evalJson,
|
|
669
|
+
evalAsync,
|
|
670
|
+
attempt,
|
|
671
|
+
waitFor,
|
|
672
|
+
query,
|
|
673
|
+
queryAll,
|
|
674
|
+
text,
|
|
675
|
+
attr,
|
|
676
|
+
click,
|
|
677
|
+
destroy,
|
|
678
|
+
subscribe,
|
|
679
|
+
get raw() {
|
|
680
|
+
return instance;
|
|
681
|
+
},
|
|
682
|
+
get state() {
|
|
683
|
+
return state;
|
|
684
|
+
},
|
|
685
|
+
get ready() {
|
|
686
|
+
return Boolean(instance);
|
|
687
|
+
},
|
|
688
|
+
get currentUrl() {
|
|
689
|
+
return currentUrl;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* @param {unknown} value
|
|
696
|
+
* @returns {string}
|
|
697
|
+
*/
|
|
698
|
+
function describe(value) {
|
|
699
|
+
if (value === null) return 'null';
|
|
700
|
+
if (Array.isArray(value)) return `array(${value.length})`;
|
|
701
|
+
return typeof value;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* @param {string} value
|
|
706
|
+
* @returns {string}
|
|
707
|
+
*/
|
|
708
|
+
export function normalizeHttpUrl(value) {
|
|
709
|
+
const raw = String(value || '').trim();
|
|
710
|
+
if (!raw) throw new Error('Enter a URL');
|
|
711
|
+
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
|
|
712
|
+
const url = new URL(candidate);
|
|
713
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
714
|
+
throw new Error('Only http or https URLs are supported');
|
|
715
|
+
}
|
|
716
|
+
return url.href;
|
|
717
|
+
}
|