@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,373 @@
|
|
|
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
|
+
cacheDir: string|null;
|
|
52
|
+
userAgent?: string;
|
|
53
|
+
resourceDir?: string;
|
|
54
|
+
name: string|undefined;
|
|
55
|
+
endpoint: string;
|
|
56
|
+
idleTimeoutMs: number|undefined;
|
|
57
|
+
prewarm: boolean|undefined;
|
|
58
|
+
logFile: string|null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The client half of the resident daemon.
|
|
62
|
+
//
|
|
63
|
+
// One connection can carry several requests at once: every message carries an
|
|
64
|
+
// `id` and the answers are matched back by it, so a caller can fire ten
|
|
65
|
+
// screenshots down one socket without waiting between them. They still come
|
|
66
|
+
// back one at a time -- there is one renderer on the other side -- so this
|
|
67
|
+
// saves the round trips, not the renders.
|
|
68
|
+
class DaemonClient extends EventEmitter {
|
|
69
|
+
private readonly socket: net.Socket;
|
|
70
|
+
private readonly endpointPath: string;
|
|
71
|
+
private readonly pending = new Map<number, Pending>();
|
|
72
|
+
private nextId = 1;
|
|
73
|
+
private header: ClientReply|null = null;
|
|
74
|
+
private reader = new FrameReader();
|
|
75
|
+
|
|
76
|
+
constructor(socket: net.Socket, endpoint: string) {
|
|
77
|
+
super();
|
|
78
|
+
this.socket = socket;
|
|
79
|
+
this.endpointPath = endpoint;
|
|
80
|
+
|
|
81
|
+
socket.on('data', (chunk: Buffer) => this.onData(chunk));
|
|
82
|
+
socket.on('error', (error: Error) => this.failAll(error));
|
|
83
|
+
socket.on('close', () => {
|
|
84
|
+
this.failAll(new Error('shotium: the daemon closed the connection'));
|
|
85
|
+
this.emit('close', {});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get endpoint(): string {
|
|
90
|
+
return this.endpointPath;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get closed(): boolean {
|
|
94
|
+
return this.socket.destroyed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private onData(chunk: Buffer): void {
|
|
98
|
+
this.reader.push(chunk);
|
|
99
|
+
for (;;) {
|
|
100
|
+
const frame = this.reader.next();
|
|
101
|
+
if (frame === null) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (this.header === null) {
|
|
105
|
+
try {
|
|
106
|
+
this.header = JSON.parse(frame.toString('utf8')) as ClientReply;
|
|
107
|
+
} catch {
|
|
108
|
+
this.failAll(
|
|
109
|
+
new Error('shotium: the daemon sent a header that is not JSON'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const header = this.header;
|
|
115
|
+
this.header = null;
|
|
116
|
+
this.settle(header, frame);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private settle(header: ClientReply, payload: Buffer): void {
|
|
121
|
+
const pending = this.pending.get(header.id);
|
|
122
|
+
if (!pending) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.pending.delete(header.id);
|
|
126
|
+
if (header.ok) {
|
|
127
|
+
pending.resolve({header, image: header.path ? null : payload});
|
|
128
|
+
} else {
|
|
129
|
+
pending.reject(new Error(header.error || 'shotium: request failed'));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private failAll(error: Error): void {
|
|
134
|
+
for (const [, pending] of this.pending) {
|
|
135
|
+
pending.reject(error);
|
|
136
|
+
}
|
|
137
|
+
this.pending.clear();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Sends one message and resolves with {header, image}.
|
|
141
|
+
send(message: Record<string, unknown>): Promise<ClientResult> {
|
|
142
|
+
return new Promise<ClientResult>((resolve, reject) => {
|
|
143
|
+
if (this.socket.destroyed) {
|
|
144
|
+
reject(new Error('shotium: not connected to a daemon'));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const id = this.nextId++;
|
|
148
|
+
this.pending.set(id, {resolve, reject});
|
|
149
|
+
this.socket.write(
|
|
150
|
+
encodeFrame(Buffer.from(JSON.stringify({...message, id}), 'utf8')));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolves to the image, or to null when `path` was given. */
|
|
155
|
+
async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
|
|
156
|
+
const request = toRequest(options);
|
|
157
|
+
const result = await this.send({
|
|
158
|
+
op: 'screenshot',
|
|
159
|
+
request,
|
|
160
|
+
timeout: timeoutFor(options),
|
|
161
|
+
});
|
|
162
|
+
return result.image;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async status(): Promise<DaemonStatus> {
|
|
166
|
+
const {header} = await this.send({op: 'status'});
|
|
167
|
+
return header as unknown as DaemonStatus;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async shutdown(): Promise<{ok: boolean}> {
|
|
171
|
+
const {header} = await this.send({op: 'shutdown'});
|
|
172
|
+
return {ok: header.ok === true};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
close(): void {
|
|
176
|
+
this.socket.end();
|
|
177
|
+
this.socket.destroy();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Opens a connection to a daemon that is already listening, and fails if there
|
|
182
|
+
// is not one. Nothing is spawned here: a caller that wants a daemon started
|
|
183
|
+
// says so, because starting one is a side effect on the machine and not the
|
|
184
|
+
// sort of thing a status query should do.
|
|
185
|
+
function connectOnly(endpoint: string): Promise<DaemonClient> {
|
|
186
|
+
return new Promise<DaemonClient>((resolve, reject) => {
|
|
187
|
+
const socket = net.connect(endpoint);
|
|
188
|
+
const onError = (error: Error) => {
|
|
189
|
+
socket.destroy();
|
|
190
|
+
reject(error);
|
|
191
|
+
};
|
|
192
|
+
socket.once('error', onError);
|
|
193
|
+
socket.once('connect', () => {
|
|
194
|
+
socket.removeListener('error', onError);
|
|
195
|
+
resolve(new DaemonClient(socket, endpoint));
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveDaemonOptions(options: DaemonOptions = {}):
|
|
201
|
+
ResolvedDaemonOptions {
|
|
202
|
+
const resolved = resolveStartOptions(options);
|
|
203
|
+
return {
|
|
204
|
+
...resolved,
|
|
205
|
+
name: options.name,
|
|
206
|
+
endpoint: endpointFor({
|
|
207
|
+
...resolved,
|
|
208
|
+
name: options.name,
|
|
209
|
+
endpoint: options.endpoint,
|
|
210
|
+
}),
|
|
211
|
+
idleTimeoutMs: options.idleTimeoutMs,
|
|
212
|
+
prewarm: options.prewarm,
|
|
213
|
+
logFile: options.logFile || process.env.SHOTIUM_DAEMON_LOG || null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function spawnDaemon(options: ResolvedDaemonOptions): void {
|
|
218
|
+
const config = {
|
|
219
|
+
cacheDir: options.cacheDir,
|
|
220
|
+
userAgent: options.userAgent,
|
|
221
|
+
resourceDir: options.resourceDir,
|
|
222
|
+
endpoint: options.endpoint,
|
|
223
|
+
idleTimeoutMs: options.idleTimeoutMs,
|
|
224
|
+
prewarm: options.prewarm,
|
|
225
|
+
};
|
|
226
|
+
const encoded =
|
|
227
|
+
Buffer.from(JSON.stringify(config), 'utf8').toString('base64');
|
|
228
|
+
|
|
229
|
+
// Detached, with the standard streams let go of: the daemon has to outlive
|
|
230
|
+
// the process that started it, and a child still holding this process's pipes
|
|
231
|
+
// would keep it from exiting -- the exact failure that makes a "background"
|
|
232
|
+
// daemon hang a shell.
|
|
233
|
+
let stdio: 'ignore'|['ignore', number, number] = 'ignore';
|
|
234
|
+
let logFd: number|null = null;
|
|
235
|
+
if (options.logFile) {
|
|
236
|
+
logFd = fs.openSync(options.logFile, 'a');
|
|
237
|
+
stdio = ['ignore', logFd, logFd];
|
|
238
|
+
}
|
|
239
|
+
const child = spawn(process.execPath, [DAEMON_MAIN, encoded], {
|
|
240
|
+
detached: true,
|
|
241
|
+
stdio,
|
|
242
|
+
windowsHide: true,
|
|
243
|
+
});
|
|
244
|
+
child.unref();
|
|
245
|
+
if (logFd !== null) {
|
|
246
|
+
fs.closeSync(logFd);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const sleep = (ms: number) =>
|
|
251
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
252
|
+
|
|
253
|
+
export interface EnsuredClient {
|
|
254
|
+
client: DaemonClient;
|
|
255
|
+
spawned: boolean;
|
|
256
|
+
endpoint: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Connects, starting a daemon if none answers.
|
|
260
|
+
//
|
|
261
|
+
// The endpoint existing is the readiness signal, so this is a connect loop
|
|
262
|
+
// rather than a handshake: a daemon that has bound can be talked to, and one
|
|
263
|
+
// that has not is indistinguishable from one that was never started. Several
|
|
264
|
+
// processes racing here is fine -- the losers' daemons exit on EADDRINUSE and
|
|
265
|
+
// everyone ends up on the winner.
|
|
266
|
+
async function ensureClient(options: DaemonOptions = {}):
|
|
267
|
+
Promise<EnsuredClient> {
|
|
268
|
+
const resolved = resolveDaemonOptions(options);
|
|
269
|
+
try {
|
|
270
|
+
const client = await connectOnly(resolved.endpoint);
|
|
271
|
+
return {client, spawned: false, endpoint: resolved.endpoint};
|
|
272
|
+
} catch {
|
|
273
|
+
if (options.spawn === false) {
|
|
274
|
+
throw new Error(`shotium: no daemon at ${resolved.endpoint}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
spawnDaemon(resolved);
|
|
279
|
+
const deadline = Date.now() +
|
|
280
|
+
(options.startTimeoutMs === undefined ? START_TIMEOUT_MS :
|
|
281
|
+
options.startTimeoutMs);
|
|
282
|
+
for (;;) {
|
|
283
|
+
try {
|
|
284
|
+
const client = await connectOnly(resolved.endpoint);
|
|
285
|
+
return {client, spawned: true, endpoint: resolved.endpoint};
|
|
286
|
+
} catch {
|
|
287
|
+
if (Date.now() >= deadline) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`shotium: the daemon did not come up at ${resolved.endpoint}`);
|
|
290
|
+
}
|
|
291
|
+
await sleep(CONNECT_RETRY_MS);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The five things a caller does with a daemon. Each opens a connection, does
|
|
297
|
+
// one thing and closes it, which is the shape a short-lived process wants; a
|
|
298
|
+
// service that will send more than one request calls connect() and keeps the
|
|
299
|
+
// client.
|
|
300
|
+
async function connect(options: DaemonOptions = {}): Promise<DaemonClient> {
|
|
301
|
+
const {client} = await ensureClient(options);
|
|
302
|
+
return client;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function start(options: DaemonOptions = {}):
|
|
306
|
+
Promise<DaemonStatus&{spawned: boolean}> {
|
|
307
|
+
const {client, spawned, endpoint} = await ensureClient(options);
|
|
308
|
+
try {
|
|
309
|
+
const status = await client.status();
|
|
310
|
+
return {...status, endpoint, spawned};
|
|
311
|
+
} finally {
|
|
312
|
+
client.close();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function status(options: DaemonOptions = {}):
|
|
317
|
+
Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}> {
|
|
318
|
+
const resolved = resolveDaemonOptions(options);
|
|
319
|
+
let client: DaemonClient;
|
|
320
|
+
try {
|
|
321
|
+
client = await connectOnly(resolved.endpoint);
|
|
322
|
+
} catch {
|
|
323
|
+
return {running: false, endpoint: resolved.endpoint};
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
return {...(await client.status()), running: true};
|
|
327
|
+
} finally {
|
|
328
|
+
client.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function stop(options: DaemonOptions = {}):
|
|
333
|
+
Promise<{stopped: boolean, endpoint: string}> {
|
|
334
|
+
const resolved = resolveDaemonOptions(options);
|
|
335
|
+
let client: DaemonClient;
|
|
336
|
+
try {
|
|
337
|
+
client = await connectOnly(resolved.endpoint);
|
|
338
|
+
} catch {
|
|
339
|
+
return {stopped: false, endpoint: resolved.endpoint};
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
await client.shutdown();
|
|
343
|
+
return {stopped: true, endpoint: resolved.endpoint};
|
|
344
|
+
} finally {
|
|
345
|
+
client.close();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// One screenshot through the daemon, connection and all. `daemon` carries the
|
|
350
|
+
// pool's configuration -- binary, workers, cache root -- and is stripped out
|
|
351
|
+
// here rather than sent, because it says which daemon to talk to and not what
|
|
352
|
+
// to photograph.
|
|
353
|
+
async function screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
|
|
354
|
+
Promise<Buffer|null> {
|
|
355
|
+
const {daemon, ...rest} = options;
|
|
356
|
+
const client = await connect(daemon || {});
|
|
357
|
+
try {
|
|
358
|
+
return await client.screenshot(rest);
|
|
359
|
+
} finally {
|
|
360
|
+
client.close();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export {
|
|
365
|
+
DaemonClient,
|
|
366
|
+
connect,
|
|
367
|
+
ensureClient,
|
|
368
|
+
resolveDaemonOptions,
|
|
369
|
+
screenshot,
|
|
370
|
+
start,
|
|
371
|
+
status,
|
|
372
|
+
stop,
|
|
373
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {StartOptions} from '../types.js';
|
|
2
|
+
|
|
3
|
+
// StartOptions with every hole filled in. `cacheDir` is still nullable here
|
|
4
|
+
// because null is an answer -- "no disk cache" -- and not an absent one.
|
|
5
|
+
export interface ResolvedStartOptions {
|
|
6
|
+
cacheDir: string|null;
|
|
7
|
+
userAgent?: string;
|
|
8
|
+
resourceDir?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The one place that decides what "no options" means.
|
|
12
|
+
//
|
|
13
|
+
// It is shared rather than duplicated because the daemon's address is a hash of
|
|
14
|
+
// its configuration: if two callers filled in defaults even slightly
|
|
15
|
+
// differently, one would compute an address no daemon is listening on and
|
|
16
|
+
// start a second engine next to the first one that was already warm. See
|
|
17
|
+
// endpoint.ts.
|
|
18
|
+
//
|
|
19
|
+
// The default for `cacheDir` is null -- no disk cache. A program holding the
|
|
20
|
+
// engine is often short-lived, and a cache it never reads twice is a directory
|
|
21
|
+
// it leaves behind. The daemon, which is the case where a cache does pay for
|
|
22
|
+
// itself, is also the case where the caller is already passing options.
|
|
23
|
+
function resolveStartOptions(options: StartOptions = {}): ResolvedStartOptions {
|
|
24
|
+
return {
|
|
25
|
+
cacheDir: options.cacheDir ?? null,
|
|
26
|
+
userAgent: options.userAgent,
|
|
27
|
+
resourceDir: options.resourceDir,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export {resolveStartOptions};
|