@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.
@@ -0,0 +1,243 @@
1
+ import {EventEmitter} from 'node:events';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import type {ResolvedStartOptions} from './config.js';
6
+ import {defaultCacheDir} from './config.js';
7
+ import type {WireRequest} from './request.js';
8
+ import type {WorkerResult} from './worker.js';
9
+ import {Worker} from './worker.js';
10
+
11
+ // A worker that exits sooner than this never really started, so its slot is
12
+ // refilled on a doubling delay rather than immediately.
13
+ const FAST_FAILURE_MS = 1000;
14
+ const RESPAWN_DELAY_MS = 100;
15
+ const MAX_RESPAWN_DELAY_MS = 5000;
16
+
17
+ export interface SubmitOptions {
18
+ /** The supervisor's deadline, in milliseconds. */
19
+ timeout: number;
20
+ /** How many times to re-send after a crash or a timeout. */
21
+ retry: number;
22
+ }
23
+
24
+ interface Job {
25
+ request: WireRequest;
26
+ timeout: number;
27
+ attemptsLeft: number;
28
+ resolve: (result: WorkerResult) => void;
29
+ reject: (error: Error) => void;
30
+ }
31
+
32
+ // A fixed set of shotium.exe --serve processes, and a queue in front of them.
33
+ //
34
+ // The pool exists because blink is a process-wide singleton: one worker renders
35
+ // one document at a time, so N concurrent screenshots means N processes. It is
36
+ // also what makes a crash survivable -- a worker that dies takes its own
37
+ // request down and nothing else, and the slot is refilled.
38
+ //
39
+ // Events:
40
+ // ready {workers} the pool is up
41
+ // exit {worker, code, signal} a worker is gone
42
+ // crash {worker, code, signal} a worker died owing an answer
43
+ // timeout {worker, timeout} a request outlived its deadline
44
+ // worker-restart {worker, reason, delay} a slot was refilled
45
+ // worker-error {worker, error} a worker could not be started
46
+ // stderr {worker, line} a diagnostic line from a worker
47
+ class Pool extends EventEmitter {
48
+ private readonly binary: string;
49
+ private readonly size: number;
50
+ private readonly args: string[];
51
+ private readonly cacheDir: string|null;
52
+ private slots: Worker[] = [];
53
+ private failures: number[] = [];
54
+ private queue: Job[] = [];
55
+ private stopping = false;
56
+ private nextId = 0;
57
+
58
+ constructor(options: ResolvedStartOptions) {
59
+ super();
60
+ this.binary = options.binary;
61
+ this.size = options.workers;
62
+ this.args = options.args || [];
63
+ this.cacheDir = options.cacheDir || null;
64
+ }
65
+
66
+ start(): void {
67
+ if (this.slots.length > 0) {
68
+ return;
69
+ }
70
+ for (let slot = 0; slot < this.size; ++slot) {
71
+ this.slots[slot] = this.spawn(slot);
72
+ }
73
+ this.emit('ready', {workers: this.size});
74
+ }
75
+
76
+ private spawn(slot: number): Worker {
77
+ const id = this.nextId++;
78
+ const args = [...this.args];
79
+ if (this.cacheDir) {
80
+ // One directory per slot, not per process: the Simple backend takes an
81
+ // exclusive lock on its directory, so sharing one would leave every
82
+ // worker but the first running uncached. Keying on the slot rather than
83
+ // the worker id means a restarted worker inherits the warm cache its
84
+ // predecessor built.
85
+ const dir = path.join(this.cacheDir, `worker-${slot}`);
86
+ fs.mkdirSync(dir, {recursive: true});
87
+ args.push(`--cache-dir=${dir}`);
88
+ }
89
+
90
+ const startedAt = Date.now();
91
+ const worker = new Worker({id, binary: this.binary, args});
92
+ worker.on('stderr', (event) => this.emit('stderr', event));
93
+ worker.on('crash', (event) => this.emit('crash', event));
94
+ // A worker that could not be started at all -- a binary that is not there,
95
+ // a path that is not executable -- reports it here. Without a listener
96
+ // EventEmitter throws the error instead, which for a resident daemon means
97
+ // a typo in a path takes the whole pool down.
98
+ worker.on('error', (error) => this.emit('worker-error', {worker: id, error}));
99
+ worker.on('exit', (event) => {
100
+ this.emit('exit', event);
101
+ if (this.stopping || this.slots[slot] !== worker) {
102
+ return;
103
+ }
104
+ // A worker that died on the way up is not a crash to recover from, it is
105
+ // a configuration that does not work, and refilling the slot as fast as
106
+ // the loop allows would spin a core until someone noticed. Back off, but
107
+ // never give up: the binary may yet appear, and a pool that stopped
108
+ // trying would have to be restarted by hand.
109
+ //
110
+ // "On the way up" is answered nothing and did not last a second, in that
111
+ // order. Age alone would misread the ordinary case this design exists
112
+ // for -- a worker killed mid-request seconds after the pool started --
113
+ // as a startup failure, and delay the slot that the retry needs.
114
+ const started = worker.served > 0 ||
115
+ (Date.now() - startedAt) >= FAST_FAILURE_MS;
116
+ if (started) {
117
+ this.failures[slot] = 0;
118
+ }
119
+ const failures = this.failures[slot] || 0;
120
+ const delay = started ?
121
+ 0 :
122
+ Math.min(MAX_RESPAWN_DELAY_MS, RESPAWN_DELAY_MS * 2 ** failures);
123
+ this.failures[slot] = failures + 1;
124
+ const refill = () => {
125
+ if (this.stopping || this.slots[slot] !== worker) {
126
+ return;
127
+ }
128
+ const replacement = this.spawn(slot);
129
+ this.slots[slot] = replacement;
130
+ this.emit('worker-restart',
131
+ {worker: replacement.id, reason: 'exit', delay});
132
+ this.pump();
133
+ };
134
+ if (delay === 0) {
135
+ refill();
136
+ return;
137
+ }
138
+ const timer = setTimeout(refill, delay);
139
+ // An unref'd timer does not hold the process open: a pool whose workers
140
+ // all failed should not be the reason a program refuses to exit.
141
+ timer.unref();
142
+ });
143
+ return worker;
144
+ }
145
+
146
+ // Queues one request. `timeout` is the supervisor's deadline, which is longer
147
+ // than the worker's own: the worker fails a slow page by itself and answers,
148
+ // and this only fires when it has stopped answering at all.
149
+ submit(request: WireRequest, {timeout, retry}: SubmitOptions):
150
+ Promise<WorkerResult> {
151
+ return new Promise<WorkerResult>((resolve, reject) => {
152
+ this.queue.push({
153
+ request,
154
+ timeout,
155
+ attemptsLeft: Math.max(0, retry) + 1,
156
+ resolve,
157
+ reject,
158
+ });
159
+ this.pump();
160
+ });
161
+ }
162
+
163
+ private pump(): void {
164
+ while (this.queue.length > 0) {
165
+ const slot = this.slots.findIndex((w) => w && w.alive && !w.busy);
166
+ if (slot < 0) {
167
+ return;
168
+ }
169
+ this.dispatch(this.slots[slot]!, this.queue.shift()!);
170
+ }
171
+ }
172
+
173
+ private dispatch(worker: Worker, job: Job): void {
174
+ job.attemptsLeft -= 1;
175
+
176
+ let settled = false;
177
+ const timer = setTimeout(() => {
178
+ if (settled) {
179
+ return;
180
+ }
181
+ settled = true;
182
+ this.emit('timeout', {worker: worker.id, timeout: job.timeout});
183
+ // The worker is not answering, so the only way to get the slot back is to
184
+ // take the process down. The exit handler refills the slot.
185
+ worker.kill();
186
+ this.retryOrFail(
187
+ job,
188
+ new Error(`shotium: no answer within ${job.timeout}ms`));
189
+ }, job.timeout);
190
+
191
+ worker.send(job.request)
192
+ .then((result) => {
193
+ if (settled) {
194
+ return;
195
+ }
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ job.resolve(result);
199
+ this.pump();
200
+ })
201
+ .catch((error: Error) => {
202
+ if (settled) {
203
+ return;
204
+ }
205
+ settled = true;
206
+ clearTimeout(timer);
207
+ this.retryOrFail(job, error);
208
+ });
209
+ }
210
+
211
+ private retryOrFail(job: Job, error: Error): void {
212
+ // A request rejected on its own merits -- a bad selector, an unreadable
213
+ // file -- would fail the same way every time, but the worker also rejects
214
+ // with the same shape when it dies. Retrying both is the safe direction:
215
+ // the cost of a pointless retry is one more render, and the cost of not
216
+ // retrying a crash is a failure the caller cannot do anything about.
217
+ if (job.attemptsLeft > 0 && !this.stopping) {
218
+ this.queue.unshift(job);
219
+ this.pump();
220
+ return;
221
+ }
222
+ job.reject(error);
223
+ this.pump();
224
+ }
225
+
226
+ async stop(): Promise<void> {
227
+ this.stopping = true;
228
+ for (const job of this.queue.splice(0)) {
229
+ job.reject(new Error('shotium: the runtime was stopped'));
230
+ }
231
+ await Promise.all(this.slots.map((worker) => new Promise<void>((resolve) => {
232
+ if (!worker || !worker.alive) {
233
+ resolve();
234
+ return;
235
+ }
236
+ worker.once('exit', () => resolve());
237
+ worker.stop();
238
+ })));
239
+ this.slots = [];
240
+ }
241
+ }
242
+
243
+ export {Pool, defaultCacheDir};
@@ -0,0 +1,53 @@
1
+ // The wire format shotium.exe --serve speaks, in both directions: a 4-byte
2
+ // little-endian length followed by that many bytes.
3
+ //
4
+ // Length-prefixed rather than line-delimited because the payload is binary and
5
+ // a newline inside a PNG is not a message boundary. See shot/shot_server.h for
6
+ // the same description from the other end.
7
+ //
8
+ // -> [len][{"file":"...","width":1248,...}]
9
+ // <- [len][{"ok":true,"bytes":97756}] [len][<PNG bytes>]
10
+ // <- [len][{"ok":false,"error":"..."}] [0]
11
+
12
+ const HEADER_BYTES = 4;
13
+
14
+ function encodeFrame(payload: Buffer): Buffer {
15
+ const header = Buffer.allocUnsafe(HEADER_BYTES);
16
+ header.writeUInt32LE(payload.length, 0);
17
+ return Buffer.concat([header, payload]);
18
+ }
19
+
20
+ function encodeRequest(request: unknown): Buffer {
21
+ return encodeFrame(Buffer.from(JSON.stringify(request), 'utf8'));
22
+ }
23
+
24
+ // Reassembles frames out of whatever sizes the pipe hands over.
25
+ //
26
+ // A stream is not a sequence of messages: one read can carry half a header, or
27
+ // three responses and the start of a fourth. Everything downstream assumes
28
+ // whole frames, so this is the only place that has to know that.
29
+ class FrameReader {
30
+ private buffer: Buffer = Buffer.alloc(0);
31
+
32
+ push(chunk: Buffer): void {
33
+ this.buffer = this.buffer.length === 0 ?
34
+ chunk :
35
+ Buffer.concat([this.buffer, chunk]);
36
+ }
37
+
38
+ // The next complete frame, or null when there is not one yet.
39
+ next(): Buffer|null {
40
+ if (this.buffer.length < HEADER_BYTES) {
41
+ return null;
42
+ }
43
+ const length = this.buffer.readUInt32LE(0);
44
+ if (this.buffer.length < HEADER_BYTES + length) {
45
+ return null;
46
+ }
47
+ const frame = this.buffer.subarray(HEADER_BYTES, HEADER_BYTES + length);
48
+ this.buffer = this.buffer.subarray(HEADER_BYTES + length);
49
+ return frame;
50
+ }
51
+ }
52
+
53
+ export {HEADER_BYTES, encodeFrame, encodeRequest, FrameReader};
@@ -0,0 +1,108 @@
1
+ import type {Clip, PageGotoParams, ScreenshotOptions} from '../types.js';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 30000;
4
+ // How much longer than the page's own deadline a supervisor waits before
5
+ // deciding the worker is not going to answer at all. The worker fails a slow
6
+ // page by itself and replies; this margin covers process startup and the
7
+ // encode, and firing it means something worse than a slow page.
8
+ const SUPERVISOR_MARGIN_MS = 10000;
9
+
10
+ // What actually goes down the pipe. It is ScreenshotOptions with the viewport
11
+ // flattened and `retry` taken out -- see toRequest below for why each.
12
+ export interface WireRequest {
13
+ file: string;
14
+ type?: 'png'|'jpeg'|'webp';
15
+ fullPage?: boolean;
16
+ selector?: string;
17
+ quality?: number;
18
+ scale?: number;
19
+ omitBackground?: boolean;
20
+ path?: string;
21
+ pageGotoParams?: PageGotoParams;
22
+ clip?: Clip;
23
+ allowFileAccess?: boolean;
24
+ width?: number;
25
+ height?: number;
26
+ }
27
+
28
+ // Everything the worker understands, and nothing else. An unknown field is a
29
+ // typo, and a typo that is silently dropped is a screenshot that quietly
30
+ // ignored what was asked for -- so this rejects rather than filters.
31
+ //
32
+ // It is a runtime check even though the argument has a type, because the
33
+ // argument having a type says nothing about a caller who is not compiled
34
+ // against it: a JavaScript program, or a JSON body from somewhere else.
35
+ const WIRE_FIELDS = new Set([
36
+ 'file',
37
+ 'type',
38
+ 'fullPage',
39
+ 'selector',
40
+ 'quality',
41
+ 'scale',
42
+ 'omitBackground',
43
+ 'path',
44
+ 'pageGotoParams',
45
+ 'clip',
46
+ 'viewport',
47
+ 'allowFileAccess',
48
+ ]);
49
+
50
+ // One ScreenshotOptions, checked and flattened into what goes on the wire.
51
+ //
52
+ // It lives here rather than in index.ts because the in-process pool and the
53
+ // daemon both send it: a request that is valid through one entry point and
54
+ // rejected through the other would be a difference nobody asked for.
55
+ function toRequest(options: ScreenshotOptions): WireRequest {
56
+ if (!options || typeof options !== 'object') {
57
+ throw new TypeError('shotium: screenshot(options) needs an object');
58
+ }
59
+ if (typeof options.file !== 'string' || options.file.length === 0) {
60
+ throw new TypeError('shotium: options.file is required');
61
+ }
62
+
63
+ const request: Record<string, unknown> = {};
64
+ for (const [key, value] of Object.entries(options)) {
65
+ if (value === undefined) {
66
+ continue;
67
+ }
68
+ // retry is the supervisor's, not the worker's: it decides how many times a
69
+ // request is re-sent, which is not something the worker could act on.
70
+ if (key === 'retry') {
71
+ continue;
72
+ }
73
+ if (!WIRE_FIELDS.has(key)) {
74
+ throw new TypeError(`shotium: unknown option "${key}"`);
75
+ }
76
+ request[key] = value;
77
+ }
78
+
79
+ // The viewport is flattened because the worker takes width and height at the
80
+ // top level: it is one screenshot's frame, not a nested object on the wire.
81
+ if (request.viewport) {
82
+ const {width, height} = request.viewport as {
83
+ width?: number,
84
+ height?: number,
85
+ };
86
+ delete request.viewport;
87
+ if (width !== undefined) {
88
+ request.width = width;
89
+ }
90
+ if (height !== undefined) {
91
+ request.height = height;
92
+ }
93
+ }
94
+ return request as unknown as WireRequest;
95
+ }
96
+
97
+ function timeoutFor(options: ScreenshotOptions): number {
98
+ const timeout = options.pageGotoParams && options.pageGotoParams.timeout;
99
+ return typeof timeout === 'number' ? timeout : DEFAULT_TIMEOUT_MS;
100
+ }
101
+
102
+ export {
103
+ DEFAULT_TIMEOUT_MS,
104
+ SUPERVISOR_MARGIN_MS,
105
+ WIRE_FIELDS,
106
+ timeoutFor,
107
+ toRequest,
108
+ };
@@ -0,0 +1,220 @@
1
+ import {spawn} from 'node:child_process';
2
+ import type {ChildProcess, StdioOptions} from 'node:child_process';
3
+ import {EventEmitter} from 'node:events';
4
+
5
+ import {FrameReader, encodeRequest} from './protocol.js';
6
+ import type {WireRequest} from './request.js';
7
+
8
+ export interface WorkerOptions {
9
+ id: number;
10
+ binary: string;
11
+ args?: string[];
12
+ }
13
+
14
+ // The header frame the worker answers with, followed by the image frame.
15
+ export interface ResponseHeader {
16
+ ok: boolean;
17
+ error?: string;
18
+ bytes?: number;
19
+ path?: string;
20
+ }
21
+
22
+ export interface WorkerResult {
23
+ header: ResponseHeader;
24
+ image: Buffer|null;
25
+ }
26
+
27
+ interface Pending {
28
+ resolve: (result: WorkerResult) => void;
29
+ reject: (error: Error) => void;
30
+ }
31
+
32
+ // One shotium.exe --serve process.
33
+ //
34
+ // Exactly one request is in flight at a time, and that is not a simplification:
35
+ // blink is a process-wide singleton bound to the worker's main thread, so a
36
+ // second request could not be rendered concurrently even if the protocol
37
+ // allowed it. Concurrency is the pool's job, and it gets it by running more
38
+ // processes.
39
+ //
40
+ // Events:
41
+ // ready the process has started
42
+ // exit {code, signal} it is gone, for any reason
43
+ // crash {code, signal} it is gone while it owed an answer
44
+ // stderr {line} a diagnostic line, useful when a render is wrong
45
+ class Worker extends EventEmitter {
46
+ readonly id: number;
47
+ // How many requests this process has answered, either way. The pool reads
48
+ // it to tell a worker that was working and then died from one that never
49
+ // came up at all.
50
+ served = 0;
51
+
52
+ private readonly binary: string;
53
+ private readonly args: string[];
54
+ private process: ChildProcess|null = null;
55
+ private pending: Pending|null = null;
56
+ private stopping = false;
57
+ private reader = new FrameReader();
58
+ private header: ResponseHeader|null = null;
59
+ private stderr = '';
60
+
61
+ constructor(options: WorkerOptions) {
62
+ super();
63
+ this.id = options.id;
64
+ this.binary = options.binary;
65
+ this.args = options.args || [];
66
+ this.start();
67
+ }
68
+
69
+ get busy(): boolean {
70
+ return this.pending !== null;
71
+ }
72
+
73
+ get alive(): boolean {
74
+ return this.process !== null && this.process.exitCode === null &&
75
+ !this.stopping;
76
+ }
77
+
78
+ private start(): void {
79
+ const stdio: StdioOptions = ['pipe', 'pipe', 'pipe'];
80
+ const child = spawn(this.binary, ['--serve', ...this.args], {
81
+ stdio,
82
+ windowsHide: true,
83
+ // Detached, which on Windows means DETACHED_PROCESS: no console, and so
84
+ // no conhost.exe beside every worker. Four of those cost 40 MB of
85
+ // working set for a console nothing writes to -- the worker's output is
86
+ // three pipes.
87
+ //
88
+ // It does not outlive its supervisor despite the name: the worker exits
89
+ // when its stdin closes, and stdin closes when this process dies.
90
+ detached: true,
91
+ });
92
+ this.process = child;
93
+
94
+ child.stdout?.on('data', (chunk: Buffer) => this.onStdout(chunk));
95
+ child.stderr?.on('data', (chunk: Buffer) => this.onStderr(chunk));
96
+ child.on('error', (error) => this.onGone(null, null, error));
97
+ child.on('exit', (code, signal) => this.onGone(code, signal, null));
98
+
99
+ // The process is up as soon as spawn resolves the executable; there is no
100
+ // handshake in the protocol, and adding one would only move the failure --
101
+ // a binary that cannot start fails the first request just as visibly.
102
+ this.emit('ready');
103
+ }
104
+
105
+ private onStdout(chunk: Buffer): void {
106
+ this.reader.push(chunk);
107
+ for (;;) {
108
+ const frame = this.reader.next();
109
+ if (frame === null) {
110
+ return;
111
+ }
112
+ if (this.header === null) {
113
+ // Frame one of two: the JSON header.
114
+ try {
115
+ this.header = JSON.parse(frame.toString('utf8')) as ResponseHeader;
116
+ } catch {
117
+ this.fail(new Error(
118
+ `shotium: worker ${this.id} sent a header that is not JSON`));
119
+ return;
120
+ }
121
+ continue;
122
+ }
123
+ // Frame two: the image, empty when the header reported a failure or when
124
+ // the worker was asked to write the file itself.
125
+ const header = this.header;
126
+ this.header = null;
127
+ this.settle(header, frame);
128
+ }
129
+ }
130
+
131
+ private onStderr(chunk: Buffer): void {
132
+ this.stderr += chunk.toString('utf8');
133
+ const lines = this.stderr.split(/\r?\n/);
134
+ this.stderr = lines.pop() ?? '';
135
+ for (const line of lines) {
136
+ if (line.length > 0) {
137
+ this.emit('stderr', {worker: this.id, line});
138
+ }
139
+ }
140
+ }
141
+
142
+ private onGone(
143
+ code: number|null, signal: NodeJS.Signals|null,
144
+ error: Error|null): void {
145
+ const wasOwed = this.pending !== null;
146
+ this.process = null;
147
+ if (wasOwed) {
148
+ // A worker that dies mid-request is indistinguishable from one that never
149
+ // answered, which is the point: the retry path does not have to tell a
150
+ // crash from a hang.
151
+ this.fail(
152
+ error ||
153
+ new Error(`shotium: worker ${this.id} exited (code ${code}, signal ${
154
+ signal}) with a request in flight`));
155
+ this.emit('crash', {worker: this.id, code, signal});
156
+ } else if (error) {
157
+ this.emit('error', error);
158
+ }
159
+ this.emit('exit', {worker: this.id, code, signal});
160
+ }
161
+
162
+ private settle(header: ResponseHeader, payload: Buffer): void {
163
+ const pending = this.pending;
164
+ if (!pending) {
165
+ return;
166
+ }
167
+ this.pending = null;
168
+ this.served += 1;
169
+ if (header.ok) {
170
+ pending.resolve({header, image: header.path ? null : payload});
171
+ } else {
172
+ pending.reject(new Error(header.error || 'shotium: request failed'));
173
+ }
174
+ }
175
+
176
+ private fail(error: Error): void {
177
+ const pending = this.pending;
178
+ if (!pending) {
179
+ return;
180
+ }
181
+ this.pending = null;
182
+ pending.reject(error);
183
+ }
184
+
185
+ // Sends one request. Rejects if the worker dies before answering; there is no
186
+ // timeout here, because the deadline belongs to whoever owns the retry
187
+ // policy.
188
+ send(request: WireRequest): Promise<WorkerResult> {
189
+ if (this.pending) {
190
+ return Promise.reject(
191
+ new Error(`shotium: worker ${this.id} is already busy`));
192
+ }
193
+ if (!this.alive) {
194
+ return Promise.reject(new Error(`shotium: worker ${this.id} is not up`));
195
+ }
196
+ return new Promise<WorkerResult>((resolve, reject) => {
197
+ this.pending = {resolve, reject};
198
+ this.process?.stdin?.write(encodeRequest(request), (error) => {
199
+ if (error) {
200
+ this.fail(error);
201
+ }
202
+ });
203
+ });
204
+ }
205
+
206
+ // Closing stdin is the shutdown message: the worker sees the frame stream end
207
+ // on a frame boundary and exits 0. kill() is for when it has stopped
208
+ // listening.
209
+ stop(): void {
210
+ this.stopping = true;
211
+ this.process?.stdin?.end();
212
+ }
213
+
214
+ kill(): void {
215
+ this.stopping = true;
216
+ this.process?.kill();
217
+ }
218
+ }
219
+
220
+ export {Worker};