@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/dist/index.js ADDED
@@ -0,0 +1,355 @@
1
+ import { a as encodeFrame, i as FrameReader, n as timeoutFor, o as endpointFor, r as toRequest, s as resolveStartOptions, t as Engine } from "./engine-Xe7nH-1i.js";
2
+ import { spawn } from "node:child_process";
3
+ import { EventEmitter } from "node:events";
4
+ import fs from "node:fs";
5
+ import net from "node:net";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ //#region src/lib/client.ts
10
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
11
+ const DAEMON_MAIN = path.join(HERE, "daemon_main.js");
12
+ const START_TIMEOUT_MS = 2e4;
13
+ const CONNECT_RETRY_MS = 20;
14
+ var DaemonClient = class extends EventEmitter {
15
+ socket;
16
+ endpointPath;
17
+ pending = /* @__PURE__ */ new Map();
18
+ nextId = 1;
19
+ header = null;
20
+ reader = new FrameReader();
21
+ constructor(socket, endpoint) {
22
+ super();
23
+ this.socket = socket;
24
+ this.endpointPath = endpoint;
25
+ socket.on("data", (chunk) => this.onData(chunk));
26
+ socket.on("error", (error) => this.failAll(error));
27
+ socket.on("close", () => {
28
+ this.failAll(/* @__PURE__ */ new Error("shotium: the daemon closed the connection"));
29
+ this.emit("close", {});
30
+ });
31
+ }
32
+ get endpoint() {
33
+ return this.endpointPath;
34
+ }
35
+ get closed() {
36
+ return this.socket.destroyed;
37
+ }
38
+ onData(chunk) {
39
+ this.reader.push(chunk);
40
+ for (;;) {
41
+ const frame = this.reader.next();
42
+ if (frame === null) return;
43
+ if (this.header === null) {
44
+ try {
45
+ this.header = JSON.parse(frame.toString("utf8"));
46
+ } catch {
47
+ this.failAll(/* @__PURE__ */ new Error("shotium: the daemon sent a header that is not JSON"));
48
+ return;
49
+ }
50
+ continue;
51
+ }
52
+ const header = this.header;
53
+ this.header = null;
54
+ this.settle(header, frame);
55
+ }
56
+ }
57
+ settle(header, payload) {
58
+ const pending = this.pending.get(header.id);
59
+ if (!pending) return;
60
+ this.pending.delete(header.id);
61
+ if (header.ok) pending.resolve({
62
+ header,
63
+ image: header.path ? null : payload
64
+ });
65
+ else pending.reject(new Error(header.error || "shotium: request failed"));
66
+ }
67
+ failAll(error) {
68
+ for (const [, pending] of this.pending) pending.reject(error);
69
+ this.pending.clear();
70
+ }
71
+ send(message) {
72
+ return new Promise((resolve, reject) => {
73
+ if (this.socket.destroyed) {
74
+ reject(/* @__PURE__ */ new Error("shotium: not connected to a daemon"));
75
+ return;
76
+ }
77
+ const id = this.nextId++;
78
+ this.pending.set(id, {
79
+ resolve,
80
+ reject
81
+ });
82
+ this.socket.write(encodeFrame(Buffer.from(JSON.stringify({
83
+ ...message,
84
+ id
85
+ }), "utf8")));
86
+ });
87
+ }
88
+ /** Resolves to the image, or to null when `path` was given. */
89
+ async screenshot(options) {
90
+ const request = toRequest(options);
91
+ return (await this.send({
92
+ op: "screenshot",
93
+ request,
94
+ timeout: timeoutFor(options)
95
+ })).image;
96
+ }
97
+ async status() {
98
+ const { header } = await this.send({ op: "status" });
99
+ return header;
100
+ }
101
+ async shutdown() {
102
+ const { header } = await this.send({ op: "shutdown" });
103
+ return { ok: header.ok === true };
104
+ }
105
+ close() {
106
+ this.socket.end();
107
+ this.socket.destroy();
108
+ }
109
+ };
110
+ function connectOnly(endpoint) {
111
+ return new Promise((resolve, reject) => {
112
+ const socket = net.connect(endpoint);
113
+ const onError = (error) => {
114
+ socket.destroy();
115
+ reject(error);
116
+ };
117
+ socket.once("error", onError);
118
+ socket.once("connect", () => {
119
+ socket.removeListener("error", onError);
120
+ resolve(new DaemonClient(socket, endpoint));
121
+ });
122
+ });
123
+ }
124
+ function resolveDaemonOptions(options = {}) {
125
+ const resolved = resolveStartOptions(options);
126
+ return {
127
+ ...resolved,
128
+ name: options.name,
129
+ endpoint: endpointFor({
130
+ ...resolved,
131
+ name: options.name,
132
+ endpoint: options.endpoint
133
+ }),
134
+ idleTimeoutMs: options.idleTimeoutMs,
135
+ prewarm: options.prewarm,
136
+ logFile: options.logFile || process.env.SHOTIUM_DAEMON_LOG || null
137
+ };
138
+ }
139
+ function spawnDaemon(options) {
140
+ const config = {
141
+ cacheDir: options.cacheDir,
142
+ userAgent: options.userAgent,
143
+ resourceDir: options.resourceDir,
144
+ endpoint: options.endpoint,
145
+ idleTimeoutMs: options.idleTimeoutMs,
146
+ prewarm: options.prewarm
147
+ };
148
+ const encoded = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
149
+ let stdio = "ignore";
150
+ let logFd = null;
151
+ if (options.logFile) {
152
+ logFd = fs.openSync(options.logFile, "a");
153
+ stdio = [
154
+ "ignore",
155
+ logFd,
156
+ logFd
157
+ ];
158
+ }
159
+ spawn(process.execPath, [DAEMON_MAIN, encoded], {
160
+ detached: true,
161
+ stdio,
162
+ windowsHide: true
163
+ }).unref();
164
+ if (logFd !== null) fs.closeSync(logFd);
165
+ }
166
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
167
+ async function ensureClient(options = {}) {
168
+ const resolved = resolveDaemonOptions(options);
169
+ try {
170
+ return {
171
+ client: await connectOnly(resolved.endpoint),
172
+ spawned: false,
173
+ endpoint: resolved.endpoint
174
+ };
175
+ } catch {
176
+ if (options.spawn === false) throw new Error(`shotium: no daemon at ${resolved.endpoint}`);
177
+ }
178
+ spawnDaemon(resolved);
179
+ const deadline = Date.now() + (options.startTimeoutMs === void 0 ? START_TIMEOUT_MS : options.startTimeoutMs);
180
+ for (;;) try {
181
+ return {
182
+ client: await connectOnly(resolved.endpoint),
183
+ spawned: true,
184
+ endpoint: resolved.endpoint
185
+ };
186
+ } catch {
187
+ if (Date.now() >= deadline) throw new Error(`shotium: the daemon did not come up at ${resolved.endpoint}`);
188
+ await sleep(CONNECT_RETRY_MS);
189
+ }
190
+ }
191
+ async function connect(options = {}) {
192
+ const { client } = await ensureClient(options);
193
+ return client;
194
+ }
195
+ async function start(options = {}) {
196
+ const { client, spawned, endpoint } = await ensureClient(options);
197
+ try {
198
+ return {
199
+ ...await client.status(),
200
+ endpoint,
201
+ spawned
202
+ };
203
+ } finally {
204
+ client.close();
205
+ }
206
+ }
207
+ async function status(options = {}) {
208
+ const resolved = resolveDaemonOptions(options);
209
+ let client;
210
+ try {
211
+ client = await connectOnly(resolved.endpoint);
212
+ } catch {
213
+ return {
214
+ running: false,
215
+ endpoint: resolved.endpoint
216
+ };
217
+ }
218
+ try {
219
+ return {
220
+ ...await client.status(),
221
+ running: true
222
+ };
223
+ } finally {
224
+ client.close();
225
+ }
226
+ }
227
+ async function stop(options = {}) {
228
+ const resolved = resolveDaemonOptions(options);
229
+ let client;
230
+ try {
231
+ client = await connectOnly(resolved.endpoint);
232
+ } catch {
233
+ return {
234
+ stopped: false,
235
+ endpoint: resolved.endpoint
236
+ };
237
+ }
238
+ try {
239
+ await client.shutdown();
240
+ return {
241
+ stopped: true,
242
+ endpoint: resolved.endpoint
243
+ };
244
+ } finally {
245
+ client.close();
246
+ }
247
+ }
248
+ async function screenshot$1(options) {
249
+ const { daemon, ...rest } = options;
250
+ const client = await connect(daemon || {});
251
+ try {
252
+ return await client.screenshot(rest);
253
+ } finally {
254
+ client.close();
255
+ }
256
+ }
257
+
258
+ //#endregion
259
+ //#region src/index.ts
260
+ /**
261
+ * The engine, and its lifecycle, in this process.
262
+ *
263
+ * import shotium from '@shotkit/shotium';
264
+ *
265
+ * shotium.runtime.start();
266
+ * const png = await shotium.screenshot({file: 'https://example.com'});
267
+ * await shotium.runtime.stop();
268
+ *
269
+ * `start` and `stop` are explicit because starting Blink is the expensive part
270
+ * -- tens of milliseconds and a working set that stays resident -- and only
271
+ * the caller knows whether the next screenshot is coming in a moment or never.
272
+ * Neither call is required: a screenshot starts the engine if it is not up.
273
+ * What they buy is control over when that cost is paid, and the certainty that
274
+ * it has been given back.
275
+ *
276
+ * `runtime` below is the singleton because there is nothing else it could be:
277
+ * Blink starts once per process and cannot be restarted, so a second Runtime
278
+ * in the same process has no engine to have. Construct one directly only to
279
+ * own the lifecycle yourself instead of using `runtime`. Parallelism is more
280
+ * processes, not more Runtimes.
281
+ *
282
+ * `daemon` is the same engine in a process of its own, behind a socket, for
283
+ * callers whose own process does not live long enough to be worth starting
284
+ * one.
285
+ */
286
+ var Runtime = class {
287
+ engine = new Engine();
288
+ get running() {
289
+ return this.engine.running;
290
+ }
291
+ /**
292
+ * Starts the engine. Safe to call twice; the second call is a no-op, so that
293
+ * library code can call it defensively. Not safe after `stop()` -- see there.
294
+ *
295
+ * Every option has a default. `cacheDir` is the HTTP disk cache and `null`
296
+ * disables it; `resourceDir` is where `shotium_data.pak` and
297
+ * `shotium_strings.pak` are, and defaults to the directory the engine was
298
+ * loaded from, which is where they ship.
299
+ */
300
+ start(options = {}) {
301
+ this.engine.start(options);
302
+ return this;
303
+ }
304
+ /**
305
+ * Stops the engine, after whatever is queued.
306
+ *
307
+ * Final for this process. Blink writes process-wide state that it has no
308
+ * path to undo, so starting again -- here or on another Runtime -- throws
309
+ * rather than quietly handing back something that cannot render. A program
310
+ * that wants another screenshot later should stay started and `purge()`.
311
+ */
312
+ stop() {
313
+ return this.engine.stop();
314
+ }
315
+ /**
316
+ * Hands back what the engine is holding but can rebuild. Worth calling when
317
+ * a batch has ended and the next one may be a while away.
318
+ */
319
+ purge(options = {}) {
320
+ this.engine.purge(options);
321
+ }
322
+ /**
323
+ * Renders one screenshot. Resolves to the encoded image, or to `null` when
324
+ * `path` was given and the engine wrote the file itself.
325
+ */
326
+ screenshot(options) {
327
+ return this.engine.screenshot(options);
328
+ }
329
+ };
330
+ /** The shared engine: one per process, started on first use. */
331
+ const runtime = new Runtime();
332
+ /** One screenshot through the shared engine, starting it if it is not up. */
333
+ const screenshot = (options) => runtime.screenshot(options);
334
+ /**
335
+ * The resident engine: a process that outlives the one that started it,
336
+ * reachable over a named pipe on Windows and a unix socket elsewhere. For
337
+ * callers that are short-lived themselves. See lib/daemon.ts.
338
+ */
339
+ const daemon = {
340
+ connect,
341
+ screenshot: screenshot$1,
342
+ start,
343
+ status,
344
+ stop
345
+ };
346
+ var src_default = {
347
+ Runtime,
348
+ runtime,
349
+ screenshot,
350
+ daemon
351
+ };
352
+
353
+ //#endregion
354
+ export { Runtime, daemon, src_default as default, runtime, screenshot };
355
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["screenshot","client.connect","client.screenshot","client.start","client.status","client.stop"],"sources":["../src/lib/client.ts","../src/index.ts"],"sourcesContent":["import {spawn} from 'node:child_process';\nimport {EventEmitter} from 'node:events';\nimport fs from 'node:fs';\nimport net from 'node:net';\nimport path from 'node:path';\nimport {fileURLToPath} from 'node:url';\n\nimport type {\n DaemonOptions,\n DaemonStatus,\n ScreenshotOptions,\n} from '../types.js';\n\nimport {resolveStartOptions} from './config.js';\nimport {endpointFor} from './endpoint.js';\nimport {FrameReader, encodeFrame} from './protocol.js';\nimport {timeoutFor, toRequest} from './request.js';\n\n// ESM has no __dirname. This is the same thing, from the module's own URL.\nconst HERE = path.dirname(fileURLToPath(import.meta.url));\n\n// The detached daemon's entry point, which is a build output beside this one.\n// It is spawned as `node <path>`, so it has to be a file on disk with a name\n// that does not move -- see tsdown.config.ts, where it is an entry of its own\n// for exactly that reason.\nconst DAEMON_MAIN = path.join(HERE, 'daemon_main.js');\n// How long to wait for a daemon this process just started to bind its\n// endpoint. Binding happens after the workers are spawned but before they are\n// warm, so this covers process startup and nothing else.\nconst START_TIMEOUT_MS = 20000;\nconst CONNECT_RETRY_MS = 20;\n\ninterface ClientReply {\n id: number;\n ok?: boolean;\n error?: string;\n path?: string;\n}\n\ninterface ClientResult {\n header: ClientReply;\n image: Buffer|null;\n}\n\ninterface Pending {\n resolve: (result: ClientResult) => void;\n reject: (error: Error) => void;\n}\n\ninterface ResolvedDaemonOptions {\n cacheDir: string|null;\n userAgent?: string;\n resourceDir?: string;\n name: string|undefined;\n endpoint: string;\n idleTimeoutMs: number|undefined;\n prewarm: boolean|undefined;\n logFile: string|null;\n}\n\n// The client half of the resident daemon.\n//\n// One connection can carry several requests at once: every message carries an\n// `id` and the answers are matched back by it, so a caller can fire ten\n// screenshots down one socket without waiting between them. They still come\n// back one at a time -- there is one renderer on the other side -- so this\n// saves the round trips, not the renders.\nclass DaemonClient extends EventEmitter {\n private readonly socket: net.Socket;\n private readonly endpointPath: string;\n private readonly pending = new Map<number, Pending>();\n private nextId = 1;\n private header: ClientReply|null = null;\n private reader = new FrameReader();\n\n constructor(socket: net.Socket, endpoint: string) {\n super();\n this.socket = socket;\n this.endpointPath = endpoint;\n\n socket.on('data', (chunk: Buffer) => this.onData(chunk));\n socket.on('error', (error: Error) => this.failAll(error));\n socket.on('close', () => {\n this.failAll(new Error('shotium: the daemon closed the connection'));\n this.emit('close', {});\n });\n }\n\n get endpoint(): string {\n return this.endpointPath;\n }\n\n get closed(): boolean {\n return this.socket.destroyed;\n }\n\n private onData(chunk: Buffer): void {\n this.reader.push(chunk);\n for (;;) {\n const frame = this.reader.next();\n if (frame === null) {\n return;\n }\n if (this.header === null) {\n try {\n this.header = JSON.parse(frame.toString('utf8')) as ClientReply;\n } catch {\n this.failAll(\n new Error('shotium: the daemon sent a header that is not JSON'));\n return;\n }\n continue;\n }\n const header = this.header;\n this.header = null;\n this.settle(header, frame);\n }\n }\n\n private settle(header: ClientReply, payload: Buffer): void {\n const pending = this.pending.get(header.id);\n if (!pending) {\n return;\n }\n this.pending.delete(header.id);\n if (header.ok) {\n pending.resolve({header, image: header.path ? null : payload});\n } else {\n pending.reject(new Error(header.error || 'shotium: request failed'));\n }\n }\n\n private failAll(error: Error): void {\n for (const [, pending] of this.pending) {\n pending.reject(error);\n }\n this.pending.clear();\n }\n\n // Sends one message and resolves with {header, image}.\n send(message: Record<string, unknown>): Promise<ClientResult> {\n return new Promise<ClientResult>((resolve, reject) => {\n if (this.socket.destroyed) {\n reject(new Error('shotium: not connected to a daemon'));\n return;\n }\n const id = this.nextId++;\n this.pending.set(id, {resolve, reject});\n this.socket.write(\n encodeFrame(Buffer.from(JSON.stringify({...message, id}), 'utf8')));\n });\n }\n\n /** Resolves to the image, or to null when `path` was given. */\n async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {\n const request = toRequest(options);\n const result = await this.send({\n op: 'screenshot',\n request,\n timeout: timeoutFor(options),\n });\n return result.image;\n }\n\n async status(): Promise<DaemonStatus> {\n const {header} = await this.send({op: 'status'});\n return header as unknown as DaemonStatus;\n }\n\n async shutdown(): Promise<{ok: boolean}> {\n const {header} = await this.send({op: 'shutdown'});\n return {ok: header.ok === true};\n }\n\n close(): void {\n this.socket.end();\n this.socket.destroy();\n }\n}\n\n// Opens a connection to a daemon that is already listening, and fails if there\n// is not one. Nothing is spawned here: a caller that wants a daemon started\n// says so, because starting one is a side effect on the machine and not the\n// sort of thing a status query should do.\nfunction connectOnly(endpoint: string): Promise<DaemonClient> {\n return new Promise<DaemonClient>((resolve, reject) => {\n const socket = net.connect(endpoint);\n const onError = (error: Error) => {\n socket.destroy();\n reject(error);\n };\n socket.once('error', onError);\n socket.once('connect', () => {\n socket.removeListener('error', onError);\n resolve(new DaemonClient(socket, endpoint));\n });\n });\n}\n\nfunction resolveDaemonOptions(options: DaemonOptions = {}):\n ResolvedDaemonOptions {\n const resolved = resolveStartOptions(options);\n return {\n ...resolved,\n name: options.name,\n endpoint: endpointFor({\n ...resolved,\n name: options.name,\n endpoint: options.endpoint,\n }),\n idleTimeoutMs: options.idleTimeoutMs,\n prewarm: options.prewarm,\n logFile: options.logFile || process.env.SHOTIUM_DAEMON_LOG || null,\n };\n}\n\nfunction spawnDaemon(options: ResolvedDaemonOptions): void {\n const config = {\n cacheDir: options.cacheDir,\n userAgent: options.userAgent,\n resourceDir: options.resourceDir,\n endpoint: options.endpoint,\n idleTimeoutMs: options.idleTimeoutMs,\n prewarm: options.prewarm,\n };\n const encoded =\n Buffer.from(JSON.stringify(config), 'utf8').toString('base64');\n\n // Detached, with the standard streams let go of: the daemon has to outlive\n // the process that started it, and a child still holding this process's pipes\n // would keep it from exiting -- the exact failure that makes a \"background\"\n // daemon hang a shell.\n let stdio: 'ignore'|['ignore', number, number] = 'ignore';\n let logFd: number|null = null;\n if (options.logFile) {\n logFd = fs.openSync(options.logFile, 'a');\n stdio = ['ignore', logFd, logFd];\n }\n const child = spawn(process.execPath, [DAEMON_MAIN, encoded], {\n detached: true,\n stdio,\n windowsHide: true,\n });\n child.unref();\n if (logFd !== null) {\n fs.closeSync(logFd);\n }\n}\n\nconst sleep = (ms: number) =>\n new Promise<void>((resolve) => setTimeout(resolve, ms));\n\nexport interface EnsuredClient {\n client: DaemonClient;\n spawned: boolean;\n endpoint: string;\n}\n\n// Connects, starting a daemon if none answers.\n//\n// The endpoint existing is the readiness signal, so this is a connect loop\n// rather than a handshake: a daemon that has bound can be talked to, and one\n// that has not is indistinguishable from one that was never started. Several\n// processes racing here is fine -- the losers' daemons exit on EADDRINUSE and\n// everyone ends up on the winner.\nasync function ensureClient(options: DaemonOptions = {}):\n Promise<EnsuredClient> {\n const resolved = resolveDaemonOptions(options);\n try {\n const client = await connectOnly(resolved.endpoint);\n return {client, spawned: false, endpoint: resolved.endpoint};\n } catch {\n if (options.spawn === false) {\n throw new Error(`shotium: no daemon at ${resolved.endpoint}`);\n }\n }\n\n spawnDaemon(resolved);\n const deadline = Date.now() +\n (options.startTimeoutMs === undefined ? START_TIMEOUT_MS :\n options.startTimeoutMs);\n for (;;) {\n try {\n const client = await connectOnly(resolved.endpoint);\n return {client, spawned: true, endpoint: resolved.endpoint};\n } catch {\n if (Date.now() >= deadline) {\n throw new Error(\n `shotium: the daemon did not come up at ${resolved.endpoint}`);\n }\n await sleep(CONNECT_RETRY_MS);\n }\n }\n}\n\n// The five things a caller does with a daemon. Each opens a connection, does\n// one thing and closes it, which is the shape a short-lived process wants; a\n// service that will send more than one request calls connect() and keeps the\n// client.\nasync function connect(options: DaemonOptions = {}): Promise<DaemonClient> {\n const {client} = await ensureClient(options);\n return client;\n}\n\nasync function start(options: DaemonOptions = {}):\n Promise<DaemonStatus&{spawned: boolean}> {\n const {client, spawned, endpoint} = await ensureClient(options);\n try {\n const status = await client.status();\n return {...status, endpoint, spawned};\n } finally {\n client.close();\n }\n}\n\nasync function status(options: DaemonOptions = {}):\n Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}> {\n const resolved = resolveDaemonOptions(options);\n let client: DaemonClient;\n try {\n client = await connectOnly(resolved.endpoint);\n } catch {\n return {running: false, endpoint: resolved.endpoint};\n }\n try {\n return {...(await client.status()), running: true};\n } finally {\n client.close();\n }\n}\n\nasync function stop(options: DaemonOptions = {}):\n Promise<{stopped: boolean, endpoint: string}> {\n const resolved = resolveDaemonOptions(options);\n let client: DaemonClient;\n try {\n client = await connectOnly(resolved.endpoint);\n } catch {\n return {stopped: false, endpoint: resolved.endpoint};\n }\n try {\n await client.shutdown();\n return {stopped: true, endpoint: resolved.endpoint};\n } finally {\n client.close();\n }\n}\n\n// One screenshot through the daemon, connection and all. `daemon` carries the\n// pool's configuration -- binary, workers, cache root -- and is stripped out\n// here rather than sent, because it says which daemon to talk to and not what\n// to photograph.\nasync function screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):\n Promise<Buffer|null> {\n const {daemon, ...rest} = options;\n const client = await connect(daemon || {});\n try {\n return await client.screenshot(rest);\n } finally {\n client.close();\n }\n}\n\nexport {\n DaemonClient,\n connect,\n ensureClient,\n resolveDaemonOptions,\n screenshot,\n start,\n status,\n stop,\n};\n","import * as client from './lib/client.js';\nimport type {DaemonClient} from './lib/client.js';\nimport {Engine} from './lib/engine.js';\nimport type {\n DaemonOptions,\n DaemonStatus,\n PurgeOptions,\n ScreenshotOptions,\n StartOptions,\n} from './types.js';\n\nexport type {\n Clip,\n DaemonOptions,\n DaemonStatus,\n PageGotoParams,\n PurgeOptions,\n ScreenshotOptions,\n StartOptions,\n Viewport,\n} from './types.js';\nexport type {DaemonClient} from './lib/client.js';\n\n/** The five things a caller does with the resident engine. */\nexport interface Daemon {\n /** Connects, starting a daemon if none is listening. */\n connect(options?: DaemonOptions): Promise<DaemonClient>;\n /** One screenshot through the daemon, connection and all. */\n screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):\n Promise<Buffer|null>;\n /** Starts one if it is not up, and reports what is there either way. */\n start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;\n status(options?: DaemonOptions):\n Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}>;\n stop(options?: DaemonOptions): Promise<{stopped: boolean, endpoint: string}>;\n}\n\n/**\n * The engine, and its lifecycle, in this process.\n *\n * import shotium from '@shotkit/shotium';\n *\n * shotium.runtime.start();\n * const png = await shotium.screenshot({file: 'https://example.com'});\n * await shotium.runtime.stop();\n *\n * `start` and `stop` are explicit because starting Blink is the expensive part\n * -- tens of milliseconds and a working set that stays resident -- and only\n * the caller knows whether the next screenshot is coming in a moment or never.\n * Neither call is required: a screenshot starts the engine if it is not up.\n * What they buy is control over when that cost is paid, and the certainty that\n * it has been given back.\n *\n * `runtime` below is the singleton because there is nothing else it could be:\n * Blink starts once per process and cannot be restarted, so a second Runtime\n * in the same process has no engine to have. Construct one directly only to\n * own the lifecycle yourself instead of using `runtime`. Parallelism is more\n * processes, not more Runtimes.\n *\n * `daemon` is the same engine in a process of its own, behind a socket, for\n * callers whose own process does not live long enough to be worth starting\n * one.\n */\nexport class Runtime {\n private engine = new Engine();\n\n get running(): boolean {\n return this.engine.running;\n }\n\n /**\n * Starts the engine. Safe to call twice; the second call is a no-op, so that\n * library code can call it defensively. Not safe after `stop()` -- see there.\n *\n * Every option has a default. `cacheDir` is the HTTP disk cache and `null`\n * disables it; `resourceDir` is where `shotium_data.pak` and\n * `shotium_strings.pak` are, and defaults to the directory the engine was\n * loaded from, which is where they ship.\n */\n start(options: StartOptions = {}): this {\n this.engine.start(options);\n return this;\n }\n\n /**\n * Stops the engine, after whatever is queued.\n *\n * Final for this process. Blink writes process-wide state that it has no\n * path to undo, so starting again -- here or on another Runtime -- throws\n * rather than quietly handing back something that cannot render. A program\n * that wants another screenshot later should stay started and `purge()`.\n */\n stop(): Promise<void> {\n return this.engine.stop();\n }\n\n /**\n * Hands back what the engine is holding but can rebuild. Worth calling when\n * a batch has ended and the next one may be a while away.\n */\n purge(options: PurgeOptions = {}): void {\n this.engine.purge(options);\n }\n\n /**\n * Renders one screenshot. Resolves to the encoded image, or to `null` when\n * `path` was given and the engine wrote the file itself.\n */\n screenshot(options: ScreenshotOptions): Promise<Buffer|null> {\n return this.engine.screenshot(options);\n }\n}\n\n/** The shared engine: one per process, started on first use. */\nconst runtime = new Runtime();\n\n/** One screenshot through the shared engine, starting it if it is not up. */\nconst screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>\n runtime.screenshot(options);\n\n/**\n * The resident engine: a process that outlives the one that started it,\n * reachable over a named pipe on Windows and a unix socket elsewhere. For\n * callers that are short-lived themselves. See lib/daemon.ts.\n */\nconst daemon: Daemon = {\n connect: client.connect,\n screenshot: client.screenshot,\n start: client.start,\n status: client.status,\n stop: client.stop,\n};\n\nexport {runtime, screenshot, daemon};\n\n// A default as well as the names, because `import shotium from` is what a\n// caller coming from `require` writes first, and the two have to be the same\n// object rather than two views that drift.\nexport default {Runtime, runtime, screenshot, daemon};\n"],"mappings":"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"}
@@ -0,0 +1,283 @@
1
+ // Copyright 2026 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+
5
+ // shotium's node addon: shot in this process, over shot_api.h.
6
+ //
7
+ // It is deliberately thin. Everything about what a screenshot means -- which
8
+ // fields exist, what they default to, what an unknown one is -- lives in
9
+ // shotium/src/lib/request.ts and shot/shot_request.cc, and this file carries
10
+ // JSON between them without reading it. Anything it understood would be a
11
+ // third opinion about the request format, and the third opinion is always the
12
+ // one that drifts.
13
+ //
14
+ // Node-API rather than V8: the ABI is stable across node versions, so one
15
+ // prebuilt .node per platform is enough and the addon does not have to be
16
+ // rebuilt every time node's internals move.
17
+
18
+ #include <node_api.h>
19
+
20
+ #include <cstring>
21
+ #include <string>
22
+ #include <utility>
23
+
24
+ #include "shot_api.h"
25
+
26
+ namespace {
27
+
28
+ // A shot_engine, as node sees it.
29
+ //
30
+ // Wrapped rather than handed over as a bare pointer so that a script dropping
31
+ // the handle on the floor still shuts the engine down: the finalizer runs when
32
+ // the object is collected. `engine` is cleared by an explicit destroy() so the
33
+ // finalizer does not do it twice.
34
+ struct EngineHandle {
35
+ shot_engine* engine = nullptr;
36
+ };
37
+
38
+ void FinalizeEngine(napi_env env, void* data, void* hint) {
39
+ auto* handle = static_cast<EngineHandle*>(data);
40
+ if (handle->engine) {
41
+ shot_engine_destroy(handle->engine);
42
+ }
43
+ delete handle;
44
+ }
45
+
46
+ // One capture in flight: a promise, and the strings on both sides of it.
47
+ //
48
+ // The request is copied rather than referenced because Execute runs on a
49
+ // libuv thread where no JS value may be touched, and the JS string it came
50
+ // from can be collected before then.
51
+ struct CaptureTask {
52
+ napi_deferred deferred = nullptr;
53
+ napi_async_work work = nullptr;
54
+ shot_engine* engine = nullptr;
55
+ std::string request;
56
+ shot_status status = SHOT_ERR_CAPTURE;
57
+ shot_buffer* image = nullptr;
58
+ shot_buffer* error = nullptr;
59
+ };
60
+
61
+ bool ReadUtf8(napi_env env, napi_value value, std::string* out) {
62
+ size_t length = 0;
63
+ if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) {
64
+ return false;
65
+ }
66
+ // Room for the NUL node insists on writing, then cut back to what it says
67
+ // it wrote.
68
+ std::string text(length + 1, '\0');
69
+ size_t written = 0;
70
+ if (napi_get_value_string_utf8(env, value, text.data(), length + 1,
71
+ &written) != napi_ok) {
72
+ return false;
73
+ }
74
+ text.resize(written);
75
+ *out = std::move(text);
76
+ return true;
77
+ }
78
+
79
+ napi_value Undefined(napi_env env) {
80
+ napi_value value = nullptr;
81
+ napi_get_undefined(env, &value);
82
+ return value;
83
+ }
84
+
85
+ // Turns a shot_buffer carrying a message into a thrown JS error. Freeing it is
86
+ // this function's job either way, because every caller is on its way out.
87
+ void ThrowFromBuffer(napi_env env, shot_buffer* message, const char* fallback) {
88
+ const char* text = fallback;
89
+ if (message && shot_buffer_size(message) > 0) {
90
+ text = reinterpret_cast<const char*>(shot_buffer_data(message));
91
+ }
92
+ napi_throw_error(env, nullptr, text);
93
+ shot_buffer_free(message);
94
+ }
95
+
96
+ bool ReadHandle(napi_env env, napi_value value, EngineHandle** out) {
97
+ void* data = nullptr;
98
+ if (napi_get_value_external(env, value, &data) != napi_ok || !data) {
99
+ napi_throw_type_error(env, nullptr, "shotium: expected an engine handle");
100
+ return false;
101
+ }
102
+ *out = static_cast<EngineHandle*>(data);
103
+ if (!(*out)->engine) {
104
+ napi_throw_error(env, nullptr, "shotium: this engine has been destroyed");
105
+ return false;
106
+ }
107
+ return true;
108
+ }
109
+
110
+ napi_value Create(napi_env env, napi_callback_info info) {
111
+ size_t argc = 1;
112
+ napi_value argv[1] = {};
113
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
114
+
115
+ std::string options;
116
+ if (argc > 0 && !ReadUtf8(env, argv[0], &options)) {
117
+ napi_throw_type_error(env, nullptr,
118
+ "shotium: create(optionsJson) wants a string");
119
+ return nullptr;
120
+ }
121
+
122
+ if (shot_abi_version() != SHOT_ABI_VERSION) {
123
+ napi_throw_error(env, nullptr,
124
+ "shotium: the shot library beside this addon speaks a "
125
+ "different ABI version; they ship together and one of "
126
+ "them has been replaced");
127
+ return nullptr;
128
+ }
129
+
130
+ shot_engine* engine = nullptr;
131
+ shot_buffer* error = nullptr;
132
+ if (shot_engine_create(options.c_str(), &engine, &error) != SHOT_OK) {
133
+ ThrowFromBuffer(env, error, "shotium: the engine would not start");
134
+ return nullptr;
135
+ }
136
+
137
+ auto* handle = new EngineHandle{engine};
138
+ napi_value external = nullptr;
139
+ if (napi_create_external(env, handle, FinalizeEngine, nullptr, &external) !=
140
+ napi_ok) {
141
+ shot_engine_destroy(engine);
142
+ delete handle;
143
+ napi_throw_error(env, nullptr, "shotium: could not wrap the engine");
144
+ return nullptr;
145
+ }
146
+ return external;
147
+ }
148
+
149
+ napi_value Destroy(napi_env env, napi_callback_info info) {
150
+ size_t argc = 1;
151
+ napi_value argv[1] = {};
152
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
153
+
154
+ void* data = nullptr;
155
+ if (argc < 1 || napi_get_value_external(env, argv[0], &data) != napi_ok ||
156
+ !data) {
157
+ napi_throw_type_error(env, nullptr, "shotium: expected an engine handle");
158
+ return nullptr;
159
+ }
160
+ auto* handle = static_cast<EngineHandle*>(data);
161
+ if (handle->engine) {
162
+ shot_engine_destroy(handle->engine);
163
+ handle->engine = nullptr;
164
+ }
165
+ return Undefined(env);
166
+ }
167
+
168
+ // Runs on a libuv thread. No napi call is legal here beyond the ones that take
169
+ // no env, which is why everything it needs was copied out first.
170
+ void ExecuteCapture(napi_env env, void* data) {
171
+ auto* task = static_cast<CaptureTask*>(data);
172
+ task->status = shot_engine_capture(task->engine, task->request.c_str(),
173
+ &task->image, &task->error);
174
+ }
175
+
176
+ void CompleteCapture(napi_env env, napi_status status, void* data) {
177
+ auto* task = static_cast<CaptureTask*>(data);
178
+
179
+ if (status == napi_ok && task->status == SHOT_OK) {
180
+ // Copied into a node Buffer rather than handed over as external memory.
181
+ // An external buffer would save a memcpy of a few hundred kilobytes
182
+ // against a render that took tens of milliseconds, and would put the
183
+ // lifetime of shot's allocation in the hands of node's GC -- across an
184
+ // allocator boundary the whole C ABI exists to keep closed.
185
+ napi_value buffer = nullptr;
186
+ napi_create_buffer_copy(env, shot_buffer_size(task->image),
187
+ shot_buffer_data(task->image), nullptr, &buffer);
188
+ napi_resolve_deferred(env, task->deferred, buffer);
189
+ } else {
190
+ const char* text = "shotium: the capture failed";
191
+ if (task->error && shot_buffer_size(task->error) > 0) {
192
+ text = reinterpret_cast<const char*>(shot_buffer_data(task->error));
193
+ }
194
+ napi_value message = nullptr;
195
+ napi_value error_value = nullptr;
196
+ napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &message);
197
+ napi_create_error(env, nullptr, message, &error_value);
198
+ napi_reject_deferred(env, task->deferred, error_value);
199
+ }
200
+
201
+ shot_buffer_free(task->image);
202
+ shot_buffer_free(task->error);
203
+ napi_delete_async_work(env, task->work);
204
+ delete task;
205
+ }
206
+
207
+ napi_value Capture(napi_env env, napi_callback_info info) {
208
+ size_t argc = 2;
209
+ napi_value argv[2] = {};
210
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
211
+
212
+ EngineHandle* handle = nullptr;
213
+ if (argc < 2 || !ReadHandle(env, argv[0], &handle)) {
214
+ return nullptr;
215
+ }
216
+
217
+ auto* task = new CaptureTask;
218
+ task->engine = handle->engine;
219
+ if (!ReadUtf8(env, argv[1], &task->request)) {
220
+ delete task;
221
+ napi_throw_type_error(env, nullptr,
222
+ "shotium: capture(engine, requestJson) wants a "
223
+ "string");
224
+ return nullptr;
225
+ }
226
+
227
+ napi_value promise = nullptr;
228
+ if (napi_create_promise(env, &task->deferred, &promise) != napi_ok) {
229
+ delete task;
230
+ napi_throw_error(env, nullptr, "shotium: could not make a promise");
231
+ return nullptr;
232
+ }
233
+
234
+ napi_value name = nullptr;
235
+ napi_create_string_utf8(env, "shot:capture", NAPI_AUTO_LENGTH, &name);
236
+ napi_create_async_work(env, nullptr, name, ExecuteCapture, CompleteCapture,
237
+ task, &task->work);
238
+ napi_queue_async_work(env, task->work);
239
+ return promise;
240
+ }
241
+
242
+ // Synchronous on purpose. A purge is milliseconds and happens when the caller
243
+ // has decided it has nothing else to do; queuing it behind the event loop
244
+ // would mean the process that just went idle stays large until something wakes
245
+ // it up.
246
+ napi_value Purge(napi_env env, napi_callback_info info) {
247
+ size_t argc = 2;
248
+ napi_value argv[2] = {};
249
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
250
+
251
+ EngineHandle* handle = nullptr;
252
+ if (argc < 1 || !ReadHandle(env, argv[0], &handle)) {
253
+ return nullptr;
254
+ }
255
+
256
+ bool release = false;
257
+ if (argc > 1) {
258
+ napi_get_value_bool(env, argv[1], &release);
259
+ }
260
+ shot_engine_purge(handle->engine, release ? 1 : 0);
261
+ return Undefined(env);
262
+ }
263
+
264
+ napi_value Init(napi_env env, napi_value exports) {
265
+ const napi_property_descriptor properties[] = {
266
+ {"create", nullptr, Create, nullptr, nullptr, nullptr, napi_default,
267
+ nullptr},
268
+ {"destroy", nullptr, Destroy, nullptr, nullptr, nullptr, napi_default,
269
+ nullptr},
270
+ {"capture", nullptr, Capture, nullptr, nullptr, nullptr, napi_default,
271
+ nullptr},
272
+ {"purge", nullptr, Purge, nullptr, nullptr, nullptr, napi_default,
273
+ nullptr},
274
+ };
275
+ napi_define_properties(env, exports,
276
+ sizeof(properties) / sizeof(properties[0]),
277
+ properties);
278
+ return exports;
279
+ }
280
+
281
+ } // namespace
282
+
283
+ NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)