@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.
- package/README.md +168 -1
- package/dist/daemon_main.d.ts +1 -0
- package/dist/daemon_main.js +305 -0
- package/dist/daemon_main.js.map +1 -0
- package/dist/engine-Xe7nH-1i.js +267 -0
- package/dist/engine-Xe7nH-1i.js.map +1 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +355 -0
- package/dist/index.js.map +1 -0
- package/native/binding.cc +283 -0
- package/native/binding.gyp +54 -0
- package/native/stage_header.js +53 -0
- package/package.json +56 -3
- package/src/daemon_main.ts +76 -0
- package/src/index.ts +139 -0
- package/src/lib/binding.ts +89 -0
- package/src/lib/client.ts +373 -0
- package/src/lib/config.ts +31 -0
- package/src/lib/daemon.ts +382 -0
- package/src/lib/endpoint.ts +70 -0
- package/src/lib/engine.ts +168 -0
- package/src/lib/platform.ts +69 -0
- package/src/lib/protocol.ts +53 -0
- package/src/lib/request.ts +97 -0
- package/src/types.ts +143 -0
|
@@ -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};
|