@shotkit/shotium 0.0.1 → 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.
@@ -0,0 +1,382 @@
1
+ import {EventEmitter} from 'node:events';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import type {DaemonOptions, DaemonStatus} from '../types.js';
8
+
9
+ import {resolveStartOptions} from './config.js';
10
+ import type {ResolvedStartOptions} from './config.js';
11
+ import {endpointFor} from './endpoint.js';
12
+ import {Engine} from './engine.js';
13
+ import {FrameReader, encodeFrame} from './protocol.js';
14
+ import type {WireRequest} from './request.js';
15
+
16
+ // Our own version, for status(). Read rather than imported: an import
17
+ // attribute would do it too, but only on a node new enough that this package
18
+ // would not run on the rest. The URL is relative to the built module, which
19
+ // sits one directory below the manifest.
20
+ const VERSION = (() => {
21
+ try {
22
+ const manifest =
23
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');
24
+ return (JSON.parse(manifest) as {version?: string}).version ?? '0.0.0';
25
+ } catch {
26
+ return '0.0.0';
27
+ }
28
+ })();
29
+
30
+ const DEFAULT_IDLE_TIMEOUT_MS = 300000;
31
+
32
+ // One message off the socket. `op` defaults to screenshot because that is what
33
+ // almost every message is.
34
+ interface DaemonMessage {
35
+ id?: number|null;
36
+ op?: 'screenshot'|'status'|'ping'|'shutdown';
37
+ request?: WireRequest;
38
+ timeout?: number;
39
+ retry?: number;
40
+ }
41
+
42
+ interface DaemonReply {
43
+ id: number|null;
44
+ ok?: boolean;
45
+ error?: string;
46
+ bytes?: number;
47
+ path?: string;
48
+ stopping?: boolean;
49
+ }
50
+
51
+ // An engine that outlives the process that asked for it.
52
+ //
53
+ // The engine in index.ts is already resident, but only for as long as the Node
54
+ // process holding it: a command-line invocation, a CI step, a serverless
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
57
+ // different process, minutes later -- pays a connect() and nothing else.
58
+ //
59
+ // A request frame of JSON, answered by a header frame and a payload frame:
60
+ //
61
+ // -> [len][{"id":7,"op":"screenshot","request":{...}}]
62
+ // <- [len][{"id":7,"ok":true,"bytes":97756}] [len][<PNG>]
63
+ //
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.
78
+ class Daemon extends EventEmitter {
79
+ private readonly options: ResolvedStartOptions;
80
+ private readonly endpointPath: string;
81
+ private readonly idleTimeoutMs: number;
82
+ private readonly prewarmOnStart: boolean;
83
+ private readonly engine = new Engine();
84
+ private server: net.Server|null = null;
85
+ private sockets = new Set<net.Socket>();
86
+ private inFlight = 0;
87
+ private served = 0;
88
+ private warmed = false;
89
+ private startedAt = Date.now();
90
+ private idleTimer: NodeJS.Timeout|null = null;
91
+ private closing = false;
92
+
93
+ constructor(options: DaemonOptions = {}) {
94
+ super();
95
+ this.options = resolveStartOptions(options);
96
+ this.endpointPath = endpointFor({
97
+ ...this.options,
98
+ name: options.name,
99
+ endpoint: options.endpoint,
100
+ });
101
+ this.idleTimeoutMs = options.idleTimeoutMs === undefined ?
102
+ DEFAULT_IDLE_TIMEOUT_MS :
103
+ options.idleTimeoutMs;
104
+ this.prewarmOnStart = options.prewarm !== false;
105
+ }
106
+
107
+ get endpoint(): string {
108
+ return this.endpointPath;
109
+ }
110
+
111
+ get warm(): boolean {
112
+ return this.warmed;
113
+ }
114
+
115
+ // Brings the engine up and starts listening. The pipe existing *is* the
116
+ // readiness signal -- a client's connect() either succeeds or the daemon is
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.
122
+ async listen(): Promise<this> {
123
+ this.engine.start(this.options);
124
+
125
+ this.server = net.createServer((socket) => this.accept(socket));
126
+ this.server.on('error', (error) => this.emit('error', error));
127
+ await this.bind();
128
+ this.armIdleTimer();
129
+ this.emit('ready', {endpoint: this.endpointPath});
130
+ if (this.prewarmOnStart) {
131
+ await this.prewarm();
132
+ }
133
+ return this;
134
+ }
135
+
136
+ private bind(): Promise<void> {
137
+ return new Promise<void>((resolve, reject) => {
138
+ const server = this.server!;
139
+ const onError = (error: NodeJS.ErrnoException) => {
140
+ // A unix socket file outlives the process that made it, so EADDRINUSE
141
+ // means either a live daemon or a leftover path. Connecting is the only
142
+ // way to tell them apart: refused means nobody is home, and the file
143
+ // can go.
144
+ if (error.code === 'EADDRINUSE' && process.platform !== 'win32') {
145
+ const probe = net.connect(this.endpointPath);
146
+ probe.on('connect', () => {
147
+ probe.destroy();
148
+ reject(error);
149
+ });
150
+ probe.on('error', () => {
151
+ try {
152
+ fs.unlinkSync(this.endpointPath);
153
+ } catch {
154
+ reject(error);
155
+ return;
156
+ }
157
+ server.listen(this.endpointPath, () => {
158
+ this.restrict();
159
+ resolve();
160
+ });
161
+ });
162
+ return;
163
+ }
164
+ reject(error);
165
+ };
166
+ server.once('error', onError);
167
+ server.listen(this.endpointPath, () => {
168
+ server.removeListener('error', onError);
169
+ this.restrict();
170
+ resolve();
171
+ });
172
+ });
173
+ }
174
+
175
+ // Who may talk to this daemon.
176
+ //
177
+ // It matters because a request may set `allowFileAccess`, so a stranger who
178
+ // can connect can have a document read this machine's filesystem and get the
179
+ // result back as a picture. On POSIX the socket is a file and 0600 says only
180
+ // its owner may connect.
181
+ //
182
+ // On Windows it is a named pipe, and node exposes no way to give one an ACL:
183
+ // the default lets any account on the machine open it. A daemon on a shared
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
186
+ // acceptable.
187
+ private restrict(): void {
188
+ if (process.platform === 'win32') {
189
+ return;
190
+ }
191
+ try {
192
+ fs.chmodSync(this.endpointPath, 0o600);
193
+ } catch (error) {
194
+ this.emit('error', error);
195
+ }
196
+ }
197
+
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.
208
+ //
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.
213
+ async prewarm(): Promise<void> {
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});
229
+ }
230
+
231
+ status(): DaemonStatus {
232
+ return {
233
+ ok: true,
234
+ pid: process.pid,
235
+ endpoint: this.endpointPath,
236
+ cacheDir: this.options.cacheDir,
237
+ userAgent: this.options.userAgent,
238
+ resourceDir: this.options.resourceDir,
239
+ warm: this.warmed,
240
+ uptimeMs: Date.now() - this.startedAt,
241
+ connections: this.sockets.size,
242
+ inFlight: this.inFlight,
243
+ served: this.served,
244
+ idleTimeoutMs: this.idleTimeoutMs,
245
+ version: VERSION,
246
+ };
247
+ }
248
+
249
+ private accept(socket: net.Socket): void {
250
+ socket.on('error', () => socket.destroy());
251
+ this.sockets.add(socket);
252
+ this.armIdleTimer();
253
+
254
+ const reader = new FrameReader();
255
+ socket.on('data', (chunk: Buffer) => {
256
+ reader.push(chunk);
257
+ for (;;) {
258
+ const frame = reader.next();
259
+ if (frame === null) {
260
+ return;
261
+ }
262
+ this.dispatch(socket, frame);
263
+ }
264
+ });
265
+ socket.on('close', () => {
266
+ this.sockets.delete(socket);
267
+ this.armIdleTimer();
268
+ });
269
+ }
270
+
271
+ private dispatch(socket: net.Socket, frame: Buffer): void {
272
+ let message: DaemonMessage;
273
+ try {
274
+ message = JSON.parse(frame.toString('utf8')) as DaemonMessage;
275
+ } catch {
276
+ this.reply(
277
+ socket, {id: null, ok: false, error: 'shotium: request is not JSON'});
278
+ return;
279
+ }
280
+
281
+ const id = message.id === undefined ? null : message.id;
282
+ const op = message.op || 'screenshot';
283
+ if (op === 'status') {
284
+ this.reply(socket, {...this.status(), id});
285
+ return;
286
+ }
287
+ if (op === 'ping') {
288
+ this.reply(socket, {id, ok: true});
289
+ return;
290
+ }
291
+ if (op === 'shutdown') {
292
+ this.reply(socket, {id, ok: true, stopping: true});
293
+ // After the reply is on the wire, not before: a client that asked for a
294
+ // shutdown is entitled to hear that it happened.
295
+ socket.end(() => void this.close());
296
+ return;
297
+ }
298
+ if (op !== 'screenshot') {
299
+ this.reply(socket, {id, ok: false, error: `shotium: unknown op "${op}"`});
300
+ return;
301
+ }
302
+
303
+ const request = message.request || ({} as WireRequest);
304
+
305
+ this.inFlight += 1;
306
+ this.armIdleTimer();
307
+ this.emit('request', {id, file: request.file});
308
+ this.engine.capture(request)
309
+ .then((image) => {
310
+ this.served += 1;
311
+ this.reply(
312
+ socket,
313
+ {
314
+ id,
315
+ ok: true,
316
+ bytes: image ? image.length : 0,
317
+ path: request.path,
318
+ },
319
+ image);
320
+ })
321
+ .catch((error: Error) => {
322
+ this.reply(
323
+ socket, {id, ok: false, error: String(error.message || error)});
324
+ })
325
+ .finally(() => {
326
+ this.inFlight -= 1;
327
+ this.emit('response', {id});
328
+ this.armIdleTimer();
329
+ });
330
+ }
331
+
332
+ private reply(
333
+ socket: net.Socket, header: DaemonReply|(DaemonStatus&{id: number|null}),
334
+ payload?: Buffer|null): void {
335
+ if (socket.destroyed) {
336
+ return;
337
+ }
338
+ socket.write(encodeFrame(Buffer.from(JSON.stringify(header), 'utf8')));
339
+ socket.write(encodeFrame(payload || Buffer.alloc(0)));
340
+ }
341
+
342
+ // Idle is "nobody connected and nothing rendering". A client that holds its
343
+ // socket open -- a long-lived service using connect() -- keeps the daemon
344
+ // alive without having to poll it.
345
+ private armIdleTimer(): void {
346
+ if (this.idleTimer) {
347
+ clearTimeout(this.idleTimer);
348
+ this.idleTimer = null;
349
+ }
350
+ if (!this.idleTimeoutMs || this.closing) {
351
+ return;
352
+ }
353
+ if (this.sockets.size > 0 || this.inFlight > 0) {
354
+ return;
355
+ }
356
+ this.idleTimer = setTimeout(() => {
357
+ this.emit('idle-exit', {idleTimeoutMs: this.idleTimeoutMs});
358
+ void this.close();
359
+ }, this.idleTimeoutMs);
360
+ this.idleTimer.unref();
361
+ }
362
+
363
+ async close(): Promise<void> {
364
+ if (this.closing) {
365
+ return;
366
+ }
367
+ this.closing = true;
368
+ if (this.idleTimer) {
369
+ clearTimeout(this.idleTimer);
370
+ this.idleTimer = null;
371
+ }
372
+ for (const socket of this.sockets) {
373
+ socket.destroy();
374
+ }
375
+ this.sockets.clear();
376
+ await new Promise<void>((resolve) => this.server!.close(() => resolve()));
377
+ await this.engine.stop();
378
+ this.emit('close', {});
379
+ }
380
+ }
381
+
382
+ export {Daemon, DEFAULT_IDLE_TIMEOUT_MS};
@@ -0,0 +1,70 @@
1
+ import crypto from 'node:crypto';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // What endpointFor() needs to know: a resolved configuration, plus the two
6
+ // ways of overriding the address it would derive from one.
7
+ export interface EndpointOptions {
8
+ cacheDir?: string|null;
9
+ userAgent?: string;
10
+ resourceDir?: string;
11
+ name?: string;
12
+ endpoint?: string;
13
+ }
14
+
15
+ // Where a daemon listens, derived from what it was asked to be.
16
+ //
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.
29
+ //
30
+ // A caller who wants a daemon by name instead of by configuration passes
31
+ // `name`, which replaces the hash. That is the escape hatch for a service that
32
+ // starts its daemon deliberately and wants clients to find it without
33
+ // repeating the configuration.
34
+ function endpointKey(options: EndpointOptions): string {
35
+ if (options.name) {
36
+ return String(options.name);
37
+ }
38
+ const identity = JSON.stringify([
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,
44
+ ]);
45
+ return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
46
+ }
47
+
48
+ // Windows has named pipes and no filesystem sockets; POSIX has the reverse.
49
+ // Both are net.connect() addresses, which is the only reason the rest of the
50
+ // daemon can ignore the difference.
51
+ //
52
+ // The pipe namespace is per-machine but the socket path is per-user, so the
53
+ // uid goes in the POSIX name to keep two users on one host from colliding on a
54
+ // path only one of them can open.
55
+ function endpointFor(options: EndpointOptions = {}): string {
56
+ if (options.endpoint) {
57
+ return options.endpoint;
58
+ }
59
+ if (process.env.SHOTIUM_ENDPOINT) {
60
+ return process.env.SHOTIUM_ENDPOINT;
61
+ }
62
+ const key = endpointKey(options);
63
+ if (process.platform === 'win32') {
64
+ return `\\\\.\\pipe\\shotium-${key}`;
65
+ }
66
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
67
+ return path.join(os.tmpdir(), `shotium-${uid}-${key}.sock`);
68
+ }
69
+
70
+ export {endpointFor, endpointKey};
@@ -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
+ }
@@ -0,0 +1,69 @@
1
+ import {createRequire} from 'node:module';
2
+ import path from 'node:path';
3
+
4
+ // require.resolve is the resolver, and ESM has no synchronous equivalent
5
+ // that answers for a package that may not be installed at all.
6
+ const require = createRequire(import.meta.url);
7
+
8
+ // Which package carries the engine for this machine.
9
+ //
10
+ // The engine is not in this package and cannot be: it is a Chromium build,
11
+ // 41 MB per platform and architecture, six of them, and `npm install` is never
12
+ // going to produce one. So the bytes live in six packages of their own and
13
+ // this one depends on all six as optionalDependencies with `os` and `cpu` set,
14
+ // which is npm's way of saying "install the one that matches this machine and
15
+ // skip the other five". A machine nobody builds for installs none of them and
16
+ // still gets a working package -- it just has to be pointed at an engine.
17
+ //
18
+ // The alternative, a postinstall script that downloads a tarball, was not
19
+ // chosen. It defeats a lockfile, which is supposed to pin what you get; it
20
+ // fails behind a registry mirror, which is the one place a large dependency
21
+ // most needs to work; and it runs code at install time in exchange for saving
22
+ // nothing that npm was not already doing.
23
+ //
24
+ // Key and name are both `${process.platform}-${process.arch}`, so the table is
25
+ // the identity map with a prefix on it. That is deliberate: the value npm
26
+ // matches `os` and `cpu` against is process.platform, and a package named for
27
+ // anything else makes the reader hold two spellings of one machine in their
28
+ // head. It is also what every other package of this shape does -- esbuild,
29
+ // swc, lightningcss all publish darwin-arm64 and win32-x64.
30
+ //
31
+ // The release archives spell it win/mac instead -- shotium-mac-arm64.7z --
32
+ // and that is not going to change either. They are downloaded by people, and
33
+ // `mac` is what people call it. So the two spellings do differ, in the one
34
+ // place where each is right: the registry gets node's, the download page gets
35
+ // the reader's.
36
+ const PACKAGES: Readonly<Record<string, string>> = {
37
+ 'win32-x64': '@shotkit/shotium-win32-x64',
38
+ 'win32-arm64': '@shotkit/shotium-win32-arm64',
39
+ 'darwin-x64': '@shotkit/shotium-darwin-x64',
40
+ 'darwin-arm64': '@shotkit/shotium-darwin-arm64',
41
+ 'linux-x64': '@shotkit/shotium-linux-x64',
42
+ 'linux-arm64': '@shotkit/shotium-linux-arm64',
43
+ };
44
+
45
+ function packageName(
46
+ platform: string = process.platform,
47
+ arch: string = process.arch): string|null {
48
+ return PACKAGES[`${platform}-${arch}`] ?? null;
49
+ }
50
+
51
+ // Where the matching platform package unpacked, or null if it is not installed.
52
+ //
53
+ // require.resolve rather than a path built from the module's own location: the
54
+ // package can be hoisted to a workspace root, nested under this one, or left
55
+ // in a pnpm store with a symlink pointing at it, and the resolver is the only
56
+ // thing that knows which of those happened.
57
+ function packageDir(): string|null {
58
+ const name = packageName();
59
+ if (!name) {
60
+ return null;
61
+ }
62
+ try {
63
+ return path.dirname(require.resolve(`${name}/package.json`));
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export {PACKAGES, packageDir, packageName};