@shotkit/shotium 0.0.1 → 0.1.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/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ import {EventEmitter} from 'node:events';
2
+
3
+ import * as client from './lib/client.js';
4
+ import type {DaemonClient} from './lib/client.js';
5
+ import {resolveStartOptions} from './lib/config.js';
6
+ import {Pool} from './lib/pool.js';
7
+ import {SUPERVISOR_MARGIN_MS, timeoutFor, toRequest} from './lib/request.js';
8
+ import type {
9
+ DaemonOptions,
10
+ DaemonStatus,
11
+ ScreenshotOptions,
12
+ StartOptions,
13
+ WorkerEvent,
14
+ } from './types.js';
15
+
16
+ export type {
17
+ Clip,
18
+ DaemonOptions,
19
+ DaemonStatus,
20
+ PageGotoParams,
21
+ PurgeOptions,
22
+ ScreenshotOptions,
23
+ StartOptions,
24
+ Viewport,
25
+ WorkerEvent,
26
+ } from './types.js';
27
+ export type {DaemonClient} from './lib/client.js';
28
+
29
+ /** The five things a caller does with the resident pool. */
30
+ export interface Daemon {
31
+ /** Connects, starting a daemon if none is listening. */
32
+ connect(options?: DaemonOptions): Promise<DaemonClient>;
33
+ /** One screenshot through the daemon, connection and all. */
34
+ screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
35
+ Promise<Buffer|null>;
36
+ /** Starts one if it is not up, and reports what is there either way. */
37
+ start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;
38
+ status(options?: DaemonOptions):
39
+ Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}>;
40
+ stop(options?: DaemonOptions): Promise<{stopped: boolean, endpoint: string}>;
41
+ }
42
+
43
+ // The events the pool forwards, and the only ones. Declared as an interface
44
+ // merged into the class below rather than as a catch-all `on(string, ...)`,
45
+ // so that a listener for an event this runtime never emits is a compile error
46
+ // rather than a callback nobody ever calls.
47
+ export interface Runtime {
48
+ on(event: 'ready', listener: (info: {workers: number}) => void): this;
49
+ on(event: 'exit', listener: (event: WorkerEvent) => void): this;
50
+ on(event: 'crash', listener: (event: WorkerEvent) => void): this;
51
+ on(event: 'timeout',
52
+ listener: (event: {worker: number, timeout: number}) => void): this;
53
+ on(event: 'worker-restart',
54
+ listener: (event: {worker: number, reason: string, delay: number}) => void):
55
+ this;
56
+ /** A worker could not be started at all -- a missing or unusable binary. */
57
+ on(event: 'worker-error',
58
+ listener: (event: {worker: number, error: Error}) => void): this;
59
+ on(event: 'stderr',
60
+ listener: (event: {worker: number, line: string}) => void): this;
61
+ }
62
+
63
+ /**
64
+ * The library's one runtime: a pool of worker processes plus its lifecycle.
65
+ *
66
+ * `runtime` below is the singleton, because the expensive part is the
67
+ * processes and a second runtime would double them for no gain. Anyone who
68
+ * genuinely wants two constructs a Runtime directly.
69
+ *
70
+ * Its pool lives and dies with this process. `daemon` is the same pool behind
71
+ * a socket, for callers whose process does not live long enough to be worth
72
+ * starting one.
73
+ */
74
+ export class Runtime extends EventEmitter {
75
+ private pool: Pool|null = null;
76
+
77
+ get running(): boolean {
78
+ return this.pool !== null;
79
+ }
80
+
81
+ /**
82
+ * Starts the pool. Safe to call twice; the second call is a no-op, so that
83
+ * library code can call it defensively.
84
+ *
85
+ * Every option has a default: the binary is `$SHOTIUM_BINARY`, then the
86
+ * platform package, then `./bin/shotium.exe`; the worker count is half the
87
+ * cores, at least one and at most four; the cache root is a directory under
88
+ * the system temp, and `null` disables caching.
89
+ */
90
+ start(options: StartOptions = {}): this {
91
+ if (this.pool) {
92
+ return this;
93
+ }
94
+ const pool = new Pool(resolveStartOptions(options));
95
+ this.pool = pool;
96
+ for (const event
97
+ of ['ready', 'exit', 'crash', 'timeout', 'worker-restart',
98
+ 'worker-error', 'stderr']) {
99
+ pool.on(event, (payload) => this.emit(event, payload));
100
+ }
101
+ pool.start();
102
+ return this;
103
+ }
104
+
105
+ /** Stops every worker. The pool can be started again afterwards. */
106
+ async stop(): Promise<void> {
107
+ if (!this.pool) {
108
+ return;
109
+ }
110
+ const pool = this.pool;
111
+ this.pool = null;
112
+ await pool.stop();
113
+ }
114
+
115
+ /**
116
+ * Renders one screenshot. Resolves to the encoded image, or to `null` when
117
+ * `path` was given and the worker wrote the file itself.
118
+ */
119
+ async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
120
+ // Validate before starting anything. A malformed request should not cost a
121
+ // pool of worker processes to discover, and toRequest() is the only check
122
+ // that can be made without one.
123
+ const request = toRequest(options);
124
+ if (!this.pool) {
125
+ this.start();
126
+ }
127
+ const retry = typeof options.retry === 'number' ? options.retry : 0;
128
+ const result = await this.pool!.submit(request, {
129
+ timeout: timeoutFor(options) + SUPERVISOR_MARGIN_MS,
130
+ retry,
131
+ });
132
+ return result.image;
133
+ }
134
+ }
135
+
136
+ /** The shared pool: one per process, started on first use. */
137
+ const runtime = new Runtime();
138
+
139
+ /** One screenshot through the shared pool, starting it if it is not up. */
140
+ const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
141
+ runtime.screenshot(options);
142
+
143
+ /**
144
+ * The resident pool: workers that outlive the process that started them,
145
+ * reachable over a named pipe on Windows and a unix socket elsewhere. For
146
+ * callers that are short-lived themselves. See lib/daemon.ts.
147
+ */
148
+ const daemon: Daemon = {
149
+ connect: client.connect,
150
+ screenshot: client.screenshot,
151
+ start: client.start,
152
+ status: client.status,
153
+ stop: client.stop,
154
+ };
155
+
156
+ export {runtime, screenshot, daemon};
157
+
158
+ // A default as well as the names, because `import shotium from` is what a
159
+ // caller coming from `require` writes first, and the two have to be the same
160
+ // object rather than two views that drift.
161
+ export default {Runtime, runtime, screenshot, daemon};
@@ -0,0 +1,377 @@
1
+ import {spawn} from 'node:child_process';
2
+ import {EventEmitter} from 'node:events';
3
+ import fs from 'node:fs';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import {fileURLToPath} from 'node:url';
7
+
8
+ import type {
9
+ DaemonOptions,
10
+ DaemonStatus,
11
+ ScreenshotOptions,
12
+ } from '../types.js';
13
+
14
+ import {resolveStartOptions} from './config.js';
15
+ import {endpointFor} from './endpoint.js';
16
+ import {FrameReader, encodeFrame} from './protocol.js';
17
+ import {timeoutFor, toRequest} from './request.js';
18
+
19
+ // ESM has no __dirname. This is the same thing, from the module's own URL.
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ // The detached daemon's entry point, which is a build output beside this one.
23
+ // It is spawned as `node <path>`, so it has to be a file on disk with a name
24
+ // that does not move -- see tsdown.config.ts, where it is an entry of its own
25
+ // for exactly that reason.
26
+ const DAEMON_MAIN = path.join(HERE, 'daemon_main.js');
27
+ // How long to wait for a daemon this process just started to bind its
28
+ // endpoint. Binding happens after the workers are spawned but before they are
29
+ // warm, so this covers process startup and nothing else.
30
+ const START_TIMEOUT_MS = 20000;
31
+ const CONNECT_RETRY_MS = 20;
32
+
33
+ interface ClientReply {
34
+ id: number;
35
+ ok?: boolean;
36
+ error?: string;
37
+ path?: string;
38
+ }
39
+
40
+ interface ClientResult {
41
+ header: ClientReply;
42
+ image: Buffer|null;
43
+ }
44
+
45
+ interface Pending {
46
+ resolve: (result: ClientResult) => void;
47
+ reject: (error: Error) => void;
48
+ }
49
+
50
+ interface ResolvedDaemonOptions {
51
+ binary: string;
52
+ workers: number;
53
+ cacheDir: string|null;
54
+ args: string[];
55
+ name: string|undefined;
56
+ endpoint: string;
57
+ idleTimeoutMs: number|undefined;
58
+ prewarm: boolean|undefined;
59
+ logFile: string|null;
60
+ }
61
+
62
+ // The client half of the resident daemon.
63
+ //
64
+ // One connection can carry several requests at once, which is the difference
65
+ // between this and the worker protocol underneath: every message carries an
66
+ // `id` and the answers are matched back by it, so a caller can fire ten
67
+ // screenshots down one socket and let the pool on the other side spread them
68
+ // across workers.
69
+ class DaemonClient extends EventEmitter {
70
+ private readonly socket: net.Socket;
71
+ private readonly endpointPath: string;
72
+ private readonly pending = new Map<number, Pending>();
73
+ private nextId = 1;
74
+ private header: ClientReply|null = null;
75
+ private reader = new FrameReader();
76
+
77
+ constructor(socket: net.Socket, endpoint: string) {
78
+ super();
79
+ this.socket = socket;
80
+ this.endpointPath = endpoint;
81
+
82
+ socket.on('data', (chunk: Buffer) => this.onData(chunk));
83
+ socket.on('error', (error: Error) => this.failAll(error));
84
+ socket.on('close', () => {
85
+ this.failAll(new Error('shotium: the daemon closed the connection'));
86
+ this.emit('close', {});
87
+ });
88
+ }
89
+
90
+ get endpoint(): string {
91
+ return this.endpointPath;
92
+ }
93
+
94
+ get closed(): boolean {
95
+ return this.socket.destroyed;
96
+ }
97
+
98
+ private onData(chunk: Buffer): void {
99
+ this.reader.push(chunk);
100
+ for (;;) {
101
+ const frame = this.reader.next();
102
+ if (frame === null) {
103
+ return;
104
+ }
105
+ if (this.header === null) {
106
+ try {
107
+ this.header = JSON.parse(frame.toString('utf8')) as ClientReply;
108
+ } catch {
109
+ this.failAll(
110
+ new Error('shotium: the daemon sent a header that is not JSON'));
111
+ return;
112
+ }
113
+ continue;
114
+ }
115
+ const header = this.header;
116
+ this.header = null;
117
+ this.settle(header, frame);
118
+ }
119
+ }
120
+
121
+ private settle(header: ClientReply, payload: Buffer): void {
122
+ const pending = this.pending.get(header.id);
123
+ if (!pending) {
124
+ return;
125
+ }
126
+ this.pending.delete(header.id);
127
+ if (header.ok) {
128
+ pending.resolve({header, image: header.path ? null : payload});
129
+ } else {
130
+ pending.reject(new Error(header.error || 'shotium: request failed'));
131
+ }
132
+ }
133
+
134
+ private failAll(error: Error): void {
135
+ for (const [, pending] of this.pending) {
136
+ pending.reject(error);
137
+ }
138
+ this.pending.clear();
139
+ }
140
+
141
+ // Sends one message and resolves with {header, image}.
142
+ send(message: Record<string, unknown>): Promise<ClientResult> {
143
+ return new Promise<ClientResult>((resolve, reject) => {
144
+ if (this.socket.destroyed) {
145
+ reject(new Error('shotium: not connected to a daemon'));
146
+ return;
147
+ }
148
+ const id = this.nextId++;
149
+ this.pending.set(id, {resolve, reject});
150
+ this.socket.write(
151
+ encodeFrame(Buffer.from(JSON.stringify({...message, id}), 'utf8')));
152
+ });
153
+ }
154
+
155
+ /** Resolves to the image, or to null when `path` was given. */
156
+ async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
157
+ const request = toRequest(options);
158
+ const retry = typeof options.retry === 'number' ? options.retry : 0;
159
+ const result = await this.send({
160
+ op: 'screenshot',
161
+ request,
162
+ timeout: timeoutFor(options),
163
+ retry,
164
+ });
165
+ return result.image;
166
+ }
167
+
168
+ async status(): Promise<DaemonStatus> {
169
+ const {header} = await this.send({op: 'status'});
170
+ return header as unknown as DaemonStatus;
171
+ }
172
+
173
+ async shutdown(): Promise<{ok: boolean}> {
174
+ const {header} = await this.send({op: 'shutdown'});
175
+ return {ok: header.ok === true};
176
+ }
177
+
178
+ close(): void {
179
+ this.socket.end();
180
+ this.socket.destroy();
181
+ }
182
+ }
183
+
184
+ // Opens a connection to a daemon that is already listening, and fails if there
185
+ // is not one. Nothing is spawned here: a caller that wants a daemon started
186
+ // says so, because starting one is a side effect on the machine and not the
187
+ // sort of thing a status query should do.
188
+ function connectOnly(endpoint: string): Promise<DaemonClient> {
189
+ return new Promise<DaemonClient>((resolve, reject) => {
190
+ const socket = net.connect(endpoint);
191
+ const onError = (error: Error) => {
192
+ socket.destroy();
193
+ reject(error);
194
+ };
195
+ socket.once('error', onError);
196
+ socket.once('connect', () => {
197
+ socket.removeListener('error', onError);
198
+ resolve(new DaemonClient(socket, endpoint));
199
+ });
200
+ });
201
+ }
202
+
203
+ function resolveDaemonOptions(options: DaemonOptions = {}):
204
+ ResolvedDaemonOptions {
205
+ const resolved = resolveStartOptions(options);
206
+ return {
207
+ ...resolved,
208
+ name: options.name,
209
+ endpoint: endpointFor({
210
+ ...resolved,
211
+ name: options.name,
212
+ endpoint: options.endpoint,
213
+ }),
214
+ idleTimeoutMs: options.idleTimeoutMs,
215
+ prewarm: options.prewarm,
216
+ logFile: options.logFile || process.env.SHOTIUM_DAEMON_LOG || null,
217
+ };
218
+ }
219
+
220
+ function spawnDaemon(options: ResolvedDaemonOptions): void {
221
+ const config = {
222
+ binary: options.binary,
223
+ workers: options.workers,
224
+ cacheDir: options.cacheDir,
225
+ args: options.args,
226
+ endpoint: options.endpoint,
227
+ idleTimeoutMs: options.idleTimeoutMs,
228
+ prewarm: options.prewarm,
229
+ };
230
+ const encoded =
231
+ Buffer.from(JSON.stringify(config), 'utf8').toString('base64');
232
+
233
+ // Detached, with the standard streams let go of: the daemon has to outlive
234
+ // the process that started it, and a child still holding this process's pipes
235
+ // would keep it from exiting -- the exact failure that makes a "background"
236
+ // daemon hang a shell.
237
+ let stdio: 'ignore'|['ignore', number, number] = 'ignore';
238
+ let logFd: number|null = null;
239
+ if (options.logFile) {
240
+ logFd = fs.openSync(options.logFile, 'a');
241
+ stdio = ['ignore', logFd, logFd];
242
+ }
243
+ const child = spawn(process.execPath, [DAEMON_MAIN, encoded], {
244
+ detached: true,
245
+ stdio,
246
+ windowsHide: true,
247
+ });
248
+ child.unref();
249
+ if (logFd !== null) {
250
+ fs.closeSync(logFd);
251
+ }
252
+ }
253
+
254
+ const sleep = (ms: number) =>
255
+ new Promise<void>((resolve) => setTimeout(resolve, ms));
256
+
257
+ export interface EnsuredClient {
258
+ client: DaemonClient;
259
+ spawned: boolean;
260
+ endpoint: string;
261
+ }
262
+
263
+ // Connects, starting a daemon if none answers.
264
+ //
265
+ // The endpoint existing is the readiness signal, so this is a connect loop
266
+ // rather than a handshake: a daemon that has bound can be talked to, and one
267
+ // that has not is indistinguishable from one that was never started. Several
268
+ // processes racing here is fine -- the losers' daemons exit on EADDRINUSE and
269
+ // everyone ends up on the winner.
270
+ async function ensureClient(options: DaemonOptions = {}):
271
+ Promise<EnsuredClient> {
272
+ const resolved = resolveDaemonOptions(options);
273
+ try {
274
+ const client = await connectOnly(resolved.endpoint);
275
+ return {client, spawned: false, endpoint: resolved.endpoint};
276
+ } catch {
277
+ if (options.spawn === false) {
278
+ throw new Error(`shotium: no daemon at ${resolved.endpoint}`);
279
+ }
280
+ }
281
+
282
+ spawnDaemon(resolved);
283
+ const deadline = Date.now() +
284
+ (options.startTimeoutMs === undefined ? START_TIMEOUT_MS :
285
+ options.startTimeoutMs);
286
+ for (;;) {
287
+ try {
288
+ const client = await connectOnly(resolved.endpoint);
289
+ return {client, spawned: true, endpoint: resolved.endpoint};
290
+ } catch {
291
+ if (Date.now() >= deadline) {
292
+ throw new Error(
293
+ `shotium: the daemon did not come up at ${resolved.endpoint}`);
294
+ }
295
+ await sleep(CONNECT_RETRY_MS);
296
+ }
297
+ }
298
+ }
299
+
300
+ // The five things a caller does with a daemon. Each opens a connection, does
301
+ // one thing and closes it, which is the shape a short-lived process wants; a
302
+ // service that will send more than one request calls connect() and keeps the
303
+ // client.
304
+ async function connect(options: DaemonOptions = {}): Promise<DaemonClient> {
305
+ const {client} = await ensureClient(options);
306
+ return client;
307
+ }
308
+
309
+ async function start(options: DaemonOptions = {}):
310
+ Promise<DaemonStatus&{spawned: boolean}> {
311
+ const {client, spawned, endpoint} = await ensureClient(options);
312
+ try {
313
+ const status = await client.status();
314
+ return {...status, endpoint, spawned};
315
+ } finally {
316
+ client.close();
317
+ }
318
+ }
319
+
320
+ async function status(options: DaemonOptions = {}):
321
+ Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}> {
322
+ const resolved = resolveDaemonOptions(options);
323
+ let client: DaemonClient;
324
+ try {
325
+ client = await connectOnly(resolved.endpoint);
326
+ } catch {
327
+ return {running: false, endpoint: resolved.endpoint};
328
+ }
329
+ try {
330
+ return {...(await client.status()), running: true};
331
+ } finally {
332
+ client.close();
333
+ }
334
+ }
335
+
336
+ async function stop(options: DaemonOptions = {}):
337
+ Promise<{stopped: boolean, endpoint: string}> {
338
+ const resolved = resolveDaemonOptions(options);
339
+ let client: DaemonClient;
340
+ try {
341
+ client = await connectOnly(resolved.endpoint);
342
+ } catch {
343
+ return {stopped: false, endpoint: resolved.endpoint};
344
+ }
345
+ try {
346
+ await client.shutdown();
347
+ return {stopped: true, endpoint: resolved.endpoint};
348
+ } finally {
349
+ client.close();
350
+ }
351
+ }
352
+
353
+ // One screenshot through the daemon, connection and all. `daemon` carries the
354
+ // pool's configuration -- binary, workers, cache root -- and is stripped out
355
+ // here rather than sent, because it says which daemon to talk to and not what
356
+ // to photograph.
357
+ async function screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
358
+ Promise<Buffer|null> {
359
+ const {daemon, ...rest} = options;
360
+ const client = await connect(daemon || {});
361
+ try {
362
+ return await client.screenshot(rest);
363
+ } finally {
364
+ client.close();
365
+ }
366
+ }
367
+
368
+ export {
369
+ DaemonClient,
370
+ connect,
371
+ ensureClient,
372
+ resolveDaemonOptions,
373
+ screenshot,
374
+ start,
375
+ status,
376
+ stop,
377
+ };
@@ -0,0 +1,86 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+
5
+ import type {StartOptions} from '../types.js';
6
+
7
+ import * as platform from './platform.js';
8
+
9
+ // ESM has no __dirname. This is the same thing, from the module's own URL.
10
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ // StartOptions with every hole filled in. `cacheDir` is still nullable here
13
+ // because null is an answer -- "no disk cache" -- and not an absent one.
14
+ export interface ResolvedStartOptions {
15
+ binary: string;
16
+ workers: number;
17
+ cacheDir: string|null;
18
+ args: string[];
19
+ }
20
+
21
+ // The one place that decides what "no options" means.
22
+ //
23
+ // It is shared rather than duplicated because the daemon's address is a hash of
24
+ // its configuration: if two callers filled in defaults even slightly
25
+ // differently, one would compute an address no daemon is listening on and
26
+ // start a second pool next to the first one that was already warm. See
27
+ // endpoint.ts.
28
+ //
29
+ // Three places, in the order a caller means them: what they said, what npm
30
+ // installed, and what they unpacked by hand. The middle one is the normal case
31
+ // and the only one that needs no instructions.
32
+ function defaultBinary(): string {
33
+ if (process.env.SHOTIUM_BINARY) {
34
+ return process.env.SHOTIUM_BINARY;
35
+ }
36
+ const dir = platform.packageDir();
37
+ if (dir) {
38
+ return path.join(dir, platform.binaryName());
39
+ }
40
+ // No platform package: an archive from the releases page, unpacked into
41
+ // bin/ beside this file. This is also the path a checkout takes, where
42
+ // nothing was installed from a registry at all.
43
+ return path.join(HERE, '..', 'bin', platform.binaryName());
44
+ }
45
+
46
+ // How many worker processes, when nobody said.
47
+ //
48
+ // Half the cores, capped. The cap is there because a worker is a process with
49
+ // blink in it, not a thread: measured on this tree it settles around 14 MB of
50
+ // private working set and holds a further ~30 MB of shotium.exe resident, so
51
+ // "half the cores" on a 32-core machine is sixteen of them and most of a
52
+ // gigabyte for a queue that is almost never sixteen deep. Four is past the
53
+ // point where a screenshot workload gets much from another one -- the corpus
54
+ // runs at 41 pages/s on four -- and anyone who has measured otherwise passes
55
+ // `workers`.
56
+ const MAXIMUM_DEFAULT_WORKERS = 4;
57
+
58
+ function defaultWorkers(): number {
59
+ const half = Math.floor((os.cpus().length || 2) / 2);
60
+ return Math.max(1, Math.min(MAXIMUM_DEFAULT_WORKERS, half));
61
+ }
62
+
63
+ function defaultCacheDir(): string {
64
+ return path.join(os.tmpdir(), 'shotium-cache');
65
+ }
66
+
67
+ // binary / workers / cacheDir / args, filled in and normalised. `cacheDir:
68
+ // null` survives as null -- it means "no disk cache", which is not the same
69
+ // request as "use the default one".
70
+ function resolveStartOptions(options: StartOptions = {}): ResolvedStartOptions {
71
+ return {
72
+ binary: options.binary || defaultBinary(),
73
+ workers: options.workers || defaultWorkers(),
74
+ cacheDir: options.cacheDir === null ?
75
+ null :
76
+ (options.cacheDir || defaultCacheDir()),
77
+ args: options.args || [],
78
+ };
79
+ }
80
+
81
+ export {
82
+ defaultBinary,
83
+ defaultCacheDir,
84
+ defaultWorkers,
85
+ resolveStartOptions,
86
+ };