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