@shotkit/shotium 0.1.0 → 0.2.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 +38 -39
- package/dist/daemon_main.js +25 -42
- package/dist/daemon_main.js.map +1 -1
- package/dist/engine-Xe7nH-1i.js +267 -0
- package/dist/engine-Xe7nH-1i.js.map +1 -0
- package/dist/index.d.ts +181 -48
- package/dist/index.js +60 -56
- package/dist/index.js.map +1 -1
- package/package.json +7 -11
- package/src/index.ts +59 -81
- package/src/lib/binding.ts +89 -0
- package/src/lib/client.ts +8 -12
- package/src/lib/config.ts +11 -66
- package/src/lib/daemon.ts +70 -58
- package/src/lib/endpoint.ts +19 -12
- package/src/lib/engine.ts +168 -0
- package/src/lib/platform.ts +1 -7
- package/src/lib/request.ts +4 -15
- package/src/types.ts +19 -45
- package/dist/native.d.ts +0 -66
- package/dist/native.js +0 -127
- package/dist/native.js.map +0 -1
- package/dist/platform-DU8DYqmA.js +0 -32
- package/dist/platform-DU8DYqmA.js.map +0 -1
- package/dist/pool-BSgS6vkr.js +0 -356
- package/dist/pool-BSgS6vkr.js.map +0 -1
- package/dist/request-qZXS3N9f.js +0 -43
- package/dist/request-qZXS3N9f.js.map +0 -1
- package/dist/types-x9HtkzeE.d.ts +0 -156
- package/src/lib/pool.ts +0 -243
- package/src/lib/worker.ts +0 -220
- package/src/native.ts +0 -234
package/README.md
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
`@shotkit/shotium` provides Node.js / TypeScript bindings for **shotium**, a stripped-down Chromium engine built specifically for fast static page rendering. By completely removing V8 and browser chrome overhead, Shotium delivers cold starts under 350 ms, single-shot captures in ~47 ms, and an idle memory footprint of ~58 MB.
|
|
13
13
|
|
|
14
|
+
The engine is loaded into your own process as a Node-API addon over a C ABI. Nothing is spawned, no image crosses a process boundary, and `screenshot()` returns the bytes Blink just encoded.
|
|
15
|
+
|
|
14
16
|
```ts
|
|
15
17
|
import shotium from '@shotkit/shotium';
|
|
16
18
|
|
|
17
|
-
shotium.runtime.start(
|
|
19
|
+
shotium.runtime.start();
|
|
18
20
|
|
|
19
21
|
const png = await shotium.screenshot({
|
|
20
22
|
file: 'https://example.com',
|
|
@@ -33,30 +35,24 @@ await shotium.runtime.stop();
|
|
|
33
35
|
npm install @shotkit/shotium
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
Prebuilt platform binaries are installed automatically via npm optional dependencies.
|
|
38
|
+
Prebuilt platform binaries are installed automatically via npm optional dependencies — six of them, covering Windows, macOS and Linux on x64 and arm64. There is no build step and no postinstall download.
|
|
39
|
+
|
|
40
|
+
The package is ESM. `import` works on Node 18 and up; `require()` of it needs Node 22.12 or 20.19, and anything older should use `await import('@shotkit/shotium')`.
|
|
37
41
|
|
|
38
42
|
---
|
|
39
43
|
|
|
40
44
|
## Usage
|
|
41
45
|
|
|
42
|
-
### 1.
|
|
43
|
-
|
|
44
|
-
Recommended for standard backend servers and continuous job queues.
|
|
46
|
+
### 1. In-Process Engine (`runtime`)
|
|
45
47
|
|
|
46
48
|
```ts
|
|
47
49
|
import { runtime, screenshot } from '@shotkit/shotium';
|
|
48
50
|
|
|
49
|
-
// Optional: listen to runtime lifecycle events
|
|
50
|
-
runtime.on('crash', ({ worker }) => console.warn(`Worker ${worker} recovered from crash`));
|
|
51
|
-
runtime.on('timeout', ({ worker, timeout }) => console.warn(`Worker ${worker} timed out (${timeout}ms)`));
|
|
52
|
-
|
|
53
|
-
// Start pool
|
|
54
51
|
runtime.start({
|
|
55
|
-
|
|
56
|
-
cacheDir: '/var/tmp/shotium-cache' // Optional HTTP disk cache
|
|
52
|
+
cacheDir: '/var/tmp/shotium-cache' // Optional HTTP disk cache. Default: null (off)
|
|
57
53
|
});
|
|
58
54
|
|
|
59
|
-
//
|
|
55
|
+
// Returns a Buffer, or null when `path` was given and the engine wrote the file
|
|
60
56
|
const buffer = await screenshot({
|
|
61
57
|
file: 'https://example.com',
|
|
62
58
|
viewport: { width: 1280, height: 720 },
|
|
@@ -64,22 +60,43 @@ const buffer = await screenshot({
|
|
|
64
60
|
quality: 85,
|
|
65
61
|
});
|
|
66
62
|
|
|
63
|
+
// Hand memory back between batches without giving up the engine
|
|
64
|
+
runtime.purge({ releaseWorkingSet: true });
|
|
65
|
+
|
|
67
66
|
await runtime.stop();
|
|
68
67
|
```
|
|
69
68
|
|
|
69
|
+
**One engine per process, ever, and not one at a time.** Starting Blink writes process-wide statics it has no path to undo, so `stop()` is final: a `start()` after it throws, and so does a second `Runtime`. Concurrent callers are queued and served one at a time, because there is one renderer. Parallelism is therefore more processes, and a program that will want another screenshot later should stay started and call `purge()` rather than stopping.
|
|
70
|
+
|
|
71
|
+
#### `StartOptions`
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
interface StartOptions {
|
|
75
|
+
/** Root of the HTTP disk cache. null (the default) disables caching. */
|
|
76
|
+
cacheDir?: string | null;
|
|
77
|
+
|
|
78
|
+
/** User-Agent sent with every request. */
|
|
79
|
+
userAgent?: string;
|
|
80
|
+
|
|
81
|
+
/** Where the engine looks for its resource packs. Defaults to the
|
|
82
|
+
* directory the addon was loaded from, which is right for an install. */
|
|
83
|
+
resourceDir?: string;
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
70
87
|
---
|
|
71
88
|
|
|
72
89
|
### 2. Resident Daemon (`daemon`)
|
|
73
90
|
|
|
74
91
|
Recommended for CLI tools, ephemeral CI tasks, or serverless workers where startup latency is critical.
|
|
75
92
|
|
|
76
|
-
|
|
93
|
+
The daemon is the same engine in a process of its own, behind a local socket (Named Pipe on Windows, Unix domain socket on POSIX). It renders a blank page on start, so it is warm before the first real request arrives.
|
|
77
94
|
|
|
78
95
|
```ts
|
|
79
96
|
import { daemon } from '@shotkit/shotium';
|
|
80
97
|
|
|
81
|
-
// Connect to existing daemon (automatically starts one if none is running)
|
|
82
|
-
const client = await daemon.connect(
|
|
98
|
+
// Connect to an existing daemon (automatically starts one if none is running)
|
|
99
|
+
const client = await daemon.connect();
|
|
83
100
|
|
|
84
101
|
const png = await client.screenshot({
|
|
85
102
|
file: 'https://example.com',
|
|
@@ -88,29 +105,12 @@ const png = await client.screenshot({
|
|
|
88
105
|
|
|
89
106
|
client.close();
|
|
90
107
|
|
|
91
|
-
// Check status or stop daemon
|
|
92
|
-
const status = await daemon.status();
|
|
108
|
+
// Check status or stop the daemon
|
|
109
|
+
const status = await daemon.status(); // { running: true, pid: 12345, warm: true, ... }
|
|
93
110
|
await daemon.stop();
|
|
94
111
|
```
|
|
95
112
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
### 3. In-Process Native Engine (`@shotkit/shotium/native`)
|
|
99
|
-
|
|
100
|
-
Recommended for single-process, single-threaded batch rendering with minimum overhead (~31 ms per shot).
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
import { native } from '@shotkit/shotium/native';
|
|
104
|
-
|
|
105
|
-
const png = await native.screenshot({
|
|
106
|
-
file: 'https://example.com',
|
|
107
|
-
viewport: { width: 1280, height: 720 },
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
// Purge cache and release working set after batch
|
|
111
|
-
native.purge({ releaseWorkingSet: true });
|
|
112
|
-
await native.stop();
|
|
113
|
-
```
|
|
113
|
+
One connection may have several requests outstanding, because every message carries an `id`. That is a convenience for the client rather than concurrency: the daemon holds one renderer too, and answers in the order it finished them. Two at once means two daemons, told apart by `name`.
|
|
114
114
|
|
|
115
115
|
---
|
|
116
116
|
|
|
@@ -158,12 +158,11 @@ interface ScreenshotOptions {
|
|
|
158
158
|
|
|
159
159
|
/** Allow document to access local file:// resources (default: false) */
|
|
160
160
|
allowFileAccess?: boolean;
|
|
161
|
-
|
|
162
|
-
/** Auto retry count on failure (default: 0) */
|
|
163
|
-
retry?: number;
|
|
164
161
|
}
|
|
165
162
|
```
|
|
166
163
|
|
|
164
|
+
An option this interface does not list is a typo, and a typo that was quietly dropped is a screenshot that ignored what you asked for — so an unknown key is a `TypeError` rather than a silent no-op. `fullPage`, `selector` and `clip` are mutually exclusive.
|
|
165
|
+
|
|
167
166
|
---
|
|
168
167
|
|
|
169
168
|
## License
|
package/dist/daemon_main.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as encodeFrame, i as FrameReader, o as endpointFor, s as resolveStartOptions, t as Engine } from "./engine-Xe7nH-1i.js";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import net from "node:net";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
5
7
|
|
|
6
8
|
//#region src/lib/daemon.ts
|
|
7
9
|
const VERSION = (() => {
|
|
@@ -12,15 +14,13 @@ const VERSION = (() => {
|
|
|
12
14
|
return "0.0.0";
|
|
13
15
|
}
|
|
14
16
|
})();
|
|
15
|
-
const SUPERVISOR_MARGIN_MS = 1e4;
|
|
16
|
-
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
17
17
|
const DEFAULT_IDLE_TIMEOUT_MS = 3e5;
|
|
18
18
|
var Daemon = class extends EventEmitter {
|
|
19
19
|
options;
|
|
20
20
|
endpointPath;
|
|
21
21
|
idleTimeoutMs;
|
|
22
22
|
prewarmOnStart;
|
|
23
|
-
|
|
23
|
+
engine = new Engine();
|
|
24
24
|
server = null;
|
|
25
25
|
sockets = /* @__PURE__ */ new Set();
|
|
26
26
|
inFlight = 0;
|
|
@@ -47,25 +47,12 @@ var Daemon = class extends EventEmitter {
|
|
|
47
47
|
return this.warmed;
|
|
48
48
|
}
|
|
49
49
|
async listen() {
|
|
50
|
-
|
|
51
|
-
this.pool = pool;
|
|
52
|
-
for (const event of [
|
|
53
|
-
"exit",
|
|
54
|
-
"crash",
|
|
55
|
-
"timeout",
|
|
56
|
-
"worker-restart",
|
|
57
|
-
"worker-error",
|
|
58
|
-
"stderr"
|
|
59
|
-
]) pool.on(event, (payload) => this.emit(event, payload));
|
|
60
|
-
pool.start();
|
|
50
|
+
this.engine.start(this.options);
|
|
61
51
|
this.server = net.createServer((socket) => this.accept(socket));
|
|
62
52
|
this.server.on("error", (error) => this.emit("error", error));
|
|
63
53
|
await this.bind();
|
|
64
54
|
this.armIdleTimer();
|
|
65
|
-
this.emit("ready", {
|
|
66
|
-
endpoint: this.endpointPath,
|
|
67
|
-
workers: this.options.workers
|
|
68
|
-
});
|
|
55
|
+
this.emit("ready", { endpoint: this.endpointPath });
|
|
69
56
|
if (this.prewarmOnStart) await this.prewarm();
|
|
70
57
|
return this;
|
|
71
58
|
}
|
|
@@ -112,29 +99,30 @@ var Daemon = class extends EventEmitter {
|
|
|
112
99
|
}
|
|
113
100
|
}
|
|
114
101
|
async prewarm() {
|
|
115
|
-
const blank =
|
|
116
|
-
|
|
117
|
-
|
|
102
|
+
const blank = path.join(os.tmpdir(), `shotium-prewarm-${process.pid}.html`);
|
|
103
|
+
try {
|
|
104
|
+
fs.writeFileSync(blank, "<!doctype html><title>shotium</title><p>shotium");
|
|
105
|
+
await this.engine.capture({
|
|
118
106
|
file: blank,
|
|
119
107
|
width: 16,
|
|
120
108
|
height: 16
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
109
|
+
});
|
|
110
|
+
this.warmed = true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.emit("error", error);
|
|
113
|
+
} finally {
|
|
114
|
+
fs.rmSync(blank, { force: true });
|
|
115
|
+
}
|
|
116
|
+
this.emit("warm", { warm: this.warmed });
|
|
128
117
|
}
|
|
129
118
|
status() {
|
|
130
119
|
return {
|
|
131
120
|
ok: true,
|
|
132
121
|
pid: process.pid,
|
|
133
122
|
endpoint: this.endpointPath,
|
|
134
|
-
binary: this.options.binary,
|
|
135
|
-
workers: this.options.workers,
|
|
136
123
|
cacheDir: this.options.cacheDir,
|
|
137
|
-
|
|
124
|
+
userAgent: this.options.userAgent,
|
|
125
|
+
resourceDir: this.options.resourceDir,
|
|
138
126
|
warm: this.warmed,
|
|
139
127
|
uptimeMs: Date.now() - this.startedAt,
|
|
140
128
|
connections: this.sockets.size,
|
|
@@ -208,25 +196,20 @@ var Daemon = class extends EventEmitter {
|
|
|
208
196
|
return;
|
|
209
197
|
}
|
|
210
198
|
const request = message.request || {};
|
|
211
|
-
const timeout = (typeof message.timeout === "number" ? message.timeout : DEFAULT_TIMEOUT_MS) + SUPERVISOR_MARGIN_MS;
|
|
212
|
-
const retry = typeof message.retry === "number" ? message.retry : 0;
|
|
213
199
|
this.inFlight += 1;
|
|
214
200
|
this.armIdleTimer();
|
|
215
201
|
this.emit("request", {
|
|
216
202
|
id,
|
|
217
203
|
file: request.file
|
|
218
204
|
});
|
|
219
|
-
this.
|
|
220
|
-
timeout,
|
|
221
|
-
retry
|
|
222
|
-
}).then((result) => {
|
|
205
|
+
this.engine.capture(request).then((image) => {
|
|
223
206
|
this.served += 1;
|
|
224
207
|
this.reply(socket, {
|
|
225
208
|
id,
|
|
226
209
|
ok: true,
|
|
227
|
-
bytes:
|
|
228
|
-
path:
|
|
229
|
-
},
|
|
210
|
+
bytes: image ? image.length : 0,
|
|
211
|
+
path: request.path
|
|
212
|
+
}, image);
|
|
230
213
|
}).catch((error) => {
|
|
231
214
|
this.reply(socket, {
|
|
232
215
|
id,
|
|
@@ -267,7 +250,7 @@ var Daemon = class extends EventEmitter {
|
|
|
267
250
|
for (const socket of this.sockets) socket.destroy();
|
|
268
251
|
this.sockets.clear();
|
|
269
252
|
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
270
|
-
await this.
|
|
253
|
+
await this.engine.stop();
|
|
271
254
|
this.emit("close", {});
|
|
272
255
|
}
|
|
273
256
|
};
|
package/dist/daemon_main.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon_main.js","names":[],"sources":["../src/lib/daemon.ts","../src/daemon_main.ts"],"sourcesContent":["import {EventEmitter} from 'node:events';\nimport fs from 'node:fs';\nimport net from 'node:net';\n\nimport type {DaemonOptions, DaemonStatus} from '../types.js';\n\nimport {resolveStartOptions} from './config.js';\nimport type {ResolvedStartOptions} from './config.js';\nimport {endpointFor} from './endpoint.js';\nimport {Pool} from './pool.js';\nimport {FrameReader, encodeFrame} from './protocol.js';\nimport type {WireRequest} from './request.js';\n\n// Our own version, for status(). Read rather than imported: an import\n// attribute would do it too, but only on a node new enough that this package\n// would not run on the rest. The URL is relative to the built module, which\n// sits one directory below the manifest.\nconst VERSION = (() => {\n try {\n const manifest =\n fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');\n return (JSON.parse(manifest) as {version?: string}).version ?? '0.0.0';\n } catch {\n return '0.0.0';\n }\n})();\n\n// How much longer than the page's own deadline the daemon waits before it\n// decides a worker is not going to answer at all. Same margin, same reasoning\n// as index.ts: the worker fails a slow page by itself and replies.\nconst SUPERVISOR_MARGIN_MS = 10000;\nconst DEFAULT_TIMEOUT_MS = 30000;\nconst DEFAULT_IDLE_TIMEOUT_MS = 300000;\n\n// One message off the socket. `op` defaults to screenshot because that is what\n// almost every message is.\ninterface DaemonMessage {\n id?: number|null;\n op?: 'screenshot'|'status'|'ping'|'shutdown';\n request?: WireRequest;\n timeout?: number;\n retry?: number;\n}\n\ninterface DaemonReply {\n id: number|null;\n ok?: boolean;\n error?: string;\n bytes?: number;\n path?: string;\n stopping?: boolean;\n}\n\n// A worker pool that outlives the process that asked for it.\n//\n// The pool in index.ts is already resident, but only for as long as the Node\n// process holding it: a command-line invocation, a CI step, a serverless\n// handler and a `node -e` all pay for starting workers and then throw them\n// away. This is the same pool behind a socket, so the second caller -- in a\n// different process, minutes later -- pays a connect() and nothing else.\n//\n// The wire format is the worker's own, one level up: a request frame of JSON,\n// answered by a header frame and a payload frame. What it adds is `id`, so one\n// connection can have several requests in flight; the worker protocol cannot,\n// because a worker renders one document at a time, and multiplexing is exactly\n// what the pool in the middle is for.\n//\n// -> [len][{\"id\":7,\"op\":\"screenshot\",\"request\":{...}}]\n// <- [len][{\"id\":7,\"ok\":true,\"bytes\":97756}] [len][<PNG>]\n//\n// Events: ready, request, response, idle-exit, error, plus the pool's own.\nclass Daemon extends EventEmitter {\n private readonly options: ResolvedStartOptions;\n private readonly endpointPath: string;\n private readonly idleTimeoutMs: number;\n private readonly prewarmOnStart: boolean;\n private pool: Pool|null = null;\n private server: net.Server|null = null;\n private sockets = new Set<net.Socket>();\n private inFlight = 0;\n private served = 0;\n private warmed = false;\n private startedAt = Date.now();\n private idleTimer: NodeJS.Timeout|null = null;\n private closing = false;\n\n constructor(options: DaemonOptions = {}) {\n super();\n this.options = resolveStartOptions(options);\n this.endpointPath = endpointFor({\n ...this.options,\n name: options.name,\n endpoint: options.endpoint,\n });\n this.idleTimeoutMs = options.idleTimeoutMs === undefined ?\n DEFAULT_IDLE_TIMEOUT_MS :\n options.idleTimeoutMs;\n this.prewarmOnStart = options.prewarm !== false;\n }\n\n get endpoint(): string {\n return this.endpointPath;\n }\n\n get warm(): boolean {\n return this.warmed;\n }\n\n // Brings the pool up and starts listening. The pipe existing *is* the\n // readiness signal -- a client's connect() either succeeds or the daemon is\n // not up -- so nothing is bound until the pool has been asked to start.\n async listen(): Promise<this> {\n const pool = new Pool(this.options);\n this.pool = pool;\n for (const event of ['exit', 'crash', 'timeout', 'worker-restart',\n 'worker-error', 'stderr']) {\n pool.on(event, (payload) => this.emit(event, payload));\n }\n pool.start();\n\n this.server = net.createServer((socket) => this.accept(socket));\n this.server.on('error', (error) => this.emit('error', error));\n await this.bind();\n this.armIdleTimer();\n this.emit('ready',\n {endpoint: this.endpointPath, workers: this.options.workers});\n if (this.prewarmOnStart) {\n await this.prewarm();\n }\n return this;\n }\n\n private bind(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const server = this.server!;\n const onError = (error: NodeJS.ErrnoException) => {\n // A unix socket file outlives the process that made it, so EADDRINUSE\n // means either a live daemon or a leftover path. Connecting is the only\n // way to tell them apart: refused means nobody is home, and the file\n // can go.\n if (error.code === 'EADDRINUSE' && process.platform !== 'win32') {\n const probe = net.connect(this.endpointPath);\n probe.on('connect', () => {\n probe.destroy();\n reject(error);\n });\n probe.on('error', () => {\n try {\n fs.unlinkSync(this.endpointPath);\n } catch {\n reject(error);\n return;\n }\n server.listen(this.endpointPath, () => {\n this.restrict();\n resolve();\n });\n });\n return;\n }\n reject(error);\n };\n server.once('error', onError);\n server.listen(this.endpointPath, () => {\n server.removeListener('error', onError);\n this.restrict();\n resolve();\n });\n });\n }\n\n // Who may talk to this daemon.\n //\n // It matters because a request may set `allowFileAccess`, so a stranger who\n // can connect can have a document read this machine's filesystem and get the\n // result back as a picture. On POSIX the socket is a file and 0600 says only\n // its owner may connect.\n //\n // On Windows it is a named pipe, and node exposes no way to give one an ACL:\n // the default lets any account on the machine open it. A daemon on a shared\n // Windows host is therefore as trusted as the machine's users are -- render\n // in-process, or with a binary that has no file access, if that is not\n // acceptable.\n private restrict(): void {\n if (process.platform === 'win32') {\n return;\n }\n try {\n fs.chmodSync(this.endpointPath, 0o600);\n } catch (error) {\n this.emit('error', error);\n }\n }\n\n // Renders one throwaway document per worker so that the first real request\n // does not pay for whatever each process initialises lazily. The pool hands\n // one request to each free worker, and there are exactly as many requests as\n // workers, so every process is touched.\n //\n // `data:` rather than a file, because a daemon started without\n // --allow-file-access would otherwise be prewarmed by a request it refuses.\n async prewarm(): Promise<void> {\n const blank = 'data:text/html,<!doctype html><title>shotium</title>';\n await Promise.all(Array.from({length: this.options.workers}, () => {\n return this.pool!\n .submit({file: blank, width: 16, height: 16},\n {timeout: DEFAULT_TIMEOUT_MS + SUPERVISOR_MARGIN_MS, retry: 1})\n .catch(() => null);\n }));\n this.warmed = true;\n this.emit('warm', {workers: this.options.workers});\n }\n\n status(): DaemonStatus {\n return {\n ok: true,\n pid: process.pid,\n endpoint: this.endpointPath,\n binary: this.options.binary,\n workers: this.options.workers,\n cacheDir: this.options.cacheDir,\n args: this.options.args,\n warm: this.warmed,\n uptimeMs: Date.now() - this.startedAt,\n connections: this.sockets.size,\n inFlight: this.inFlight,\n served: this.served,\n idleTimeoutMs: this.idleTimeoutMs,\n version: VERSION,\n };\n }\n\n private accept(socket: net.Socket): void {\n socket.on('error', () => socket.destroy());\n this.sockets.add(socket);\n this.armIdleTimer();\n\n const reader = new FrameReader();\n socket.on('data', (chunk: Buffer) => {\n reader.push(chunk);\n for (;;) {\n const frame = reader.next();\n if (frame === null) {\n return;\n }\n this.dispatch(socket, frame);\n }\n });\n socket.on('close', () => {\n this.sockets.delete(socket);\n this.armIdleTimer();\n });\n }\n\n private dispatch(socket: net.Socket, frame: Buffer): void {\n let message: DaemonMessage;\n try {\n message = JSON.parse(frame.toString('utf8')) as DaemonMessage;\n } catch {\n this.reply(\n socket, {id: null, ok: false, error: 'shotium: request is not JSON'});\n return;\n }\n\n const id = message.id === undefined ? null : message.id;\n const op = message.op || 'screenshot';\n if (op === 'status') {\n this.reply(socket, {...this.status(), id});\n return;\n }\n if (op === 'ping') {\n this.reply(socket, {id, ok: true});\n return;\n }\n if (op === 'shutdown') {\n this.reply(socket, {id, ok: true, stopping: true});\n // After the reply is on the wire, not before: a client that asked for a\n // shutdown is entitled to hear that it happened.\n socket.end(() => void this.close());\n return;\n }\n if (op !== 'screenshot') {\n this.reply(socket, {id, ok: false, error: `shotium: unknown op \"${op}\"`});\n return;\n }\n\n const request = message.request || ({} as WireRequest);\n const timeout = (typeof message.timeout === 'number' ? message.timeout :\n DEFAULT_TIMEOUT_MS) +\n SUPERVISOR_MARGIN_MS;\n const retry = typeof message.retry === 'number' ? message.retry : 0;\n\n this.inFlight += 1;\n this.armIdleTimer();\n this.emit('request', {id, file: request.file});\n this.pool!.submit(request, {timeout, retry})\n .then((result) => {\n this.served += 1;\n this.reply(\n socket,\n {\n id,\n ok: true,\n bytes: result.image ? result.image.length : 0,\n path: result.header ? result.header.path : undefined,\n },\n result.image);\n })\n .catch((error: Error) => {\n this.reply(\n socket, {id, ok: false, error: String(error.message || error)});\n })\n .finally(() => {\n this.inFlight -= 1;\n this.emit('response', {id});\n this.armIdleTimer();\n });\n }\n\n private reply(\n socket: net.Socket, header: DaemonReply|(DaemonStatus&{id: number|null}),\n payload?: Buffer|null): void {\n if (socket.destroyed) {\n return;\n }\n socket.write(encodeFrame(Buffer.from(JSON.stringify(header), 'utf8')));\n socket.write(encodeFrame(payload || Buffer.alloc(0)));\n }\n\n // Idle is \"nobody connected and nothing rendering\". A client that holds its\n // socket open -- a long-lived service using connect() -- keeps the daemon\n // alive without having to poll it.\n private armIdleTimer(): void {\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n if (!this.idleTimeoutMs || this.closing) {\n return;\n }\n if (this.sockets.size > 0 || this.inFlight > 0) {\n return;\n }\n this.idleTimer = setTimeout(() => {\n this.emit('idle-exit', {idleTimeoutMs: this.idleTimeoutMs});\n void this.close();\n }, this.idleTimeoutMs);\n this.idleTimer.unref();\n }\n\n async close(): Promise<void> {\n if (this.closing) {\n return;\n }\n this.closing = true;\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n for (const socket of this.sockets) {\n socket.destroy();\n }\n this.sockets.clear();\n await new Promise<void>((resolve) => this.server!.close(() => resolve()));\n await this.pool!.stop();\n this.emit('close', {});\n }\n}\n\nexport {Daemon, DEFAULT_IDLE_TIMEOUT_MS};\n","// The entry point of a detached daemon process.\n//\n// The configuration arrives as one base64 argument rather than as flags,\n// because it contains paths that a Windows command line would otherwise quote\n// badly, and because the client and the daemon have to agree on it exactly:\n// the endpoint is a hash of these fields, so a value mangled in transit would\n// produce a daemon listening where nobody looks. See endpoint.ts.\n//\n// It is a build entry of its own, and not a chunk, because lib/client.ts\n// spawns it by path -- `node dist/daemon_main.js <base64 json>` -- and a name\n// the bundler chose would be a name that changes.\n\nimport {Daemon} from './lib/daemon.js';\nimport type {DaemonOptions} from './types.js';\n\nasync function main(): Promise<void> {\n const encoded = process.argv[2];\n if (!encoded) {\n process.stderr.write('shotium: daemon_main expects a base64 config\\n');\n process.exit(2);\n }\n const options = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as\n DaemonOptions;\n const daemon = new Daemon(options);\n\n daemon.on('stderr', ({worker, line}: {worker: number, line: string}) => {\n process.stderr.write(`shotium worker ${worker}: ${line}\\n`);\n });\n for (const event of ['crash', 'timeout', 'worker-restart', 'worker-error',\n 'idle-exit']) {\n daemon.on(event, (payload: {error?: unknown}) => {\n // An Error does not survive JSON.stringify -- it comes out as {} -- and\n // its message is the whole point of logging a worker that would not\n // start.\n const detail = payload && payload.error ?\n {\n ...payload,\n error: String(\n (payload.error as Error).message ?? payload.error),\n } :\n payload;\n process.stderr.write(\n `shotium daemon ${event}: ${JSON.stringify(detail)}\\n`);\n });\n }\n // An 'error' with nobody listening is thrown by EventEmitter itself, which\n // would turn a socket that failed after binding -- something the daemon can\n // survive -- into a dead pool.\n daemon.on('error', (error: Error) => {\n process.stderr.write(\n `shotium daemon error: ${(error && error.message) || error}\\n`);\n });\n\n try {\n await daemon.listen();\n } catch (error) {\n // Losing the race to bind is the ordinary outcome when two clients start a\n // daemon at the same moment: the other one is up, this one is not needed,\n // and the client that spawned it will connect to the winner. Anything else\n // is a real failure and says so.\n if ((error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE') {\n process.exit(0);\n }\n process.stderr.write(`shotium: daemon failed to start: ${error}\\n`);\n process.exit(1);\n }\n\n const shutdown = () => {\n daemon.close().then(() => process.exit(0), () => process.exit(1));\n };\n process.on('SIGINT', shutdown);\n process.on('SIGTERM', shutdown);\n daemon.on('close', () => process.exit(0));\n}\n\nvoid main();\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"daemon_main.js","names":[],"sources":["../src/lib/daemon.ts","../src/daemon_main.ts"],"sourcesContent":["import {EventEmitter} from 'node:events';\nimport fs from 'node:fs';\nimport net from 'node:net';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport type {DaemonOptions, DaemonStatus} from '../types.js';\n\nimport {resolveStartOptions} from './config.js';\nimport type {ResolvedStartOptions} from './config.js';\nimport {endpointFor} from './endpoint.js';\nimport {Engine} from './engine.js';\nimport {FrameReader, encodeFrame} from './protocol.js';\nimport type {WireRequest} from './request.js';\n\n// Our own version, for status(). Read rather than imported: an import\n// attribute would do it too, but only on a node new enough that this package\n// would not run on the rest. The URL is relative to the built module, which\n// sits one directory below the manifest.\nconst VERSION = (() => {\n try {\n const manifest =\n fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');\n return (JSON.parse(manifest) as {version?: string}).version ?? '0.0.0';\n } catch {\n return '0.0.0';\n }\n})();\n\nconst DEFAULT_IDLE_TIMEOUT_MS = 300000;\n\n// One message off the socket. `op` defaults to screenshot because that is what\n// almost every message is.\ninterface DaemonMessage {\n id?: number|null;\n op?: 'screenshot'|'status'|'ping'|'shutdown';\n request?: WireRequest;\n timeout?: number;\n retry?: number;\n}\n\ninterface DaemonReply {\n id: number|null;\n ok?: boolean;\n error?: string;\n bytes?: number;\n path?: string;\n stopping?: boolean;\n}\n\n// An engine that outlives the process that asked for it.\n//\n// The engine in index.ts is already resident, but only for as long as the Node\n// process holding it: a command-line invocation, a CI step, a serverless\n// handler and a `node -e` all pay for starting Blink and then throw it away.\n// This is the same engine behind a socket, so the second caller -- in a\n// different process, minutes later -- pays a connect() and nothing else.\n//\n// A request frame of JSON, answered by a header frame and a payload frame:\n//\n// -> [len][{\"id\":7,\"op\":\"screenshot\",\"request\":{...}}]\n// <- [len][{\"id\":7,\"ok\":true,\"bytes\":97756}] [len][<PNG>]\n//\n// `id` is on the wire so that a client may have several requests outstanding\n// on one connection. That is a convenience for the client, not concurrency:\n// there is one renderer here, because Blink is a process-wide singleton, so\n// the requests queue and come back in the order the engine finished them.\n// Wanting two at once means wanting two daemons, addressed by `name`.\n//\n// Nothing supervises a capture. The pool this replaced could time a worker out\n// and kill it; an in-process engine has no such seam -- there is no way to\n// abandon a render without abandoning the process. A page's own deadline\n// (`pageGotoParams.timeout`) is what bounds it, and the engine answers slow\n// pages by itself. `timeout` and `retry` on the wire are accepted and ignored,\n// so that an older client still talks to this.\n//\n// Events: ready, warm, request, response, idle-exit, error, close.\nclass Daemon extends EventEmitter {\n private readonly options: ResolvedStartOptions;\n private readonly endpointPath: string;\n private readonly idleTimeoutMs: number;\n private readonly prewarmOnStart: boolean;\n private readonly engine = new Engine();\n private server: net.Server|null = null;\n private sockets = new Set<net.Socket>();\n private inFlight = 0;\n private served = 0;\n private warmed = false;\n private startedAt = Date.now();\n private idleTimer: NodeJS.Timeout|null = null;\n private closing = false;\n\n constructor(options: DaemonOptions = {}) {\n super();\n this.options = resolveStartOptions(options);\n this.endpointPath = endpointFor({\n ...this.options,\n name: options.name,\n endpoint: options.endpoint,\n });\n this.idleTimeoutMs = options.idleTimeoutMs === undefined ?\n DEFAULT_IDLE_TIMEOUT_MS :\n options.idleTimeoutMs;\n this.prewarmOnStart = options.prewarm !== false;\n }\n\n get endpoint(): string {\n return this.endpointPath;\n }\n\n get warm(): boolean {\n return this.warmed;\n }\n\n // Brings the engine up and starts listening. The pipe existing *is* the\n // readiness signal -- a client's connect() either succeeds or the daemon is\n // not up -- so nothing is bound until the engine has started.\n //\n // Starting it here rather than on the first request is deliberate: a machine\n // with no engine for its platform should fail while the caller is still\n // watching, not answer a connect() and then reject every request on it.\n async listen(): Promise<this> {\n this.engine.start(this.options);\n\n this.server = net.createServer((socket) => this.accept(socket));\n this.server.on('error', (error) => this.emit('error', error));\n await this.bind();\n this.armIdleTimer();\n this.emit('ready', {endpoint: this.endpointPath});\n if (this.prewarmOnStart) {\n await this.prewarm();\n }\n return this;\n }\n\n private bind(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const server = this.server!;\n const onError = (error: NodeJS.ErrnoException) => {\n // A unix socket file outlives the process that made it, so EADDRINUSE\n // means either a live daemon or a leftover path. Connecting is the only\n // way to tell them apart: refused means nobody is home, and the file\n // can go.\n if (error.code === 'EADDRINUSE' && process.platform !== 'win32') {\n const probe = net.connect(this.endpointPath);\n probe.on('connect', () => {\n probe.destroy();\n reject(error);\n });\n probe.on('error', () => {\n try {\n fs.unlinkSync(this.endpointPath);\n } catch {\n reject(error);\n return;\n }\n server.listen(this.endpointPath, () => {\n this.restrict();\n resolve();\n });\n });\n return;\n }\n reject(error);\n };\n server.once('error', onError);\n server.listen(this.endpointPath, () => {\n server.removeListener('error', onError);\n this.restrict();\n resolve();\n });\n });\n }\n\n // Who may talk to this daemon.\n //\n // It matters because a request may set `allowFileAccess`, so a stranger who\n // can connect can have a document read this machine's filesystem and get the\n // result back as a picture. On POSIX the socket is a file and 0600 says only\n // its owner may connect.\n //\n // On Windows it is a named pipe, and node exposes no way to give one an ACL:\n // the default lets any account on the machine open it. A daemon on a shared\n // Windows host is therefore as trusted as the machine's users are -- use the\n // engine in your own process, where nothing is listening, if that is not\n // acceptable.\n private restrict(): void {\n if (process.platform === 'win32') {\n return;\n }\n try {\n fs.chmodSync(this.endpointPath, 0o600);\n } catch (error) {\n this.emit('error', error);\n }\n }\n\n // Renders one throwaway document so that the first real request does not pay\n // for whatever the engine initialises lazily. One is enough: there is one\n // renderer, and it is the same one every request lands on.\n //\n // A temporary file, not a `data:` URL. This used to send\n // `data:text/html,...`, which the renderer rejects -- shot_capture.cc takes\n // file, http and https and nothing else -- so every prewarm failed into the\n // catch below and the step had never once done anything. The failure was\n // invisible because a prewarm that does not work looks exactly like one that\n // does, only slower on the first request.\n //\n // The document names no subresources, so it renders identically whether or\n // not this daemon allows file access -- which is what the `data:` URL was\n // reaching for. A top-level file: URL always loads; `allowFileAccess` gates\n // what the document may then pull in.\n async prewarm(): Promise<void> {\n const blank = path.join(\n os.tmpdir(), `shotium-prewarm-${process.pid}.html`);\n try {\n fs.writeFileSync(\n blank, '<!doctype html><title>shotium</title><p>shotium');\n await this.engine.capture({file: blank, width: 16, height: 16});\n this.warmed = true;\n } catch (error) {\n // Not fatal: a daemon that could not prewarm still serves. But it is not\n // warm, and status() should not claim it is.\n this.emit('error', error);\n } finally {\n fs.rmSync(blank, {force: true});\n }\n this.emit('warm', {warm: this.warmed});\n }\n\n status(): DaemonStatus {\n return {\n ok: true,\n pid: process.pid,\n endpoint: this.endpointPath,\n cacheDir: this.options.cacheDir,\n userAgent: this.options.userAgent,\n resourceDir: this.options.resourceDir,\n warm: this.warmed,\n uptimeMs: Date.now() - this.startedAt,\n connections: this.sockets.size,\n inFlight: this.inFlight,\n served: this.served,\n idleTimeoutMs: this.idleTimeoutMs,\n version: VERSION,\n };\n }\n\n private accept(socket: net.Socket): void {\n socket.on('error', () => socket.destroy());\n this.sockets.add(socket);\n this.armIdleTimer();\n\n const reader = new FrameReader();\n socket.on('data', (chunk: Buffer) => {\n reader.push(chunk);\n for (;;) {\n const frame = reader.next();\n if (frame === null) {\n return;\n }\n this.dispatch(socket, frame);\n }\n });\n socket.on('close', () => {\n this.sockets.delete(socket);\n this.armIdleTimer();\n });\n }\n\n private dispatch(socket: net.Socket, frame: Buffer): void {\n let message: DaemonMessage;\n try {\n message = JSON.parse(frame.toString('utf8')) as DaemonMessage;\n } catch {\n this.reply(\n socket, {id: null, ok: false, error: 'shotium: request is not JSON'});\n return;\n }\n\n const id = message.id === undefined ? null : message.id;\n const op = message.op || 'screenshot';\n if (op === 'status') {\n this.reply(socket, {...this.status(), id});\n return;\n }\n if (op === 'ping') {\n this.reply(socket, {id, ok: true});\n return;\n }\n if (op === 'shutdown') {\n this.reply(socket, {id, ok: true, stopping: true});\n // After the reply is on the wire, not before: a client that asked for a\n // shutdown is entitled to hear that it happened.\n socket.end(() => void this.close());\n return;\n }\n if (op !== 'screenshot') {\n this.reply(socket, {id, ok: false, error: `shotium: unknown op \"${op}\"`});\n return;\n }\n\n const request = message.request || ({} as WireRequest);\n\n this.inFlight += 1;\n this.armIdleTimer();\n this.emit('request', {id, file: request.file});\n this.engine.capture(request)\n .then((image) => {\n this.served += 1;\n this.reply(\n socket,\n {\n id,\n ok: true,\n bytes: image ? image.length : 0,\n path: request.path,\n },\n image);\n })\n .catch((error: Error) => {\n this.reply(\n socket, {id, ok: false, error: String(error.message || error)});\n })\n .finally(() => {\n this.inFlight -= 1;\n this.emit('response', {id});\n this.armIdleTimer();\n });\n }\n\n private reply(\n socket: net.Socket, header: DaemonReply|(DaemonStatus&{id: number|null}),\n payload?: Buffer|null): void {\n if (socket.destroyed) {\n return;\n }\n socket.write(encodeFrame(Buffer.from(JSON.stringify(header), 'utf8')));\n socket.write(encodeFrame(payload || Buffer.alloc(0)));\n }\n\n // Idle is \"nobody connected and nothing rendering\". A client that holds its\n // socket open -- a long-lived service using connect() -- keeps the daemon\n // alive without having to poll it.\n private armIdleTimer(): void {\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n if (!this.idleTimeoutMs || this.closing) {\n return;\n }\n if (this.sockets.size > 0 || this.inFlight > 0) {\n return;\n }\n this.idleTimer = setTimeout(() => {\n this.emit('idle-exit', {idleTimeoutMs: this.idleTimeoutMs});\n void this.close();\n }, this.idleTimeoutMs);\n this.idleTimer.unref();\n }\n\n async close(): Promise<void> {\n if (this.closing) {\n return;\n }\n this.closing = true;\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n for (const socket of this.sockets) {\n socket.destroy();\n }\n this.sockets.clear();\n await new Promise<void>((resolve) => this.server!.close(() => resolve()));\n await this.engine.stop();\n this.emit('close', {});\n }\n}\n\nexport {Daemon, DEFAULT_IDLE_TIMEOUT_MS};\n","// The entry point of a detached daemon process.\n//\n// The configuration arrives as one base64 argument rather than as flags,\n// because it contains paths that a Windows command line would otherwise quote\n// badly, and because the client and the daemon have to agree on it exactly:\n// the endpoint is a hash of these fields, so a value mangled in transit would\n// produce a daemon listening where nobody looks. See endpoint.ts.\n//\n// It is a build entry of its own, and not a chunk, because lib/client.ts\n// spawns it by path -- `node dist/daemon_main.js <base64 json>` -- and a name\n// the bundler chose would be a name that changes.\n\nimport {Daemon} from './lib/daemon.js';\nimport type {DaemonOptions} from './types.js';\n\nasync function main(): Promise<void> {\n const encoded = process.argv[2];\n if (!encoded) {\n process.stderr.write('shotium: daemon_main expects a base64 config\\n');\n process.exit(2);\n }\n const options = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as\n DaemonOptions;\n const daemon = new Daemon(options);\n\n daemon.on('stderr', ({worker, line}: {worker: number, line: string}) => {\n process.stderr.write(`shotium worker ${worker}: ${line}\\n`);\n });\n for (const event of ['crash', 'timeout', 'worker-restart', 'worker-error',\n 'idle-exit']) {\n daemon.on(event, (payload: {error?: unknown}) => {\n // An Error does not survive JSON.stringify -- it comes out as {} -- and\n // its message is the whole point of logging a worker that would not\n // start.\n const detail = payload && payload.error ?\n {\n ...payload,\n error: String(\n (payload.error as Error).message ?? payload.error),\n } :\n payload;\n process.stderr.write(\n `shotium daemon ${event}: ${JSON.stringify(detail)}\\n`);\n });\n }\n // An 'error' with nobody listening is thrown by EventEmitter itself, which\n // would turn a socket that failed after binding -- something the daemon can\n // survive -- into a dead pool.\n daemon.on('error', (error: Error) => {\n process.stderr.write(\n `shotium daemon error: ${(error && error.message) || error}\\n`);\n });\n\n try {\n await daemon.listen();\n } catch (error) {\n // Losing the race to bind is the ordinary outcome when two clients start a\n // daemon at the same moment: the other one is up, this one is not needed,\n // and the client that spawned it will connect to the winner. Anything else\n // is a real failure and says so.\n if ((error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE') {\n process.exit(0);\n }\n process.stderr.write(`shotium: daemon failed to start: ${error}\\n`);\n process.exit(1);\n }\n\n const shutdown = () => {\n daemon.close().then(() => process.exit(0), () => process.exit(1));\n };\n process.on('SIGINT', shutdown);\n process.on('SIGTERM', shutdown);\n daemon.on('close', () => process.exit(0));\n}\n\nvoid main();\n"],"mappings":"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"}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
//#region src/lib/config.ts
|
|
9
|
+
function resolveStartOptions(options = {}) {
|
|
10
|
+
return {
|
|
11
|
+
cacheDir: options.cacheDir ?? null,
|
|
12
|
+
userAgent: options.userAgent,
|
|
13
|
+
resourceDir: options.resourceDir
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/lib/endpoint.ts
|
|
19
|
+
function endpointKey(options) {
|
|
20
|
+
if (options.name) return String(options.name);
|
|
21
|
+
const identity = JSON.stringify([
|
|
22
|
+
options.cacheDir === null || options.cacheDir === void 0 ? null : path.resolve(options.cacheDir),
|
|
23
|
+
options.userAgent ?? null,
|
|
24
|
+
options.resourceDir ? path.resolve(options.resourceDir) : null
|
|
25
|
+
]);
|
|
26
|
+
return crypto.createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
27
|
+
}
|
|
28
|
+
function endpointFor(options = {}) {
|
|
29
|
+
if (options.endpoint) return options.endpoint;
|
|
30
|
+
if (process.env.SHOTIUM_ENDPOINT) return process.env.SHOTIUM_ENDPOINT;
|
|
31
|
+
const key = endpointKey(options);
|
|
32
|
+
if (process.platform === "win32") return `\\\\.\\pipe\\shotium-${key}`;
|
|
33
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
34
|
+
return path.join(os.tmpdir(), `shotium-${uid}-${key}.sock`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/lib/protocol.ts
|
|
39
|
+
const HEADER_BYTES = 4;
|
|
40
|
+
function encodeFrame(payload) {
|
|
41
|
+
const header = Buffer.allocUnsafe(4);
|
|
42
|
+
header.writeUInt32LE(payload.length, 0);
|
|
43
|
+
return Buffer.concat([header, payload]);
|
|
44
|
+
}
|
|
45
|
+
var FrameReader = class {
|
|
46
|
+
buffer = Buffer.alloc(0);
|
|
47
|
+
push(chunk) {
|
|
48
|
+
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
49
|
+
}
|
|
50
|
+
next() {
|
|
51
|
+
if (this.buffer.length < 4) return null;
|
|
52
|
+
const length = this.buffer.readUInt32LE(0);
|
|
53
|
+
if (this.buffer.length < 4 + length) return null;
|
|
54
|
+
const frame = this.buffer.subarray(4, 4 + length);
|
|
55
|
+
this.buffer = this.buffer.subarray(4 + length);
|
|
56
|
+
return frame;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/lib/request.ts
|
|
62
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
63
|
+
const WIRE_FIELDS = /* @__PURE__ */ new Set([
|
|
64
|
+
"file",
|
|
65
|
+
"type",
|
|
66
|
+
"fullPage",
|
|
67
|
+
"selector",
|
|
68
|
+
"quality",
|
|
69
|
+
"scale",
|
|
70
|
+
"omitBackground",
|
|
71
|
+
"path",
|
|
72
|
+
"pageGotoParams",
|
|
73
|
+
"clip",
|
|
74
|
+
"viewport",
|
|
75
|
+
"allowFileAccess"
|
|
76
|
+
]);
|
|
77
|
+
function toRequest(options) {
|
|
78
|
+
if (!options || typeof options !== "object") throw new TypeError("shotium: screenshot(options) needs an object");
|
|
79
|
+
if (typeof options.file !== "string" || options.file.length === 0) throw new TypeError("shotium: options.file is required");
|
|
80
|
+
const request = {};
|
|
81
|
+
for (const [key, value] of Object.entries(options)) {
|
|
82
|
+
if (value === void 0) continue;
|
|
83
|
+
if (!WIRE_FIELDS.has(key)) throw new TypeError(`shotium: unknown option "${key}"`);
|
|
84
|
+
request[key] = value;
|
|
85
|
+
}
|
|
86
|
+
if (request.viewport) {
|
|
87
|
+
const { width, height } = request.viewport;
|
|
88
|
+
delete request.viewport;
|
|
89
|
+
if (width !== void 0) request.width = width;
|
|
90
|
+
if (height !== void 0) request.height = height;
|
|
91
|
+
}
|
|
92
|
+
return request;
|
|
93
|
+
}
|
|
94
|
+
function timeoutFor(options) {
|
|
95
|
+
const timeout = options.pageGotoParams && options.pageGotoParams.timeout;
|
|
96
|
+
return typeof timeout === "number" ? timeout : DEFAULT_TIMEOUT_MS;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/lib/platform.ts
|
|
101
|
+
const require$1 = createRequire(import.meta.url);
|
|
102
|
+
const PACKAGES = {
|
|
103
|
+
"win32-x64": "@shotkit/shotium-win32-x64",
|
|
104
|
+
"win32-arm64": "@shotkit/shotium-win32-arm64",
|
|
105
|
+
"darwin-x64": "@shotkit/shotium-darwin-x64",
|
|
106
|
+
"darwin-arm64": "@shotkit/shotium-darwin-arm64",
|
|
107
|
+
"linux-x64": "@shotkit/shotium-linux-x64",
|
|
108
|
+
"linux-arm64": "@shotkit/shotium-linux-arm64"
|
|
109
|
+
};
|
|
110
|
+
function packageName(platform = process.platform, arch = process.arch) {
|
|
111
|
+
return PACKAGES[`${platform}-${arch}`] ?? null;
|
|
112
|
+
}
|
|
113
|
+
function packageDir() {
|
|
114
|
+
const name = packageName();
|
|
115
|
+
if (!name) return null;
|
|
116
|
+
try {
|
|
117
|
+
return path.dirname(require$1.resolve(`${name}/package.json`));
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/lib/binding.ts
|
|
125
|
+
const require = createRequire(import.meta.url);
|
|
126
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
127
|
+
function candidates() {
|
|
128
|
+
const found = [];
|
|
129
|
+
const dir = packageDir();
|
|
130
|
+
if (dir) found.push(path.join(dir, "shotium.node"));
|
|
131
|
+
found.push(path.join(HERE, "..", "native", "build", "Release", "shotium.node"));
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
let binding = null;
|
|
135
|
+
let loadedFrom = null;
|
|
136
|
+
/**
|
|
137
|
+
* The addon, loaded once. Throws if there is none for this platform, which is
|
|
138
|
+
* the only failure this package cannot work around: there is nothing else to
|
|
139
|
+
* fall back to.
|
|
140
|
+
*/
|
|
141
|
+
function load() {
|
|
142
|
+
if (binding) return binding;
|
|
143
|
+
const tried = candidates();
|
|
144
|
+
for (const candidate of tried) {
|
|
145
|
+
if (!fs.existsSync(candidate)) continue;
|
|
146
|
+
binding = require(candidate);
|
|
147
|
+
loadedFrom = path.dirname(candidate);
|
|
148
|
+
return binding;
|
|
149
|
+
}
|
|
150
|
+
const expected = packageName();
|
|
151
|
+
throw new Error(`shotium: no engine for this platform.
|
|
152
|
+
looked in:\n ${tried.join("\n ")}\n` + (expected ? ` It ships in ${expected}, which npm installs as an optional dependency of this package. If the install skipped optional dependencies, it is not there.
|
|
153
|
+
` : ` There is no build for ${process.platform}-${process.arch}.\n`));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The directory the addon came from, or null before the first load(). The
|
|
157
|
+
* resource packs ship beside it, which is what this is for.
|
|
158
|
+
*/
|
|
159
|
+
function directory() {
|
|
160
|
+
return loadedFrom;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/lib/engine.ts
|
|
165
|
+
let startedInThisProcess = false;
|
|
166
|
+
/**
|
|
167
|
+
* Blink, in this process, and the queue in front of it.
|
|
168
|
+
*
|
|
169
|
+
* There is one renderer and there is no way to have two. Blink is a
|
|
170
|
+
* process-wide singleton: it is initialised once, there is no path to a second
|
|
171
|
+
* one, and `worker_threads` do not change that because they share the process.
|
|
172
|
+
* So captures are serialised however many callers there are, and a program
|
|
173
|
+
* that wants four at once wants four processes.
|
|
174
|
+
*
|
|
175
|
+
* The queue is not about fairness. Each capture occupies a libuv thread pool
|
|
176
|
+
* thread for as long as the render takes, and there are four of those by
|
|
177
|
+
* default, shared with fs and dns -- so letting four screenshots go at once
|
|
178
|
+
* would stall the host's file reads for a fifth of a second at a time while
|
|
179
|
+
* gaining nothing, since the engine serialises them anyway.
|
|
180
|
+
*/
|
|
181
|
+
var Engine = class {
|
|
182
|
+
handle = null;
|
|
183
|
+
stopped = false;
|
|
184
|
+
tail = Promise.resolve();
|
|
185
|
+
get running() {
|
|
186
|
+
return this.handle !== null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Starts the engine. Safe to call twice; the second call is a no-op, so that
|
|
190
|
+
* library code can call it defensively.
|
|
191
|
+
*
|
|
192
|
+
* Not safe to call after `stop()`, and not because of anything here: Blink
|
|
193
|
+
* starts once per process and cannot be restarted. Another engine means
|
|
194
|
+
* another process.
|
|
195
|
+
*/
|
|
196
|
+
start(options = {}) {
|
|
197
|
+
if (this.handle) return this;
|
|
198
|
+
if (this.stopped) throw new Error("shotium: this engine was stopped, and Blink cannot be started again in a process that has already run it. Start another process, or keep the engine up between screenshots.");
|
|
199
|
+
if (startedInThisProcess) throw new Error("shotium: an engine has already run in this process. Blink is a process-wide singleton -- there is one per process, ever -- so a second Runtime cannot have one. Use the shared `runtime`, or run another process.");
|
|
200
|
+
const native = load();
|
|
201
|
+
const resolved = resolveStartOptions(options);
|
|
202
|
+
const engineOptions = {};
|
|
203
|
+
if (resolved.cacheDir !== null) engineOptions.cacheDir = resolved.cacheDir;
|
|
204
|
+
if (resolved.userAgent !== void 0) engineOptions.userAgent = resolved.userAgent;
|
|
205
|
+
engineOptions.resourceDir = resolved.resourceDir ?? directory();
|
|
206
|
+
this.handle = native.create(JSON.stringify(engineOptions));
|
|
207
|
+
startedInThisProcess = true;
|
|
208
|
+
return this;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Stops the engine, after whatever is queued.
|
|
212
|
+
*
|
|
213
|
+
* Final for this process: see the note above. A program that will want
|
|
214
|
+
* another screenshot later should leave the engine up and call `purge()`
|
|
215
|
+
* instead, which hands back the memory without giving up the engine.
|
|
216
|
+
*/
|
|
217
|
+
async stop() {
|
|
218
|
+
if (!this.handle) return;
|
|
219
|
+
this.stopped = true;
|
|
220
|
+
const handle = this.handle;
|
|
221
|
+
this.handle = null;
|
|
222
|
+
await this.tail.catch(() => {});
|
|
223
|
+
load().destroy(handle);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Hands back what the engine is holding but can rebuild.
|
|
227
|
+
* `releaseWorkingSet` additionally asks the OS for the pages, which the next
|
|
228
|
+
* screenshot pays back in soft faults -- worth it when there may not be a
|
|
229
|
+
* next one soon.
|
|
230
|
+
*
|
|
231
|
+
* The daemon does this for itself on a timer because it can watch its own
|
|
232
|
+
* request stream go quiet. Here the queue belongs to the caller, so the
|
|
233
|
+
* caller is the one who knows a batch has ended.
|
|
234
|
+
*/
|
|
235
|
+
purge({ releaseWorkingSet = false } = {}) {
|
|
236
|
+
if (!this.handle) return;
|
|
237
|
+
load().purge(this.handle, releaseWorkingSet);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Renders one screenshot. Resolves to the encoded image, or to `null` when
|
|
241
|
+
* `path` was given and the engine wrote the file itself.
|
|
242
|
+
*/
|
|
243
|
+
async screenshot(options) {
|
|
244
|
+
return this.capture(toRequest(options));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The same, for a request that is already in wire form.
|
|
248
|
+
*
|
|
249
|
+
* The daemon reads these off a socket, where they arrived having been
|
|
250
|
+
* validated by the client that sent them. Re-deriving one from
|
|
251
|
+
* ScreenshotOptions would mean the daemon validating a request it cannot see
|
|
252
|
+
* the original of, and rejecting fields a newer client legitimately sent.
|
|
253
|
+
*/
|
|
254
|
+
async capture(request) {
|
|
255
|
+
if (!this.handle) this.start();
|
|
256
|
+
const handle = this.handle;
|
|
257
|
+
const native = load();
|
|
258
|
+
const result = this.tail.catch(() => {}).then(() => native.capture(handle, JSON.stringify(request)));
|
|
259
|
+
this.tail = result.catch(() => {});
|
|
260
|
+
const image = await result;
|
|
261
|
+
return request.path ? null : image;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
//#endregion
|
|
266
|
+
export { encodeFrame as a, FrameReader as i, timeoutFor as n, endpointFor as o, toRequest as r, resolveStartOptions as s, Engine as t };
|
|
267
|
+
//# sourceMappingURL=engine-Xe7nH-1i.js.map
|