wasm-quickjs-dom 0.0.0-stage → 0.0.3
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/LICENSE +21 -0
- package/README.md +224 -2
- package/THIRD-PARTY-NOTICES.md +54 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +16 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +197 -0
- package/dist/edge-js-payload.js +2 -0
- package/dist/index.d.ts +165 -0
- package/dist/index.js +480 -0
- package/dist/limits.d.ts +34 -0
- package/dist/limits.js +114 -0
- package/dist/quickjs-edge-light.d.ts +1 -0
- package/dist/quickjs-edge-light.js +9 -0
- package/dist/quickjs-node.d.ts +1 -0
- package/dist/quickjs-node.js +20 -0
- package/dist/quickjs-wasm.d.ts +2 -0
- package/dist/quickjs-web.d.ts +1 -0
- package/dist/quickjs-web.js +23 -0
- package/dist/quickjs-workerd.d.ts +1 -0
- package/dist/quickjs-workerd.js +8 -0
- package/dist/quickjs.wasm +0 -0
- package/dist/runtime.d.ts +62 -0
- package/dist/runtime.js +174 -0
- package/package.json +71 -3
- package/vendor/quickjs/LICENSE +24 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { type FetchLimits } from "./limits.js";
|
|
2
|
+
export { isPrivateHost, type FetchLimits } from "./limits.js";
|
|
3
|
+
/** Something that happened in the page, for finding out why it rendered the way it did. */
|
|
4
|
+
export type PageLogEntry =
|
|
5
|
+
/** `console.log` / `warn` / `error` (and the page's timer callbacks that threw). */
|
|
6
|
+
{
|
|
7
|
+
type: "console";
|
|
8
|
+
level: "log" | "warn" | "error";
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
/** A script (inline, external, module, or code run by `eval()`'s page-side hooks) threw. `source` is its URL or `inline`. */
|
|
12
|
+
| {
|
|
13
|
+
type: "error";
|
|
14
|
+
message: string;
|
|
15
|
+
source: string;
|
|
16
|
+
}
|
|
17
|
+
/** A promise was rejected and nothing handled it (the browser's `unhandledrejection`). */
|
|
18
|
+
| {
|
|
19
|
+
type: "rejection";
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
/** A request the page made (`fetch`, `XMLHttpRequest`, scripts, modules, the page itself), with its outcome. */
|
|
23
|
+
| {
|
|
24
|
+
type: "request";
|
|
25
|
+
method: string;
|
|
26
|
+
url: string;
|
|
27
|
+
status?: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
/** Options of the script phase. */
|
|
31
|
+
export interface JavaScriptOptions {
|
|
32
|
+
/** Upper bound for the script phase in ms (default 10000). */
|
|
33
|
+
timeout?: number;
|
|
34
|
+
/** Extra time after the `load` event for timers and fetches to finish, in ms (default 1000). */
|
|
35
|
+
settleMs?: number;
|
|
36
|
+
/** CSS selector to wait for after `load`; replaces `settleMs` when given. */
|
|
37
|
+
waitFor?: string;
|
|
38
|
+
}
|
|
39
|
+
/** What the script phase produced. */
|
|
40
|
+
export interface ScriptedPage {
|
|
41
|
+
/** The DOM after the scripts ran, serialized as HTML. */
|
|
42
|
+
html: string;
|
|
43
|
+
/** The document's final URL (after redirects), for resolving relative resources. */
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
}
|
|
46
|
+
/** What {@link QuickJSDOM.toOptions} reads and writes of an options object (render options fit). */
|
|
47
|
+
interface PageInput {
|
|
48
|
+
url?: string;
|
|
49
|
+
value?: unknown;
|
|
50
|
+
baseUrl?: string;
|
|
51
|
+
javascript?: unknown;
|
|
52
|
+
}
|
|
53
|
+
/** Where the QuickJS engine comes from. */
|
|
54
|
+
export interface EdgeJavaScriptEngine {
|
|
55
|
+
/**
|
|
56
|
+
* A compiled QuickJS module of your own. Omit it: the package supplies its own, chosen by the runtime
|
|
57
|
+
* (`#quickjs-wasm` in package.json: pre-compiled on Cloudflare Workers, read or fetched elsewhere).
|
|
58
|
+
*/
|
|
59
|
+
quickjsWasm?: WebAssembly.Module;
|
|
60
|
+
}
|
|
61
|
+
/** Options of {@link createDOM}: the script phase's, the viewport width and the QuickJS module. */
|
|
62
|
+
export interface CreateDOMOptions extends JavaScriptOptions, EdgeJavaScriptEngine {
|
|
63
|
+
/**
|
|
64
|
+
* What the page may fetch: its own `fetch` / `XMLHttpRequest`, the scripts and modules it loads, and `source.url`
|
|
65
|
+
* itself. Unlimited by default, so set it for pages you do not control (see {@link FetchLimits}).
|
|
66
|
+
*/
|
|
67
|
+
limits?: FetchLimits;
|
|
68
|
+
/**
|
|
69
|
+
* Return true for a URL the page should not get: its request fails (and a script's `error` event fires) without
|
|
70
|
+
* going to the network. Analytics, ads and the like are most of what a real site spends its time on, and a render
|
|
71
|
+
* rarely needs them. The page itself (`source.url`) is never skipped.
|
|
72
|
+
*/
|
|
73
|
+
skip?: (url: string) => boolean;
|
|
74
|
+
/** Called for every {@link PageLogEntry} as it happens; they are also kept in {@link QuickJSDOM.logs}. */
|
|
75
|
+
onLog?: (entry: PageLogEntry) => void;
|
|
76
|
+
/** `window.innerWidth` of the page (default 800). */
|
|
77
|
+
width?: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A page living in QuickJS, kept open after its scripts ran (a small JSDOM for runtimes without `eval`).
|
|
81
|
+
*
|
|
82
|
+
* The DOM is inside the QuickJS sandbox, so it is reached with {@link QuickJSDOM.eval} and JSON values rather than
|
|
83
|
+
* through live objects. The page's clock runs only while one of these methods is waiting: timers and fetches of the
|
|
84
|
+
* page progress during `eval()` and `settle()`, not in between. Each call may take up to `timeout` ms.
|
|
85
|
+
*/
|
|
86
|
+
export interface QuickJSDOM {
|
|
87
|
+
/** The document's URL (after redirects when it was fetched). */
|
|
88
|
+
readonly baseUrl: string;
|
|
89
|
+
/** What happened in the page so far: console output, script errors, unhandled rejections and requests (the last 1000). */
|
|
90
|
+
readonly logs: readonly PageLogEntry[];
|
|
91
|
+
/**
|
|
92
|
+
* Run code in the page (global scope, like `window.eval`) and return its value as JSON. A promise is awaited, so
|
|
93
|
+
* `fetch(...).then((r) => r.text())` works. A DOM node cannot be returned: return what you read from it
|
|
94
|
+
* (`document.querySelector("h1").textContent`). Throws what the code threw. Calls run one after another.
|
|
95
|
+
*/
|
|
96
|
+
eval<T = unknown>(code: string): Promise<T>;
|
|
97
|
+
/**
|
|
98
|
+
* Run a function in the page, with JSON-able arguments: `dom.eval((sel) => document.querySelectorAll(sel).length, "a")`.
|
|
99
|
+
* The function is sent as its source text, so it must not use anything from the surrounding scope (and a transpiler
|
|
100
|
+
* must leave its body alone: no helpers injected from the outside, no renamed globals).
|
|
101
|
+
*/
|
|
102
|
+
eval<T = unknown>(fn: (...args: any[]) => T | Promise<T>, ...args: unknown[]): Promise<T>;
|
|
103
|
+
/** Wait (up to `timeout`) until a CSS selector matches something; false when it never did. */
|
|
104
|
+
waitForSelector(selector: string): Promise<boolean>;
|
|
105
|
+
/** Dispatch a bubbling `click` on the first match of the selector. False when nothing matched. */
|
|
106
|
+
click(selector: string): Promise<boolean>;
|
|
107
|
+
/** Set the `value` of the first match (or its text, for an element without one) and fire `input` and `change`. False when nothing matched. */
|
|
108
|
+
type(selector: string, text: string): Promise<boolean>;
|
|
109
|
+
/**
|
|
110
|
+
* Make a function of yours callable from the page as `await window[name](...args)`. Arguments and the result
|
|
111
|
+
* travel as JSON; a throw becomes a rejection with the same message. The page's call counts as a request in flight, so
|
|
112
|
+
* `settle()` waits for it.
|
|
113
|
+
*/
|
|
114
|
+
expose(name: string, fn: (...args: any[]) => unknown): void;
|
|
115
|
+
/** Let the page's timers and fetches run for `ms` (default `settleMs`) and until its fetches are done. */
|
|
116
|
+
settle(ms?: number): Promise<void>;
|
|
117
|
+
/** The document as it is now, as HTML. */
|
|
118
|
+
serialize(): string;
|
|
119
|
+
/**
|
|
120
|
+
* The page as HTML for a layout engine: every element's `class` starts with `qjsl-<n>` (its number, in document
|
|
121
|
+
* order), so boxes that report `class` can be matched back to elements. The page itself is not changed.
|
|
122
|
+
* Pair it with {@link QuickJSDOM.setLayout}; `wasm-html-to-image/javascript` has `measureLayout()` doing both.
|
|
123
|
+
*/
|
|
124
|
+
markForLayout(): string;
|
|
125
|
+
/**
|
|
126
|
+
* Give the page a layout: `boxes` maps an element number (see {@link QuickJSDOM.markForLayout}) to its
|
|
127
|
+
* `[x, y, width, height]` in page coordinates. Afterwards `getBoundingClientRect()`, `getClientRects()`,
|
|
128
|
+
* `offsetWidth` / `offsetHeight` / `offsetLeft` / `offsetTop`, `clientWidth` / `clientHeight` and
|
|
129
|
+
* `scrollWidth` / `scrollHeight` answer from it (the offsets are measured from the page, not from an offset
|
|
130
|
+
* parent). An element without a box is 0 x 0. The layout is not kept in step with later changes to the page:
|
|
131
|
+
* measure again.
|
|
132
|
+
*/
|
|
133
|
+
setLayout(boxes: Record<number, [number, number, number, number]>): void;
|
|
134
|
+
/** Options for a renderer that draws this page: `value` is the serialized DOM and `baseUrl` is this page's. */
|
|
135
|
+
toOptions<T extends PageInput>(options: T): T;
|
|
136
|
+
/** Free the QuickJS runtime. The page cannot be used afterwards. */
|
|
137
|
+
close(): void;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Run a page's scripts in QuickJS and keep the page for further use.
|
|
141
|
+
* Give `url` to fetch the page, or `html` (with an optional `baseUrl`) for markup you already have.
|
|
142
|
+
* Call `close()` when done.
|
|
143
|
+
*
|
|
144
|
+
* ```ts
|
|
145
|
+
* const dom = await createDOM({ html: "<h1>Hi</h1><script>document.title = 'x'</script>" });
|
|
146
|
+
* await dom.eval("document.title"); // "x"
|
|
147
|
+
* dom.close();
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export declare function createDOM(source: {
|
|
151
|
+
url?: string;
|
|
152
|
+
html?: string;
|
|
153
|
+
baseUrl?: string;
|
|
154
|
+
}, options?: CreateDOMOptions): Promise<QuickJSDOM>;
|
|
155
|
+
/**
|
|
156
|
+
* Run a page's scripts in QuickJS and return the resulting HTML.
|
|
157
|
+
* Give `url` to fetch the page, or `html` (with an optional `baseUrl`) for markup you already have.
|
|
158
|
+
* `option` is what {@link createDOM} takes (`true` or omitted: the defaults); the page is closed for you.
|
|
159
|
+
* `viewportWidth` and `engine.quickjsWasm` are the older way of passing `width` and `quickjsWasm`.
|
|
160
|
+
*/
|
|
161
|
+
export declare function runPageScriptsQuickJS(source: {
|
|
162
|
+
url?: string;
|
|
163
|
+
html?: string;
|
|
164
|
+
baseUrl?: string;
|
|
165
|
+
}, option?: true | CreateDOMOptions, viewportWidth?: number, engine?: EdgeJavaScriptEngine): Promise<ScriptedPage>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small JSDOM for runtimes without Node: a page's scripts run in QuickJS (compiled to WebAssembly) on a linkedom DOM.
|
|
3
|
+
*
|
|
4
|
+
* Runs wherever WebAssembly does, `eval` or not, so it also works on Cloudflare Workers, Vercel Edge, Deno, Bun and
|
|
5
|
+
* browsers. The guest program (`dist/edge-js-payload.js`, built by `scripts/build-edge-js.mjs`) holds the DOM and the
|
|
6
|
+
* browser globals a bare QuickJS lacks; this host fetches scripts, keeps time for the timers and serializes the DOM.
|
|
7
|
+
*
|
|
8
|
+
* It is a smaller DOM than jsdom's and an interpreter: no layout (like jsdom), no canvas, and heavy pages are slower.
|
|
9
|
+
* It is for trusted pages: the page's scripts can `fetch`.
|
|
10
|
+
*
|
|
11
|
+
* The QuickJS wasm is chosen by the runtime (`#quickjs-wasm` in package.json), so nothing needs importing for it:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { createDOM } from "wasm-quickjs-dom";
|
|
15
|
+
* const dom = await createDOM({ url: "https://example.com/" });
|
|
16
|
+
* console.log(await dom.eval("document.title"));
|
|
17
|
+
* dom.close();
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Nothing here knows about images: `toOptions()` only fills `value` / `baseUrl` of an options object.
|
|
21
|
+
*/
|
|
22
|
+
// @ts-expect-error — dist/edge-js-payload.js is generated at build time
|
|
23
|
+
import { guestBytecode } from "../dist/edge-js-payload.js";
|
|
24
|
+
import defaultQuickJSWasm from "#quickjs-wasm";
|
|
25
|
+
import { createGuard } from "./limits.js";
|
|
26
|
+
import { QuickJSInstance } from "./runtime.js";
|
|
27
|
+
export { isPrivateHost } from "./limits.js";
|
|
28
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
29
|
+
const DEFAULT_SETTLE_MS = 1000;
|
|
30
|
+
const MEMORY_LIMIT = 256 * 1024 * 1024;
|
|
31
|
+
const MAX_MODULES = 300;
|
|
32
|
+
const MAX_LOGS = 1000;
|
|
33
|
+
// a fetched body crosses into the page as base64, so binary data survives
|
|
34
|
+
function toBase64(bytes) {
|
|
35
|
+
let s = "";
|
|
36
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
37
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
38
|
+
return btoa(s);
|
|
39
|
+
}
|
|
40
|
+
// QuickJS recurses on the host's own stack, which is about 1 MB on V8: its limit must stop a runaway script first
|
|
41
|
+
const STACK_LIMIT = 192 * 1024;
|
|
42
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
|
+
// four random words for the page's Math.random (the guest is copied from one start, so it must not keep its own)
|
|
44
|
+
function randomSeed() {
|
|
45
|
+
const words = new Uint32Array(4);
|
|
46
|
+
if (globalThis.crypto?.getRandomValues)
|
|
47
|
+
globalThis.crypto.getRandomValues(words);
|
|
48
|
+
else
|
|
49
|
+
for (let i = 0; i < 4; i++)
|
|
50
|
+
words[i] = (Math.random() * 4294967296) >>> 0;
|
|
51
|
+
return Array.from(words);
|
|
52
|
+
}
|
|
53
|
+
// the guest program as QuickJS bytecode (made at build time by scripts/build-edge-js.mjs with this very QuickJS)
|
|
54
|
+
let guestBytes;
|
|
55
|
+
const guest = () => (guestBytes ??= Uint8Array.from(atob(guestBytecode), (c) => c.charCodeAt(0)));
|
|
56
|
+
// every page runs in an instance of its own: a compiled module is all that is shared
|
|
57
|
+
async function loadModule(wasm) {
|
|
58
|
+
const compiled = wasm ?? (await defaultQuickJSWasm());
|
|
59
|
+
if (!compiled)
|
|
60
|
+
throw new Error("The QuickJS wasm (quickjs.wasm of wasm-quickjs-dom) could not be loaded; pass a compiled module as `quickjsWasm`.");
|
|
61
|
+
return compiled;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Run a page's scripts in QuickJS and keep the page for further use.
|
|
65
|
+
* Give `url` to fetch the page, or `html` (with an optional `baseUrl`) for markup you already have.
|
|
66
|
+
* Call `close()` when done.
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* const dom = await createDOM({ html: "<h1>Hi</h1><script>document.title = 'x'</script>" });
|
|
70
|
+
* await dom.eval("document.title"); // "x"
|
|
71
|
+
* dom.close();
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export async function createDOM(source, options = {}) {
|
|
75
|
+
const opts = options;
|
|
76
|
+
const viewportWidth = options.width ?? 800;
|
|
77
|
+
const timeout = opts.timeout ?? DEFAULT_TIMEOUT;
|
|
78
|
+
let started = Date.now();
|
|
79
|
+
let deadline = started + timeout;
|
|
80
|
+
let vmDeadline;
|
|
81
|
+
// every call into the page gets a fresh `timeout`
|
|
82
|
+
const begin = () => {
|
|
83
|
+
started = Date.now();
|
|
84
|
+
deadline = started + timeout;
|
|
85
|
+
vmDeadline?.(deadline);
|
|
86
|
+
};
|
|
87
|
+
const guard = createGuard(opts.limits);
|
|
88
|
+
const logs = [];
|
|
89
|
+
const log = (entry) => {
|
|
90
|
+
logs.push(entry);
|
|
91
|
+
if (logs.length > MAX_LOGS)
|
|
92
|
+
logs.shift();
|
|
93
|
+
try {
|
|
94
|
+
opts.onLog?.(entry);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// a listener's failure is not the page's
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const loggedFetch = async (input, init = {}, isPage = false) => {
|
|
101
|
+
const entry = { type: "request", method: init.method ?? "GET", url: String(input) };
|
|
102
|
+
try {
|
|
103
|
+
if (!isPage && opts.skip?.(entry.url))
|
|
104
|
+
throw new Error(`Skipped by the skip option: ${entry.url}`);
|
|
105
|
+
const res = await guard.fetch(input, init);
|
|
106
|
+
log({ ...entry, status: res.status });
|
|
107
|
+
return res;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
log({ ...entry, error: String(error) });
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const text = async (res) => new TextDecoder().decode(await guard.bytes(res));
|
|
115
|
+
const remaining = () => Math.max(0, timeout - (Date.now() - started));
|
|
116
|
+
let html = source.html ?? "";
|
|
117
|
+
let baseUrl = source.baseUrl || "about:blank";
|
|
118
|
+
if (source.url) {
|
|
119
|
+
const res = await Promise.race([
|
|
120
|
+
loggedFetch(source.url, {}, true),
|
|
121
|
+
sleep(timeout).then(() => {
|
|
122
|
+
throw new Error(`Timed out loading ${source.url} for the \`javascript\` option (${timeout} ms).`);
|
|
123
|
+
}),
|
|
124
|
+
]);
|
|
125
|
+
html = await text(res);
|
|
126
|
+
baseUrl = res.url || source.url;
|
|
127
|
+
}
|
|
128
|
+
const wasm = await loadModule(opts.quickjsWasm);
|
|
129
|
+
let closed = false;
|
|
130
|
+
const timers = [];
|
|
131
|
+
const exposed = new Map();
|
|
132
|
+
const inflight = new Set();
|
|
133
|
+
const debug = typeof process !== "undefined" && !!process.env?.WASM_HTML_TO_IMAGE_DEBUG;
|
|
134
|
+
// ES modules: QuickJS loads them synchronously, so the host fetches a module's whole import graph first
|
|
135
|
+
// (static imports and `import("literal")`), then serves the sources from memory
|
|
136
|
+
const modules = new Map();
|
|
137
|
+
const resolveModule = (base, name) => {
|
|
138
|
+
try {
|
|
139
|
+
return new URL(name, base).href;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return name;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
// let the page's reactions to what happened run
|
|
146
|
+
let pump = () => { };
|
|
147
|
+
const vm = QuickJSInstance.create(wasm, {
|
|
148
|
+
log(level, message) {
|
|
149
|
+
if (debug)
|
|
150
|
+
console.error("[page " + level + "]", message);
|
|
151
|
+
log({ type: "console", level: level === "warn" || level === "error" ? level : "log", message });
|
|
152
|
+
},
|
|
153
|
+
rejection(message) {
|
|
154
|
+
if (debug)
|
|
155
|
+
console.error("[page unhandled rejection]", message);
|
|
156
|
+
log({ type: "rejection", message });
|
|
157
|
+
},
|
|
158
|
+
timer(id, ms) {
|
|
159
|
+
timers.push({ id, at: Date.now() + ms });
|
|
160
|
+
},
|
|
161
|
+
fetch(id, target, method) {
|
|
162
|
+
const settle = (ok, text) => {
|
|
163
|
+
if (closed)
|
|
164
|
+
return;
|
|
165
|
+
vm.settle(id, ok, text);
|
|
166
|
+
pump();
|
|
167
|
+
};
|
|
168
|
+
const track = (work) => {
|
|
169
|
+
const task = work.finally(() => {
|
|
170
|
+
inflight.delete(task);
|
|
171
|
+
pump();
|
|
172
|
+
});
|
|
173
|
+
inflight.add(task);
|
|
174
|
+
};
|
|
175
|
+
// expose(): the page's call arrives as a fetch of "expose:<name>" whose "method" is the JSON arguments
|
|
176
|
+
if (target.startsWith("expose:")) {
|
|
177
|
+
const fn = exposed.get(target.slice("expose:".length));
|
|
178
|
+
track((async () => {
|
|
179
|
+
try {
|
|
180
|
+
if (!fn)
|
|
181
|
+
throw new Error(`${target.slice("expose:".length)} is not exposed`);
|
|
182
|
+
settle(true, JSON.stringify({ v: await fn(...JSON.parse(method)) }));
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
settle(false, error instanceof Error ? error.message : String(error));
|
|
186
|
+
}
|
|
187
|
+
})());
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
track(loggedFetch(new URL(target, baseUrl), { method })
|
|
191
|
+
.then(async (r) => ({ status: r.status, url: r.url, headers: Object.fromEntries(r.headers), body: toBase64(await guard.bytes(r)) }))
|
|
192
|
+
.then((o) => settle(true, JSON.stringify(o)), (e) => settle(false, String(e))));
|
|
193
|
+
},
|
|
194
|
+
module(kind, base, name) {
|
|
195
|
+
if (kind === 0)
|
|
196
|
+
return resolveModule(base, name);
|
|
197
|
+
const m = modules.get(base);
|
|
198
|
+
return typeof m === "string" ? m : undefined;
|
|
199
|
+
},
|
|
200
|
+
}, MEMORY_LIMIT, STACK_LIMIT,
|
|
201
|
+
// the guest program is started once per module and copied from then on (see QuickJSInstance.create)
|
|
202
|
+
(instance) => {
|
|
203
|
+
const loaded = instance.evalBytecode(guest());
|
|
204
|
+
if (loaded.type === "error")
|
|
205
|
+
throw new Error(`The QuickJS engine could not load its guest program: ${loaded.value}`);
|
|
206
|
+
});
|
|
207
|
+
vm.setDeadline(deadline);
|
|
208
|
+
vmDeadline = (ms) => vm.setDeadline(ms);
|
|
209
|
+
pump = () => {
|
|
210
|
+
if (!closed)
|
|
211
|
+
vm.jobs();
|
|
212
|
+
};
|
|
213
|
+
// evaluate code; false when it threw (a page's script error never stops the render)
|
|
214
|
+
const run = (code, name, module = false) => {
|
|
215
|
+
if (closed)
|
|
216
|
+
return false;
|
|
217
|
+
const result = vm.eval(code, name, module);
|
|
218
|
+
if (result.type === "error")
|
|
219
|
+
log({ type: "error", message: result.value, source: name });
|
|
220
|
+
pump();
|
|
221
|
+
return result.type !== "error";
|
|
222
|
+
};
|
|
223
|
+
const call = (expression) => {
|
|
224
|
+
if (closed)
|
|
225
|
+
return undefined;
|
|
226
|
+
const result = vm.eval(expression, "call.js");
|
|
227
|
+
if (result.type === "error")
|
|
228
|
+
return undefined;
|
|
229
|
+
pump();
|
|
230
|
+
return result.type === "undefined" ? undefined : result.type === "json" ? JSON.parse(result.value) : result.value;
|
|
231
|
+
};
|
|
232
|
+
// the instance owns everything it allocated: letting go of it frees the runtime. A fetch still in flight finds
|
|
233
|
+
// `closed` and does nothing.
|
|
234
|
+
const close = () => {
|
|
235
|
+
closed = true;
|
|
236
|
+
};
|
|
237
|
+
try {
|
|
238
|
+
// QuickJS gives `import.meta` no `url`, so the module's own URL is written into its source
|
|
239
|
+
const prefetchModules = async (source, name) => {
|
|
240
|
+
const code = source.replace(/\bimport\.meta\.url\b/g, JSON.stringify(name));
|
|
241
|
+
modules.set(name, code);
|
|
242
|
+
const pending = [];
|
|
243
|
+
for (const m of code.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)(["'`])(\.{0,2}\/[^"'`$]+|https?:[^"'`$]+)\1/g)) {
|
|
244
|
+
const target = resolveModule(name, m[2]);
|
|
245
|
+
if (!modules.has(target) && modules.size < MAX_MODULES) {
|
|
246
|
+
modules.set(target, new Error(`could not load module ${target}`));
|
|
247
|
+
pending.push(target);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
await Promise.all(pending.map(async (target) => {
|
|
251
|
+
try {
|
|
252
|
+
const res = await loggedFetch(target, { signal: AbortSignal.timeout(Math.max(1, remaining())) });
|
|
253
|
+
if (!res.ok)
|
|
254
|
+
return;
|
|
255
|
+
await prefetchModules(await text(res), target);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// left as the load error set above
|
|
259
|
+
}
|
|
260
|
+
}));
|
|
261
|
+
return code;
|
|
262
|
+
};
|
|
263
|
+
const boot = vm.eval(`__edgeBoot(${JSON.stringify(html)}, ${JSON.stringify(baseUrl)}, ${Math.round(viewportWidth)}, ${JSON.stringify(randomSeed())})`, "boot.js");
|
|
264
|
+
if (boot.type === "error")
|
|
265
|
+
throw new Error(`The QuickJS engine could not build the page: ${boot.value}`);
|
|
266
|
+
// scripts in document order, the ones that scripts add included; each external one is fetched by the host
|
|
267
|
+
const runScripts = async () => {
|
|
268
|
+
for (;;) {
|
|
269
|
+
const raw = call("__edgeNextScripts()");
|
|
270
|
+
const list = typeof raw === "string" ? JSON.parse(raw) : [];
|
|
271
|
+
if (!list.length || remaining() <= 0)
|
|
272
|
+
return;
|
|
273
|
+
// the external ones are all requested at once; they still run one by one, in document order
|
|
274
|
+
const sources = list.map((s) => {
|
|
275
|
+
if (!s.src)
|
|
276
|
+
return undefined;
|
|
277
|
+
const name = new URL(s.src, baseUrl).href;
|
|
278
|
+
return loggedFetch(name, { signal: AbortSignal.timeout(Math.max(1, remaining())) })
|
|
279
|
+
.then(text)
|
|
280
|
+
.then((code) => ({ name, code }), () => undefined);
|
|
281
|
+
});
|
|
282
|
+
for (const [i, s] of list.entries()) {
|
|
283
|
+
let code = s.text;
|
|
284
|
+
let name = "inline";
|
|
285
|
+
if (s.src) {
|
|
286
|
+
const loaded = await sources[i];
|
|
287
|
+
if (!loaded) {
|
|
288
|
+
call(`__edgeScriptEvent(${s.idx}, false)`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
({ name, code } = loaded);
|
|
292
|
+
}
|
|
293
|
+
call(`__edgeSetCurrent(${s.idx})`);
|
|
294
|
+
if (s.module) {
|
|
295
|
+
if (!s.src)
|
|
296
|
+
name = `${baseUrl}#module-${s.idx}`;
|
|
297
|
+
code = await prefetchModules(code, name);
|
|
298
|
+
}
|
|
299
|
+
const ok = run(code, name, s.module);
|
|
300
|
+
call("__edgeSetCurrent(-1)");
|
|
301
|
+
if (s.src)
|
|
302
|
+
call(`__edgeScriptEvent(${s.idx}, ${ok})`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
// timers that are due, scripts that were added, then a short sleep
|
|
307
|
+
const tick = async () => {
|
|
308
|
+
pump();
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
const due = timers.filter((t) => t.at <= now).sort((a, b) => a.at - b.at);
|
|
311
|
+
for (const t of due) {
|
|
312
|
+
timers.splice(timers.indexOf(t), 1);
|
|
313
|
+
call(`__fire(${t.id})`);
|
|
314
|
+
}
|
|
315
|
+
await runScripts();
|
|
316
|
+
await sleep(4);
|
|
317
|
+
};
|
|
318
|
+
// let the page run for `ms`, or until `waitFor` matches, and until its fetches are done
|
|
319
|
+
// Nothing can change the page unless a timer fires or a request answers (or a script is added, which a tick
|
|
320
|
+
// finds), so a page with neither pending is finished: waiting out the rest of the time would change nothing.
|
|
321
|
+
const wait = async (ms, waitFor) => {
|
|
322
|
+
if (waitFor) {
|
|
323
|
+
const found = () => !!call(`__edgeHas(${JSON.stringify(waitFor)})`);
|
|
324
|
+
while (remaining() > 0 && !found()) {
|
|
325
|
+
await tick();
|
|
326
|
+
if (inflight.size === 0 && timers.length === 0 && !found())
|
|
327
|
+
break; // it can no longer appear
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const until = Date.now() + Math.min(ms, remaining());
|
|
332
|
+
let first = true;
|
|
333
|
+
while (remaining() > 0 && (Date.now() < until || inflight.size > 0)) {
|
|
334
|
+
if (!first && inflight.size === 0 && !timers.some((t) => t.at <= until))
|
|
335
|
+
break;
|
|
336
|
+
first = false;
|
|
337
|
+
await tick();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const serialize = () => {
|
|
341
|
+
if (closed)
|
|
342
|
+
throw new Error("The DOM is closed.");
|
|
343
|
+
begin();
|
|
344
|
+
const out = call("__edgeSerialize()");
|
|
345
|
+
if (typeof out !== "string")
|
|
346
|
+
throw new Error("The QuickJS engine could not serialize the page.");
|
|
347
|
+
return out;
|
|
348
|
+
};
|
|
349
|
+
await runScripts();
|
|
350
|
+
call("__edgeDocumentEvents()");
|
|
351
|
+
await wait(opts.settleMs ?? DEFAULT_SETTLE_MS, opts.waitFor);
|
|
352
|
+
// eval(): the result lands in a global once the page's promise settles; the host ticks until it is there
|
|
353
|
+
let queue = Promise.resolve();
|
|
354
|
+
const evaluate = async (code) => {
|
|
355
|
+
if (closed)
|
|
356
|
+
throw new Error("The DOM is closed.");
|
|
357
|
+
begin();
|
|
358
|
+
call("globalThis.__edgeResult = undefined");
|
|
359
|
+
run(`Promise.resolve().then(() => (0, eval)(${JSON.stringify(code)}))` +
|
|
360
|
+
`.then((v) => { try { return { ok: true, v: JSON.stringify(v) }; } catch (e) { return { ok: false, v: String(e) }; } },` +
|
|
361
|
+
` (e) => ({ ok: false, v: (e && e.message ? (e.name || 'Error') + ': ' + e.message : String(e)) }))` +
|
|
362
|
+
`.then((r) => { globalThis.__edgeResult = r; });`, "eval.js");
|
|
363
|
+
for (;;) {
|
|
364
|
+
const raw = call("globalThis.__edgeResult ? JSON.stringify(globalThis.__edgeResult) : undefined");
|
|
365
|
+
if (typeof raw === "string") {
|
|
366
|
+
const r = JSON.parse(raw);
|
|
367
|
+
if (!r.ok)
|
|
368
|
+
throw new Error(r.v);
|
|
369
|
+
return r.v === undefined ? undefined : JSON.parse(r.v);
|
|
370
|
+
}
|
|
371
|
+
if (closed)
|
|
372
|
+
throw new Error("The DOM is closed.");
|
|
373
|
+
if (remaining() <= 0)
|
|
374
|
+
throw new Error(`eval() did not finish within ${timeout} ms.`);
|
|
375
|
+
await tick();
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
const dom = {
|
|
379
|
+
baseUrl,
|
|
380
|
+
logs,
|
|
381
|
+
eval(code, ...args) {
|
|
382
|
+
let source;
|
|
383
|
+
if (typeof code === "function")
|
|
384
|
+
source = `(${code.toString()})(...${JSON.stringify(args)})`;
|
|
385
|
+
else if (args.length)
|
|
386
|
+
throw new TypeError("eval(code, ...args): arguments need a function; a string of code takes none");
|
|
387
|
+
else
|
|
388
|
+
source = code;
|
|
389
|
+
const next = queue.then(() => evaluate(source));
|
|
390
|
+
queue = next.catch(() => undefined);
|
|
391
|
+
return next;
|
|
392
|
+
},
|
|
393
|
+
async waitForSelector(selector) {
|
|
394
|
+
if (closed)
|
|
395
|
+
throw new Error("The DOM is closed.");
|
|
396
|
+
begin();
|
|
397
|
+
await wait(0, selector);
|
|
398
|
+
return !!call(`__edgeHas(${JSON.stringify(selector)})`);
|
|
399
|
+
},
|
|
400
|
+
click(selector) {
|
|
401
|
+
return dom.eval((s) => {
|
|
402
|
+
const el = document.querySelector(s);
|
|
403
|
+
if (!el)
|
|
404
|
+
return false;
|
|
405
|
+
el.dispatchEvent(typeof MouseEvent === "function" ? new MouseEvent("click", { bubbles: true, cancelable: true }) : new Event("click", { bubbles: true, cancelable: true }));
|
|
406
|
+
return true;
|
|
407
|
+
}, selector);
|
|
408
|
+
},
|
|
409
|
+
type(selector, text) {
|
|
410
|
+
return dom.eval((s, t) => {
|
|
411
|
+
const el = document.querySelector(s);
|
|
412
|
+
if (!el)
|
|
413
|
+
return false;
|
|
414
|
+
if ("value" in el)
|
|
415
|
+
el.value = t;
|
|
416
|
+
else
|
|
417
|
+
el.textContent = t;
|
|
418
|
+
for (const name of ["input", "change"])
|
|
419
|
+
el.dispatchEvent(new Event(name, { bubbles: true }));
|
|
420
|
+
return true;
|
|
421
|
+
}, selector, text);
|
|
422
|
+
},
|
|
423
|
+
expose(name, fn) {
|
|
424
|
+
if (closed)
|
|
425
|
+
throw new Error("The DOM is closed.");
|
|
426
|
+
exposed.set(name, fn);
|
|
427
|
+
run(`globalThis[${JSON.stringify(name)}] = (...args) => __host.fetch("expose:" + ${JSON.stringify(name)}, JSON.stringify(args)).then((t) => JSON.parse(t).v, (e) => { throw new Error(String(e)); });`, "expose.js");
|
|
428
|
+
},
|
|
429
|
+
async settle(ms) {
|
|
430
|
+
if (closed)
|
|
431
|
+
throw new Error("The DOM is closed.");
|
|
432
|
+
begin();
|
|
433
|
+
await wait(ms ?? opts.settleMs ?? DEFAULT_SETTLE_MS);
|
|
434
|
+
},
|
|
435
|
+
serialize,
|
|
436
|
+
markForLayout() {
|
|
437
|
+
if (closed)
|
|
438
|
+
throw new Error("The DOM is closed.");
|
|
439
|
+
begin();
|
|
440
|
+
const out = call("__edgeMarkLayout()");
|
|
441
|
+
if (typeof out !== "string")
|
|
442
|
+
throw new Error("The QuickJS engine could not mark the page for layout.");
|
|
443
|
+
return out;
|
|
444
|
+
},
|
|
445
|
+
setLayout(boxes) {
|
|
446
|
+
if (closed)
|
|
447
|
+
throw new Error("The DOM is closed.");
|
|
448
|
+
begin();
|
|
449
|
+
if (!run(`__edgeSetLayout(${JSON.stringify(JSON.stringify(boxes))})`, "layout.js"))
|
|
450
|
+
throw new Error("The QuickJS engine could not apply the layout.");
|
|
451
|
+
},
|
|
452
|
+
toOptions(options) {
|
|
453
|
+
const { javascript: _javascript, ...rest } = options;
|
|
454
|
+
return { ...rest, url: undefined, value: serialize(), baseUrl: options.baseUrl ?? baseUrl };
|
|
455
|
+
},
|
|
456
|
+
close,
|
|
457
|
+
};
|
|
458
|
+
return dom;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
close();
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Run a page's scripts in QuickJS and return the resulting HTML.
|
|
467
|
+
* Give `url` to fetch the page, or `html` (with an optional `baseUrl`) for markup you already have.
|
|
468
|
+
* `option` is what {@link createDOM} takes (`true` or omitted: the defaults); the page is closed for you.
|
|
469
|
+
* `viewportWidth` and `engine.quickjsWasm` are the older way of passing `width` and `quickjsWasm`.
|
|
470
|
+
*/
|
|
471
|
+
export async function runPageScriptsQuickJS(source, option = {}, viewportWidth, engine = {}) {
|
|
472
|
+
const given = option === true ? {} : option;
|
|
473
|
+
const dom = await createDOM(source, { ...given, width: viewportWidth ?? given.width, quickjsWasm: engine.quickjsWasm ?? given.quickjsWasm });
|
|
474
|
+
try {
|
|
475
|
+
return { html: dom.serialize(), baseUrl: dom.baseUrl };
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
dom.close();
|
|
479
|
+
}
|
|
480
|
+
}
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a page may fetch. The page's scripts can `fetch`, and so can the script and module tags it loads: without limits
|
|
3
|
+
* a page can reach any host this runtime can, internal ones included. The names follow the `limits` of
|
|
4
|
+
* `wasm-html-to-image`'s `render()`.
|
|
5
|
+
*/
|
|
6
|
+
export interface FetchLimits {
|
|
7
|
+
/** Allowed URL protocols, e.g. `["https:"]`. */
|
|
8
|
+
allowedProtocols?: string[];
|
|
9
|
+
/** Only these hostnames may be fetched (exact match). */
|
|
10
|
+
allowedHosts?: string[];
|
|
11
|
+
/** These hostnames may not be fetched (exact match). */
|
|
12
|
+
blockedHosts?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Refuse `localhost`, `*.localhost` / `*.local` / `*.internal` and addresses of private, loopback and link-local
|
|
15
|
+
* networks (IPv4 and IPv6 literals). A hostname that merely resolves to such an address cannot be told apart in
|
|
16
|
+
* here: pair this with `allowedHosts` when the target must be trusted.
|
|
17
|
+
*/
|
|
18
|
+
blockPrivateHosts?: boolean;
|
|
19
|
+
/** At most this many requests for the whole page (counted per call, redirects not counted again). */
|
|
20
|
+
maxResourceCount?: number;
|
|
21
|
+
/** A response over this many bytes is refused. */
|
|
22
|
+
maxResourceBytes?: number;
|
|
23
|
+
/** All responses together over this many bytes are refused. */
|
|
24
|
+
maxTotalResourceBytes?: number;
|
|
25
|
+
}
|
|
26
|
+
/** True for localhost and private, loopback and link-local addresses (`host` as `URL.hostname` gives it). */
|
|
27
|
+
export declare function isPrivateHost(host: string): boolean;
|
|
28
|
+
/** `fetch` and body reading that obey {@link FetchLimits}. With no limits they are plain `fetch` and `arrayBuffer`. */
|
|
29
|
+
export declare function createGuard(limits?: FetchLimits): {
|
|
30
|
+
/** Fetch `input`; with host or protocol limits every redirect is checked before it is followed. */
|
|
31
|
+
fetch(input: string | URL, init?: RequestInit): Promise<Response>;
|
|
32
|
+
/** The body of `res`, refused when it is over the size limits. */
|
|
33
|
+
bytes(res: Response): Promise<Uint8Array>;
|
|
34
|
+
};
|