@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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import {EventEmitter} from 'node:events';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
|
|
5
|
+
import type {DaemonOptions, DaemonStatus} from '../types.js';
|
|
6
|
+
|
|
7
|
+
import {resolveStartOptions} from './config.js';
|
|
8
|
+
import type {ResolvedStartOptions} from './config.js';
|
|
9
|
+
import {endpointFor} from './endpoint.js';
|
|
10
|
+
import {Pool} from './pool.js';
|
|
11
|
+
import {FrameReader, encodeFrame} from './protocol.js';
|
|
12
|
+
import type {WireRequest} from './request.js';
|
|
13
|
+
|
|
14
|
+
// Our own version, for status(). Read rather than imported: an import
|
|
15
|
+
// attribute would do it too, but only on a node new enough that this package
|
|
16
|
+
// would not run on the rest. The URL is relative to the built module, which
|
|
17
|
+
// sits one directory below the manifest.
|
|
18
|
+
const VERSION = (() => {
|
|
19
|
+
try {
|
|
20
|
+
const manifest =
|
|
21
|
+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
22
|
+
return (JSON.parse(manifest) as {version?: string}).version ?? '0.0.0';
|
|
23
|
+
} catch {
|
|
24
|
+
return '0.0.0';
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
// How much longer than the page's own deadline the daemon waits before it
|
|
29
|
+
// decides a worker is not going to answer at all. Same margin, same reasoning
|
|
30
|
+
// as index.ts: the worker fails a slow page by itself and replies.
|
|
31
|
+
const SUPERVISOR_MARGIN_MS = 10000;
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
33
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 300000;
|
|
34
|
+
|
|
35
|
+
// One message off the socket. `op` defaults to screenshot because that is what
|
|
36
|
+
// almost every message is.
|
|
37
|
+
interface DaemonMessage {
|
|
38
|
+
id?: number|null;
|
|
39
|
+
op?: 'screenshot'|'status'|'ping'|'shutdown';
|
|
40
|
+
request?: WireRequest;
|
|
41
|
+
timeout?: number;
|
|
42
|
+
retry?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface DaemonReply {
|
|
46
|
+
id: number|null;
|
|
47
|
+
ok?: boolean;
|
|
48
|
+
error?: string;
|
|
49
|
+
bytes?: number;
|
|
50
|
+
path?: string;
|
|
51
|
+
stopping?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A worker pool that outlives the process that asked for it.
|
|
55
|
+
//
|
|
56
|
+
// The pool in index.ts is already resident, but only for as long as the Node
|
|
57
|
+
// process holding it: a command-line invocation, a CI step, a serverless
|
|
58
|
+
// handler and a `node -e` all pay for starting workers and then throw them
|
|
59
|
+
// away. This is the same pool behind a socket, so the second caller -- in a
|
|
60
|
+
// different process, minutes later -- pays a connect() and nothing else.
|
|
61
|
+
//
|
|
62
|
+
// The wire format is the worker's own, one level up: a request frame of JSON,
|
|
63
|
+
// answered by a header frame and a payload frame. What it adds is `id`, so one
|
|
64
|
+
// connection can have several requests in flight; the worker protocol cannot,
|
|
65
|
+
// because a worker renders one document at a time, and multiplexing is exactly
|
|
66
|
+
// what the pool in the middle is for.
|
|
67
|
+
//
|
|
68
|
+
// -> [len][{"id":7,"op":"screenshot","request":{...}}]
|
|
69
|
+
// <- [len][{"id":7,"ok":true,"bytes":97756}] [len][<PNG>]
|
|
70
|
+
//
|
|
71
|
+
// Events: ready, request, response, idle-exit, error, plus the pool's own.
|
|
72
|
+
class Daemon extends EventEmitter {
|
|
73
|
+
private readonly options: ResolvedStartOptions;
|
|
74
|
+
private readonly endpointPath: string;
|
|
75
|
+
private readonly idleTimeoutMs: number;
|
|
76
|
+
private readonly prewarmOnStart: boolean;
|
|
77
|
+
private pool: Pool|null = null;
|
|
78
|
+
private server: net.Server|null = null;
|
|
79
|
+
private sockets = new Set<net.Socket>();
|
|
80
|
+
private inFlight = 0;
|
|
81
|
+
private served = 0;
|
|
82
|
+
private warmed = false;
|
|
83
|
+
private startedAt = Date.now();
|
|
84
|
+
private idleTimer: NodeJS.Timeout|null = null;
|
|
85
|
+
private closing = false;
|
|
86
|
+
|
|
87
|
+
constructor(options: DaemonOptions = {}) {
|
|
88
|
+
super();
|
|
89
|
+
this.options = resolveStartOptions(options);
|
|
90
|
+
this.endpointPath = endpointFor({
|
|
91
|
+
...this.options,
|
|
92
|
+
name: options.name,
|
|
93
|
+
endpoint: options.endpoint,
|
|
94
|
+
});
|
|
95
|
+
this.idleTimeoutMs = options.idleTimeoutMs === undefined ?
|
|
96
|
+
DEFAULT_IDLE_TIMEOUT_MS :
|
|
97
|
+
options.idleTimeoutMs;
|
|
98
|
+
this.prewarmOnStart = options.prewarm !== false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get endpoint(): string {
|
|
102
|
+
return this.endpointPath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get warm(): boolean {
|
|
106
|
+
return this.warmed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Brings the pool up and starts listening. The pipe existing *is* the
|
|
110
|
+
// readiness signal -- a client's connect() either succeeds or the daemon is
|
|
111
|
+
// not up -- so nothing is bound until the pool has been asked to start.
|
|
112
|
+
async listen(): Promise<this> {
|
|
113
|
+
const pool = new Pool(this.options);
|
|
114
|
+
this.pool = pool;
|
|
115
|
+
for (const event of ['exit', 'crash', 'timeout', 'worker-restart',
|
|
116
|
+
'worker-error', 'stderr']) {
|
|
117
|
+
pool.on(event, (payload) => this.emit(event, payload));
|
|
118
|
+
}
|
|
119
|
+
pool.start();
|
|
120
|
+
|
|
121
|
+
this.server = net.createServer((socket) => this.accept(socket));
|
|
122
|
+
this.server.on('error', (error) => this.emit('error', error));
|
|
123
|
+
await this.bind();
|
|
124
|
+
this.armIdleTimer();
|
|
125
|
+
this.emit('ready',
|
|
126
|
+
{endpoint: this.endpointPath, workers: this.options.workers});
|
|
127
|
+
if (this.prewarmOnStart) {
|
|
128
|
+
await this.prewarm();
|
|
129
|
+
}
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private bind(): Promise<void> {
|
|
134
|
+
return new Promise<void>((resolve, reject) => {
|
|
135
|
+
const server = this.server!;
|
|
136
|
+
const onError = (error: NodeJS.ErrnoException) => {
|
|
137
|
+
// A unix socket file outlives the process that made it, so EADDRINUSE
|
|
138
|
+
// means either a live daemon or a leftover path. Connecting is the only
|
|
139
|
+
// way to tell them apart: refused means nobody is home, and the file
|
|
140
|
+
// can go.
|
|
141
|
+
if (error.code === 'EADDRINUSE' && process.platform !== 'win32') {
|
|
142
|
+
const probe = net.connect(this.endpointPath);
|
|
143
|
+
probe.on('connect', () => {
|
|
144
|
+
probe.destroy();
|
|
145
|
+
reject(error);
|
|
146
|
+
});
|
|
147
|
+
probe.on('error', () => {
|
|
148
|
+
try {
|
|
149
|
+
fs.unlinkSync(this.endpointPath);
|
|
150
|
+
} catch {
|
|
151
|
+
reject(error);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
server.listen(this.endpointPath, () => {
|
|
155
|
+
this.restrict();
|
|
156
|
+
resolve();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
reject(error);
|
|
162
|
+
};
|
|
163
|
+
server.once('error', onError);
|
|
164
|
+
server.listen(this.endpointPath, () => {
|
|
165
|
+
server.removeListener('error', onError);
|
|
166
|
+
this.restrict();
|
|
167
|
+
resolve();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Who may talk to this daemon.
|
|
173
|
+
//
|
|
174
|
+
// It matters because a request may set `allowFileAccess`, so a stranger who
|
|
175
|
+
// can connect can have a document read this machine's filesystem and get the
|
|
176
|
+
// result back as a picture. On POSIX the socket is a file and 0600 says only
|
|
177
|
+
// its owner may connect.
|
|
178
|
+
//
|
|
179
|
+
// On Windows it is a named pipe, and node exposes no way to give one an ACL:
|
|
180
|
+
// the default lets any account on the machine open it. A daemon on a shared
|
|
181
|
+
// Windows host is therefore as trusted as the machine's users are -- render
|
|
182
|
+
// in-process, or with a binary that has no file access, if that is not
|
|
183
|
+
// acceptable.
|
|
184
|
+
private restrict(): void {
|
|
185
|
+
if (process.platform === 'win32') {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
fs.chmodSync(this.endpointPath, 0o600);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.emit('error', error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Renders one throwaway document per worker so that the first real request
|
|
196
|
+
// does not pay for whatever each process initialises lazily. The pool hands
|
|
197
|
+
// one request to each free worker, and there are exactly as many requests as
|
|
198
|
+
// workers, so every process is touched.
|
|
199
|
+
//
|
|
200
|
+
// `data:` rather than a file, because a daemon started without
|
|
201
|
+
// --allow-file-access would otherwise be prewarmed by a request it refuses.
|
|
202
|
+
async prewarm(): Promise<void> {
|
|
203
|
+
const blank = 'data:text/html,<!doctype html><title>shotium</title>';
|
|
204
|
+
await Promise.all(Array.from({length: this.options.workers}, () => {
|
|
205
|
+
return this.pool!
|
|
206
|
+
.submit({file: blank, width: 16, height: 16},
|
|
207
|
+
{timeout: DEFAULT_TIMEOUT_MS + SUPERVISOR_MARGIN_MS, retry: 1})
|
|
208
|
+
.catch(() => null);
|
|
209
|
+
}));
|
|
210
|
+
this.warmed = true;
|
|
211
|
+
this.emit('warm', {workers: this.options.workers});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
status(): DaemonStatus {
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
pid: process.pid,
|
|
218
|
+
endpoint: this.endpointPath,
|
|
219
|
+
binary: this.options.binary,
|
|
220
|
+
workers: this.options.workers,
|
|
221
|
+
cacheDir: this.options.cacheDir,
|
|
222
|
+
args: this.options.args,
|
|
223
|
+
warm: this.warmed,
|
|
224
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
225
|
+
connections: this.sockets.size,
|
|
226
|
+
inFlight: this.inFlight,
|
|
227
|
+
served: this.served,
|
|
228
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
229
|
+
version: VERSION,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private accept(socket: net.Socket): void {
|
|
234
|
+
socket.on('error', () => socket.destroy());
|
|
235
|
+
this.sockets.add(socket);
|
|
236
|
+
this.armIdleTimer();
|
|
237
|
+
|
|
238
|
+
const reader = new FrameReader();
|
|
239
|
+
socket.on('data', (chunk: Buffer) => {
|
|
240
|
+
reader.push(chunk);
|
|
241
|
+
for (;;) {
|
|
242
|
+
const frame = reader.next();
|
|
243
|
+
if (frame === null) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.dispatch(socket, frame);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
socket.on('close', () => {
|
|
250
|
+
this.sockets.delete(socket);
|
|
251
|
+
this.armIdleTimer();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private dispatch(socket: net.Socket, frame: Buffer): void {
|
|
256
|
+
let message: DaemonMessage;
|
|
257
|
+
try {
|
|
258
|
+
message = JSON.parse(frame.toString('utf8')) as DaemonMessage;
|
|
259
|
+
} catch {
|
|
260
|
+
this.reply(
|
|
261
|
+
socket, {id: null, ok: false, error: 'shotium: request is not JSON'});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const id = message.id === undefined ? null : message.id;
|
|
266
|
+
const op = message.op || 'screenshot';
|
|
267
|
+
if (op === 'status') {
|
|
268
|
+
this.reply(socket, {...this.status(), id});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (op === 'ping') {
|
|
272
|
+
this.reply(socket, {id, ok: true});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (op === 'shutdown') {
|
|
276
|
+
this.reply(socket, {id, ok: true, stopping: true});
|
|
277
|
+
// After the reply is on the wire, not before: a client that asked for a
|
|
278
|
+
// shutdown is entitled to hear that it happened.
|
|
279
|
+
socket.end(() => void this.close());
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (op !== 'screenshot') {
|
|
283
|
+
this.reply(socket, {id, ok: false, error: `shotium: unknown op "${op}"`});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const request = message.request || ({} as WireRequest);
|
|
288
|
+
const timeout = (typeof message.timeout === 'number' ? message.timeout :
|
|
289
|
+
DEFAULT_TIMEOUT_MS) +
|
|
290
|
+
SUPERVISOR_MARGIN_MS;
|
|
291
|
+
const retry = typeof message.retry === 'number' ? message.retry : 0;
|
|
292
|
+
|
|
293
|
+
this.inFlight += 1;
|
|
294
|
+
this.armIdleTimer();
|
|
295
|
+
this.emit('request', {id, file: request.file});
|
|
296
|
+
this.pool!.submit(request, {timeout, retry})
|
|
297
|
+
.then((result) => {
|
|
298
|
+
this.served += 1;
|
|
299
|
+
this.reply(
|
|
300
|
+
socket,
|
|
301
|
+
{
|
|
302
|
+
id,
|
|
303
|
+
ok: true,
|
|
304
|
+
bytes: result.image ? result.image.length : 0,
|
|
305
|
+
path: result.header ? result.header.path : undefined,
|
|
306
|
+
},
|
|
307
|
+
result.image);
|
|
308
|
+
})
|
|
309
|
+
.catch((error: Error) => {
|
|
310
|
+
this.reply(
|
|
311
|
+
socket, {id, ok: false, error: String(error.message || error)});
|
|
312
|
+
})
|
|
313
|
+
.finally(() => {
|
|
314
|
+
this.inFlight -= 1;
|
|
315
|
+
this.emit('response', {id});
|
|
316
|
+
this.armIdleTimer();
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private reply(
|
|
321
|
+
socket: net.Socket, header: DaemonReply|(DaemonStatus&{id: number|null}),
|
|
322
|
+
payload?: Buffer|null): void {
|
|
323
|
+
if (socket.destroyed) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
socket.write(encodeFrame(Buffer.from(JSON.stringify(header), 'utf8')));
|
|
327
|
+
socket.write(encodeFrame(payload || Buffer.alloc(0)));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Idle is "nobody connected and nothing rendering". A client that holds its
|
|
331
|
+
// socket open -- a long-lived service using connect() -- keeps the daemon
|
|
332
|
+
// alive without having to poll it.
|
|
333
|
+
private armIdleTimer(): void {
|
|
334
|
+
if (this.idleTimer) {
|
|
335
|
+
clearTimeout(this.idleTimer);
|
|
336
|
+
this.idleTimer = null;
|
|
337
|
+
}
|
|
338
|
+
if (!this.idleTimeoutMs || this.closing) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (this.sockets.size > 0 || this.inFlight > 0) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
this.idleTimer = setTimeout(() => {
|
|
345
|
+
this.emit('idle-exit', {idleTimeoutMs: this.idleTimeoutMs});
|
|
346
|
+
void this.close();
|
|
347
|
+
}, this.idleTimeoutMs);
|
|
348
|
+
this.idleTimer.unref();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async close(): Promise<void> {
|
|
352
|
+
if (this.closing) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
this.closing = true;
|
|
356
|
+
if (this.idleTimer) {
|
|
357
|
+
clearTimeout(this.idleTimer);
|
|
358
|
+
this.idleTimer = null;
|
|
359
|
+
}
|
|
360
|
+
for (const socket of this.sockets) {
|
|
361
|
+
socket.destroy();
|
|
362
|
+
}
|
|
363
|
+
this.sockets.clear();
|
|
364
|
+
await new Promise<void>((resolve) => this.server!.close(() => resolve()));
|
|
365
|
+
await this.pool!.stop();
|
|
366
|
+
this.emit('close', {});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export {Daemon, DEFAULT_IDLE_TIMEOUT_MS};
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
binary?: string;
|
|
9
|
+
workers?: number;
|
|
10
|
+
cacheDir?: string|null;
|
|
11
|
+
args?: string[];
|
|
12
|
+
name?: string;
|
|
13
|
+
endpoint?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Where a daemon listens, derived from what it was asked to be.
|
|
17
|
+
//
|
|
18
|
+
// The address is a hash of the configuration -- binary, worker count, cache
|
|
19
|
+
// root, extra flags -- rather than a fixed name, because attaching to whatever
|
|
20
|
+
// daemon happens to be up would mean rendering with someone else's binary and
|
|
21
|
+
// someone else's flags. Two configurations are two daemons; the same
|
|
22
|
+
// configuration, from any process, is one.
|
|
23
|
+
//
|
|
24
|
+
// A caller who wants a daemon by name instead of by configuration passes
|
|
25
|
+
// `name`, which replaces the hash. That is the escape hatch for a service that
|
|
26
|
+
// starts its daemon deliberately and wants clients to find it without
|
|
27
|
+
// repeating the configuration.
|
|
28
|
+
function endpointKey(options: EndpointOptions): string {
|
|
29
|
+
if (options.name) {
|
|
30
|
+
return String(options.name);
|
|
31
|
+
}
|
|
32
|
+
const identity = JSON.stringify([
|
|
33
|
+
path.resolve(options.binary || ''),
|
|
34
|
+
options.workers,
|
|
35
|
+
options.cacheDir === null ? null : path.resolve(options.cacheDir || ''),
|
|
36
|
+
options.args || [],
|
|
37
|
+
]);
|
|
38
|
+
return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Windows has named pipes and no filesystem sockets; POSIX has the reverse.
|
|
42
|
+
// Both are net.connect() addresses, which is the only reason the rest of the
|
|
43
|
+
// daemon can ignore the difference.
|
|
44
|
+
//
|
|
45
|
+
// The pipe namespace is per-machine but the socket path is per-user, so the
|
|
46
|
+
// uid goes in the POSIX name to keep two users on one host from colliding on a
|
|
47
|
+
// path only one of them can open.
|
|
48
|
+
function endpointFor(options: EndpointOptions = {}): string {
|
|
49
|
+
if (options.endpoint) {
|
|
50
|
+
return options.endpoint;
|
|
51
|
+
}
|
|
52
|
+
if (process.env.SHOTIUM_ENDPOINT) {
|
|
53
|
+
return process.env.SHOTIUM_ENDPOINT;
|
|
54
|
+
}
|
|
55
|
+
const key = endpointKey(options);
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
return `\\\\.\\pipe\\shotium-${key}`;
|
|
58
|
+
}
|
|
59
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
60
|
+
return path.join(os.tmpdir(), `shotium-${uid}-${key}.sock`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export {endpointFor, endpointKey};
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
// What the engine executable is called, which is not what the platform calls
|
|
70
|
+
// it: Windows wants the extension and nothing else does.
|
|
71
|
+
function binaryName(): string {
|
|
72
|
+
return process.platform === 'win32' ? 'shotium.exe' : 'shotium';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export {PACKAGES, binaryName, packageDir, packageName};
|