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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite integration.
|
|
3
|
+
*
|
|
4
|
+
* The engine is a real WebAssembly payload, so it cannot be fetched from a CDN
|
|
5
|
+
* and cannot be inlined. Two things must therefore be true before the engine
|
|
6
|
+
* will boot, and neither is something a bundler does for you:
|
|
7
|
+
*
|
|
8
|
+
* 1. The engine assets must be served. This plugin copies them into the build
|
|
9
|
+
* output, and serves them directly in dev.
|
|
10
|
+
* 2. The document must be cross-origin isolated. Without
|
|
11
|
+
* `Cross-Origin-Opener-Policy: same-origin` and
|
|
12
|
+
* `Cross-Origin-Embedder-Policy: require-corp` there is no
|
|
13
|
+
* `SharedArrayBuffer`, and the engine refuses to start.
|
|
14
|
+
*
|
|
15
|
+
* Both are handled here, so the common case is a two-line config.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createReadStream } from 'node:fs';
|
|
19
|
+
import { cp, mkdir, readdir, stat } from 'node:fs/promises';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { pipeline } from 'node:stream/promises';
|
|
23
|
+
|
|
24
|
+
/** Where the engine lives relative to this file inside the published package. */
|
|
25
|
+
const DEFAULT_ENGINE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'engine');
|
|
26
|
+
|
|
27
|
+
/** Mount point the runtime defaults to. */
|
|
28
|
+
const DEFAULT_MOUNT = '/engine';
|
|
29
|
+
|
|
30
|
+
/** Only these are served; nothing else in the engine directory is exposed. */
|
|
31
|
+
const ENGINE_FILES = new Set(['gecko.js', 'gecko.wasm.zst']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {import('../../types/vite.js').GeckoVitePluginOptions} GeckoVitePluginOptions
|
|
35
|
+
* @typedef {import('../../types/vite.js').GeckoVitePlugin} GeckoVitePlugin
|
|
36
|
+
* @typedef {import('../../types/vite.js').GeckoMiddleware} GeckoMiddleware
|
|
37
|
+
* @typedef {import('../../types/vite.js').GeckoDevServer} GeckoDevServer
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Serves the engine and sets the cross-origin isolation headers.
|
|
42
|
+
*
|
|
43
|
+
* Vite is deliberately not referenced by type here: it is an optional peer, and
|
|
44
|
+
* a `import('vite')` in a JSDoc annotation would make this file unresolvable
|
|
45
|
+
* for anyone who installed the package without Vite.
|
|
46
|
+
*
|
|
47
|
+
* @param {GeckoVitePluginOptions} [options]
|
|
48
|
+
* @returns {GeckoVitePlugin}
|
|
49
|
+
*/
|
|
50
|
+
export function geckoWebView(options = {}) {
|
|
51
|
+
const engineDir = resolve(options.engineDir || DEFAULT_ENGINE_DIR);
|
|
52
|
+
const mount = options.mount || DEFAULT_MOUNT;
|
|
53
|
+
const copyOnBuild = options.copyOnBuild !== false;
|
|
54
|
+
let verified = false;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name: 'webcanvas-wasm',
|
|
58
|
+
|
|
59
|
+
async buildStart() {
|
|
60
|
+
if (verified) return;
|
|
61
|
+
await assertEnginePresent(engineDir);
|
|
62
|
+
verified = true;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The headers belong on every response, not just the document that boots the
|
|
67
|
+
* engine: a subresource served without them is what actually breaks
|
|
68
|
+
* isolation, usually as a worker that fails to start.
|
|
69
|
+
*
|
|
70
|
+
* @param {GeckoDevServer} server
|
|
71
|
+
*/
|
|
72
|
+
async configureServer(server) {
|
|
73
|
+
// `buildStart` does not run for `vite dev`, so the check is repeated
|
|
74
|
+
// here. Without it a missing engine shows up as a 404 on the first fetch,
|
|
75
|
+
// long after the mistake was made.
|
|
76
|
+
await assertEnginePresent(engineDir);
|
|
77
|
+
|
|
78
|
+
/** @type {GeckoMiddleware} */
|
|
79
|
+
const isolation = (req, res, next) => {
|
|
80
|
+
void req;
|
|
81
|
+
setIsolationHeaders(res);
|
|
82
|
+
next();
|
|
83
|
+
};
|
|
84
|
+
server.middlewares.use(isolation);
|
|
85
|
+
|
|
86
|
+
/** @type {GeckoMiddleware} */
|
|
87
|
+
const engineFiles = (req, res, next) => {
|
|
88
|
+
// Strip the query string, then the leading slash, leaving a bare
|
|
89
|
+
// filename that can be checked against the allow-list.
|
|
90
|
+
const name = String(req.url).split('?')[0]?.replace(/^\/+/, '') ?? '';
|
|
91
|
+
// Only the two known assets are exposed; a miss falls through to the
|
|
92
|
+
// next handler so a genuine 404 stays a 404.
|
|
93
|
+
if (!ENGINE_FILES.has(name)) {
|
|
94
|
+
next();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
sendEngine(res, join(engineDir, name), name.endsWith('.zst')).catch(next);
|
|
98
|
+
};
|
|
99
|
+
server.middlewares.use(`${mount}/`, engineFiles);
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {{ dir?: string }} outputOptions
|
|
104
|
+
*/
|
|
105
|
+
async writeBundle(outputOptions) {
|
|
106
|
+
if (!copyOnBuild) return;
|
|
107
|
+
// `dir` is the resolved output directory; falling back to cwd keeps a
|
|
108
|
+
// misconfigured build from writing engine files into the source tree.
|
|
109
|
+
const outDir = resolve(String(outputOptions.dir || 'dist'));
|
|
110
|
+
const target = join(outDir, mount.replace(/^\/+/, ''));
|
|
111
|
+
await mkdir(target, { recursive: true });
|
|
112
|
+
for (const file of ENGINE_FILES) {
|
|
113
|
+
await cp(join(engineDir, file), join(target, file));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fail early and clearly. A missing engine otherwise surfaces as a blank canvas
|
|
121
|
+
* and a console error several seconds after boot, which is hard to trace back.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} engineDir
|
|
124
|
+
*/
|
|
125
|
+
async function assertEnginePresent(engineDir) {
|
|
126
|
+
let available;
|
|
127
|
+
try {
|
|
128
|
+
available = await readdir(engineDir);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`webcanvas-wasm: no engine assets in ${engineDir}. Pass \`engineDir\` to point at a ` +
|
|
132
|
+
'local engine build.'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const missing = [...ENGINE_FILES].filter(file => !available.includes(file));
|
|
136
|
+
if (missing.length) {
|
|
137
|
+
throw new Error(`webcanvas-wasm: engine is missing ${missing.join(', ')} in ${engineDir}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The headers the engine requires.
|
|
143
|
+
*
|
|
144
|
+
* @param {{ setHeader(name: string, value: string): void }} res
|
|
145
|
+
*/
|
|
146
|
+
export function setIsolationHeaders(res) {
|
|
147
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
148
|
+
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
|
149
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Streams the file rather than reading it: the compressed wasm is tens of
|
|
154
|
+
* megabytes, and holding it in memory per request is how a dev server falls
|
|
155
|
+
* over when a page reloads in a loop.
|
|
156
|
+
*
|
|
157
|
+
* @param {import('node:http').ServerResponse} res
|
|
158
|
+
* @param {string} file
|
|
159
|
+
* @param {boolean} compressed
|
|
160
|
+
*/
|
|
161
|
+
async function sendEngine(res, file, compressed) {
|
|
162
|
+
const info = await stat(file);
|
|
163
|
+
if (!info.isFile()) throw new Error(`not a file: ${file}`);
|
|
164
|
+
res.setHeader('Content-Type', compressed ? 'application/wasm' : 'text/javascript');
|
|
165
|
+
if (compressed) res.setHeader('Content-Encoding', 'zstd');
|
|
166
|
+
res.setHeader('Content-Length', String(info.size));
|
|
167
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
168
|
+
await pipeline(createReadStream(file), res);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default geckoWebView;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { access, stat } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
// The Wisp dependency ships no types; it is only used for its routeRequest hook.
|
|
8
|
+
const wispModule = /** @type {{ server: any }} */ (
|
|
9
|
+
/** @type {any} */ (await import('@mercuryworkshop/wisp-js/server'))
|
|
10
|
+
);
|
|
11
|
+
const wispDefault = wispModule.server;
|
|
12
|
+
|
|
13
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const MIME = new Map([
|
|
15
|
+
['.html', 'text/html; charset=utf-8'],
|
|
16
|
+
['.js', 'text/javascript; charset=utf-8'],
|
|
17
|
+
['.css', 'text/css; charset=utf-8'],
|
|
18
|
+
['.zst', 'application/zstd'],
|
|
19
|
+
['.json', 'application/json; charset=utf-8']
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} root
|
|
24
|
+
* @param {string} requestPath
|
|
25
|
+
* @returns {string | null}
|
|
26
|
+
*/
|
|
27
|
+
function safeFile(root, requestPath) {
|
|
28
|
+
const relative = requestPath.replace(/^\/+/, '');
|
|
29
|
+
const resolved = path.resolve(root, relative);
|
|
30
|
+
return resolved === root || resolved.startsWith(`${root}${path.sep}`) ? resolved : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} [url]
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function requestPathname(url = '/') {
|
|
38
|
+
try { return new URL(url, 'http://localhost').pathname; }
|
|
39
|
+
catch { return '/'; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('node:http').ServerResponse} res
|
|
44
|
+
* @param {string} file
|
|
45
|
+
* @param {{ engine?: boolean }} [sendOptions]
|
|
46
|
+
*/
|
|
47
|
+
async function sendFile(res, file, { engine = false } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
await access(file);
|
|
50
|
+
const info = await stat(file);
|
|
51
|
+
if (!info.isFile()) throw new Error('not a file');
|
|
52
|
+
} catch {
|
|
53
|
+
res.writeHead(404).end('Not found');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const ext = path.extname(file).toLowerCase();
|
|
57
|
+
res.setHeader('Content-Type', MIME.get(ext) || 'application/octet-stream');
|
|
58
|
+
res.setHeader('Content-Length', String((await stat(file)).size));
|
|
59
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
60
|
+
if (engine) {
|
|
61
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
62
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
63
|
+
}
|
|
64
|
+
res.writeHead(200);
|
|
65
|
+
createReadStream(file).pipe(res);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {object} WebviewServerOptions
|
|
70
|
+
* @property {string} [publicDir] static root, defaults to the demo directory
|
|
71
|
+
* @property {string} [libDir] library mount point, defaults to src/gecko
|
|
72
|
+
* @property {string} [engineDir] engine assets, defaults to engine
|
|
73
|
+
* @property {{ routeRequest: (req: any, socket: any, head: any) => void }} [wispServer]
|
|
74
|
+
*
|
|
75
|
+
* Serves the demo, the library and the engine with the cross-origin isolation
|
|
76
|
+
* headers the engine requires, and routes `/wisp/` upgrades to Wisp.
|
|
77
|
+
*
|
|
78
|
+
* @param {WebviewServerOptions} [options]
|
|
79
|
+
* @returns {import('node:http').Server}
|
|
80
|
+
*/
|
|
81
|
+
export function createWebviewServer(options = {}) {
|
|
82
|
+
const publicDir = path.resolve(options.publicDir || path.join(ROOT, 'demo'));
|
|
83
|
+
const libDir = path.resolve(options.libDir || path.join(ROOT, 'src', 'gecko'));
|
|
84
|
+
const engineDir = path.resolve(options.engineDir || path.join(ROOT, 'engine'));
|
|
85
|
+
const wispServer = options.wispServer || wispDefault;
|
|
86
|
+
|
|
87
|
+
const server = createServer(
|
|
88
|
+
/**
|
|
89
|
+
* @param {import('node:http').IncomingMessage} req
|
|
90
|
+
* @param {import('node:http').ServerResponse} res
|
|
91
|
+
*/
|
|
92
|
+
async (req, res) => {
|
|
93
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
94
|
+
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
|
95
|
+
const pathname = requestPathname(req.url);
|
|
96
|
+
if (pathname.startsWith('/lib/')) {
|
|
97
|
+
const file = safeFile(libDir, pathname.slice('/lib/'.length));
|
|
98
|
+
if (!file) return res.writeHead(400).end('Bad path');
|
|
99
|
+
return sendFile(res, file);
|
|
100
|
+
}
|
|
101
|
+
if (pathname.startsWith('/engine/')) {
|
|
102
|
+
const file = safeFile(engineDir, pathname.slice('/engine/'.length));
|
|
103
|
+
if (!file) return res.writeHead(400).end('Bad path');
|
|
104
|
+
return sendFile(res, file, { engine: true });
|
|
105
|
+
}
|
|
106
|
+
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
107
|
+
const file = safeFile(publicDir, relative);
|
|
108
|
+
if (!file) return res.writeHead(400).end('Bad path');
|
|
109
|
+
return sendFile(res, file);
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
server.on('upgrade', (req, socket, head) => {
|
|
114
|
+
if (requestPathname(req.url) === '/wisp/') {
|
|
115
|
+
wispServer.routeRequest(req, socket, head);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
socket.destroy();
|
|
119
|
+
});
|
|
120
|
+
return server;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
124
|
+
const host = process.env.HOST || '127.0.0.1';
|
|
125
|
+
const port = Number(process.env.PORT || 8080);
|
|
126
|
+
const server = createWebviewServer();
|
|
127
|
+
server.listen(port, host, () => console.log(`webcanvas-wasm http://${host}:${port}`));
|
|
128
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the vendored Gecko WASM engine bundle.
|
|
3
|
+
*
|
|
4
|
+
* The bundle (`engine/gecko.js`) is a third-party build artifact, not source we
|
|
5
|
+
* own, so it ships no types. Everything here was verified by reading the
|
|
6
|
+
* bundle and by exercising the running engine; see README "Engine artifacts".
|
|
7
|
+
*
|
|
8
|
+
* Two behaviours below are load-bearing and were confirmed empirically, so
|
|
9
|
+
* treat them as contract rather than incidental detail:
|
|
10
|
+
*
|
|
11
|
+
* 1. `evalChrome` runs in the *content* window of the currently loaded page,
|
|
12
|
+
* NOT in chrome. `Services`, `Cc`, `Ci` and `ChromeUtils` are all
|
|
13
|
+
* `undefined` there. `window` and `document` are the real page objects.
|
|
14
|
+
* 2. `evalChrome` resolves to `''` both when the snippet throws and when no
|
|
15
|
+
* page has been loaded yet. Callers cannot tell those apart, which is why
|
|
16
|
+
* `types/session.d.ts` wraps it in a discriminated union.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Engine environment knobs that are actually useful to set. */
|
|
20
|
+
export interface KnownGeckoEnv {
|
|
21
|
+
/** Disable the Wasm JIT tiers. Off by default upstream; required for sites
|
|
22
|
+
* with deeply nested scripts, which otherwise fail with "too much recursion". */
|
|
23
|
+
GECKO_NOWASMJIT?: '0' | '1';
|
|
24
|
+
/** WebGL2 compositor. Requires the canvas to carry `id="screen"`, and forbids
|
|
25
|
+
* the host page from taking a 2D context on the same canvas. */
|
|
26
|
+
GECKO_GPU?: '0' | '1';
|
|
27
|
+
GECKO_DARK?: '0' | '1';
|
|
28
|
+
/** Where the ~13 MB GRE remote package is fetched from. */
|
|
29
|
+
GECKO_GRE_PROVIDER?: string;
|
|
30
|
+
GECKO_PROFILE_PROVIDER?: string;
|
|
31
|
+
GECKO_OPFS_MOUNT?: string;
|
|
32
|
+
GECKO_SKIP_OPFS?: '0' | '1';
|
|
33
|
+
GECKO_DISK_CACHE?: string;
|
|
34
|
+
GECKO_APZ?: '0' | '1';
|
|
35
|
+
GECKO_STYLO_THREADS?: string;
|
|
36
|
+
GECKO_COARSE_CLOCK?: '0' | '1';
|
|
37
|
+
GECKO_IMG_PASSTHROUGH?: '0' | '1';
|
|
38
|
+
GECKO_WASM_PASSTHROUGH?: '0' | '1';
|
|
39
|
+
GECKO_GL_PASSTHROUGH?: '0' | '1';
|
|
40
|
+
GECKO_NO_NURSERY?: '0' | '1';
|
|
41
|
+
GECKO_NO_COMPACT?: '0' | '1';
|
|
42
|
+
GECKO_MIRROR?: string;
|
|
43
|
+
GECKO_CHROME?: string;
|
|
44
|
+
GECKO_CONTENT_CONSOLE?: '0' | '1';
|
|
45
|
+
GECKO_B_THRESHOLD?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The engine exposes 426 `GECKO_*` names in total. Most are `GECKO_WJ_*`
|
|
50
|
+
* SpiderMonkey/Warp debug switches, so the index signature stays open instead
|
|
51
|
+
* of pretending the known list is exhaustive.
|
|
52
|
+
*/
|
|
53
|
+
export type GeckoEnv = KnownGeckoEnv & Record<string, string | undefined>;
|
|
54
|
+
|
|
55
|
+
export interface GeckoWasmSource {
|
|
56
|
+
url: string;
|
|
57
|
+
/** The shipped artifact is zstd-deflated, so this is true in practice. */
|
|
58
|
+
compressed: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface GeckoOptions {
|
|
62
|
+
canvas: HTMLCanvasElement;
|
|
63
|
+
width?: number;
|
|
64
|
+
height?: number;
|
|
65
|
+
profile?: string;
|
|
66
|
+
wasm: GeckoWasmSource;
|
|
67
|
+
/** All target-site traffic tunnels through this same-origin WISP endpoint. */
|
|
68
|
+
wispUrl: string;
|
|
69
|
+
env?: GeckoEnv;
|
|
70
|
+
print?: (line: string) => void;
|
|
71
|
+
printErr?: (line: string) => void;
|
|
72
|
+
/** `false` skips attaching mouse/keyboard handlers. Defaults to attached. */
|
|
73
|
+
forwardInput?: boolean;
|
|
74
|
+
locateFile?: (path: string) => string;
|
|
75
|
+
fs?: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A raw command for the engine's serial queue, as accepted by {@link Gecko.run}.
|
|
80
|
+
*
|
|
81
|
+
* The opcodes are the bundle's own constant table, written as literals because a
|
|
82
|
+
* `.d.ts` cannot declare a frozen object:
|
|
83
|
+
*
|
|
84
|
+
* ```text
|
|
85
|
+
* 0 Load 1 Mouse 2 Key 3 Wheel 4 Paint 5 Eval 9 ClipSet
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* The runtime above drives the engine through the named methods rather than this
|
|
89
|
+
* queue; it is declared because `run` is part of the vendored class.
|
|
90
|
+
*/
|
|
91
|
+
export type GeckoCommand =
|
|
92
|
+
| { op: 0; url: string }
|
|
93
|
+
| { op: 5; url: string }
|
|
94
|
+
| { op: 9; url: string }
|
|
95
|
+
| { op: 4 }
|
|
96
|
+
| { op: 1; x: number; y: number; button?: number; buttons?: number; clickCount?: number; modifiers?: number }
|
|
97
|
+
| { op: 2; key?: string; keyCode?: number; charCode?: number; modifiers?: number }
|
|
98
|
+
| { op: 3; deltaX?: number; deltaY?: number };
|
|
99
|
+
|
|
100
|
+
export declare class Gecko {
|
|
101
|
+
constructor(options: GeckoOptions);
|
|
102
|
+
|
|
103
|
+
readonly gpu: boolean;
|
|
104
|
+
readonly running: boolean;
|
|
105
|
+
|
|
106
|
+
/** Instantiate the engine, mount GRE files, resolve when READY. */
|
|
107
|
+
init(): Promise<void>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Navigate to an http(s) URL. Deliberately not awaited by callers upstream:
|
|
111
|
+
* a Gecko navigation can be in flight while its command promise stays
|
|
112
|
+
* pending, so awaiting it can hang.
|
|
113
|
+
*/
|
|
114
|
+
load(url: string): Promise<unknown>;
|
|
115
|
+
|
|
116
|
+
resize(width: number, height: number): Promise<unknown>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Evaluate a snippet in the loaded page's content window and return the
|
|
120
|
+
* stringified completion value.
|
|
121
|
+
*
|
|
122
|
+
* Resolves to `''` on throw and before any page has loaded. Does not await
|
|
123
|
+
* promises: `fetch(url).then(...)` stringifies to `"[object Promise]"`.
|
|
124
|
+
*/
|
|
125
|
+
evalChrome(js: string): Promise<string>;
|
|
126
|
+
|
|
127
|
+
/** Stops loops and detaches input handlers. The wasm module is not torn down. */
|
|
128
|
+
destroy(): void;
|
|
129
|
+
|
|
130
|
+
/** Enqueue a raw command on the engine's serial command queue. */
|
|
131
|
+
run(item: GeckoCommand): Promise<unknown>;
|
|
132
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `webcanvas-wasm` — a Gecko WebView in WebAssembly.
|
|
3
|
+
*
|
|
4
|
+
* The engine is a real browser engine compiled to wasm, so a page it loads runs
|
|
5
|
+
* with genuine Gecko behaviour rather than a DOM shim. This module is the
|
|
6
|
+
* framework-agnostic core; see `webcanvas-wasm/react` and `webcanvas-wasm/vite`.
|
|
7
|
+
*
|
|
8
|
+
* Every value below is declared in this directory rather than re-exported from
|
|
9
|
+
* the `.js` sources. A consumer without `allowJs` cannot read the sources, so a
|
|
10
|
+
* re-export would silently degrade to `any` — which, among other things, breaks
|
|
11
|
+
* `instanceof` narrowing on {@link GeckoEvalError}.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { createGeckoRuntime, normalizeHttpUrl, GeckoEvalError, isGeckoEvalError } from './session.js';
|
|
15
|
+
export { createGeckoPage } from './page.js';
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
GeckoSession,
|
|
19
|
+
SessionState,
|
|
20
|
+
SessionOptions,
|
|
21
|
+
EvaluateOptions,
|
|
22
|
+
WaitForOptions,
|
|
23
|
+
EvalResult,
|
|
24
|
+
GeckoErrorCode,
|
|
25
|
+
TypeGuard,
|
|
26
|
+
ElementSnapshot,
|
|
27
|
+
ElementRect
|
|
28
|
+
} from './session.js';
|
|
29
|
+
|
|
30
|
+
export type { GeckoPage } from './page.js';
|
package/types/page.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page object: task-shaped helpers over a {@link GeckoSession}.
|
|
3
|
+
*
|
|
4
|
+
* Each method is a single round-trip to the page, so a call site reads like the
|
|
5
|
+
* script it replaces.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ElementSnapshot, GeckoSession } from './session.js';
|
|
9
|
+
|
|
10
|
+
export interface GeckoPage {
|
|
11
|
+
/** The session this page drives, for anything not modelled here. */
|
|
12
|
+
readonly session: GeckoSession;
|
|
13
|
+
|
|
14
|
+
/** Navigate and wait until the document is scriptable. */
|
|
15
|
+
open(url: string, timeoutMs?: number): Promise<string>;
|
|
16
|
+
|
|
17
|
+
reload(): string | null;
|
|
18
|
+
|
|
19
|
+
url(): Promise<string>;
|
|
20
|
+
origin(): Promise<string>;
|
|
21
|
+
title(): Promise<string>;
|
|
22
|
+
|
|
23
|
+
/** Evaluate in the page. Throws {@link GeckoEvalError} on failure. */
|
|
24
|
+
eval<T = unknown>(code: string, options?: { timeoutMs?: number }): Promise<T>;
|
|
25
|
+
|
|
26
|
+
/** Poll a boolean snippet until it holds or the timeout elapses. */
|
|
27
|
+
waitFor(predicate: string, options?: { timeoutMs?: number; intervalMs?: number }): Promise<boolean>;
|
|
28
|
+
|
|
29
|
+
/** Poll until a selector matches. Resolves to `null` on timeout. */
|
|
30
|
+
waitForSelector<T = ElementSnapshot>(
|
|
31
|
+
selector: string,
|
|
32
|
+
options?: { timeoutMs?: number; intervalMs?: number }
|
|
33
|
+
): Promise<T | null>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Brief pause after an action so a follow-up read does not race the handler.
|
|
37
|
+
*
|
|
38
|
+
* There is no reliable completion signal for an in-page click, so this is a
|
|
39
|
+
* deliberate small delay rather than a guarantee.
|
|
40
|
+
*/
|
|
41
|
+
settle(ms?: number): Promise<void>;
|
|
42
|
+
|
|
43
|
+
exists(selector: string): Promise<boolean>;
|
|
44
|
+
count(selector: string): Promise<number>;
|
|
45
|
+
|
|
46
|
+
/** Snapshot the first match, or `null` when nothing matches. */
|
|
47
|
+
query<T = unknown>(selector: string): Promise<T | null>;
|
|
48
|
+
queryAll<T = unknown>(selector: string): Promise<T[]>;
|
|
49
|
+
|
|
50
|
+
/** Trimmed `textContent` of the first match, or `''` when unmatched. */
|
|
51
|
+
text(selector: string): Promise<string>;
|
|
52
|
+
|
|
53
|
+
/** `getAttribute` on the first match, or `null` when absent or unmatched. */
|
|
54
|
+
attr(name: string, selector: string): Promise<string | null>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set a form control's value and fire `input` plus `change`.
|
|
58
|
+
*
|
|
59
|
+
* Assigning `element.value` directly is invisible to React and to any other
|
|
60
|
+
* framework that tracks the last value it set, so the component would keep
|
|
61
|
+
* its own state and revert the change on the next render. This drives the
|
|
62
|
+
* native setter and dispatches the events real typing produces.
|
|
63
|
+
*
|
|
64
|
+
* @throws when the selector matches nothing.
|
|
65
|
+
*/
|
|
66
|
+
fill(selector: string, value: string): Promise<boolean>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Type a value one character at a time, firing key events between edits.
|
|
70
|
+
*
|
|
71
|
+
* Use this instead of {@link GeckoPage.fill} for sites that only respond to
|
|
72
|
+
* real key events, such as search-as-you-type inputs and shortcuts.
|
|
73
|
+
*/
|
|
74
|
+
type(selector: string, value: string): Promise<boolean>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scroll into view, click, then settle briefly.
|
|
78
|
+
*
|
|
79
|
+
* @returns `false` when the selector matched nothing.
|
|
80
|
+
*/
|
|
81
|
+
click(selector: string): Promise<boolean>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Wrap a session in the task-shaped helper surface. */
|
|
85
|
+
export declare function createGeckoPage(session: GeckoSession): GeckoPage;
|
package/types/react.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `webcanvas-wasm/react` — React bindings.
|
|
3
|
+
*
|
|
4
|
+
* The engine lives outside React; these types describe how the component tree
|
|
5
|
+
* observes it. React is an optional peer dependency, so importing this module
|
|
6
|
+
* does not pull React into a non-React project.
|
|
7
|
+
*
|
|
8
|
+
* The functions are declared here rather than re-exported from the `.js` source
|
|
9
|
+
* for the same reason as the core entry: a consumer without `allowJs` cannot
|
|
10
|
+
* read the source, so a re-export would silently become `any`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context, ReactElement, ReactNode, RefObject } from 'react';
|
|
14
|
+
|
|
15
|
+
import type { GeckoPage } from './page.js';
|
|
16
|
+
import type { GeckoSession, SessionOptions, SessionState } from './session.js';
|
|
17
|
+
|
|
18
|
+
export interface GeckoContextValue {
|
|
19
|
+
/** Null until the engine has been created; do not dereference during render. */
|
|
20
|
+
readonly session: GeckoSession | null;
|
|
21
|
+
/** Task-shaped helpers, available at the same time as {@link GeckoContextValue.session}. */
|
|
22
|
+
readonly page: GeckoPage | null;
|
|
23
|
+
readonly state: SessionState;
|
|
24
|
+
/** Whatever `init()` rejected with, cleared on a successful boot. */
|
|
25
|
+
readonly error: unknown;
|
|
26
|
+
readonly ready: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Changes whenever the engine handle is replaced.
|
|
29
|
+
*
|
|
30
|
+
* Include it in a dependency list to re-read a value after a restart.
|
|
31
|
+
*/
|
|
32
|
+
readonly generation: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GeckoProviderProps extends Omit<SessionOptions, 'canvas'> {
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
/**
|
|
38
|
+
* Use an existing canvas instead of letting the provider render one.
|
|
39
|
+
*
|
|
40
|
+
* A canvas supplied this way is *not* rendered, so it must be mounted
|
|
41
|
+
* somewhere in the tree and visible, otherwise the engine has no surface.
|
|
42
|
+
*/
|
|
43
|
+
canvasRef?: RefObject<HTMLCanvasElement | null>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lifecycle of a value read from the page.
|
|
48
|
+
*
|
|
49
|
+
* A read is `'pending'` until it lands and keeps the previous value meanwhile,
|
|
50
|
+
* so a navigation does not blank the screen.
|
|
51
|
+
*/
|
|
52
|
+
export type GeckoRunResult<T> =
|
|
53
|
+
| { readonly status: 'idle'; readonly value: undefined }
|
|
54
|
+
| { readonly status: 'pending'; readonly value: T | undefined }
|
|
55
|
+
| { readonly status: 'ok'; readonly value: T }
|
|
56
|
+
| { readonly status: 'error'; readonly value: T | undefined; readonly error: unknown };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Provides one Gecko engine to the subtree below it.
|
|
60
|
+
*
|
|
61
|
+
* The engine is created on mount, after React's render phase — creating it
|
|
62
|
+
* during render would boot two engines, because React renders twice in
|
|
63
|
+
* development — and torn down when the provider unmounts.
|
|
64
|
+
*/
|
|
65
|
+
export function GeckoProvider(props: GeckoProviderProps): ReactElement;
|
|
66
|
+
|
|
67
|
+
/** The full context. Throws if used outside a {@link GeckoProvider}. */
|
|
68
|
+
export function useGecko(): GeckoContextValue;
|
|
69
|
+
|
|
70
|
+
/** The page object, or `null` before the engine exists. */
|
|
71
|
+
export function useGeckoPage(): GeckoPage | null;
|
|
72
|
+
|
|
73
|
+
/** Just the lifecycle state, for components that only show a spinner. */
|
|
74
|
+
export function useGeckoState(): SessionState;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run an async read against the page and keep the result in state.
|
|
78
|
+
*
|
|
79
|
+
* A result from a superseded run is discarded, so a slow evaluation cannot
|
|
80
|
+
* overwrite a newer one. `deps` is the dependency list; the callback itself is
|
|
81
|
+
* held in a ref, so an inline closure does not re-run on every render.
|
|
82
|
+
*/
|
|
83
|
+
export function useGeckoValue<T>(
|
|
84
|
+
run: (session: GeckoSession) => Promise<T> | T,
|
|
85
|
+
deps?: unknown[]
|
|
86
|
+
): GeckoRunResult<T>;
|
|
87
|
+
|
|
88
|
+
/** Convenience wrapper around {@link useGeckoValue} for a single `eval`. */
|
|
89
|
+
export function useGeckoEval<T = unknown>(
|
|
90
|
+
code: string,
|
|
91
|
+
options?: { deps?: unknown[]; evaluateOptions?: { timeoutMs?: number } }
|
|
92
|
+
): GeckoRunResult<T>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The context itself, for consumers writing their own provider.
|
|
96
|
+
*
|
|
97
|
+
* It holds `null` outside a provider, which is what lets {@link useGecko} throw
|
|
98
|
+
* a message naming the problem instead of failing on a null dereference.
|
|
99
|
+
*/
|
|
100
|
+
export const GeckoContext: Context<GeckoContextValue | null>;
|
|
101
|
+
|
|
102
|
+
export default GeckoProvider;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `webcanvas-wasm/server` — a small static server for apps that embed the engine.
|
|
3
|
+
*
|
|
4
|
+
* Useful for local development and for single-process hosting: it serves the
|
|
5
|
+
* engine, mounts the library, applies the cross-origin isolation headers and
|
|
6
|
+
* proxies `/wisp/` upgrades to the Wisp network, all from one `node` process.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface WebviewServerOptions {
|
|
10
|
+
/** Static root. Defaults to the demo directory shipped with the package. */
|
|
11
|
+
publicDir?: string;
|
|
12
|
+
/** Library mount point. Defaults to `src/gecko`. */
|
|
13
|
+
libDir?: string;
|
|
14
|
+
/** Engine assets. Defaults to the `engine` directory. */
|
|
15
|
+
engineDir?: string;
|
|
16
|
+
/** Wisp request router, usually `server` from `@mercuryworkshop/wisp-js/server`. */
|
|
17
|
+
wispServer?: { routeRequest(request: unknown, socket: unknown, head: unknown): void };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createWebviewServer(options?: WebviewServerOptions): import('node:http').Server;
|