@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/src/lib/daemon.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {EventEmitter} from 'node:events';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
4
6
|
|
|
5
7
|
import type {DaemonOptions, DaemonStatus} from '../types.js';
|
|
6
8
|
|
|
7
9
|
import {resolveStartOptions} from './config.js';
|
|
8
10
|
import type {ResolvedStartOptions} from './config.js';
|
|
9
11
|
import {endpointFor} from './endpoint.js';
|
|
10
|
-
import {
|
|
12
|
+
import {Engine} from './engine.js';
|
|
11
13
|
import {FrameReader, encodeFrame} from './protocol.js';
|
|
12
14
|
import type {WireRequest} from './request.js';
|
|
13
15
|
|
|
@@ -25,11 +27,6 @@ const VERSION = (() => {
|
|
|
25
27
|
}
|
|
26
28
|
})();
|
|
27
29
|
|
|
28
|
-
// How much longer than the page's own deadline the daemon waits before it
|
|
29
|
-
// decides a worker is not going to answer at all. Same margin, same reasoning
|
|
30
|
-
// as index.ts: the worker fails a slow page by itself and replies.
|
|
31
|
-
const SUPERVISOR_MARGIN_MS = 10000;
|
|
32
|
-
const DEFAULT_TIMEOUT_MS = 30000;
|
|
33
30
|
const DEFAULT_IDLE_TIMEOUT_MS = 300000;
|
|
34
31
|
|
|
35
32
|
// One message off the socket. `op` defaults to screenshot because that is what
|
|
@@ -51,30 +48,39 @@ interface DaemonReply {
|
|
|
51
48
|
stopping?: boolean;
|
|
52
49
|
}
|
|
53
50
|
|
|
54
|
-
//
|
|
51
|
+
// An engine that outlives the process that asked for it.
|
|
55
52
|
//
|
|
56
|
-
// The
|
|
53
|
+
// The engine in index.ts is already resident, but only for as long as the Node
|
|
57
54
|
// process holding it: a command-line invocation, a CI step, a serverless
|
|
58
|
-
// handler and a `node -e` all pay for starting
|
|
59
|
-
//
|
|
55
|
+
// handler and a `node -e` all pay for starting Blink and then throw it away.
|
|
56
|
+
// This is the same engine behind a socket, so the second caller -- in a
|
|
60
57
|
// different process, minutes later -- pays a connect() and nothing else.
|
|
61
58
|
//
|
|
62
|
-
//
|
|
63
|
-
// answered by a header frame and a payload frame. What it adds is `id`, so one
|
|
64
|
-
// connection can have several requests in flight; the worker protocol cannot,
|
|
65
|
-
// because a worker renders one document at a time, and multiplexing is exactly
|
|
66
|
-
// what the pool in the middle is for.
|
|
59
|
+
// A request frame of JSON, answered by a header frame and a payload frame:
|
|
67
60
|
//
|
|
68
61
|
// -> [len][{"id":7,"op":"screenshot","request":{...}}]
|
|
69
62
|
// <- [len][{"id":7,"ok":true,"bytes":97756}] [len][<PNG>]
|
|
70
63
|
//
|
|
71
|
-
//
|
|
64
|
+
// `id` is on the wire so that a client may have several requests outstanding
|
|
65
|
+
// on one connection. That is a convenience for the client, not concurrency:
|
|
66
|
+
// there is one renderer here, because Blink is a process-wide singleton, so
|
|
67
|
+
// the requests queue and come back in the order the engine finished them.
|
|
68
|
+
// Wanting two at once means wanting two daemons, addressed by `name`.
|
|
69
|
+
//
|
|
70
|
+
// Nothing supervises a capture. The pool this replaced could time a worker out
|
|
71
|
+
// and kill it; an in-process engine has no such seam -- there is no way to
|
|
72
|
+
// abandon a render without abandoning the process. A page's own deadline
|
|
73
|
+
// (`pageGotoParams.timeout`) is what bounds it, and the engine answers slow
|
|
74
|
+
// pages by itself. `timeout` and `retry` on the wire are accepted and ignored,
|
|
75
|
+
// so that an older client still talks to this.
|
|
76
|
+
//
|
|
77
|
+
// Events: ready, warm, request, response, idle-exit, error, close.
|
|
72
78
|
class Daemon extends EventEmitter {
|
|
73
79
|
private readonly options: ResolvedStartOptions;
|
|
74
80
|
private readonly endpointPath: string;
|
|
75
81
|
private readonly idleTimeoutMs: number;
|
|
76
82
|
private readonly prewarmOnStart: boolean;
|
|
77
|
-
private
|
|
83
|
+
private readonly engine = new Engine();
|
|
78
84
|
private server: net.Server|null = null;
|
|
79
85
|
private sockets = new Set<net.Socket>();
|
|
80
86
|
private inFlight = 0;
|
|
@@ -106,24 +112,21 @@ class Daemon extends EventEmitter {
|
|
|
106
112
|
return this.warmed;
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
// Brings the
|
|
115
|
+
// Brings the engine up and starts listening. The pipe existing *is* the
|
|
110
116
|
// readiness signal -- a client's connect() either succeeds or the daemon is
|
|
111
|
-
// not up -- so nothing is bound until the
|
|
117
|
+
// not up -- so nothing is bound until the engine has started.
|
|
118
|
+
//
|
|
119
|
+
// Starting it here rather than on the first request is deliberate: a machine
|
|
120
|
+
// with no engine for its platform should fail while the caller is still
|
|
121
|
+
// watching, not answer a connect() and then reject every request on it.
|
|
112
122
|
async listen(): Promise<this> {
|
|
113
|
-
|
|
114
|
-
this.pool = pool;
|
|
115
|
-
for (const event of ['exit', 'crash', 'timeout', 'worker-restart',
|
|
116
|
-
'worker-error', 'stderr']) {
|
|
117
|
-
pool.on(event, (payload) => this.emit(event, payload));
|
|
118
|
-
}
|
|
119
|
-
pool.start();
|
|
123
|
+
this.engine.start(this.options);
|
|
120
124
|
|
|
121
125
|
this.server = net.createServer((socket) => this.accept(socket));
|
|
122
126
|
this.server.on('error', (error) => this.emit('error', error));
|
|
123
127
|
await this.bind();
|
|
124
128
|
this.armIdleTimer();
|
|
125
|
-
this.emit('ready',
|
|
126
|
-
{endpoint: this.endpointPath, workers: this.options.workers});
|
|
129
|
+
this.emit('ready', {endpoint: this.endpointPath});
|
|
127
130
|
if (this.prewarmOnStart) {
|
|
128
131
|
await this.prewarm();
|
|
129
132
|
}
|
|
@@ -178,8 +181,8 @@ class Daemon extends EventEmitter {
|
|
|
178
181
|
//
|
|
179
182
|
// On Windows it is a named pipe, and node exposes no way to give one an ACL:
|
|
180
183
|
// the default lets any account on the machine open it. A daemon on a shared
|
|
181
|
-
// Windows host is therefore as trusted as the machine's users are --
|
|
182
|
-
// in
|
|
184
|
+
// Windows host is therefore as trusted as the machine's users are -- use the
|
|
185
|
+
// engine in your own process, where nothing is listening, if that is not
|
|
183
186
|
// acceptable.
|
|
184
187
|
private restrict(): void {
|
|
185
188
|
if (process.platform === 'win32') {
|
|
@@ -192,23 +195,37 @@ class Daemon extends EventEmitter {
|
|
|
192
195
|
}
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
// Renders one throwaway document
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
198
|
+
// Renders one throwaway document so that the first real request does not pay
|
|
199
|
+
// for whatever the engine initialises lazily. One is enough: there is one
|
|
200
|
+
// renderer, and it is the same one every request lands on.
|
|
201
|
+
//
|
|
202
|
+
// A temporary file, not a `data:` URL. This used to send
|
|
203
|
+
// `data:text/html,...`, which the renderer rejects -- shot_capture.cc takes
|
|
204
|
+
// file, http and https and nothing else -- so every prewarm failed into the
|
|
205
|
+
// catch below and the step had never once done anything. The failure was
|
|
206
|
+
// invisible because a prewarm that does not work looks exactly like one that
|
|
207
|
+
// does, only slower on the first request.
|
|
199
208
|
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
209
|
+
// The document names no subresources, so it renders identically whether or
|
|
210
|
+
// not this daemon allows file access -- which is what the `data:` URL was
|
|
211
|
+
// reaching for. A top-level file: URL always loads; `allowFileAccess` gates
|
|
212
|
+
// what the document may then pull in.
|
|
202
213
|
async prewarm(): Promise<void> {
|
|
203
|
-
const blank =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
214
|
+
const blank = path.join(
|
|
215
|
+
os.tmpdir(), `shotium-prewarm-${process.pid}.html`);
|
|
216
|
+
try {
|
|
217
|
+
fs.writeFileSync(
|
|
218
|
+
blank, '<!doctype html><title>shotium</title><p>shotium');
|
|
219
|
+
await this.engine.capture({file: blank, width: 16, height: 16});
|
|
220
|
+
this.warmed = true;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// Not fatal: a daemon that could not prewarm still serves. But it is not
|
|
223
|
+
// warm, and status() should not claim it is.
|
|
224
|
+
this.emit('error', error);
|
|
225
|
+
} finally {
|
|
226
|
+
fs.rmSync(blank, {force: true});
|
|
227
|
+
}
|
|
228
|
+
this.emit('warm', {warm: this.warmed});
|
|
212
229
|
}
|
|
213
230
|
|
|
214
231
|
status(): DaemonStatus {
|
|
@@ -216,10 +233,9 @@ class Daemon extends EventEmitter {
|
|
|
216
233
|
ok: true,
|
|
217
234
|
pid: process.pid,
|
|
218
235
|
endpoint: this.endpointPath,
|
|
219
|
-
binary: this.options.binary,
|
|
220
|
-
workers: this.options.workers,
|
|
221
236
|
cacheDir: this.options.cacheDir,
|
|
222
|
-
|
|
237
|
+
userAgent: this.options.userAgent,
|
|
238
|
+
resourceDir: this.options.resourceDir,
|
|
223
239
|
warm: this.warmed,
|
|
224
240
|
uptimeMs: Date.now() - this.startedAt,
|
|
225
241
|
connections: this.sockets.size,
|
|
@@ -285,26 +301,22 @@ class Daemon extends EventEmitter {
|
|
|
285
301
|
}
|
|
286
302
|
|
|
287
303
|
const request = message.request || ({} as WireRequest);
|
|
288
|
-
const timeout = (typeof message.timeout === 'number' ? message.timeout :
|
|
289
|
-
DEFAULT_TIMEOUT_MS) +
|
|
290
|
-
SUPERVISOR_MARGIN_MS;
|
|
291
|
-
const retry = typeof message.retry === 'number' ? message.retry : 0;
|
|
292
304
|
|
|
293
305
|
this.inFlight += 1;
|
|
294
306
|
this.armIdleTimer();
|
|
295
307
|
this.emit('request', {id, file: request.file});
|
|
296
|
-
this.
|
|
297
|
-
.then((
|
|
308
|
+
this.engine.capture(request)
|
|
309
|
+
.then((image) => {
|
|
298
310
|
this.served += 1;
|
|
299
311
|
this.reply(
|
|
300
312
|
socket,
|
|
301
313
|
{
|
|
302
314
|
id,
|
|
303
315
|
ok: true,
|
|
304
|
-
bytes:
|
|
305
|
-
path:
|
|
316
|
+
bytes: image ? image.length : 0,
|
|
317
|
+
path: request.path,
|
|
306
318
|
},
|
|
307
|
-
|
|
319
|
+
image);
|
|
308
320
|
})
|
|
309
321
|
.catch((error: Error) => {
|
|
310
322
|
this.reply(
|
|
@@ -362,7 +374,7 @@ class Daemon extends EventEmitter {
|
|
|
362
374
|
}
|
|
363
375
|
this.sockets.clear();
|
|
364
376
|
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
|
365
|
-
await this.
|
|
377
|
+
await this.engine.stop();
|
|
366
378
|
this.emit('close', {});
|
|
367
379
|
}
|
|
368
380
|
}
|
package/src/lib/endpoint.ts
CHANGED
|
@@ -5,21 +5,27 @@ import path from 'node:path';
|
|
|
5
5
|
// What endpointFor() needs to know: a resolved configuration, plus the two
|
|
6
6
|
// ways of overriding the address it would derive from one.
|
|
7
7
|
export interface EndpointOptions {
|
|
8
|
-
binary?: string;
|
|
9
|
-
workers?: number;
|
|
10
8
|
cacheDir?: string|null;
|
|
11
|
-
|
|
9
|
+
userAgent?: string;
|
|
10
|
+
resourceDir?: string;
|
|
12
11
|
name?: string;
|
|
13
12
|
endpoint?: string;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
// Where a daemon listens, derived from what it was asked to be.
|
|
17
16
|
//
|
|
18
|
-
// The address is a hash of the configuration --
|
|
19
|
-
//
|
|
20
|
-
// daemon happens to be up would mean rendering with someone else's
|
|
21
|
-
//
|
|
22
|
-
//
|
|
17
|
+
// The address is a hash of the configuration -- cache root, user agent,
|
|
18
|
+
// resource directory -- rather than a fixed name, because attaching to
|
|
19
|
+
// whatever daemon happens to be up would mean rendering with someone else's
|
|
20
|
+
// settings. Two configurations are two daemons; the same configuration, from
|
|
21
|
+
// any process, is one.
|
|
22
|
+
//
|
|
23
|
+
// Every field of EndpointOptions is optional, so nothing here fails to compile
|
|
24
|
+
// when a field is dropped from the configuration -- it just stops being part
|
|
25
|
+
// of the identity, and every caller collapses onto one address. That happened
|
|
26
|
+
// once, when the worker pool went away and this was left hashing three fields
|
|
27
|
+
// that no longer existed. If a field is added to StartOptions and it changes
|
|
28
|
+
// what the engine renders, it belongs in the array below.
|
|
23
29
|
//
|
|
24
30
|
// A caller who wants a daemon by name instead of by configuration passes
|
|
25
31
|
// `name`, which replaces the hash. That is the escape hatch for a service that
|
|
@@ -30,10 +36,11 @@ function endpointKey(options: EndpointOptions): string {
|
|
|
30
36
|
return String(options.name);
|
|
31
37
|
}
|
|
32
38
|
const identity = JSON.stringify([
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
options.
|
|
39
|
+
options.cacheDir === null || options.cacheDir === undefined ?
|
|
40
|
+
null :
|
|
41
|
+
path.resolve(options.cacheDir),
|
|
42
|
+
options.userAgent ?? null,
|
|
43
|
+
options.resourceDir ? path.resolve(options.resourceDir) : null,
|
|
37
44
|
]);
|
|
38
45
|
return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
|
|
39
46
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import * as binding from './binding.js';
|
|
2
|
+
import type {Engine as Handle} from './binding.js';
|
|
3
|
+
import {toRequest} from './request.js';
|
|
4
|
+
import type {WireRequest} from './request.js';
|
|
5
|
+
import type {PurgeOptions, ScreenshotOptions, StartOptions} from '../types.js';
|
|
6
|
+
|
|
7
|
+
import {resolveStartOptions} from './config.js';
|
|
8
|
+
|
|
9
|
+
// One per process, ever. Not one at a time -- one.
|
|
10
|
+
//
|
|
11
|
+
// This is not a rule of this file, it is what Blink is: initialising it writes
|
|
12
|
+
// process-wide statics it has no path to undo, so shot_engine_destroy() gives
|
|
13
|
+
// back what it can and the process still cannot make another. The C API
|
|
14
|
+
// returns SHOT_ERR_STATE for a second create whether or not the first is
|
|
15
|
+
// still alive. See shot/shot_api.h.
|
|
16
|
+
//
|
|
17
|
+
// So `stop()` is final for the process, and this flag exists to say that in
|
|
18
|
+
// words at the call site. Without it a caller who stops and starts again gets
|
|
19
|
+
// SHOT_ERR_STATE out of the addon -- a true error, arriving one layer too deep
|
|
20
|
+
// to explain that the answer is a second process rather than a retry.
|
|
21
|
+
let startedInThisProcess = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Blink, in this process, and the queue in front of it.
|
|
25
|
+
*
|
|
26
|
+
* There is one renderer and there is no way to have two. Blink is a
|
|
27
|
+
* process-wide singleton: it is initialised once, there is no path to a second
|
|
28
|
+
* one, and `worker_threads` do not change that because they share the process.
|
|
29
|
+
* So captures are serialised however many callers there are, and a program
|
|
30
|
+
* that wants four at once wants four processes.
|
|
31
|
+
*
|
|
32
|
+
* The queue is not about fairness. Each capture occupies a libuv thread pool
|
|
33
|
+
* thread for as long as the render takes, and there are four of those by
|
|
34
|
+
* default, shared with fs and dns -- so letting four screenshots go at once
|
|
35
|
+
* would stall the host's file reads for a fifth of a second at a time while
|
|
36
|
+
* gaining nothing, since the engine serialises them anyway.
|
|
37
|
+
*/
|
|
38
|
+
export class Engine {
|
|
39
|
+
private handle: Handle|null = null;
|
|
40
|
+
private stopped = false;
|
|
41
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
42
|
+
|
|
43
|
+
get running(): boolean {
|
|
44
|
+
return this.handle !== null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Starts the engine. Safe to call twice; the second call is a no-op, so that
|
|
49
|
+
* library code can call it defensively.
|
|
50
|
+
*
|
|
51
|
+
* Not safe to call after `stop()`, and not because of anything here: Blink
|
|
52
|
+
* starts once per process and cannot be restarted. Another engine means
|
|
53
|
+
* another process.
|
|
54
|
+
*/
|
|
55
|
+
start(options: StartOptions = {}): this {
|
|
56
|
+
if (this.handle) {
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
if (this.stopped) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'shotium: this engine was stopped, and Blink cannot be started ' +
|
|
62
|
+
'again in a process that has already run it. Start another ' +
|
|
63
|
+
'process, or keep the engine up between screenshots.');
|
|
64
|
+
}
|
|
65
|
+
if (startedInThisProcess) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'shotium: an engine has already run in this process. Blink is a ' +
|
|
68
|
+
'process-wide singleton -- there is one per process, ever -- so a ' +
|
|
69
|
+
'second Runtime cannot have one. Use the shared `runtime`, or run ' +
|
|
70
|
+
'another process.');
|
|
71
|
+
}
|
|
72
|
+
const native = binding.load();
|
|
73
|
+
const resolved = resolveStartOptions(options);
|
|
74
|
+
|
|
75
|
+
const engineOptions: Record<string, unknown> = {};
|
|
76
|
+
if (resolved.cacheDir !== null) {
|
|
77
|
+
engineOptions.cacheDir = resolved.cacheDir;
|
|
78
|
+
}
|
|
79
|
+
if (resolved.userAgent !== undefined) {
|
|
80
|
+
engineOptions.userAgent = resolved.userAgent;
|
|
81
|
+
}
|
|
82
|
+
// The packs sit beside the library, and the library cannot find itself on
|
|
83
|
+
// Linux -- the path the engine resolves for "this module" goes through
|
|
84
|
+
// /proc/self/exe, which names node. Saying it here is cheaper than
|
|
85
|
+
// teaching the engine a second way to look. See shot_api.h.
|
|
86
|
+
engineOptions.resourceDir = resolved.resourceDir ?? binding.directory();
|
|
87
|
+
|
|
88
|
+
this.handle = native.create(JSON.stringify(engineOptions));
|
|
89
|
+
startedInThisProcess = true;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stops the engine, after whatever is queued.
|
|
95
|
+
*
|
|
96
|
+
* Final for this process: see the note above. A program that will want
|
|
97
|
+
* another screenshot later should leave the engine up and call `purge()`
|
|
98
|
+
* instead, which hands back the memory without giving up the engine.
|
|
99
|
+
*/
|
|
100
|
+
async stop(): Promise<void> {
|
|
101
|
+
if (!this.handle) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.stopped = true;
|
|
105
|
+
// After the queue, not before: destroy() waits for a capture in flight
|
|
106
|
+
// anyway, and doing it in order means a caller's last screenshot resolves
|
|
107
|
+
// rather than racing the shutdown.
|
|
108
|
+
const handle = this.handle;
|
|
109
|
+
this.handle = null;
|
|
110
|
+
await this.tail.catch(() => {});
|
|
111
|
+
binding.load().destroy(handle);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Hands back what the engine is holding but can rebuild.
|
|
116
|
+
* `releaseWorkingSet` additionally asks the OS for the pages, which the next
|
|
117
|
+
* screenshot pays back in soft faults -- worth it when there may not be a
|
|
118
|
+
* next one soon.
|
|
119
|
+
*
|
|
120
|
+
* The daemon does this for itself on a timer because it can watch its own
|
|
121
|
+
* request stream go quiet. Here the queue belongs to the caller, so the
|
|
122
|
+
* caller is the one who knows a batch has ended.
|
|
123
|
+
*/
|
|
124
|
+
purge({releaseWorkingSet = false}: PurgeOptions = {}): void {
|
|
125
|
+
if (!this.handle) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
binding.load().purge(this.handle, releaseWorkingSet);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Renders one screenshot. Resolves to the encoded image, or to `null` when
|
|
133
|
+
* `path` was given and the engine wrote the file itself.
|
|
134
|
+
*/
|
|
135
|
+
// `async` and not a plain function returning capture()'s promise: toRequest()
|
|
136
|
+
// throws, and a caller who wrote `screenshot(bad).catch(...)` would get the
|
|
137
|
+
// throw past the catch and into the surrounding frame. The whole surface is
|
|
138
|
+
// promise-shaped, so a bad request is a rejection like everything else.
|
|
139
|
+
async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
|
|
140
|
+
// Before anything else, and before the queue: a malformed request should
|
|
141
|
+
// be a rejection now rather than one that waits its turn.
|
|
142
|
+
return this.capture(toRequest(options));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The same, for a request that is already in wire form.
|
|
147
|
+
*
|
|
148
|
+
* The daemon reads these off a socket, where they arrived having been
|
|
149
|
+
* validated by the client that sent them. Re-deriving one from
|
|
150
|
+
* ScreenshotOptions would mean the daemon validating a request it cannot see
|
|
151
|
+
* the original of, and rejecting fields a newer client legitimately sent.
|
|
152
|
+
*/
|
|
153
|
+
async capture(request: WireRequest): Promise<Buffer|null> {
|
|
154
|
+
if (!this.handle) {
|
|
155
|
+
this.start();
|
|
156
|
+
}
|
|
157
|
+
const handle = this.handle;
|
|
158
|
+
const native = binding.load();
|
|
159
|
+
|
|
160
|
+
// Chain onto the tail so that captures run one at a time. The catch keeps
|
|
161
|
+
// one failure from poisoning everything queued behind it.
|
|
162
|
+
const result = this.tail.catch(() => {}).then(
|
|
163
|
+
() => native.capture(handle, JSON.stringify(request)));
|
|
164
|
+
this.tail = result.catch(() => {});
|
|
165
|
+
const image = await result;
|
|
166
|
+
return request.path ? null : image;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/lib/platform.ts
CHANGED
|
@@ -66,10 +66,4 @@ function packageDir(): string|null {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
// it: Windows wants the extension and nothing else does.
|
|
71
|
-
function binaryName(): string {
|
|
72
|
-
return process.platform === 'win32' ? 'shotium.exe' : 'shotium';
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export {PACKAGES, binaryName, packageDir, packageName};
|
|
69
|
+
export {PACKAGES, packageDir, packageName};
|
package/src/lib/request.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import type {Clip, PageGotoParams, ScreenshotOptions} from '../types.js';
|
|
2
2
|
|
|
3
3
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
4
|
-
// How much longer than the page's own deadline a supervisor waits before
|
|
5
|
-
// deciding the worker is not going to answer at all. The worker fails a slow
|
|
6
|
-
// page by itself and replies; this margin covers process startup and the
|
|
7
|
-
// encode, and firing it means something worse than a slow page.
|
|
8
|
-
const SUPERVISOR_MARGIN_MS = 10000;
|
|
9
4
|
|
|
10
|
-
// What actually goes down the pipe
|
|
11
|
-
// flattened
|
|
5
|
+
// What actually goes down the pipe: ScreenshotOptions with the viewport
|
|
6
|
+
// flattened -- see toRequest below for why.
|
|
12
7
|
export interface WireRequest {
|
|
13
8
|
file: string;
|
|
14
9
|
type?: 'png'|'jpeg'|'webp';
|
|
@@ -49,8 +44,8 @@ const WIRE_FIELDS = new Set([
|
|
|
49
44
|
|
|
50
45
|
// One ScreenshotOptions, checked and flattened into what goes on the wire.
|
|
51
46
|
//
|
|
52
|
-
// It lives here rather than in index.ts because the in
|
|
53
|
-
// daemon both send it: a request that is valid through one entry point and
|
|
47
|
+
// It lives here rather than in index.ts because the engine in this process and
|
|
48
|
+
// the daemon both send it: a request that is valid through one entry point and
|
|
54
49
|
// rejected through the other would be a difference nobody asked for.
|
|
55
50
|
function toRequest(options: ScreenshotOptions): WireRequest {
|
|
56
51
|
if (!options || typeof options !== 'object') {
|
|
@@ -65,11 +60,6 @@ function toRequest(options: ScreenshotOptions): WireRequest {
|
|
|
65
60
|
if (value === undefined) {
|
|
66
61
|
continue;
|
|
67
62
|
}
|
|
68
|
-
// retry is the supervisor's, not the worker's: it decides how many times a
|
|
69
|
-
// request is re-sent, which is not something the worker could act on.
|
|
70
|
-
if (key === 'retry') {
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
63
|
if (!WIRE_FIELDS.has(key)) {
|
|
74
64
|
throw new TypeError(`shotium: unknown option "${key}"`);
|
|
75
65
|
}
|
|
@@ -101,7 +91,6 @@ function timeoutFor(options: ScreenshotOptions): number {
|
|
|
101
91
|
|
|
102
92
|
export {
|
|
103
93
|
DEFAULT_TIMEOUT_MS,
|
|
104
|
-
SUPERVISOR_MARGIN_MS,
|
|
105
94
|
WIRE_FIELDS,
|
|
106
95
|
timeoutFor,
|
|
107
96
|
toRequest,
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// The vocabulary of the package: what a caller passes in and what comes back.
|
|
2
2
|
//
|
|
3
3
|
// It lives in one file rather than beside the code that reads each field
|
|
4
|
-
// because these types are the published API
|
|
5
|
-
//
|
|
6
|
-
// point they came through -- and, more to the point, so there is one place
|
|
7
|
-
// where adding an option means adding it.
|
|
4
|
+
// because these types are the published API: index.ts re-exports them, so
|
|
5
|
+
// there is one place where adding an option means adding it.
|
|
8
6
|
|
|
9
7
|
/** A region of the document, in CSS pixels. */
|
|
10
8
|
export interface Clip {
|
|
@@ -70,35 +68,29 @@ export interface ScreenshotOptions {
|
|
|
70
68
|
* is rendered on.
|
|
71
69
|
*/
|
|
72
70
|
allowFileAccess?: boolean;
|
|
73
|
-
/** How many times to re-send after a crash or a timeout. Default 0. */
|
|
74
|
-
retry?: number;
|
|
75
71
|
}
|
|
76
72
|
|
|
77
73
|
export interface StartOptions {
|
|
78
74
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
75
|
+
* Root of the HTTP disk cache. `null` disables caching entirely, which is
|
|
76
|
+
* the default: a program holding the engine is often short-lived, and a
|
|
77
|
+
* cache it never reads twice is a directory it leaves behind.
|
|
81
78
|
*/
|
|
82
|
-
binary?: string;
|
|
83
|
-
/** Worker processes. Default half the cores, at least one, at most four. */
|
|
84
|
-
workers?: number;
|
|
85
|
-
/** Root of the per-worker HTTP disk caches. `null` disables caching. */
|
|
86
79
|
cacheDir?: string|null;
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
signal?: NodeJS.Signals|null;
|
|
80
|
+
/** Overrides the built-in user agent string. */
|
|
81
|
+
userAgent?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Where `shotium_data.pak` and `shotium_strings.pak` are. Defaults to the
|
|
84
|
+
* directory the engine was loaded from, which is where they ship.
|
|
85
|
+
*/
|
|
86
|
+
resourceDir?: string;
|
|
95
87
|
}
|
|
96
88
|
|
|
97
89
|
export interface DaemonOptions extends StartOptions {
|
|
98
90
|
/**
|
|
99
91
|
* Address the daemon by name instead of by configuration. Without it the
|
|
100
|
-
* endpoint is a hash of `
|
|
101
|
-
* client never attaches to a
|
|
92
|
+
* endpoint is a hash of `cacheDir`, `userAgent` and `resourceDir`, so a
|
|
93
|
+
* client never attaches to a daemon that renders with something other than
|
|
102
94
|
* what it asked for.
|
|
103
95
|
*/
|
|
104
96
|
name?: string;
|
|
@@ -110,9 +102,8 @@ export interface DaemonOptions extends StartOptions {
|
|
|
110
102
|
*/
|
|
111
103
|
idleTimeoutMs?: number;
|
|
112
104
|
/**
|
|
113
|
-
* Render one throwaway document
|
|
114
|
-
*
|
|
115
|
-
* true.
|
|
105
|
+
* Render one throwaway document at startup, so the first real request does
|
|
106
|
+
* not pay for whatever the engine initialises lazily. Default true.
|
|
116
107
|
*/
|
|
117
108
|
prewarm?: boolean;
|
|
118
109
|
/** Fail instead of starting a daemon when none is listening. */
|
|
@@ -129,11 +120,10 @@ export interface DaemonStatus {
|
|
|
129
120
|
spawned?: boolean;
|
|
130
121
|
pid: number;
|
|
131
122
|
endpoint: string;
|
|
132
|
-
binary: string;
|
|
133
|
-
workers: number;
|
|
134
123
|
cacheDir: string|null;
|
|
135
|
-
|
|
136
|
-
|
|
124
|
+
userAgent?: string;
|
|
125
|
+
resourceDir?: string;
|
|
126
|
+
/** The engine has rendered at least once. */
|
|
137
127
|
warm: boolean;
|
|
138
128
|
uptimeMs: number;
|
|
139
129
|
connections: number;
|
|
@@ -143,22 +133,6 @@ export interface DaemonStatus {
|
|
|
143
133
|
version: string;
|
|
144
134
|
}
|
|
145
135
|
|
|
146
|
-
export interface NativeStartOptions {
|
|
147
|
-
/**
|
|
148
|
-
* Root of the HTTP disk cache. `null` disables caching entirely, which is
|
|
149
|
-
* the default here: an in-process engine is often a short-lived program, and
|
|
150
|
-
* a cache it never reads twice is a directory it leaves behind.
|
|
151
|
-
*/
|
|
152
|
-
cacheDir?: string|null;
|
|
153
|
-
/** Overrides the built-in user agent string. */
|
|
154
|
-
userAgent?: string;
|
|
155
|
-
/**
|
|
156
|
-
* Where `shotium_data.pak` and `shotium_strings.pak` are. Defaults to the
|
|
157
|
-
* directory the native engine was loaded from, which is where they ship.
|
|
158
|
-
*/
|
|
159
|
-
resourceDir?: string;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
136
|
export interface PurgeOptions {
|
|
163
137
|
/**
|
|
164
138
|
* Also ask the OS to take the engine's pages back. The next screenshot pays
|