@shotkit/shotium 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -65
- package/dist/daemon_main.js +29 -44
- package/dist/daemon_main.js.map +1 -1
- package/dist/index.d.ts +551 -57
- package/dist/index.js +425 -73
- package/dist/index.js.map +1 -1
- package/dist/protocol-BTeWJDOa.js +474 -0
- package/dist/protocol-BTeWJDOa.js.map +1 -0
- package/native/binding.cc +184 -3
- package/package.json +7 -11
- package/src/index.ts +179 -89
- package/src/lib/binding.ts +110 -0
- package/src/lib/cache.ts +323 -0
- package/src/lib/client.ts +32 -17
- package/src/lib/config.ts +113 -61
- package/src/lib/daemon.ts +97 -62
- package/src/lib/endpoint.ts +19 -12
- package/src/lib/engine.ts +356 -0
- package/src/lib/platform.ts +1 -7
- package/src/lib/request.ts +14 -16
- package/src/types.ts +216 -42
- package/dist/native.d.ts +0 -66
- package/dist/native.js +0 -127
- package/dist/native.js.map +0 -1
- package/dist/platform-DU8DYqmA.js +0 -32
- package/dist/platform-DU8DYqmA.js.map +0 -1
- package/dist/pool-BSgS6vkr.js +0 -356
- package/dist/pool-BSgS6vkr.js.map +0 -1
- package/dist/request-qZXS3N9f.js +0 -43
- package/dist/request-qZXS3N9f.js.map +0 -1
- package/dist/types-x9HtkzeE.d.ts +0 -156
- package/src/lib/pool.ts +0 -243
- package/src/lib/worker.ts +0 -220
- package/src/native.ts +0 -234
package/src/lib/pool.ts
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
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};
|
package/src/lib/worker.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
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};
|
package/src/native.ts
DELETED
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import {createRequire} from 'node:module';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import {fileURLToPath} from 'node:url';
|
|
5
|
-
|
|
6
|
-
import * as platformPackage from './lib/platform.js';
|
|
7
|
-
import {toRequest} from './lib/request.js';
|
|
8
|
-
import type {
|
|
9
|
-
NativeStartOptions,
|
|
10
|
-
PurgeOptions,
|
|
11
|
-
ScreenshotOptions,
|
|
12
|
-
} from './types.js';
|
|
13
|
-
|
|
14
|
-
export type {
|
|
15
|
-
NativeStartOptions,
|
|
16
|
-
PurgeOptions,
|
|
17
|
-
ScreenshotOptions,
|
|
18
|
-
} from './types.js';
|
|
19
|
-
|
|
20
|
-
// A .node addon is a CommonJS artefact: there is no ESM loader for one.
|
|
21
|
-
const require = createRequire(import.meta.url);
|
|
22
|
-
|
|
23
|
-
// ESM has no __dirname. This is the same thing, from the module's own URL.
|
|
24
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
|
|
26
|
-
// The engine handle the addon hands back. It is opaque on purpose: everything
|
|
27
|
-
// that can be done with it is a call on the binding below.
|
|
28
|
-
type Engine = unknown;
|
|
29
|
-
|
|
30
|
-
// What native/binding.cc exports. See shot/shot_api.h for the C ABI under it.
|
|
31
|
-
interface NativeBinding {
|
|
32
|
-
create(optionsJson: string): Engine;
|
|
33
|
-
destroy(engine: Engine): void;
|
|
34
|
-
purge(engine: Engine, releaseWorkingSet: boolean): void;
|
|
35
|
-
capture(engine: Engine, requestJson: string): Promise<Buffer>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// shot in this process, instead of in workers beside it.
|
|
39
|
-
//
|
|
40
|
-
// The difference from `runtime` is not the API, which is the same
|
|
41
|
-
// screenshot(options), and not the request format, which is byte for byte the
|
|
42
|
-
// same JSON. It is where blink is:
|
|
43
|
-
//
|
|
44
|
-
// runtime N worker processes, one screenshot each at a time, a crash is a
|
|
45
|
-
// retry, memory is N copies of an engine
|
|
46
|
-
// native one engine in this process, one screenshot at a time ever, a
|
|
47
|
-
// crash takes the program with it, memory is one copy
|
|
48
|
-
//
|
|
49
|
-
// One at a time is not a limitation of this file. Blink is a process-wide
|
|
50
|
-
// singleton -- it is initialised once and there is no path to a second one --
|
|
51
|
-
// so an in-process engine is one renderer no matter how it is driven, and
|
|
52
|
-
// worker_threads do not change that because they share the process. A caller
|
|
53
|
-
// who wants four screenshots at once wants four processes, which is what the
|
|
54
|
-
// pool is for.
|
|
55
|
-
//
|
|
56
|
-
// What it buys is that there is no process to start, nothing to find on disk,
|
|
57
|
-
// no pipe, and no supervisor: a program that takes a handful of screenshots
|
|
58
|
-
// and exits pays for one engine and talks to it directly.
|
|
59
|
-
|
|
60
|
-
// Where the addon and the library beside it live.
|
|
61
|
-
//
|
|
62
|
-
// The platform package is what ships -- the .node sits next to the shared
|
|
63
|
-
// library it is linked against, which is the whole reason the two travel in
|
|
64
|
-
// one package rather than two. native/build/Release is where node-gyp puts a
|
|
65
|
-
// local build; it exists in a checkout and not in an install, so the two never
|
|
66
|
-
// compete in practice. Both paths are relative to this file's build output,
|
|
67
|
-
// which is one directory below the package root.
|
|
68
|
-
function candidates(): string[] {
|
|
69
|
-
const found: string[] = [];
|
|
70
|
-
const dir = platformPackage.packageDir();
|
|
71
|
-
if (dir) {
|
|
72
|
-
found.push(path.join(dir, 'shotium.node'));
|
|
73
|
-
}
|
|
74
|
-
found.push(
|
|
75
|
-
path.join(HERE, '..', 'native', 'build', 'Release', 'shotium.node'));
|
|
76
|
-
return found;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
let binding: NativeBinding|null = null;
|
|
80
|
-
let bindingDir: string|null = null;
|
|
81
|
-
|
|
82
|
-
function load(): NativeBinding {
|
|
83
|
-
if (binding) {
|
|
84
|
-
return binding;
|
|
85
|
-
}
|
|
86
|
-
const tried = candidates();
|
|
87
|
-
for (const candidate of tried) {
|
|
88
|
-
if (!fs.existsSync(candidate)) {
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
// Not wrapped in a try: a .node that is there and will not load is a
|
|
92
|
-
// broken installation, and the loader's own message -- a missing
|
|
93
|
-
// dependency, an architecture mismatch -- says more than anything that
|
|
94
|
-
// could be substituted for it.
|
|
95
|
-
binding = require(candidate) as NativeBinding;
|
|
96
|
-
bindingDir = path.dirname(candidate);
|
|
97
|
-
return binding;
|
|
98
|
-
}
|
|
99
|
-
const expected = platformPackage.packageName();
|
|
100
|
-
throw new Error(
|
|
101
|
-
'shotium: no native engine for this platform.\n' +
|
|
102
|
-
` looked in:\n ${tried.join('\n ')}\n` +
|
|
103
|
-
(expected ?
|
|
104
|
-
` It ships in ${expected}, which npm installs as an optional ` +
|
|
105
|
-
'dependency of this package.\n' :
|
|
106
|
-
` There is no build for ${process.platform}-${process.arch}.\n`) +
|
|
107
|
-
' import("@shotkit/shotium") uses worker processes instead and needs ' +
|
|
108
|
-
'no addon.');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The engine, in this process, and the queue in front of it.
|
|
113
|
-
*
|
|
114
|
-
* Same options and same output as `runtime`, and a different set of trades.
|
|
115
|
-
* There is no worker process, so there is nothing to start, nothing to find on
|
|
116
|
-
* disk and no pipe: a screenshot costs about a third less than through the
|
|
117
|
-
* pool, and the whole thing is one process instead of five.
|
|
118
|
-
*
|
|
119
|
-
* What it gives up is what a separate process was providing for free. One
|
|
120
|
-
* renderer, because blink is a process-wide singleton and `worker_threads`
|
|
121
|
-
* share the process, so requests are serialised however many callers there
|
|
122
|
-
* are. And no crash isolation: a renderer that dies takes the host program
|
|
123
|
-
* with it, where the pool would have retried.
|
|
124
|
-
*
|
|
125
|
-
* The queue is not about fairness. Each capture occupies a libuv thread pool
|
|
126
|
-
* thread for as long as the render takes, and there are four of those by
|
|
127
|
-
* default, shared with fs and dns -- so letting four screenshots go at once
|
|
128
|
-
* would stall the host's file reads for a fifth of a second at a time while
|
|
129
|
-
* gaining nothing, since the engine serialises them anyway.
|
|
130
|
-
*/
|
|
131
|
-
export class NativeRuntime {
|
|
132
|
-
private engine: Engine|null = null;
|
|
133
|
-
private tail: Promise<unknown> = Promise.resolve();
|
|
134
|
-
|
|
135
|
-
get running(): boolean {
|
|
136
|
-
return this.engine !== null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Starts the engine. Safe to call twice; the second call is a no-op.
|
|
141
|
-
*
|
|
142
|
-
* `cacheDir` is the HTTP disk cache and `null` disables it, which is the
|
|
143
|
-
* default here. `resourceDir` is where `shotium_data.pak` and
|
|
144
|
-
* `shotium_strings.pak` are, and defaults to the directory the addon was
|
|
145
|
-
* loaded from, which is where they ship.
|
|
146
|
-
*/
|
|
147
|
-
start(options: NativeStartOptions = {}): this {
|
|
148
|
-
if (this.engine) {
|
|
149
|
-
return this;
|
|
150
|
-
}
|
|
151
|
-
const native = load();
|
|
152
|
-
|
|
153
|
-
const engineOptions: Record<string, unknown> = {};
|
|
154
|
-
if (options.cacheDir !== null && options.cacheDir !== undefined) {
|
|
155
|
-
engineOptions.cacheDir = options.cacheDir;
|
|
156
|
-
}
|
|
157
|
-
if (options.userAgent !== undefined) {
|
|
158
|
-
engineOptions.userAgent = options.userAgent;
|
|
159
|
-
}
|
|
160
|
-
// The packs sit beside the library, and the library cannot find itself on
|
|
161
|
-
// Linux -- the path the engine resolves for "this module" goes through
|
|
162
|
-
// /proc/self/exe, which names node. Saying it here is cheaper than
|
|
163
|
-
// teaching the engine a second way to look. See shot_api.h.
|
|
164
|
-
engineOptions.resourceDir = options.resourceDir || bindingDir;
|
|
165
|
-
|
|
166
|
-
this.engine = native.create(JSON.stringify(engineOptions));
|
|
167
|
-
return this;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Stops the engine, after whatever is queued. */
|
|
171
|
-
async stop(): Promise<void> {
|
|
172
|
-
if (!this.engine) {
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
// After the queue, not before: destroy() waits for a capture in flight
|
|
176
|
-
// anyway, and doing it in order means a caller's last screenshot resolves
|
|
177
|
-
// rather than racing the shutdown.
|
|
178
|
-
const engine = this.engine;
|
|
179
|
-
this.engine = null;
|
|
180
|
-
await this.tail.catch(() => {});
|
|
181
|
-
load().destroy(engine);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Hands back what the engine is holding but can rebuild.
|
|
186
|
-
* `releaseWorkingSet` additionally asks the OS for the pages, which the next
|
|
187
|
-
* screenshot pays back in soft faults -- worth it when there may not be a
|
|
188
|
-
* next one soon.
|
|
189
|
-
*
|
|
190
|
-
* The resident worker does this for itself on a timer because it can watch
|
|
191
|
-
* its own request stream go quiet. Here the queue belongs to the caller, so
|
|
192
|
-
* the caller is the one who knows a batch has ended.
|
|
193
|
-
*/
|
|
194
|
-
purge({releaseWorkingSet = false}: PurgeOptions = {}): void {
|
|
195
|
-
if (!this.engine) {
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
load().purge(this.engine, releaseWorkingSet);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Renders one screenshot. Resolves to the encoded image, or to `null` when
|
|
203
|
-
* `path` was given and the engine wrote the file itself.
|
|
204
|
-
*/
|
|
205
|
-
async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
|
|
206
|
-
// Before anything else, and before the queue: a malformed request should
|
|
207
|
-
// be a rejection now rather than one that waits its turn.
|
|
208
|
-
const request = toRequest(options);
|
|
209
|
-
if (!this.engine) {
|
|
210
|
-
this.start();
|
|
211
|
-
}
|
|
212
|
-
const engine = this.engine;
|
|
213
|
-
const native = load();
|
|
214
|
-
|
|
215
|
-
// Chain onto the tail so that captures run one at a time. The catch keeps
|
|
216
|
-
// one failure from poisoning everything queued behind it.
|
|
217
|
-
const result = this.tail.catch(() => {}).then(
|
|
218
|
-
() => native.capture(engine, JSON.stringify(request)));
|
|
219
|
-
this.tail = result.catch(() => {});
|
|
220
|
-
const image = await result;
|
|
221
|
-
return request.path ? null : image;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** The shared in-process engine, started on first use. */
|
|
226
|
-
const native = new NativeRuntime();
|
|
227
|
-
|
|
228
|
-
/** One screenshot through the shared in-process engine. */
|
|
229
|
-
const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
|
|
230
|
-
native.screenshot(options);
|
|
231
|
-
|
|
232
|
-
export {native, screenshot};
|
|
233
|
-
|
|
234
|
-
export default {NativeRuntime, native, screenshot};
|