wasm-quickjs-dom 0.0.0-stage → 0.0.1
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 +222 -2
- package/THIRD-PARTY-NOTICES.md +32 -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 +73 -4
- package/vendor/quickjs/LICENSE +24 -0
- package/vendor/quickjs/README.md +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SoraKumo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the \"Software\"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,223 @@
|
|
|
1
|
-
#
|
|
1
|
+
# wasm-quickjs-dom
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run a web page's JavaScript **without a browser and without `eval`** — and read the resulting DOM.
|
|
4
|
+
|
|
5
|
+
It is a small JSDOM for places where JSDOM cannot go: Cloudflare Workers, Vercel / Next.js Edge, Deno, Bun, browsers, and plain Node.js. The page's scripts run in [QuickJS-ng](https://github.com/quickjs-ng/quickjs) compiled to WebAssembly, on a [linkedom](https://github.com/WebReflection/linkedom) DOM, so nothing needs `eval`, native modules or a headless browser.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createDOM } from "wasm-quickjs-dom";
|
|
9
|
+
|
|
10
|
+
const dom = await createDOM({ url: "https://example.com/" });
|
|
11
|
+
console.log(await dom.eval("document.title"));
|
|
12
|
+
console.log(dom.serialize()); // the HTML after the page's scripts ran
|
|
13
|
+
dom.close();
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Good for:** pre-rendering single-page apps to HTML (SEO, OGP, link previews), reading data that a page builds with JavaScript, running DOM code in tests on edge runtimes, and handing a scripted page to an image renderer such as [wasm-html-to-image](https://www.npmjs.com/package/wasm-html-to-image).
|
|
17
|
+
|
|
18
|
+
**Not a browser:** there is no layout of its own, no canvas, no `WebSocket` or `Worker`, and QuickJS is an interpreter, so very heavy pages are slow. See [What works](#what-works-and-what-does-not).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install wasm-quickjs-dom
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
ESM only. Node.js 20 or later; the other runtimes below need nothing extra.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
### Get the HTML a page has after its scripts ran
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createDOM } from "wasm-quickjs-dom";
|
|
34
|
+
|
|
35
|
+
const dom = await createDOM(
|
|
36
|
+
{ url: "https://example.com/" }, // or { html: "<p>…</p>", baseUrl: "https://example.com/" }
|
|
37
|
+
{ waitFor: "#app" }, // wait until this selector matches (default: let timers and requests settle)
|
|
38
|
+
);
|
|
39
|
+
try {
|
|
40
|
+
const html = dom.serialize();
|
|
41
|
+
} finally {
|
|
42
|
+
dom.close(); // always close: it frees the page
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Read values out of the page
|
|
47
|
+
|
|
48
|
+
`eval` runs code in the page and gives you the result as JSON. It takes a string, or a function and JSON arguments:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const title = await dom.eval("document.title");
|
|
52
|
+
const count = await dom.eval((selector: string) => document.querySelectorAll(selector).length, "a.item");
|
|
53
|
+
const rows = await dom.eval(() => [...document.querySelectorAll("tr")].map((tr) => tr.textContent));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A DOM node cannot cross the boundary: return what you read from it. A function is sent as source text, so it cannot use variables from your own scope.
|
|
57
|
+
|
|
58
|
+
### Drive the page
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
await dom.type("#search", "wasm"); // sets the value, fires input and change
|
|
62
|
+
await dom.click("button[type=submit]"); // dispatches a bubbling click
|
|
63
|
+
await dom.waitForSelector(".results"); // resolves true, or false if it never appears
|
|
64
|
+
dom.expose("saveRow", (row) => db.save(row)); // the page can call: await window.saveRow({...})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Inside a Worker
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { createDOM } from "wasm-quickjs-dom";
|
|
71
|
+
|
|
72
|
+
export default {
|
|
73
|
+
async fetch(request: Request) {
|
|
74
|
+
const target = new URL(request.url).searchParams.get("url")!;
|
|
75
|
+
const dom = await createDOM(
|
|
76
|
+
{ url: target },
|
|
77
|
+
{ limits: { allowedProtocols: ["https:"], blockPrivateHosts: true, maxResourceBytes: 5_000_000 } },
|
|
78
|
+
);
|
|
79
|
+
try {
|
|
80
|
+
return new Response(dom.serialize(), { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
81
|
+
} finally {
|
|
82
|
+
dom.close();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
There is nothing to configure for the WebAssembly: the right way to load it is picked for the runtime. A complete Worker is in the repository: [`packages/cloudflare-prerender`](../cloudflare-prerender).
|
|
89
|
+
|
|
90
|
+
### From the command line
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
npx wasm-quickjs-dom https://example.com/ --wait "#app" > rendered.html
|
|
94
|
+
npx wasm-quickjs-dom https://example.com/ -e "document.title"
|
|
95
|
+
npx wasm-quickjs-dom page.html --base-url https://example.com/ # a file; "-" reads standard input
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
See [Command line](#command-line) below.
|
|
99
|
+
|
|
100
|
+
## Pages you do not control
|
|
101
|
+
|
|
102
|
+
A page's scripts can `fetch`, and so can the scripts and modules it loads. **Without `limits`, a page can reach every host your runtime can reach, internal ones included.** For anything you did not write, set them:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const dom = await createDOM({ url }, {
|
|
106
|
+
limits: {
|
|
107
|
+
allowedProtocols: ["https:"],
|
|
108
|
+
allowedHosts: ["example.com", "cdn.example.com"], // exact hostnames
|
|
109
|
+
blockPrivateHosts: true, // localhost, private, loopback, link-local
|
|
110
|
+
maxResourceCount: 50,
|
|
111
|
+
maxResourceBytes: 5_000_000,
|
|
112
|
+
maxTotalResourceBytes: 20_000_000,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- The limits cover the page itself (`url`), the page's own `fetch` / `XMLHttpRequest`, and every script and module it loads. With host or protocol limits, each redirect is checked before it is followed.
|
|
118
|
+
- `blockPrivateHosts` looks at hostnames and IP literals. It cannot see where a hostname resolves through DNS, so use `allowedHosts` as well when the target is not trusted.
|
|
119
|
+
- The scripts run inside the WebAssembly sandbox: they have no file system, no `process`, and reach the outside only through `fetch` (the limits above) and the functions you `expose`.
|
|
120
|
+
- Also bounded for you: each call is limited by `timeout`, and memory and stack by fixed caps.
|
|
121
|
+
|
|
122
|
+
## API
|
|
123
|
+
|
|
124
|
+
### `createDOM(source, options?)` → `Promise<QuickJSDOM>`
|
|
125
|
+
|
|
126
|
+
`source` is `{ url }`, or `{ html, baseUrl? }` for markup you already have (`baseUrl` is where relative URLs resolve and what `location` says).
|
|
127
|
+
|
|
128
|
+
| Option | Default | |
|
|
129
|
+
| ------ | ------- | --- |
|
|
130
|
+
| `timeout` | `10000` | Upper limit in ms for **each call** into the page (the initial run, each `eval`, `settle`, …). |
|
|
131
|
+
| `settleMs` | `1000` | After load, run timers and requests for at most this long. It is an upper bound: the page finishes as soon as nothing is pending. |
|
|
132
|
+
| `waitFor` | | A CSS selector to wait for after load instead of `settleMs`. |
|
|
133
|
+
| `width` | `800` | `window.innerWidth`; also what `matchMedia` answers for. |
|
|
134
|
+
| `limits` | none | What the page may fetch (see above). |
|
|
135
|
+
| `skip` | | `(url) => boolean`: return `true` for a URL the page should not get. The request fails without going to the network (a `<script>` gets its `error` event, a `fetch` rejects). Analytics and ads are most of a real site's script time, and a render rarely needs them. The page itself is never skipped. |
|
|
136
|
+
| `onLog` | | Called with each log entry as it happens (see `logs`). |
|
|
137
|
+
| `quickjsWasm` | | A compiled `WebAssembly.Module` of your own, instead of the packaged one. |
|
|
138
|
+
|
|
139
|
+
### The page: `QuickJSDOM`
|
|
140
|
+
|
|
141
|
+
| Member | |
|
|
142
|
+
| ------ | --- |
|
|
143
|
+
| `baseUrl` | The document's URL (after redirects, when it was fetched). |
|
|
144
|
+
| `eval(code)` / `eval(fn, ...args)` | Run code in the page's global scope; get the result as JSON. Promises are awaited. A throw becomes a rejected promise. Calls run one after another. |
|
|
145
|
+
| `waitForSelector(selector)` | Wait up to `timeout` for a match; `false` if there is none (or can no longer be). |
|
|
146
|
+
| `click(selector)` | Dispatch a bubbling `click` on the first match; `false` if nothing matched. |
|
|
147
|
+
| `type(selector, text)` | Set the first match's `value` (or text), then fire `input` and `change`; `false` if nothing matched. |
|
|
148
|
+
| `expose(name, fn)` | Make a function of yours callable from the page as `await window[name](...args)`. Arguments and result are JSON; a throw rejects with the same message. |
|
|
149
|
+
| `settle(ms?)` | Let timers and requests run for up to `ms` (default `settleMs`). |
|
|
150
|
+
| `serialize()` | The document as HTML, as it is now. |
|
|
151
|
+
| `logs` | What happened so far (the last 1000): `console` output, script errors, unhandled promise rejections, and requests with their status or failure. The first place to look when a page comes back empty. |
|
|
152
|
+
| `close()` | Free the page. It cannot be used afterwards. |
|
|
153
|
+
| `toOptions(options)` | Fill `value` (the serialized DOM) and `baseUrl` of an options object — for example the render options of wasm-html-to-image. |
|
|
154
|
+
| `markForLayout()` / `setLayout(boxes)` | Give the page a layout (see below). |
|
|
155
|
+
|
|
156
|
+
**The page's clock runs only while one of these calls is waiting.** Timers and requests progress during `eval`, `settle`, `waitForSelector` and so on, and stand still in between.
|
|
157
|
+
|
|
158
|
+
Log entries look like `{ type: "console", level, message }`, `{ type: "error", message, source }`, `{ type: "rejection", message }` and `{ type: "request", method, url, status?, error? }`.
|
|
159
|
+
|
|
160
|
+
### `runPageScriptsQuickJS(source, options?)` → `Promise<{ html, baseUrl }>`
|
|
161
|
+
|
|
162
|
+
For when you only want the HTML: runs the page with the same options as `createDOM` and closes it for you.
|
|
163
|
+
|
|
164
|
+
### Layout
|
|
165
|
+
|
|
166
|
+
There is no layout inside QuickJS, so `getBoundingClientRect()`, `offsetWidth` and the like return 0 — until you give the page boxes with `setLayout({ [elementNumber]: [x, y, width, height] })`. With `wasm-html-to-image` this is one call, `measureLayout(dom, render)`, which lays the page out with its engine and fills the boxes in (inline elements included). Use it for pages whose scripts size things from the real layout.
|
|
167
|
+
|
|
168
|
+
## What works, and what does not
|
|
169
|
+
|
|
170
|
+
**Works:** inline, external, dynamically added and `type="module"` scripts (static `import`s and `import("literal")` are fetched up front); `fetch` and `XMLHttpRequest` (binary safe); timers, `requestAnimationFrame`, promises; `history.pushState`, `localStorage` / `sessionStorage` (in memory), `customElements`, `MutationObserver`, `crypto.getRandomValues` / `randomUUID`; `matchMedia` for the viewport width; `getComputedStyle` (the element's own style plus initial values); `IntersectionObserver` and `ResizeObserver` (each reports once on `observe()`, so lazy content loads). React and Next.js clients hydrate and run their effects.
|
|
171
|
+
|
|
172
|
+
**Does not:** layout (see above), `canvas`, `WebSocket`, `EventSource`, `Worker`, `iframe` content, CSS cascade computation, and anything that needs a real browser engine. A DOM smaller than JSDOM's. If a script throws, the page carries on with what it had; `dom.logs` tells you what went wrong.
|
|
173
|
+
|
|
174
|
+
**Speed:** a page starts in about 3 ms (the first in a process pays about 120 ms). A typical page, including its network, takes a few hundred ms; parsing runs at roughly 0.3 MB/s, so very large pages take seconds.
|
|
175
|
+
|
|
176
|
+
## Where it runs
|
|
177
|
+
|
|
178
|
+
| Runtime | How the WebAssembly is loaded |
|
|
179
|
+
| ------- | ----------------------------- |
|
|
180
|
+
| Cloudflare Workers | imported as a pre-compiled module (`workerd` condition) |
|
|
181
|
+
| Vercel / Next.js Edge | imported with `?module` (`edge-light` condition) |
|
|
182
|
+
| Node.js, Bun | read from the package and compiled once |
|
|
183
|
+
| Deno, browsers | fetched from beside the module and compiled once |
|
|
184
|
+
|
|
185
|
+
All of that is automatic. If your setup differs, pass your own module: `createDOM(source, { quickjsWasm })` (`wasm-quickjs-dom/quickjs.wasm` is the file).
|
|
186
|
+
|
|
187
|
+
## Command line
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
wasm-quickjs-dom [options] <url | file | ->
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Prints the HTML after the page's scripts ran.
|
|
194
|
+
|
|
195
|
+
| Option | |
|
|
196
|
+
| ------ | --- |
|
|
197
|
+
| `-o, --output <file>` | Write to a file instead of standard output. |
|
|
198
|
+
| `-e, --eval <code>` | Print the value of this code instead (a string as it is, anything else as JSON). |
|
|
199
|
+
| `--json` | Print `{ baseUrl, html, logs }` as JSON. |
|
|
200
|
+
| `--logs` | Also write the page's log (console, errors, rejections, requests) to standard error. |
|
|
201
|
+
| `--wait <selector>`, `--settle <ms>`, `--timeout <ms>`, `--width <px>` | Shape the run (same as the options above). |
|
|
202
|
+
| `--base-url <url>` | The page's URL, for a file or standard input. |
|
|
203
|
+
| `--protocol <p>`, `--allow-host <h>`, `--block-host <h>`, `--block-private`, `--max-bytes <n>`, `--max-requests <n>` | The limits (repeat `--protocol`, `--allow-host` and `--block-host`). Set them for pages you do not control. |
|
|
204
|
+
| `--skip <text>` | Do not fetch URLs containing this text (repeatable). |
|
|
205
|
+
|
|
206
|
+
Exit code `0` is done, `1` the page could not be loaded (or `-e` threw), `2` wrong usage. `--help` lists everything.
|
|
207
|
+
|
|
208
|
+
## How it works
|
|
209
|
+
|
|
210
|
+
Each page gets its own WebAssembly instance of QuickJS-ng. The browser APIs a bare QuickJS lacks, and linkedom, are compiled to QuickJS bytecode when the package is built; the first page of a process starts that program, and every later page is a copy of that started instance, which is why a page starts in about 3 ms. Nothing is shared between pages. Each gets its own clock origin and `Math.random` seed.
|
|
211
|
+
|
|
212
|
+
## For contributors
|
|
213
|
+
|
|
214
|
+
The package builds its own QuickJS: `vendor/quickjs` (QuickJS-ng, unmodified) and `csrc/qjsdom.c` are compiled to a standalone `dist/quickjs.wasm` with Emscripten (`emcc` on `PATH`), and the guest program is compiled to bytecode with that very wasm.
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
pnpm --filter wasm-quickjs-dom build
|
|
218
|
+
pnpm --filter wasm-quickjs-dom test
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT. QuickJS-ng, linkedom and web-streams-polyfill are included under their own licenses: see `THIRD-PARTY-NOTICES.md`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
`wasm-quickjs-dom` ships these works inside `dist/`.
|
|
4
|
+
|
|
5
|
+
- **QuickJS-ng** (v0.18.0, <https://github.com/quickjs-ng/quickjs>), compiled to `dist/quickjs.wasm`. Source in `vendor/quickjs/`. MIT:
|
|
6
|
+
|
|
7
|
+
The MIT License (MIT)
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2017-2026 Fabrice Bellard
|
|
10
|
+
Copyright (c) 2017-2024 Charlie Gordon
|
|
11
|
+
Copyright (c) 2023-2026 Ben Noordhuis
|
|
12
|
+
Copyright (c) 2023-2026 Saúl Ibarra Corretgé
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in
|
|
22
|
+
all copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
27
|
+
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
30
|
+
THE SOFTWARE.
|
|
31
|
+
|
|
32
|
+
- **linkedom** (<https://github.com/WebReflection/linkedom>), ISC, and **web-streams-polyfill** (<https://github.com/MattiasBuelens/web-streams-polyfill>), MIT, are bundled into the guest program `dist/edge-js-payload.js`.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { run } from "./cli.js";
|
|
4
|
+
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
5
|
+
const code = await run(process.argv.slice(2), {
|
|
6
|
+
stdin: async () => {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
for await (const chunk of process.stdin)
|
|
9
|
+
chunks.push(chunk);
|
|
10
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
11
|
+
},
|
|
12
|
+
// a pipe closed early (`| head`) is not an error worth a stack trace
|
|
13
|
+
stdout: (text) => void process.stdout.write(text),
|
|
14
|
+
stderr: (text) => void process.stderr.write(text),
|
|
15
|
+
}, version);
|
|
16
|
+
process.exitCode = code;
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface CliIO {
|
|
2
|
+
/** Everything of standard input (read only when the input is `-`). */
|
|
3
|
+
stdin(): Promise<string>;
|
|
4
|
+
stdout(text: string): void;
|
|
5
|
+
stderr(text: string): void;
|
|
6
|
+
}
|
|
7
|
+
/** Run the command. Returns the exit code: 0 done, 1 the page could not be loaded, 2 wrong usage. */
|
|
8
|
+
export declare function run(argv: string[], io: CliIO, version?: string): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `wasm-quickjs-dom` command: load a page, let its scripts run, print the result.
|
|
3
|
+
*
|
|
4
|
+
* wasm-quickjs-dom https://example.com/ --wait "#app" > rendered.html
|
|
5
|
+
* wasm-quickjs-dom page.html --base-url https://example.com/ -e "document.title"
|
|
6
|
+
* curl -s https://example.com/ | wasm-quickjs-dom - --base-url https://example.com/
|
|
7
|
+
*
|
|
8
|
+
* `run` takes its streams as arguments so it can be tested; `bin.ts` wires it to the process.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { createDOM } from "./index.js";
|
|
13
|
+
const HELP = `wasm-quickjs-dom - run a page's scripts in QuickJS (WebAssembly) and print the result
|
|
14
|
+
|
|
15
|
+
Usage: wasm-quickjs-dom [options] <url | file | ->
|
|
16
|
+
|
|
17
|
+
<url> an http(s) page to fetch and run
|
|
18
|
+
<file> an HTML file ("-" reads standard input); use --base-url to say where it belongs,
|
|
19
|
+
which is also where its relative scripts are fetched from
|
|
20
|
+
|
|
21
|
+
Output (default: the HTML as it is after the scripts ran)
|
|
22
|
+
-o, --output <file> write to a file instead of standard output
|
|
23
|
+
-e, --eval <code> print the value of this code, run in the page (strings as they are, the rest as JSON)
|
|
24
|
+
--json print { baseUrl, html, logs } as JSON
|
|
25
|
+
--logs also print what happened in the page (console, errors, rejections, requests) to standard error
|
|
26
|
+
|
|
27
|
+
Running
|
|
28
|
+
--wait <selector> wait until a CSS selector matches (up to --timeout)
|
|
29
|
+
--settle <ms> at most this long for timers and requests after load (default 1000; ends early when idle)
|
|
30
|
+
--timeout <ms> upper limit for the scripts (default 10000)
|
|
31
|
+
--width <px> window width of the page (default 800)
|
|
32
|
+
--base-url <url> the page's URL, for file and standard input
|
|
33
|
+
|
|
34
|
+
Limits (set these for pages you do not control)
|
|
35
|
+
--protocol <p> allowed protocol such as https: (repeatable)
|
|
36
|
+
--allow-host <host> only these hosts may be fetched (repeatable)
|
|
37
|
+
--block-host <host> these hosts may not be fetched (repeatable)
|
|
38
|
+
--block-private refuse localhost and private networks
|
|
39
|
+
--max-bytes <n> refuse a response over n bytes
|
|
40
|
+
--max-requests <n> at most n requests
|
|
41
|
+
--skip <text> do not fetch URLs containing text (repeatable; analytics, ads, ...)
|
|
42
|
+
|
|
43
|
+
-h, --help show this help
|
|
44
|
+
-v, --version show the version
|
|
45
|
+
`;
|
|
46
|
+
const fail = (io, message) => {
|
|
47
|
+
io.stderr(`wasm-quickjs-dom: ${message}\n`);
|
|
48
|
+
return 2;
|
|
49
|
+
};
|
|
50
|
+
/** An error's message with the reason under it (Node's `fetch failed` hides the real one in `cause`). */
|
|
51
|
+
function describe(error) {
|
|
52
|
+
const e = error;
|
|
53
|
+
const why = e.cause?.message ?? e.cause?.code;
|
|
54
|
+
return why && !e.message.includes(why) ? `${e.message}: ${why}` : e.message;
|
|
55
|
+
}
|
|
56
|
+
function number(name, value) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const n = Number(value);
|
|
60
|
+
return Number.isFinite(n) && n >= 0 ? n : new Error(`--${name} needs a number, got "${value}"`);
|
|
61
|
+
}
|
|
62
|
+
function formatLog(entry) {
|
|
63
|
+
switch (entry.type) {
|
|
64
|
+
case "console":
|
|
65
|
+
return `console.${entry.level}: ${entry.message}`;
|
|
66
|
+
case "error":
|
|
67
|
+
return `error in ${entry.source}: ${entry.message.trim()}`;
|
|
68
|
+
case "rejection":
|
|
69
|
+
return `unhandled rejection: ${entry.message.trim()}`;
|
|
70
|
+
case "request":
|
|
71
|
+
return `${entry.method} ${entry.url} -> ${entry.error ?? entry.status}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Run the command. Returns the exit code: 0 done, 1 the page could not be loaded, 2 wrong usage. */
|
|
75
|
+
export async function run(argv, io, version = "0.0.0") {
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = parseArgs({
|
|
79
|
+
args: argv,
|
|
80
|
+
allowPositionals: true,
|
|
81
|
+
options: {
|
|
82
|
+
output: { type: "string", short: "o" },
|
|
83
|
+
eval: { type: "string", short: "e" },
|
|
84
|
+
json: { type: "boolean" },
|
|
85
|
+
logs: { type: "boolean" },
|
|
86
|
+
wait: { type: "string" },
|
|
87
|
+
settle: { type: "string" },
|
|
88
|
+
timeout: { type: "string" },
|
|
89
|
+
width: { type: "string" },
|
|
90
|
+
"base-url": { type: "string" },
|
|
91
|
+
protocol: { type: "string", multiple: true },
|
|
92
|
+
"allow-host": { type: "string", multiple: true },
|
|
93
|
+
"block-host": { type: "string", multiple: true },
|
|
94
|
+
"block-private": { type: "boolean" },
|
|
95
|
+
"max-bytes": { type: "string" },
|
|
96
|
+
"max-requests": { type: "string" },
|
|
97
|
+
skip: { type: "string", multiple: true },
|
|
98
|
+
help: { type: "boolean", short: "h" },
|
|
99
|
+
version: { type: "boolean", short: "v" },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return fail(io, `${error.message}\nTry --help.`);
|
|
105
|
+
}
|
|
106
|
+
const { values, positionals } = parsed;
|
|
107
|
+
if (values.help) {
|
|
108
|
+
io.stdout(HELP);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (values.version) {
|
|
112
|
+
io.stdout(`${version}\n`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (positionals.length !== 1)
|
|
116
|
+
return fail(io, `give exactly one page (a URL, a file, or - for standard input)\nTry --help.`);
|
|
117
|
+
if (values.json && values.eval !== undefined)
|
|
118
|
+
return fail(io, "--json and --eval cannot be used together");
|
|
119
|
+
const numbers = {
|
|
120
|
+
settle: number("settle", values.settle),
|
|
121
|
+
timeout: number("timeout", values.timeout),
|
|
122
|
+
width: number("width", values.width),
|
|
123
|
+
maxBytes: number("max-bytes", values["max-bytes"]),
|
|
124
|
+
maxRequests: number("max-requests", values["max-requests"]),
|
|
125
|
+
};
|
|
126
|
+
for (const v of Object.values(numbers))
|
|
127
|
+
if (v instanceof Error)
|
|
128
|
+
return fail(io, v.message);
|
|
129
|
+
const n = numbers;
|
|
130
|
+
const limits = {
|
|
131
|
+
allowedProtocols: values.protocol,
|
|
132
|
+
allowedHosts: values["allow-host"],
|
|
133
|
+
blockedHosts: values["block-host"],
|
|
134
|
+
blockPrivateHosts: values["block-private"] || undefined,
|
|
135
|
+
maxResourceBytes: n.maxBytes,
|
|
136
|
+
maxResourceCount: n.maxRequests,
|
|
137
|
+
};
|
|
138
|
+
const skips = values.skip ?? [];
|
|
139
|
+
const options = {
|
|
140
|
+
settleMs: n.settle,
|
|
141
|
+
timeout: n.timeout,
|
|
142
|
+
waitFor: values.wait,
|
|
143
|
+
width: n.width,
|
|
144
|
+
limits,
|
|
145
|
+
skip: skips.length ? (url) => skips.some((s) => url.includes(s)) : undefined,
|
|
146
|
+
};
|
|
147
|
+
const input = positionals[0];
|
|
148
|
+
let source;
|
|
149
|
+
try {
|
|
150
|
+
if (/^https?:\/\//i.test(input))
|
|
151
|
+
source = { url: input };
|
|
152
|
+
else
|
|
153
|
+
source = { html: input === "-" ? await io.stdin() : readFileSync(input, "utf8"), baseUrl: values["base-url"] };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return fail(io, `cannot read ${input}: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
let dom;
|
|
159
|
+
try {
|
|
160
|
+
dom = await createDOM(source, options);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
io.stderr(`wasm-quickjs-dom: ${describe(error)}\n`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
let out;
|
|
168
|
+
if (values.eval !== undefined) {
|
|
169
|
+
const value = await dom.eval(values.eval);
|
|
170
|
+
out = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "undefined";
|
|
171
|
+
}
|
|
172
|
+
else if (values.json) {
|
|
173
|
+
out = JSON.stringify({ baseUrl: dom.baseUrl, html: dom.serialize(), logs: dom.logs }, null, 2);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
out = dom.serialize();
|
|
177
|
+
}
|
|
178
|
+
if (values.logs)
|
|
179
|
+
for (const entry of dom.logs)
|
|
180
|
+
io.stderr(`${formatLog(entry)}\n`);
|
|
181
|
+
if (values.output)
|
|
182
|
+
writeFileSync(values.output, out.endsWith("\n") ? out : `${out}\n`);
|
|
183
|
+
else
|
|
184
|
+
io.stdout(out.endsWith("\n") ? out : `${out}\n`);
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (values.logs)
|
|
189
|
+
for (const entry of dom.logs)
|
|
190
|
+
io.stderr(`${formatLog(entry)}\n`);
|
|
191
|
+
io.stderr(`wasm-quickjs-dom: ${error.message}\n`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
dom.close();
|
|
196
|
+
}
|
|
197
|
+
}
|