@shotkit/shotium 0.1.0 → 0.3.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 +321 -65
- package/dist/daemon_main.js +29 -44
- package/dist/daemon_main.js.map +1 -1
- package/dist/index.d.ts +551 -57
- package/dist/index.js +425 -73
- package/dist/index.js.map +1 -1
- package/dist/protocol-BTeWJDOa.js +474 -0
- package/dist/protocol-BTeWJDOa.js.map +1 -0
- package/native/binding.cc +184 -3
- package/package.json +7 -11
- package/src/index.ts +179 -89
- package/src/lib/binding.ts +110 -0
- package/src/lib/cache.ts +323 -0
- package/src/lib/client.ts +32 -17
- package/src/lib/config.ts +113 -61
- package/src/lib/daemon.ts +97 -62
- package/src/lib/endpoint.ts +19 -12
- package/src/lib/engine.ts +356 -0
- package/src/lib/platform.ts +1 -7
- package/src/lib/request.ts +14 -16
- package/src/types.ts +216 -42
- 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,19 @@
|
|
|
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
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
CaptureStats,
|
|
9
|
+
DaemonOptions,
|
|
10
|
+
DaemonStatus,
|
|
11
|
+
} from '../types.js';
|
|
6
12
|
|
|
7
13
|
import {resolveStartOptions} from './config.js';
|
|
8
14
|
import type {ResolvedStartOptions} from './config.js';
|
|
9
15
|
import {endpointFor} from './endpoint.js';
|
|
10
|
-
import {
|
|
16
|
+
import {Engine} from './engine.js';
|
|
11
17
|
import {FrameReader, encodeFrame} from './protocol.js';
|
|
12
18
|
import type {WireRequest} from './request.js';
|
|
13
19
|
|
|
@@ -25,11 +31,6 @@ const VERSION = (() => {
|
|
|
25
31
|
}
|
|
26
32
|
})();
|
|
27
33
|
|
|
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
34
|
const DEFAULT_IDLE_TIMEOUT_MS = 300000;
|
|
34
35
|
|
|
35
36
|
// One message off the socket. `op` defaults to screenshot because that is what
|
|
@@ -49,32 +50,46 @@ interface DaemonReply {
|
|
|
49
50
|
bytes?: number;
|
|
50
51
|
path?: string;
|
|
51
52
|
stopping?: boolean;
|
|
53
|
+
// What the capture cost, on the success header and on the failure one. The
|
|
54
|
+
// client turns it back into the same CaptureStats the in-process engine
|
|
55
|
+
// returns, so a program moving between the two changes an import and
|
|
56
|
+
// nothing else.
|
|
57
|
+
stats?: CaptureStats;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
//
|
|
60
|
+
// An engine that outlives the process that asked for it.
|
|
55
61
|
//
|
|
56
|
-
// The
|
|
62
|
+
// The engine in index.ts is already resident, but only for as long as the Node
|
|
57
63
|
// process holding it: a command-line invocation, a CI step, a serverless
|
|
58
|
-
// handler and a `node -e` all pay for starting
|
|
59
|
-
//
|
|
64
|
+
// handler and a `node -e` all pay for starting Blink and then throw it away.
|
|
65
|
+
// This is the same engine behind a socket, so the second caller -- in a
|
|
60
66
|
// different process, minutes later -- pays a connect() and nothing else.
|
|
61
67
|
//
|
|
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.
|
|
68
|
+
// A request frame of JSON, answered by a header frame and a payload frame:
|
|
67
69
|
//
|
|
68
70
|
// -> [len][{"id":7,"op":"screenshot","request":{...}}]
|
|
69
71
|
// <- [len][{"id":7,"ok":true,"bytes":97756}] [len][<PNG>]
|
|
70
72
|
//
|
|
71
|
-
//
|
|
73
|
+
// `id` is on the wire so that a client may have several requests outstanding
|
|
74
|
+
// on one connection. That is a convenience for the client, not concurrency:
|
|
75
|
+
// there is one renderer here, because Blink is a process-wide singleton, so
|
|
76
|
+
// the requests queue and come back in the order the engine finished them.
|
|
77
|
+
// Wanting two at once means wanting two daemons, addressed by `name`.
|
|
78
|
+
//
|
|
79
|
+
// Nothing supervises a capture. The pool this replaced could time a worker out
|
|
80
|
+
// and kill it; an in-process engine has no such seam -- there is no way to
|
|
81
|
+
// abandon a render without abandoning the process. A page's own deadline
|
|
82
|
+
// (`pageGotoParams.timeout`) is what bounds it, and the engine answers slow
|
|
83
|
+
// pages by itself. `timeout` and `retry` on the wire are accepted and ignored,
|
|
84
|
+
// so that an older client still talks to this.
|
|
85
|
+
//
|
|
86
|
+
// Events: ready, warm, request, response, idle-exit, error, close.
|
|
72
87
|
class Daemon extends EventEmitter {
|
|
73
88
|
private readonly options: ResolvedStartOptions;
|
|
74
89
|
private readonly endpointPath: string;
|
|
75
90
|
private readonly idleTimeoutMs: number;
|
|
76
91
|
private readonly prewarmOnStart: boolean;
|
|
77
|
-
private
|
|
92
|
+
private readonly engine = new Engine();
|
|
78
93
|
private server: net.Server|null = null;
|
|
79
94
|
private sockets = new Set<net.Socket>();
|
|
80
95
|
private inFlight = 0;
|
|
@@ -106,24 +121,21 @@ class Daemon extends EventEmitter {
|
|
|
106
121
|
return this.warmed;
|
|
107
122
|
}
|
|
108
123
|
|
|
109
|
-
// Brings the
|
|
124
|
+
// Brings the engine up and starts listening. The pipe existing *is* the
|
|
110
125
|
// readiness signal -- a client's connect() either succeeds or the daemon is
|
|
111
|
-
// not up -- so nothing is bound until the
|
|
126
|
+
// not up -- so nothing is bound until the engine has started.
|
|
127
|
+
//
|
|
128
|
+
// Starting it here rather than on the first request is deliberate: a machine
|
|
129
|
+
// with no engine for its platform should fail while the caller is still
|
|
130
|
+
// watching, not answer a connect() and then reject every request on it.
|
|
112
131
|
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();
|
|
132
|
+
this.engine.start(this.options);
|
|
120
133
|
|
|
121
134
|
this.server = net.createServer((socket) => this.accept(socket));
|
|
122
135
|
this.server.on('error', (error) => this.emit('error', error));
|
|
123
136
|
await this.bind();
|
|
124
137
|
this.armIdleTimer();
|
|
125
|
-
this.emit('ready',
|
|
126
|
-
{endpoint: this.endpointPath, workers: this.options.workers});
|
|
138
|
+
this.emit('ready', {endpoint: this.endpointPath});
|
|
127
139
|
if (this.prewarmOnStart) {
|
|
128
140
|
await this.prewarm();
|
|
129
141
|
}
|
|
@@ -178,8 +190,8 @@ class Daemon extends EventEmitter {
|
|
|
178
190
|
//
|
|
179
191
|
// On Windows it is a named pipe, and node exposes no way to give one an ACL:
|
|
180
192
|
// 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
|
|
193
|
+
// Windows host is therefore as trusted as the machine's users are -- use the
|
|
194
|
+
// engine in your own process, where nothing is listening, if that is not
|
|
183
195
|
// acceptable.
|
|
184
196
|
private restrict(): void {
|
|
185
197
|
if (process.platform === 'win32') {
|
|
@@ -192,23 +204,37 @@ class Daemon extends EventEmitter {
|
|
|
192
204
|
}
|
|
193
205
|
}
|
|
194
206
|
|
|
195
|
-
// Renders one throwaway document
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
207
|
+
// Renders one throwaway document so that the first real request does not pay
|
|
208
|
+
// for whatever the engine initialises lazily. One is enough: there is one
|
|
209
|
+
// renderer, and it is the same one every request lands on.
|
|
210
|
+
//
|
|
211
|
+
// A temporary file, not a `data:` URL. This used to send
|
|
212
|
+
// `data:text/html,...`, which the renderer rejects -- shot_capture.cc takes
|
|
213
|
+
// file, http and https and nothing else -- so every prewarm failed into the
|
|
214
|
+
// catch below and the step had never once done anything. The failure was
|
|
215
|
+
// invisible because a prewarm that does not work looks exactly like one that
|
|
216
|
+
// does, only slower on the first request.
|
|
199
217
|
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
218
|
+
// The document names no subresources, so it renders identically whether or
|
|
219
|
+
// not this daemon allows file access -- which is what the `data:` URL was
|
|
220
|
+
// reaching for. A top-level file: URL always loads; `allowFileAccess` gates
|
|
221
|
+
// what the document may then pull in.
|
|
202
222
|
async prewarm(): Promise<void> {
|
|
203
|
-
const blank =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
223
|
+
const blank = path.join(
|
|
224
|
+
os.tmpdir(), `shotium-prewarm-${process.pid}.html`);
|
|
225
|
+
try {
|
|
226
|
+
fs.writeFileSync(
|
|
227
|
+
blank, '<!doctype html><title>shotium</title><p>shotium');
|
|
228
|
+
await this.engine.capture({file: blank, width: 16, height: 16});
|
|
229
|
+
this.warmed = true;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Not fatal: a daemon that could not prewarm still serves. But it is not
|
|
232
|
+
// warm, and status() should not claim it is.
|
|
233
|
+
this.emit('error', error);
|
|
234
|
+
} finally {
|
|
235
|
+
fs.rmSync(blank, {force: true});
|
|
236
|
+
}
|
|
237
|
+
this.emit('warm', {warm: this.warmed});
|
|
212
238
|
}
|
|
213
239
|
|
|
214
240
|
status(): DaemonStatus {
|
|
@@ -216,10 +242,9 @@ class Daemon extends EventEmitter {
|
|
|
216
242
|
ok: true,
|
|
217
243
|
pid: process.pid,
|
|
218
244
|
endpoint: this.endpointPath,
|
|
219
|
-
binary: this.options.binary,
|
|
220
|
-
workers: this.options.workers,
|
|
221
245
|
cacheDir: this.options.cacheDir,
|
|
222
|
-
|
|
246
|
+
userAgent: this.options.userAgent,
|
|
247
|
+
resourceDir: this.options.resourceDir,
|
|
223
248
|
warm: this.warmed,
|
|
224
249
|
uptimeMs: Date.now() - this.startedAt,
|
|
225
250
|
connections: this.sockets.size,
|
|
@@ -285,30 +310,34 @@ class Daemon extends EventEmitter {
|
|
|
285
310
|
}
|
|
286
311
|
|
|
287
312
|
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
313
|
|
|
293
314
|
this.inFlight += 1;
|
|
294
315
|
this.armIdleTimer();
|
|
295
316
|
this.emit('request', {id, file: request.file});
|
|
296
|
-
this.
|
|
297
|
-
.then((
|
|
317
|
+
this.engine.capture(request)
|
|
318
|
+
.then(({image, stats}) => {
|
|
298
319
|
this.served += 1;
|
|
299
320
|
this.reply(
|
|
300
321
|
socket,
|
|
301
322
|
{
|
|
302
323
|
id,
|
|
303
324
|
ok: true,
|
|
304
|
-
bytes:
|
|
305
|
-
path:
|
|
325
|
+
bytes: image ? image.length : 0,
|
|
326
|
+
path: request.path,
|
|
327
|
+
stats,
|
|
306
328
|
},
|
|
307
|
-
|
|
329
|
+
image);
|
|
308
330
|
})
|
|
309
|
-
.catch((error: Error) => {
|
|
310
|
-
|
|
311
|
-
|
|
331
|
+
.catch((error: Error&{stats?: CaptureStats}) => {
|
|
332
|
+
// The counters go back with the failure, matching the in-process
|
|
333
|
+
// engine: a capture that timed out after fetching forty subresources
|
|
334
|
+
// has already said why, and the message alone has not.
|
|
335
|
+
this.reply(socket, {
|
|
336
|
+
id,
|
|
337
|
+
ok: false,
|
|
338
|
+
error: String(error.message || error),
|
|
339
|
+
stats: error.stats,
|
|
340
|
+
});
|
|
312
341
|
})
|
|
313
342
|
.finally(() => {
|
|
314
343
|
this.inFlight -= 1;
|
|
@@ -362,7 +391,13 @@ class Daemon extends EventEmitter {
|
|
|
362
391
|
}
|
|
363
392
|
this.sockets.clear();
|
|
364
393
|
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
|
365
|
-
|
|
394
|
+
// dispose() rather than stop(), and this is the one caller that should.
|
|
395
|
+
// The daemon owns its process and is leaving it, so the real teardown is
|
|
396
|
+
// available and worth taking: joining the engine thread unwinds the
|
|
397
|
+
// network stack, which is what lets the disk cache write its index. A
|
|
398
|
+
// daemon that merely stood the engine down would leave the index dirty and
|
|
399
|
+
// make the next daemon rebuild it by scanning the directory.
|
|
400
|
+
await this.engine.dispose();
|
|
366
401
|
this.emit('close', {});
|
|
367
402
|
}
|
|
368
403
|
}
|
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,356 @@
|
|
|
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 {
|
|
6
|
+
CaptureStats,
|
|
7
|
+
ReleaseMemoryOptions,
|
|
8
|
+
ScreenshotOptions,
|
|
9
|
+
ScreenshotResult,
|
|
10
|
+
StartOptions,
|
|
11
|
+
StartResult,
|
|
12
|
+
} from '../types.js';
|
|
13
|
+
|
|
14
|
+
import type {ResolvedStartOptions} from './config.js';
|
|
15
|
+
import {resolveStartOptions} from './config.js';
|
|
16
|
+
|
|
17
|
+
// The engine this process has, held above every Engine object that uses it.
|
|
18
|
+
//
|
|
19
|
+
// Blink is initialised once and has no undo: it writes process-wide statics
|
|
20
|
+
// that shot_engine_destroy() cannot take back, and the C API refuses a second
|
|
21
|
+
// create for the lifetime of the process whether or not the first is still
|
|
22
|
+
// alive.
|
|
23
|
+
//
|
|
24
|
+
// That fact used to be exposed directly -- `stop()` destroyed the engine and
|
|
25
|
+
// every later `start()` threw. It was the wrong shape. `stop()` and `start()`
|
|
26
|
+
// are a caller saying "I am done for now" and "I want it again", and a library
|
|
27
|
+
// whose engine can be asked for exactly once turns an ordinary pair of calls
|
|
28
|
+
// into a thing that has to be rationed. It also made the disk cache
|
|
29
|
+
// nonsensical: the whole point of a cache is the *next* run, and the next run
|
|
30
|
+
// could not have the engine that reads it.
|
|
31
|
+
//
|
|
32
|
+
// So the handle lives here rather than on the instance. `stop()` stands the
|
|
33
|
+
// engine down -- the queue drains, the memory goes back, nothing more is
|
|
34
|
+
// accepted -- and `start()` picks the same one up again, as many times as a
|
|
35
|
+
// caller likes. The process is the engine's lifetime, which is what it always
|
|
36
|
+
// was; the difference is that the API no longer pretends to offer a shorter
|
|
37
|
+
// one.
|
|
38
|
+
let shared: Handle|null = null;
|
|
39
|
+
|
|
40
|
+
// What `shared` was created with. Kept because those options are fixed for the
|
|
41
|
+
// life of the process -- a later `start()` asking for a different cache
|
|
42
|
+
// directory cannot be given one, and is told so rather than handed an engine
|
|
43
|
+
// that quietly uses the first caller's.
|
|
44
|
+
let sharedOptions: ResolvedStartOptions|null = null;
|
|
45
|
+
|
|
46
|
+
// Whether the one create this process gets has been spent.
|
|
47
|
+
//
|
|
48
|
+
// Separate from `shared` being non-null because `dispose()` clears the handle
|
|
49
|
+
// and does not give the ability back: after a real teardown there is no engine
|
|
50
|
+
// and there cannot be another. Nothing in the public surface calls dispose()
|
|
51
|
+
// -- the daemon does, on its way out of a process it owns.
|
|
52
|
+
let spent = false;
|
|
53
|
+
|
|
54
|
+
/** The engine handle this process has, or null if it has none. */
|
|
55
|
+
function sharedHandle(): Handle|null {
|
|
56
|
+
return shared;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The options that are fixed at create time, and the report a mismatch gets.
|
|
60
|
+
//
|
|
61
|
+
// Only the ones the caller actually named are checked: `start()` with no
|
|
62
|
+
// arguments is a caller saying "whatever is there", which is exactly what
|
|
63
|
+
// adopting a running engine gives them. Naming an option that disagrees is
|
|
64
|
+
// different -- it is a request that cannot be honoured, and silently rendering
|
|
65
|
+
// with the other value is the failure this exists to prevent.
|
|
66
|
+
function conflictingOption(
|
|
67
|
+
options: StartOptions, current: ResolvedStartOptions): string|null {
|
|
68
|
+
const wanted = resolveStartOptions(options);
|
|
69
|
+
for (const key of ['cacheDir', 'cacheMaxBytes', 'userAgent', 'resourceDir'] as
|
|
70
|
+
const) {
|
|
71
|
+
if (options[key] === undefined || wanted[key] === current[key]) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
return `${key} is ${JSON.stringify(current[key])}, and this start() ` +
|
|
75
|
+
`asked for ${JSON.stringify(wanted[key])}`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Blink, in this process, and the queue in front of it.
|
|
82
|
+
*
|
|
83
|
+
* There is one renderer and there is no way to have two. Blink is a
|
|
84
|
+
* process-wide singleton: it is initialised once, there is no path to a second
|
|
85
|
+
* one, and `worker_threads` do not change that because they share the process.
|
|
86
|
+
* So captures are serialised however many callers there are, and a program
|
|
87
|
+
* that wants four at once wants four processes.
|
|
88
|
+
*
|
|
89
|
+
* The queue is not about fairness. Each capture occupies a libuv thread pool
|
|
90
|
+
* thread for as long as the render takes, and there are four of those by
|
|
91
|
+
* default, shared with fs and dns -- so letting four screenshots go at once
|
|
92
|
+
* would stall the host's file reads for a fifth of a second at a time while
|
|
93
|
+
* gaining nothing, since the engine serialises them anyway.
|
|
94
|
+
*/
|
|
95
|
+
export class Engine {
|
|
96
|
+
// Whether *this* object considers itself started. The engine behind it may
|
|
97
|
+
// well be up for somebody else; `running` is about this lifecycle, not about
|
|
98
|
+
// whether the process has an engine.
|
|
99
|
+
private active = false;
|
|
100
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
101
|
+
|
|
102
|
+
get running(): boolean {
|
|
103
|
+
return this.active && shared !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The addon's engine handle, or null when this process has never had one.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately not conditional on `running`. It is the cache that asks, and
|
|
110
|
+
* what the cache needs to know is whether a backend exists in this process
|
|
111
|
+
* -- because within one process a cache directory has one backend, so
|
|
112
|
+
* reading or clearing the directory the engine holds means borrowing it
|
|
113
|
+
* rather than opening a second one. A stood-down engine still holds its
|
|
114
|
+
* directory, so a caller who calls `stop()` and then `cache.getFiles()` is
|
|
115
|
+
* asking about a live backend and has to be routed to it. Nothing else
|
|
116
|
+
* should reach for this.
|
|
117
|
+
*/
|
|
118
|
+
get nativeHandle(): Handle|null {
|
|
119
|
+
return sharedHandle();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Starts the engine, or picks the running one back up.
|
|
124
|
+
*
|
|
125
|
+
* Callable as often as a caller likes, in any order with `stop()`. The first
|
|
126
|
+
* call in a process builds the engine; every later one adopts it, which is
|
|
127
|
+
* the same engine and the same warm cache. Library code can call it
|
|
128
|
+
* defensively.
|
|
129
|
+
*
|
|
130
|
+
* The one thing that cannot be adopted is a different configuration. The
|
|
131
|
+
* options below are fixed when the engine is built and there is no second
|
|
132
|
+
* build, so naming one that disagrees with what is running throws rather
|
|
133
|
+
* than rendering with a value the caller did not ask for.
|
|
134
|
+
*/
|
|
135
|
+
start(options: StartOptions = {}): StartResult {
|
|
136
|
+
if (shared) {
|
|
137
|
+
const conflict = conflictingOption(options, sharedOptions!);
|
|
138
|
+
if (conflict) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'shotium: this process already has an engine, and its ' +
|
|
141
|
+
conflict + '. Blink is initialised once per process and cannot ' +
|
|
142
|
+
'be built again, so an engine\'s options are fixed for as long ' +
|
|
143
|
+
'as the process lives -- stop() does not undo them. Use the ' +
|
|
144
|
+
'engine that is up, or run another process.');
|
|
145
|
+
}
|
|
146
|
+
this.active = true;
|
|
147
|
+
return this.status();
|
|
148
|
+
}
|
|
149
|
+
if (spent) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
'shotium: this process had an engine and it was disposed of. ' +
|
|
152
|
+
'Blink is initialised once per process and cannot be built again. ' +
|
|
153
|
+
'Run another process.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const native = binding.load();
|
|
157
|
+
const resolved = resolveStartOptions(options);
|
|
158
|
+
|
|
159
|
+
const engineOptions: Record<string, unknown> = {};
|
|
160
|
+
if (resolved.cacheDir !== null) {
|
|
161
|
+
engineOptions.cacheDir = resolved.cacheDir;
|
|
162
|
+
engineOptions.cacheMaxBytes = resolved.cacheMaxBytes;
|
|
163
|
+
}
|
|
164
|
+
if (resolved.userAgent !== undefined) {
|
|
165
|
+
engineOptions.userAgent = resolved.userAgent;
|
|
166
|
+
}
|
|
167
|
+
// The packs sit beside the library, and the library cannot find itself on
|
|
168
|
+
// Linux -- the path the engine resolves for "this module" goes through
|
|
169
|
+
// /proc/self/exe, which names node. Saying it here is cheaper than
|
|
170
|
+
// teaching the engine a second way to look. See shot_api.h.
|
|
171
|
+
engineOptions.resourceDir = resolved.resourceDir ?? binding.directory();
|
|
172
|
+
|
|
173
|
+
shared = native.create(JSON.stringify(engineOptions));
|
|
174
|
+
sharedOptions = resolved;
|
|
175
|
+
this.active = true;
|
|
176
|
+
return this.status();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What the engine came up as: whether this lifecycle is started, which cache
|
|
181
|
+
* directory the engine has, and whether it actually got it.
|
|
182
|
+
*
|
|
183
|
+
* The last of those is the one worth reading. A directory that cannot be
|
|
184
|
+
* created or written to -- no permission, no space, a path that is a file --
|
|
185
|
+
* fails invisibly: the engine renders exactly as well without a cache, only
|
|
186
|
+
* slower, and every capture pays the network again for a reason nothing
|
|
187
|
+
* reports. The engine opens the cache during `start()` so that this is
|
|
188
|
+
* answerable before the first screenshot rather than after it.
|
|
189
|
+
*
|
|
190
|
+
* The cache half is answered from the engine whenever this process has one,
|
|
191
|
+
* including after `stop()`. A stood-down engine still holds its directory,
|
|
192
|
+
* and reporting `null` for it would say the cache had gone away when what
|
|
193
|
+
* went away was the willingness to render.
|
|
194
|
+
*/
|
|
195
|
+
status(): StartResult {
|
|
196
|
+
if (!shared) {
|
|
197
|
+
return {running: false, cacheDir: null, cacheActive: false};
|
|
198
|
+
}
|
|
199
|
+
const reported =
|
|
200
|
+
JSON.parse(binding.load().status(shared)) as Omit<StartResult, 'running'>;
|
|
201
|
+
return {running: this.running, ...reported};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Stands the engine down, after whatever is queued.
|
|
206
|
+
*
|
|
207
|
+
* The queue drains, the memory the engine can rebuild goes back to the OS,
|
|
208
|
+
* and `running` becomes false. What does not happen is a teardown of Blink,
|
|
209
|
+
* because there is no such thing -- see the note at the top of this file --
|
|
210
|
+
* so the disk cache stays where it is and `start()` picks the same engine up
|
|
211
|
+
* again whenever the caller wants it.
|
|
212
|
+
*
|
|
213
|
+
* Which makes this exactly what it says: not a destructor, a caller saying
|
|
214
|
+
* they are done for now. A program that will want another screenshot in a
|
|
215
|
+
* moment can equally well stay started and call `releaseMemory()`; the two
|
|
216
|
+
* do the same work, and this one also stops accepting captures.
|
|
217
|
+
*/
|
|
218
|
+
async stop(): Promise<void> {
|
|
219
|
+
if (!this.active) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.active = false;
|
|
223
|
+
// After the queue, not before: a caller's last screenshot should resolve
|
|
224
|
+
// rather than race the stand-down, and the memory is not worth handing
|
|
225
|
+
// back until the thing still using it has finished.
|
|
226
|
+
await this.tail.catch(() => {});
|
|
227
|
+
if (shared) {
|
|
228
|
+
binding.load().purge(shared, /*releaseWorkingSet=*/ true);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The real teardown: joins the engine thread, unwinds the network stack, and
|
|
234
|
+
* lets the disk cache write its index.
|
|
235
|
+
*
|
|
236
|
+
* Final, and final for the process rather than for this object -- which is
|
|
237
|
+
* why it is not on the public surface. The daemon calls it as it exits a
|
|
238
|
+
* process it owns, where the index flush is worth having and nothing is
|
|
239
|
+
* going to ask for another screenshot. Everything else wants `stop()`.
|
|
240
|
+
*/
|
|
241
|
+
async dispose(): Promise<void> {
|
|
242
|
+
this.active = false;
|
|
243
|
+
await this.tail.catch(() => {});
|
|
244
|
+
const handle = shared;
|
|
245
|
+
shared = null;
|
|
246
|
+
sharedOptions = null;
|
|
247
|
+
if (handle) {
|
|
248
|
+
spent = true;
|
|
249
|
+
binding.load().destroy(handle);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Hands back what the engine is holding but can rebuild.
|
|
255
|
+
* `releaseWorkingSet` additionally asks the OS for the pages, which the next
|
|
256
|
+
* screenshot pays back in soft faults -- worth it when there may not be a
|
|
257
|
+
* next one soon.
|
|
258
|
+
*
|
|
259
|
+
* The daemon does this for itself on a timer because it can watch its own
|
|
260
|
+
* request stream go quiet. Here the queue belongs to the caller, so the
|
|
261
|
+
* caller is the one who knows a batch has ended.
|
|
262
|
+
*/
|
|
263
|
+
releaseMemory({releaseWorkingSet = false}: ReleaseMemoryOptions = {}): void {
|
|
264
|
+
if (!shared) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
binding.load().purge(shared, releaseWorkingSet);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Renders one screenshot. Resolves to the encoded image, or to `null` when
|
|
272
|
+
* `path` was given and the engine wrote the file itself.
|
|
273
|
+
*/
|
|
274
|
+
// `async` and not a plain function returning capture()'s promise: toRequest()
|
|
275
|
+
// throws, and a caller who wrote `screenshot(bad).catch(...)` would get the
|
|
276
|
+
// throw past the catch and into the surrounding frame. The whole surface is
|
|
277
|
+
// promise-shaped, so a bad request is a rejection like everything else.
|
|
278
|
+
async screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {
|
|
279
|
+
// Before anything else, and before the queue: a malformed request should
|
|
280
|
+
// be a rejection now rather than one that waits its turn.
|
|
281
|
+
return this.capture(toRequest(options));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The same, for a request that is already in wire form.
|
|
286
|
+
*
|
|
287
|
+
* The daemon reads these off a socket, where they arrived having been
|
|
288
|
+
* validated by the client that sent them. Re-deriving one from
|
|
289
|
+
* ScreenshotOptions would mean the daemon validating a request it cannot see
|
|
290
|
+
* the original of, and rejecting fields a newer client legitimately sent.
|
|
291
|
+
*/
|
|
292
|
+
async capture(request: WireRequest): Promise<ScreenshotResult> {
|
|
293
|
+
// Starts, or restarts, or adopts -- a screenshot after `stop()` is an
|
|
294
|
+
// ordinary thing to ask for and gets the engine back.
|
|
295
|
+
if (!this.running) {
|
|
296
|
+
this.start();
|
|
297
|
+
}
|
|
298
|
+
const handle = shared!;
|
|
299
|
+
const native = binding.load();
|
|
300
|
+
|
|
301
|
+
// Chain onto the tail so that captures run one at a time. The catch keeps
|
|
302
|
+
// one failure from poisoning everything queued behind it.
|
|
303
|
+
const result = this.tail.catch(() => {}).then(
|
|
304
|
+
() => native.capture(handle, JSON.stringify(request)));
|
|
305
|
+
this.tail = result.catch(() => {});
|
|
306
|
+
|
|
307
|
+
let captured;
|
|
308
|
+
try {
|
|
309
|
+
captured = await result;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
// The addon attaches the capture's statistics to the rejection as
|
|
312
|
+
// unparsed JSON, the same way it hands them back on success -- see
|
|
313
|
+
// NativeCapture. Parsing them here rather than leaving a string on the
|
|
314
|
+
// error is what makes `error.stats` the same CaptureStats a successful
|
|
315
|
+
// call returns, which is the whole point of attaching it: the failure is
|
|
316
|
+
// the case where the counters explain the most.
|
|
317
|
+
const withStats = error as Error&{stats?: string | CaptureStats};
|
|
318
|
+
if (typeof withStats.stats === 'string') {
|
|
319
|
+
withStats.stats = JSON.parse(withStats.stats) as CaptureStats;
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
image: request.path ? null : captured.image,
|
|
326
|
+
stats: parseStats(captured.stats),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// A zeroed set of counters.
|
|
332
|
+
//
|
|
333
|
+
// Zeroes rather than undefined because the alternative is every caller writing
|
|
334
|
+
// `stats?.timing?.total ?? 0` around a field that is present for every capture
|
|
335
|
+
// that actually ran. The only case that produces none is a request rejected
|
|
336
|
+
// before it started, and that path throws rather than returning.
|
|
337
|
+
function emptyStats(): CaptureStats {
|
|
338
|
+
return {
|
|
339
|
+
requests: 0,
|
|
340
|
+
fromCache: 0,
|
|
341
|
+
failed: 0,
|
|
342
|
+
bytes: 0,
|
|
343
|
+
httpStatus: 0,
|
|
344
|
+
finalUrl: '',
|
|
345
|
+
timing: {fetch: 0, render: 0, encode: 0, total: 0},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The addon hands statistics over as unparsed JSON -- see NativeCapture -- so
|
|
350
|
+
// this is where the string becomes an object. The daemon's client has them
|
|
351
|
+
// parsed already, from its own response header, and uses emptyStats directly.
|
|
352
|
+
function parseStats(json: string|undefined): CaptureStats {
|
|
353
|
+
return json ? JSON.parse(json) as CaptureStats : emptyStats();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export {emptyStats, parseStats, sharedHandle};
|