@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/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 {DaemonOptions, DaemonStatus} from '../types.js';
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 {Pool} from './pool.js';
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
- // A worker pool that outlives the process that asked for it.
60
+ // An engine that outlives the process that asked for it.
55
61
  //
56
- // The pool in index.ts is already resident, but only for as long as the Node
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 workers and then throw them
59
- // away. This is the same pool behind a socket, so the second caller -- in a
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
- // The wire format is the worker's own, one level up: a request frame of JSON,
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
- // Events: ready, request, response, idle-exit, error, plus the pool's own.
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 pool: Pool|null = null;
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 pool up and starts listening. The pipe existing *is* 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 pool has been asked to start.
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
- const pool = new Pool(this.options);
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 -- render
182
- // in-process, or with a binary that has no file access, if that is not
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 per worker so that the first real request
196
- // does not pay for whatever each process initialises lazily. The pool hands
197
- // one request to each free worker, and there are exactly as many requests as
198
- // workers, so every process is touched.
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
- // `data:` rather than a file, because a daemon started without
201
- // --allow-file-access would otherwise be prewarmed by a request it refuses.
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 = 'data:text/html,<!doctype html><title>shotium</title>';
204
- await Promise.all(Array.from({length: this.options.workers}, () => {
205
- return this.pool!
206
- .submit({file: blank, width: 16, height: 16},
207
- {timeout: DEFAULT_TIMEOUT_MS + SUPERVISOR_MARGIN_MS, retry: 1})
208
- .catch(() => null);
209
- }));
210
- this.warmed = true;
211
- this.emit('warm', {workers: this.options.workers});
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
- args: this.options.args,
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.pool!.submit(request, {timeout, retry})
297
- .then((result) => {
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: result.image ? result.image.length : 0,
305
- path: result.header ? result.header.path : undefined,
325
+ bytes: image ? image.length : 0,
326
+ path: request.path,
327
+ stats,
306
328
  },
307
- result.image);
329
+ image);
308
330
  })
309
- .catch((error: Error) => {
310
- this.reply(
311
- socket, {id, ok: false, error: String(error.message || error)});
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
- await this.pool!.stop();
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
  }
@@ -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
- args?: string[];
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 -- binary, worker count, cache
19
- // root, extra flags -- rather than a fixed name, because attaching to whatever
20
- // daemon happens to be up would mean rendering with someone else's binary and
21
- // someone else's flags. Two configurations are two daemons; the same
22
- // configuration, from any process, is one.
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
- path.resolve(options.binary || ''),
34
- options.workers,
35
- options.cacheDir === null ? null : path.resolve(options.cacheDir || ''),
36
- options.args || [],
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};