@shotkit/shotium 0.2.0 → 0.3.1

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 CHANGED
@@ -7,110 +7,182 @@
7
7
 
8
8
  ---
9
9
 
10
- ## Overview
10
+ ## Quick Start
11
11
 
12
- `@shotkit/shotium` provides Node.js / TypeScript bindings for **shotium**, a stripped-down Chromium engine built specifically for fast static page rendering. By completely removing V8 and browser chrome overhead, Shotium delivers cold starts under 350 ms, single-shot captures in ~47 ms, and an idle memory footprint of ~58 MB.
12
+ ### 1. Installation
13
13
 
14
- The engine is loaded into your own process as a Node-API addon over a C ABI. Nothing is spawned, no image crosses a process boundary, and `screenshot()` returns the bytes Blink just encoded.
14
+ ```bash
15
+ # npm
16
+ npm install @shotkit/shotium
17
+
18
+ # pnpm
19
+ pnpm add @shotkit/shotium
20
+
21
+ # yarn
22
+ yarn add @shotkit/shotium
23
+
24
+ # bun
25
+ bun add @shotkit/shotium
26
+ ```
27
+
28
+ Prebuilt platform binaries are installed automatically via npm optional dependencies across six architectures (Windows, macOS, and Linux on x64 and arm64). No local compiler or postinstall build script is required.
29
+
30
+ The package is published as native ESM:
31
+ - `import` is supported on Node.js 18+.
32
+ - Synchronous `require()` is supported on Node.js 22.12+ / 20.19+.
33
+ - Earlier Node.js versions can use dynamic `await import('@shotkit/shotium')`.
34
+
35
+ ### 2. Basic Example
15
36
 
16
37
  ```ts
17
- import shotium from '@shotkit/shotium';
38
+ import shotium, { screenshot } from '@shotkit/shotium';
18
39
 
19
- shotium.runtime.start();
40
+ // 1. Initialize engine
41
+ shotium.start();
20
42
 
21
- const png = await shotium.screenshot({
22
- file: 'https://example.com',
23
- viewport: { width: 1280, height: 720 },
24
- fullPage: true,
43
+ // 2. Render remote URLs, local HTML files, or inline HTML strings (data:text/html)
44
+ const { image, stats } = await screenshot({
45
+ file: 'data:text/html,<h1 style="color: #0969da; font-family: sans-serif;">Hello Shotium</h1>',
46
+ viewport: { width: 800, height: 400 },
25
47
  });
26
48
 
27
- await shotium.runtime.stop();
49
+ console.log(`Rendered in ${stats.timing.render}ms, Total: ${stats.timing.total}ms`);
50
+
51
+ // 3. Shut down engine and release resources
52
+ await shotium.stop();
28
53
  ```
29
54
 
30
55
  ---
31
56
 
32
- ## Installation
57
+ ## Table of Contents
58
+
59
+ - [Overview & Key Highlights](#overview--key-highlights)
60
+ - [Execution Mode Selection Guide](#execution-mode-selection-guide)
61
+ - [Usage Modes](#usage-modes)
62
+ - [1. In-Process Engine](#1-in-process-engine)
63
+ - [2. Resident Daemon](#2-resident-daemon-daemon)
64
+ - [API Reference](#api-reference)
65
+ - [`ScreenshotOptions` & `ScreenshotResult`](#screenshotoptions)
66
+ - [`StartOptions` & `StartResult`](#startoptions)
67
+ - [`CaptureStats` Metrics Breakdown](#capturestats)
68
+ - [`daemon` Module & Status](#daemon-module)
69
+ - [`cache` Management Module](#cache-module)
70
+ - [License](#license)
33
71
 
34
- ```bash
35
- npm install @shotkit/shotium
36
- ```
72
+ ---
73
+
74
+ ## Overview & Key Highlights
75
+
76
+ `@shotkit/shotium` provides Node.js / TypeScript bindings for **shotium**, a stripped-down Chromium engine built specifically for fast static page rendering.
77
+
78
+ By completely removing V8 and multi-process browser overhead, Shotium delivers:
79
+ - **Fast Cold Starts**: Under 350 ms
80
+ - **High Render Speed**: ~47 ms for single viewport capture (down to ~31 ms in-process)
81
+ - **Low Memory Footprint**: ~58 MB idle memory
82
+
83
+ The engine is loaded into the host process as a Node-API addon wrapping a shared library. With zero child process overhead, no inter-process image copying is needed, and `screenshot()` directly returns the encoded image buffer from Blink.
84
+
85
+ ---
37
86
 
38
- Prebuilt platform binaries are installed automatically via npm optional dependencies — six of them, covering Windows, macOS and Linux on x64 and arm64. There is no build step and no postinstall download.
87
+ ## Execution Mode Selection Guide
39
88
 
40
- The package is ESM. `import` works on Node 18 and up; `require()` of it needs Node 22.12 or 20.19, and anything older should use `await import('@shotkit/shotium')`.
89
+ | Use Case | Recommended Mode | Key Benefit |
90
+ |---|---|---|
91
+ | **Long-Running Web / API Services** (Express, Fastify, NestJS) | **In-Process Engine** | Zero IPC overhead, zero process startup cost, lowest per-shot latency (~31 ms). |
92
+ | **CLI Tools / CI Pipelines / Serverless Tasks** | **Resident Daemon** | Cross-process pre-warmed engine reuse; connects in **2.3 ms**, eliminating cold starts. |
41
93
 
42
94
  ---
43
95
 
44
- ## Usage
96
+ ## Usage Modes
97
+
98
+ ### 1. In-Process Engine
45
99
 
46
- ### 1. In-Process Engine (`runtime`)
100
+ The engine runs directly inside your Node.js process via Node-API, bound to the C ABI in [`shot/shot_api.h`](https://github.com/sj817/shotium/blob/main/shot/shot_api.h). `screenshot()` returns the image buffer encoded directly by Blink (~**31 ms** per shot).
47
101
 
48
102
  ```ts
49
- import { runtime, screenshot } from '@shotkit/shotium';
103
+ import shotium, { screenshot } from '@shotkit/shotium';
50
104
 
51
- runtime.start({
52
- cacheDir: '/var/tmp/shotium-cache' // Optional HTTP disk cache. Default: null (off)
53
- });
105
+ // Start engine and retrieve cache status
106
+ const { cacheDir, cacheActive } = shotium.start();
54
107
 
55
- // Returns a Buffer, or null when `path` was given and the engine wrote the file
56
- const buffer = await screenshot({
108
+ // 1. Capture remote URL
109
+ const res1 = await screenshot({
57
110
  file: 'https://example.com',
58
111
  viewport: { width: 1280, height: 720 },
59
112
  type: 'webp',
60
113
  quality: 85,
61
114
  });
62
115
 
63
- // Hand memory back between batches without giving up the engine
64
- runtime.purge({ releaseWorkingSet: true });
65
-
66
- await runtime.stop();
67
- ```
68
-
69
- **One engine per process, ever, and not one at a time.** Starting Blink writes process-wide statics it has no path to undo, so `stop()` is final: a `start()` after it throws, and so does a second `Runtime`. Concurrent callers are queued and served one at a time, because there is one renderer. Parallelism is therefore more processes, and a program that will want another screenshot later should stay started and call `purge()` rather than stopping.
70
-
71
- #### `StartOptions`
72
-
73
- ```ts
74
- interface StartOptions {
75
- /** Root of the HTTP disk cache. null (the default) disables caching. */
76
- cacheDir?: string | null;
116
+ // 2. Capture dynamically assembled inline HTML string (no temporary files on disk)
117
+ const html = `<div style="padding: 24px; background: #f6f8fa;"><h2>Invoice #1024</h2></div>`;
118
+ const res2 = await screenshot({
119
+ file: `data:text/html;charset=utf-8,${encodeURIComponent(html)}`,
120
+ viewport: { width: 600, height: 300 },
121
+ });
77
122
 
78
- /** User-Agent sent with every request. */
79
- userAgent?: string;
123
+ // 3. Memory reclamation strategies:
124
+ // - releaseMemory(): clears Blink heap, Skia caches, and PartitionAlloc free lists (instant)
125
+ // - releaseWorkingSet: true: additionally asks OS to reclaim physical working set memory
126
+ shotium.releaseMemory({ releaseWorkingSet: true });
80
127
 
81
- /** Where the engine looks for its resource packs. Defaults to the
82
- * directory the addon was loaded from, which is right for an install. */
83
- resourceDir?: string;
84
- }
128
+ // 4. Shut down engine
129
+ await shotium.stop();
85
130
  ```
86
131
 
132
+ #### Lifecycle & Execution Model
133
+
134
+ - **Process Singleton**:
135
+ - Blink relies on process-wide statics that cannot be cleanly uninitialized.
136
+ - Each process hosts a single engine instance; subsequent `new Runtime()` calls reuse the existing instance.
137
+ - **Start / Stop Semantics**:
138
+ - Calling `stop()` drains the task queue, marks `running: false`, and releases working set memory.
139
+ - Calling `start()` subsequent times re-activates the engine and preserves its warm disk cache.
140
+ - **Configuration Consistency**:
141
+ - Engine options are established at initial creation.
142
+ - Passing conflicting options in subsequent `start()` calls throws an explicit error rather than silently ignoring parameters.
143
+ - **Concurrency & Parallelism**:
144
+ - Within a single engine instance, concurrent screenshot calls are queued and rendered sequentially.
145
+ - To achieve parallel throughput, scale horizontally across multiple worker processes.
146
+ - **Status Reporting**:
147
+ - `start()` and `status()` return `{ running, cacheDir, cacheActive }`.
148
+ - If `cacheDir` cannot be opened, `cacheActive` is set to `false`, and the engine operates safely in cacheless mode.
149
+
87
150
  ---
88
151
 
89
152
  ### 2. Resident Daemon (`daemon`)
90
153
 
91
154
  Recommended for CLI tools, ephemeral CI tasks, or serverless workers where startup latency is critical.
92
155
 
93
- The daemon is the same engine in a process of its own, behind a local socket (Named Pipe on Windows, Unix domain socket on POSIX). It renders a blank page on start, so it is warm before the first real request arrives.
156
+ The daemon runs the engine in a standalone background process exposed via a local IPC socket (Named Pipe on Windows, Unix domain socket on POSIX). It pre-renders a blank page upon startup to warm up all subsystems, allowing clients to connect with ~**2.3 ms** latency.
94
157
 
95
158
  ```ts
96
159
  import { daemon } from '@shotkit/shotium';
97
160
 
98
- // Connect to an existing daemon (automatically starts one if none is running)
161
+ // Connect to existing daemon, or automatically launch one in background
99
162
  const client = await daemon.connect();
100
163
 
101
- const png = await client.screenshot({
164
+ // Dispatch screenshot request
165
+ const { image, stats } = await client.screenshot({
102
166
  file: 'https://example.com',
103
167
  viewport: { width: 1280, height: 720 },
104
168
  });
105
169
 
170
+ // Manage daemon status or release memory from client connection
171
+ const clientStatus = await client.status();
172
+ await client.releaseMemory({ releaseWorkingSet: false });
173
+
106
174
  client.close();
107
175
 
108
- // Check status or stop the daemon
109
- const status = await daemon.status(); // { running: true, pid: 12345, warm: true, ... }
176
+ // Global daemon operations (optional)
177
+ const daemonInfo = await daemon.status();
178
+ console.log(`Daemon PID: ${daemonInfo.pid}, Uptime: ${daemonInfo.uptimeMs}ms, Served: ${daemonInfo.served}`);
179
+
180
+ // Stop daemon
110
181
  await daemon.stop();
111
182
  ```
112
183
 
113
- One connection may have several requests outstanding, because every message carries an `id`. That is a convenience for the client rather than concurrency: the daemon holds one renderer too, and answers in the order it finished them. Two at once means two daemons, told apart by `name`.
184
+ - **Request Multiplexing**: Multiple requests can be dispatched concurrently over a single connection; each message carries a unique `id`. The daemon processes and returns responses in completion order.
185
+ - **Multi-Instance Isolation**: Each daemon instance renders requests serially. To scale concurrent rendering, launch multiple distinct daemon instances using unique `name` identifiers.
114
186
 
115
187
  ---
116
188
 
@@ -120,10 +192,10 @@ One connection may have several requests outstanding, because every message carr
120
192
 
121
193
  ```ts
122
194
  interface ScreenshotOptions {
123
- /** Target URL (http/https/file) or local file path */
195
+ /** Target URL (http/https/file/data) or local file path */
124
196
  file: string;
125
197
 
126
- /** Output format (default: 'png') */
198
+ /** Output image format (default: 'png') */
127
199
  type?: 'png' | 'jpeg' | 'webp';
128
200
 
129
201
  /** Viewport dimensions (default: 1280x720) */
@@ -132,36 +204,226 @@ interface ScreenshotOptions {
132
204
  /** Capture full scrollable document */
133
205
  fullPage?: boolean;
134
206
 
135
- /** Capture element bounding box matching selector */
207
+ /** Capture bounding box of matching CSS selector (resolved via Document::querySelector) */
136
208
  selector?: string;
137
209
 
138
- /** Capture specific rectangular crop */
210
+ /** Capture specific rectangular coordinate region */
139
211
  clip?: { x: number; y: number; width: number; height: number };
140
212
 
141
- /** Image compression quality: 1-100 (jpeg and webp only, default: 90) */
213
+ /** Compression quality: 1-100 (jpeg and webp only, default: 90) */
142
214
  quality?: number;
143
215
 
144
216
  /** Device scale factor: 0.01 - 8.0 (default: 1.0) */
145
217
  scale?: number;
146
218
 
147
- /** Preserve transparent background (png/webp only) */
219
+ /** Preserve transparent background (png and webp only; jpeg has no alpha channel) */
148
220
  omitBackground?: boolean;
149
221
 
150
- /** Output file destination path (returns null if specified) */
222
+ /** Output file path. If specified, writes directly to disk and image returns null */
151
223
  path?: string;
152
224
 
153
- /** Navigation & wait options */
225
+ /** Page navigation options */
154
226
  pageGotoParams?: {
227
+ /** Timeout in milliseconds (default: 30000) */
155
228
  timeout?: number;
229
+ /**
230
+ * load: wait until DOM is parsed and basic resources loaded (default)
231
+ * networkidle: additionally wait for 500ms window with 0 in-flight requests (for late WebFonts/CSS)
232
+ */
156
233
  waitUntil?: 'load' | 'networkidle';
157
234
  };
158
235
 
159
- /** Allow document to access local file:// resources (default: false) */
236
+ /** Allow document to read local file:// subresources (default: false) */
160
237
  allowFileAccess?: boolean;
238
+
239
+ /** HTTP cache strategy (default: 'default') */
240
+ cache?: 'default' | 'reload' | 'no-store' | 'only-if-cached';
241
+
242
+ /** Extra request headers, sent to same-origin URLs only */
243
+ headers?: Record<string, string>;
244
+ }
245
+ ```
246
+
247
+ > **Note**: `fullPage`, `selector`, and `clip` are mutually exclusive. Specifying more than one will throw a validation error.
248
+
249
+ #### `ScreenshotResult` Return Structure
250
+
251
+ ```ts
252
+ interface ScreenshotResult {
253
+ /** Encoded image buffer; null when path parameter was specified (written directly to disk) */
254
+ image: Buffer | null;
255
+ /** Detailed capture timing and network statistics */
256
+ stats: CaptureStats;
257
+ }
258
+ ```
259
+
260
+ #### Parameter Details
261
+
262
+ - **`file` Input Schemes**:
263
+ - Remote URLs: `https://example.com`
264
+ - Local Paths: `./template.html`, `/absolute/path/index.html`, `file:///...`
265
+ - Inline HTML Strings: `data:text/html;charset=utf-8,<h1>Hello</h1>`
266
+ - **`cache` Strategies** (follows Web Fetch API, applies to document and subresources):
267
+ - `default`: Standard HTTP caching behavior.
268
+ - `reload`: Bypasses existing cache, fetches fresh resources from server, and updates cache.
269
+ - `no-store`: Completely disables reading and writing to cache.
270
+ - `only-if-cached`: Retrieves cached entries only; throws an immediate error if cache misses (no network request).
271
+ - **`headers` Scope**:
272
+ - Strictly adheres to Same-Origin policy.
273
+ - Passed headers (such as `Authorization` or `Cookie`) are sent only to the target site, and never forwarded to cross-origin external stylesheets or fonts.
274
+
275
+ ---
276
+
277
+ ### `StartOptions`
278
+
279
+ ```ts
280
+ interface StartOptions {
281
+ /**
282
+ * HTTP disk cache directory. Defaults to a project-specific directory
283
+ * under ~/.shotium/cache; set to null to disable disk caching.
284
+ */
285
+ cacheDir?: string | null;
286
+
287
+ /** Maximum size ceiling for cache directory in bytes (default: 256 MB) */
288
+ cacheMaxBytes?: number;
289
+
290
+ /** Custom User-Agent string */
291
+ userAgent?: string;
292
+
293
+ /** Directory containing shotium_data.pak and shotium_strings.pak */
294
+ resourceDir?: string;
295
+ }
296
+ ```
297
+
298
+ #### `StartResult` Return Structure
299
+
300
+ ```ts
301
+ interface StartResult {
302
+ /** Whether the runtime instance is currently started */
303
+ running: boolean;
304
+ /** Active cache directory path (null when caching is disabled) */
305
+ cacheDir: string | null;
306
+ /** Whether cache directory was opened successfully and is active */
307
+ cacheActive: boolean;
308
+ }
309
+ ```
310
+
311
+ ---
312
+
313
+ ### `CaptureStats`
314
+
315
+ Every capture operation returns detailed timing breakdown and network statistics:
316
+
317
+ ```ts
318
+ interface CaptureStats {
319
+ requests: number; // Total resources requested by document (including itself)
320
+ fromCache: number; // Number of resource bodies served from HTTP disk cache
321
+ failed: number; // Number of failed subresource requests
322
+ bytes: number; // Total decoded body bytes (not transfer size)
323
+ httpStatus: number; // Main document HTTP status code (0 for file: / data: URLs)
324
+ finalUrl: string; // Final URL after resolving redirects
325
+ timing: {
326
+ fetch: number; // Document retrieval: DNS, TCP, TLS, and round-trip latency
327
+ render: number; // Rendering: parsing, subresources, styles, layout, paint
328
+ setup: number; // Page/frame creation and document installation
329
+ wait: number; // Parsing, load completion and subresource wait
330
+ lifecycle: number; // Capture selection, style, layout and lifecycle
331
+ paint: number; // PaintRecord extraction
332
+ raster: number; // Surface preparation and PaintRecord replay
333
+ encode: number; // Image encoding duration
334
+ total: number; // Total wall-clock duration
335
+ };
336
+ }
337
+ ```
338
+
339
+ #### Metrics & Timing Breakdown
340
+
341
+ - **Network Latency Breakdown**: For cold `https:` requests, `timing.fetch` represents the majority of total latency. Cache hits reduce fetch latency to sub-millisecond levels:
342
+
343
+ | Scenario | `fetch` Latency | `render` Latency | `total` Latency |
344
+ |---|---|---|---|
345
+ | **Local file / Inline HTML** (`file:` / `data:`) | 0.2 ms | 20 ms | 25 ms |
346
+ | **HTTPS (Cold request)** | 321.1 ms | 16 ms | 350 ms |
347
+ | **HTTPS (Cache hit)** | 0.7 ms | 18 ms | 31 ms |
348
+
349
+ - **`fromCache` Semantics**: Indicates that the response body was served from disk. If an entry is revalidated via conditional request (304 Not Modified), network round-trip latency is still incurred while saving body payload transfer.
350
+ - **Failure Diagnostics (`error.stats`)**: When a capture fails or times out, the error object includes `error.stats` containing network metrics prior to the error.
351
+
352
+ ---
353
+
354
+ ### `daemon` Module
355
+
356
+ Manages resident daemon instances and IPC connections:
357
+
358
+ ```ts
359
+ import { daemon } from '@shotkit/shotium';
360
+
361
+ // 1. Establish IPC connection
362
+ const client = await daemon.connect({
363
+ name: 'custom-pool', // Optional: daemon naming isolation
364
+ idleTimeoutMs: 300000, // Idle exit timeout when no connections active (default 5 min; 0 = never)
365
+ prewarm: true, // Pre-renders a blank page upon startup to warm up engine (default true)
366
+ });
367
+
368
+ // 2. Client instance methods
369
+ const res = await client.screenshot({ file: 'https://example.com' });
370
+ const status = await client.status();
371
+ await client.releaseMemory({ releaseWorkingSet: false });
372
+ client.close();
373
+
374
+ // 3. Global daemon management
375
+ const info: DaemonStatus = await daemon.status();
376
+ await daemon.stop();
377
+ ```
378
+
379
+ #### `DaemonStatus` Structure
380
+
381
+ ```ts
382
+ interface DaemonStatus {
383
+ pid: number; // Daemon OS process ID
384
+ endpoint: string; // IPC socket path / named pipe
385
+ cacheDir: string | null; // Active disk cache directory
386
+ warm: boolean; // Whether engine pre-warm has completed
387
+ uptimeMs: number; // Uptime in milliseconds
388
+ connections: number; // Current active client connections
389
+ inFlight: number; // Requests currently being rendered
390
+ served: number; // Total completed requests
391
+ idleTimeoutMs: number; // Configured idle timeout
392
+ version: string; // Engine version
161
393
  }
162
394
  ```
163
395
 
164
- An option this interface does not list is a typo, and a typo that was quietly dropped is a screenshot that ignored what you asked for — so an unknown key is a `TypeError` rather than a silent no-op. `fullPage`, `selector` and `clip` are mutually exclusive.
396
+ ---
397
+
398
+ ### `cache` Module
399
+
400
+ Manages persistent HTTP disk caching across processes and engine lifecycles:
401
+
402
+ ```ts
403
+ import { cache } from '@shotkit/shotium';
404
+
405
+ // 1. Directory query
406
+ cache.getDir(); // Current project's cache directory (absolute path)
407
+ cache.getDirs({ target: 'all' }); // List all shotium cache directories on the system
408
+
409
+ // 2. List cached file metadata
410
+ const files = await cache.getFiles(); // [{ url, lastUsedMs, bytes, dir }, ...]
411
+
412
+ // 3. Evict cache and inspect result
413
+ const result: CacheClearResult = await cache.clear({
414
+ glob: ['https://example.com/**'], // Evict by URL glob pattern
415
+ maxAge: 86400, // Evict entries unused for > 24h (seconds)
416
+ maxSize: 64 * 1024 * 1024, // Evict via LRU to under 64 MB
417
+ });
418
+
419
+ console.log(`Removed: ${result.removed}, Bytes before: ${result.bytesBefore}, Bytes after: ${result.bytesAfter}`);
420
+ ```
421
+
422
+ #### Cache Design & Guidelines
423
+
424
+ - **Directory Structure**: Stored under `~/.shotium/cache/<project-hash>`, avoiding ephemeral `/tmp` directories that are automatically purged on reboot.
425
+ - **Index Integrity**: Cache files are stored using URL-key hashes with an internal index file. Cache eviction must be performed via the `cache` API rather than manual file deletion to preserve index consistency.
426
+ - **Cross-Process Sharing**: Multiple processes may concurrently access the same cache directory safely.
165
427
 
166
428
  ---
167
429
 
@@ -1,9 +1,9 @@
1
- import { a as encodeFrame, i as FrameReader, o as endpointFor, s as resolveStartOptions, t as Engine } from "./engine-Xe7nH-1i.js";
2
- import { EventEmitter } from "node:events";
1
+ import { d as resolveStartOptions, i as Engine, n as encodeFrame, r as endpointFor, t as FrameReader } from "./protocol-rQEcQPAC.js";
3
2
  import fs from "node:fs";
4
- import net from "node:net";
5
3
  import path from "node:path";
6
4
  import os from "node:os";
5
+ import { EventEmitter } from "node:events";
6
+ import net from "node:net";
7
7
 
8
8
  //#region src/lib/daemon.ts
9
9
  const VERSION = (() => {
@@ -202,19 +202,21 @@ var Daemon = class extends EventEmitter {
202
202
  id,
203
203
  file: request.file
204
204
  });
205
- this.engine.capture(request).then((image) => {
205
+ this.engine.capture(request).then(({ image, stats }) => {
206
206
  this.served += 1;
207
207
  this.reply(socket, {
208
208
  id,
209
209
  ok: true,
210
210
  bytes: image ? image.length : 0,
211
- path: request.path
211
+ path: request.path,
212
+ stats
212
213
  }, image);
213
214
  }).catch((error) => {
214
215
  this.reply(socket, {
215
216
  id,
216
217
  ok: false,
217
- error: String(error.message || error)
218
+ error: String(error.message || error),
219
+ stats: error.stats
218
220
  });
219
221
  }).finally(() => {
220
222
  this.inFlight -= 1;
@@ -250,7 +252,7 @@ var Daemon = class extends EventEmitter {
250
252
  for (const socket of this.sockets) socket.destroy();
251
253
  this.sockets.clear();
252
254
  await new Promise((resolve) => this.server.close(() => resolve()));
253
- await this.engine.stop();
255
+ await this.engine.dispose();
254
256
  this.emit("close", {});
255
257
  }
256
258
  };
@@ -1 +1 @@
1
- {"version":3,"file":"daemon_main.js","names":[],"sources":["../src/lib/daemon.ts","../src/daemon_main.ts"],"sourcesContent":["import {EventEmitter} from 'node:events';\nimport fs from 'node:fs';\nimport net from 'node:net';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport type {DaemonOptions, DaemonStatus} from '../types.js';\n\nimport {resolveStartOptions} from './config.js';\nimport type {ResolvedStartOptions} from './config.js';\nimport {endpointFor} from './endpoint.js';\nimport {Engine} from './engine.js';\nimport {FrameReader, encodeFrame} from './protocol.js';\nimport type {WireRequest} from './request.js';\n\n// Our own version, for status(). Read rather than imported: an import\n// attribute would do it too, but only on a node new enough that this package\n// would not run on the rest. The URL is relative to the built module, which\n// sits one directory below the manifest.\nconst VERSION = (() => {\n try {\n const manifest =\n fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');\n return (JSON.parse(manifest) as {version?: string}).version ?? '0.0.0';\n } catch {\n return '0.0.0';\n }\n})();\n\nconst DEFAULT_IDLE_TIMEOUT_MS = 300000;\n\n// One message off the socket. `op` defaults to screenshot because that is what\n// almost every message is.\ninterface DaemonMessage {\n id?: number|null;\n op?: 'screenshot'|'status'|'ping'|'shutdown';\n request?: WireRequest;\n timeout?: number;\n retry?: number;\n}\n\ninterface DaemonReply {\n id: number|null;\n ok?: boolean;\n error?: string;\n bytes?: number;\n path?: string;\n stopping?: boolean;\n}\n\n// An engine that outlives the process that asked for it.\n//\n// The engine in index.ts is already resident, but only for as long as the Node\n// process holding it: a command-line invocation, a CI step, a serverless\n// handler and a `node -e` all pay for starting Blink and then throw it away.\n// This is the same engine behind a socket, so the second caller -- in a\n// different process, minutes later -- pays a connect() and nothing else.\n//\n// A request frame of JSON, answered by a header frame and a payload frame:\n//\n// -> [len][{\"id\":7,\"op\":\"screenshot\",\"request\":{...}}]\n// <- [len][{\"id\":7,\"ok\":true,\"bytes\":97756}] [len][<PNG>]\n//\n// `id` is on the wire so that a client may have several requests outstanding\n// on one connection. That is a convenience for the client, not concurrency:\n// there is one renderer here, because Blink is a process-wide singleton, so\n// the requests queue and come back in the order the engine finished them.\n// Wanting two at once means wanting two daemons, addressed by `name`.\n//\n// Nothing supervises a capture. The pool this replaced could time a worker out\n// and kill it; an in-process engine has no such seam -- there is no way to\n// abandon a render without abandoning the process. A page's own deadline\n// (`pageGotoParams.timeout`) is what bounds it, and the engine answers slow\n// pages by itself. `timeout` and `retry` on the wire are accepted and ignored,\n// so that an older client still talks to this.\n//\n// Events: ready, warm, request, response, idle-exit, error, close.\nclass Daemon extends EventEmitter {\n private readonly options: ResolvedStartOptions;\n private readonly endpointPath: string;\n private readonly idleTimeoutMs: number;\n private readonly prewarmOnStart: boolean;\n private readonly engine = new Engine();\n private server: net.Server|null = null;\n private sockets = new Set<net.Socket>();\n private inFlight = 0;\n private served = 0;\n private warmed = false;\n private startedAt = Date.now();\n private idleTimer: NodeJS.Timeout|null = null;\n private closing = false;\n\n constructor(options: DaemonOptions = {}) {\n super();\n this.options = resolveStartOptions(options);\n this.endpointPath = endpointFor({\n ...this.options,\n name: options.name,\n endpoint: options.endpoint,\n });\n this.idleTimeoutMs = options.idleTimeoutMs === undefined ?\n DEFAULT_IDLE_TIMEOUT_MS :\n options.idleTimeoutMs;\n this.prewarmOnStart = options.prewarm !== false;\n }\n\n get endpoint(): string {\n return this.endpointPath;\n }\n\n get warm(): boolean {\n return this.warmed;\n }\n\n // Brings the engine up and starts listening. The pipe existing *is* the\n // readiness signal -- a client's connect() either succeeds or the daemon is\n // not up -- so nothing is bound until the engine has started.\n //\n // Starting it here rather than on the first request is deliberate: a machine\n // with no engine for its platform should fail while the caller is still\n // watching, not answer a connect() and then reject every request on it.\n async listen(): Promise<this> {\n this.engine.start(this.options);\n\n this.server = net.createServer((socket) => this.accept(socket));\n this.server.on('error', (error) => this.emit('error', error));\n await this.bind();\n this.armIdleTimer();\n this.emit('ready', {endpoint: this.endpointPath});\n if (this.prewarmOnStart) {\n await this.prewarm();\n }\n return this;\n }\n\n private bind(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const server = this.server!;\n const onError = (error: NodeJS.ErrnoException) => {\n // A unix socket file outlives the process that made it, so EADDRINUSE\n // means either a live daemon or a leftover path. Connecting is the only\n // way to tell them apart: refused means nobody is home, and the file\n // can go.\n if (error.code === 'EADDRINUSE' && process.platform !== 'win32') {\n const probe = net.connect(this.endpointPath);\n probe.on('connect', () => {\n probe.destroy();\n reject(error);\n });\n probe.on('error', () => {\n try {\n fs.unlinkSync(this.endpointPath);\n } catch {\n reject(error);\n return;\n }\n server.listen(this.endpointPath, () => {\n this.restrict();\n resolve();\n });\n });\n return;\n }\n reject(error);\n };\n server.once('error', onError);\n server.listen(this.endpointPath, () => {\n server.removeListener('error', onError);\n this.restrict();\n resolve();\n });\n });\n }\n\n // Who may talk to this daemon.\n //\n // It matters because a request may set `allowFileAccess`, so a stranger who\n // can connect can have a document read this machine's filesystem and get the\n // result back as a picture. On POSIX the socket is a file and 0600 says only\n // its owner may connect.\n //\n // On Windows it is a named pipe, and node exposes no way to give one an ACL:\n // the default lets any account on the machine open it. A daemon on a shared\n // Windows host is therefore as trusted as the machine's users are -- use the\n // engine in your own process, where nothing is listening, if that is not\n // acceptable.\n private restrict(): void {\n if (process.platform === 'win32') {\n return;\n }\n try {\n fs.chmodSync(this.endpointPath, 0o600);\n } catch (error) {\n this.emit('error', error);\n }\n }\n\n // Renders one throwaway document so that the first real request does not pay\n // for whatever the engine initialises lazily. One is enough: there is one\n // renderer, and it is the same one every request lands on.\n //\n // A temporary file, not a `data:` URL. This used to send\n // `data:text/html,...`, which the renderer rejects -- shot_capture.cc takes\n // file, http and https and nothing else -- so every prewarm failed into the\n // catch below and the step had never once done anything. The failure was\n // invisible because a prewarm that does not work looks exactly like one that\n // does, only slower on the first request.\n //\n // The document names no subresources, so it renders identically whether or\n // not this daemon allows file access -- which is what the `data:` URL was\n // reaching for. A top-level file: URL always loads; `allowFileAccess` gates\n // what the document may then pull in.\n async prewarm(): Promise<void> {\n const blank = path.join(\n os.tmpdir(), `shotium-prewarm-${process.pid}.html`);\n try {\n fs.writeFileSync(\n blank, '<!doctype html><title>shotium</title><p>shotium');\n await this.engine.capture({file: blank, width: 16, height: 16});\n this.warmed = true;\n } catch (error) {\n // Not fatal: a daemon that could not prewarm still serves. But it is not\n // warm, and status() should not claim it is.\n this.emit('error', error);\n } finally {\n fs.rmSync(blank, {force: true});\n }\n this.emit('warm', {warm: this.warmed});\n }\n\n status(): DaemonStatus {\n return {\n ok: true,\n pid: process.pid,\n endpoint: this.endpointPath,\n cacheDir: this.options.cacheDir,\n userAgent: this.options.userAgent,\n resourceDir: this.options.resourceDir,\n warm: this.warmed,\n uptimeMs: Date.now() - this.startedAt,\n connections: this.sockets.size,\n inFlight: this.inFlight,\n served: this.served,\n idleTimeoutMs: this.idleTimeoutMs,\n version: VERSION,\n };\n }\n\n private accept(socket: net.Socket): void {\n socket.on('error', () => socket.destroy());\n this.sockets.add(socket);\n this.armIdleTimer();\n\n const reader = new FrameReader();\n socket.on('data', (chunk: Buffer) => {\n reader.push(chunk);\n for (;;) {\n const frame = reader.next();\n if (frame === null) {\n return;\n }\n this.dispatch(socket, frame);\n }\n });\n socket.on('close', () => {\n this.sockets.delete(socket);\n this.armIdleTimer();\n });\n }\n\n private dispatch(socket: net.Socket, frame: Buffer): void {\n let message: DaemonMessage;\n try {\n message = JSON.parse(frame.toString('utf8')) as DaemonMessage;\n } catch {\n this.reply(\n socket, {id: null, ok: false, error: 'shotium: request is not JSON'});\n return;\n }\n\n const id = message.id === undefined ? null : message.id;\n const op = message.op || 'screenshot';\n if (op === 'status') {\n this.reply(socket, {...this.status(), id});\n return;\n }\n if (op === 'ping') {\n this.reply(socket, {id, ok: true});\n return;\n }\n if (op === 'shutdown') {\n this.reply(socket, {id, ok: true, stopping: true});\n // After the reply is on the wire, not before: a client that asked for a\n // shutdown is entitled to hear that it happened.\n socket.end(() => void this.close());\n return;\n }\n if (op !== 'screenshot') {\n this.reply(socket, {id, ok: false, error: `shotium: unknown op \"${op}\"`});\n return;\n }\n\n const request = message.request || ({} as WireRequest);\n\n this.inFlight += 1;\n this.armIdleTimer();\n this.emit('request', {id, file: request.file});\n this.engine.capture(request)\n .then((image) => {\n this.served += 1;\n this.reply(\n socket,\n {\n id,\n ok: true,\n bytes: image ? image.length : 0,\n path: request.path,\n },\n image);\n })\n .catch((error: Error) => {\n this.reply(\n socket, {id, ok: false, error: String(error.message || error)});\n })\n .finally(() => {\n this.inFlight -= 1;\n this.emit('response', {id});\n this.armIdleTimer();\n });\n }\n\n private reply(\n socket: net.Socket, header: DaemonReply|(DaemonStatus&{id: number|null}),\n payload?: Buffer|null): void {\n if (socket.destroyed) {\n return;\n }\n socket.write(encodeFrame(Buffer.from(JSON.stringify(header), 'utf8')));\n socket.write(encodeFrame(payload || Buffer.alloc(0)));\n }\n\n // Idle is \"nobody connected and nothing rendering\". A client that holds its\n // socket open -- a long-lived service using connect() -- keeps the daemon\n // alive without having to poll it.\n private armIdleTimer(): void {\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n if (!this.idleTimeoutMs || this.closing) {\n return;\n }\n if (this.sockets.size > 0 || this.inFlight > 0) {\n return;\n }\n this.idleTimer = setTimeout(() => {\n this.emit('idle-exit', {idleTimeoutMs: this.idleTimeoutMs});\n void this.close();\n }, this.idleTimeoutMs);\n this.idleTimer.unref();\n }\n\n async close(): Promise<void> {\n if (this.closing) {\n return;\n }\n this.closing = true;\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n for (const socket of this.sockets) {\n socket.destroy();\n }\n this.sockets.clear();\n await new Promise<void>((resolve) => this.server!.close(() => resolve()));\n await this.engine.stop();\n this.emit('close', {});\n }\n}\n\nexport {Daemon, DEFAULT_IDLE_TIMEOUT_MS};\n","// The entry point of a detached daemon process.\n//\n// The configuration arrives as one base64 argument rather than as flags,\n// because it contains paths that a Windows command line would otherwise quote\n// badly, and because the client and the daemon have to agree on it exactly:\n// the endpoint is a hash of these fields, so a value mangled in transit would\n// produce a daemon listening where nobody looks. See endpoint.ts.\n//\n// It is a build entry of its own, and not a chunk, because lib/client.ts\n// spawns it by path -- `node dist/daemon_main.js <base64 json>` -- and a name\n// the bundler chose would be a name that changes.\n\nimport {Daemon} from './lib/daemon.js';\nimport type {DaemonOptions} from './types.js';\n\nasync function main(): Promise<void> {\n const encoded = process.argv[2];\n if (!encoded) {\n process.stderr.write('shotium: daemon_main expects a base64 config\\n');\n process.exit(2);\n }\n const options = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as\n DaemonOptions;\n const daemon = new Daemon(options);\n\n daemon.on('stderr', ({worker, line}: {worker: number, line: string}) => {\n process.stderr.write(`shotium worker ${worker}: ${line}\\n`);\n });\n for (const event of ['crash', 'timeout', 'worker-restart', 'worker-error',\n 'idle-exit']) {\n daemon.on(event, (payload: {error?: unknown}) => {\n // An Error does not survive JSON.stringify -- it comes out as {} -- and\n // its message is the whole point of logging a worker that would not\n // start.\n const detail = payload && payload.error ?\n {\n ...payload,\n error: String(\n (payload.error as Error).message ?? payload.error),\n } :\n payload;\n process.stderr.write(\n `shotium daemon ${event}: ${JSON.stringify(detail)}\\n`);\n });\n }\n // An 'error' with nobody listening is thrown by EventEmitter itself, which\n // would turn a socket that failed after binding -- something the daemon can\n // survive -- into a dead pool.\n daemon.on('error', (error: Error) => {\n process.stderr.write(\n `shotium daemon error: ${(error && error.message) || error}\\n`);\n });\n\n try {\n await daemon.listen();\n } catch (error) {\n // Losing the race to bind is the ordinary outcome when two clients start a\n // daemon at the same moment: the other one is up, this one is not needed,\n // and the client that spawned it will connect to the winner. Anything else\n // is a real failure and says so.\n if ((error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE') {\n process.exit(0);\n }\n process.stderr.write(`shotium: daemon failed to start: ${error}\\n`);\n process.exit(1);\n }\n\n const shutdown = () => {\n daemon.close().then(() => process.exit(0), () => process.exit(1));\n };\n process.on('SIGINT', shutdown);\n process.on('SIGTERM', shutdown);\n daemon.on('close', () => process.exit(0));\n}\n\nvoid main();\n"],"mappings":"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"}
1
+ {"version":3,"file":"daemon_main.js","names":[],"sources":["../src/lib/daemon.ts","../src/daemon_main.ts"],"sourcesContent":["import {EventEmitter} from 'node:events';\nimport fs from 'node:fs';\nimport net from 'node:net';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport type {\n CaptureStats,\n DaemonOptions,\n DaemonStatus,\n} from '../types.js';\n\nimport {resolveStartOptions} from './config.js';\nimport type {ResolvedStartOptions} from './config.js';\nimport {endpointFor} from './endpoint.js';\nimport {Engine} from './engine.js';\nimport {FrameReader, encodeFrame} from './protocol.js';\nimport type {WireRequest} from './request.js';\n\n// Our own version, for status(). Read rather than imported: an import\n// attribute would do it too, but only on a node new enough that this package\n// would not run on the rest. The URL is relative to the built module, which\n// sits one directory below the manifest.\nconst VERSION = (() => {\n try {\n const manifest =\n fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');\n return (JSON.parse(manifest) as {version?: string}).version ?? '0.0.0';\n } catch {\n return '0.0.0';\n }\n})();\n\nconst DEFAULT_IDLE_TIMEOUT_MS = 300000;\n\n// One message off the socket. `op` defaults to screenshot because that is what\n// almost every message is.\ninterface DaemonMessage {\n id?: number|null;\n op?: 'screenshot'|'status'|'ping'|'shutdown';\n request?: WireRequest;\n timeout?: number;\n retry?: number;\n}\n\ninterface DaemonReply {\n id: number|null;\n ok?: boolean;\n error?: string;\n bytes?: number;\n path?: string;\n stopping?: boolean;\n // What the capture cost, on the success header and on the failure one. The\n // client turns it back into the same CaptureStats the in-process engine\n // returns, so a program moving between the two changes an import and\n // nothing else.\n stats?: CaptureStats;\n}\n\n// An engine that outlives the process that asked for it.\n//\n// The engine in index.ts is already resident, but only for as long as the Node\n// process holding it: a command-line invocation, a CI step, a serverless\n// handler and a `node -e` all pay for starting Blink and then throw it away.\n// This is the same engine behind a socket, so the second caller -- in a\n// different process, minutes later -- pays a connect() and nothing else.\n//\n// A request frame of JSON, answered by a header frame and a payload frame:\n//\n// -> [len][{\"id\":7,\"op\":\"screenshot\",\"request\":{...}}]\n// <- [len][{\"id\":7,\"ok\":true,\"bytes\":97756}] [len][<PNG>]\n//\n// `id` is on the wire so that a client may have several requests outstanding\n// on one connection. That is a convenience for the client, not concurrency:\n// there is one renderer here, because Blink is a process-wide singleton, so\n// the requests queue and come back in the order the engine finished them.\n// Wanting two at once means wanting two daemons, addressed by `name`.\n//\n// Nothing supervises a capture. The pool this replaced could time a worker out\n// and kill it; an in-process engine has no such seam -- there is no way to\n// abandon a render without abandoning the process. A page's own deadline\n// (`pageGotoParams.timeout`) is what bounds it, and the engine answers slow\n// pages by itself. `timeout` and `retry` on the wire are accepted and ignored,\n// so that an older client still talks to this.\n//\n// Events: ready, warm, request, response, idle-exit, error, close.\nclass Daemon extends EventEmitter {\n private readonly options: ResolvedStartOptions;\n private readonly endpointPath: string;\n private readonly idleTimeoutMs: number;\n private readonly prewarmOnStart: boolean;\n private readonly engine = new Engine();\n private server: net.Server|null = null;\n private sockets = new Set<net.Socket>();\n private inFlight = 0;\n private served = 0;\n private warmed = false;\n private startedAt = Date.now();\n private idleTimer: NodeJS.Timeout|null = null;\n private closing = false;\n\n constructor(options: DaemonOptions = {}) {\n super();\n this.options = resolveStartOptions(options);\n this.endpointPath = endpointFor({\n ...this.options,\n name: options.name,\n endpoint: options.endpoint,\n });\n this.idleTimeoutMs = options.idleTimeoutMs === undefined ?\n DEFAULT_IDLE_TIMEOUT_MS :\n options.idleTimeoutMs;\n this.prewarmOnStart = options.prewarm !== false;\n }\n\n get endpoint(): string {\n return this.endpointPath;\n }\n\n get warm(): boolean {\n return this.warmed;\n }\n\n // Brings the engine up and starts listening. The pipe existing *is* the\n // readiness signal -- a client's connect() either succeeds or the daemon is\n // not up -- so nothing is bound until the engine has started.\n //\n // Starting it here rather than on the first request is deliberate: a machine\n // with no engine for its platform should fail while the caller is still\n // watching, not answer a connect() and then reject every request on it.\n async listen(): Promise<this> {\n this.engine.start(this.options);\n\n this.server = net.createServer((socket) => this.accept(socket));\n this.server.on('error', (error) => this.emit('error', error));\n await this.bind();\n this.armIdleTimer();\n this.emit('ready', {endpoint: this.endpointPath});\n if (this.prewarmOnStart) {\n await this.prewarm();\n }\n return this;\n }\n\n private bind(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const server = this.server!;\n const onError = (error: NodeJS.ErrnoException) => {\n // A unix socket file outlives the process that made it, so EADDRINUSE\n // means either a live daemon or a leftover path. Connecting is the only\n // way to tell them apart: refused means nobody is home, and the file\n // can go.\n if (error.code === 'EADDRINUSE' && process.platform !== 'win32') {\n const probe = net.connect(this.endpointPath);\n probe.on('connect', () => {\n probe.destroy();\n reject(error);\n });\n probe.on('error', () => {\n try {\n fs.unlinkSync(this.endpointPath);\n } catch {\n reject(error);\n return;\n }\n server.listen(this.endpointPath, () => {\n this.restrict();\n resolve();\n });\n });\n return;\n }\n reject(error);\n };\n server.once('error', onError);\n server.listen(this.endpointPath, () => {\n server.removeListener('error', onError);\n this.restrict();\n resolve();\n });\n });\n }\n\n // Who may talk to this daemon.\n //\n // It matters because a request may set `allowFileAccess`, so a stranger who\n // can connect can have a document read this machine's filesystem and get the\n // result back as a picture. On POSIX the socket is a file and 0600 says only\n // its owner may connect.\n //\n // On Windows it is a named pipe, and node exposes no way to give one an ACL:\n // the default lets any account on the machine open it. A daemon on a shared\n // Windows host is therefore as trusted as the machine's users are -- use the\n // engine in your own process, where nothing is listening, if that is not\n // acceptable.\n private restrict(): void {\n if (process.platform === 'win32') {\n return;\n }\n try {\n fs.chmodSync(this.endpointPath, 0o600);\n } catch (error) {\n this.emit('error', error);\n }\n }\n\n // Renders one throwaway document so that the first real request does not pay\n // for whatever the engine initialises lazily. One is enough: there is one\n // renderer, and it is the same one every request lands on.\n //\n // A temporary file, not a `data:` URL. This used to send\n // `data:text/html,...`, which the renderer rejects -- shot_capture.cc takes\n // file, http and https and nothing else -- so every prewarm failed into the\n // catch below and the step had never once done anything. The failure was\n // invisible because a prewarm that does not work looks exactly like one that\n // does, only slower on the first request.\n //\n // The document names no subresources, so it renders identically whether or\n // not this daemon allows file access -- which is what the `data:` URL was\n // reaching for. A top-level file: URL always loads; `allowFileAccess` gates\n // what the document may then pull in.\n async prewarm(): Promise<void> {\n const blank = path.join(\n os.tmpdir(), `shotium-prewarm-${process.pid}.html`);\n try {\n fs.writeFileSync(\n blank, '<!doctype html><title>shotium</title><p>shotium');\n await this.engine.capture({file: blank, width: 16, height: 16});\n this.warmed = true;\n } catch (error) {\n // Not fatal: a daemon that could not prewarm still serves. But it is not\n // warm, and status() should not claim it is.\n this.emit('error', error);\n } finally {\n fs.rmSync(blank, {force: true});\n }\n this.emit('warm', {warm: this.warmed});\n }\n\n status(): DaemonStatus {\n return {\n ok: true,\n pid: process.pid,\n endpoint: this.endpointPath,\n cacheDir: this.options.cacheDir,\n userAgent: this.options.userAgent,\n resourceDir: this.options.resourceDir,\n warm: this.warmed,\n uptimeMs: Date.now() - this.startedAt,\n connections: this.sockets.size,\n inFlight: this.inFlight,\n served: this.served,\n idleTimeoutMs: this.idleTimeoutMs,\n version: VERSION,\n };\n }\n\n private accept(socket: net.Socket): void {\n socket.on('error', () => socket.destroy());\n this.sockets.add(socket);\n this.armIdleTimer();\n\n const reader = new FrameReader();\n socket.on('data', (chunk: Buffer) => {\n reader.push(chunk);\n for (;;) {\n const frame = reader.next();\n if (frame === null) {\n return;\n }\n this.dispatch(socket, frame);\n }\n });\n socket.on('close', () => {\n this.sockets.delete(socket);\n this.armIdleTimer();\n });\n }\n\n private dispatch(socket: net.Socket, frame: Buffer): void {\n let message: DaemonMessage;\n try {\n message = JSON.parse(frame.toString('utf8')) as DaemonMessage;\n } catch {\n this.reply(\n socket, {id: null, ok: false, error: 'shotium: request is not JSON'});\n return;\n }\n\n const id = message.id === undefined ? null : message.id;\n const op = message.op || 'screenshot';\n if (op === 'status') {\n this.reply(socket, {...this.status(), id});\n return;\n }\n if (op === 'ping') {\n this.reply(socket, {id, ok: true});\n return;\n }\n if (op === 'shutdown') {\n this.reply(socket, {id, ok: true, stopping: true});\n // After the reply is on the wire, not before: a client that asked for a\n // shutdown is entitled to hear that it happened.\n socket.end(() => void this.close());\n return;\n }\n if (op !== 'screenshot') {\n this.reply(socket, {id, ok: false, error: `shotium: unknown op \"${op}\"`});\n return;\n }\n\n const request = message.request || ({} as WireRequest);\n\n this.inFlight += 1;\n this.armIdleTimer();\n this.emit('request', {id, file: request.file});\n this.engine.capture(request)\n .then(({image, stats}) => {\n this.served += 1;\n this.reply(\n socket,\n {\n id,\n ok: true,\n bytes: image ? image.length : 0,\n path: request.path,\n stats,\n },\n image);\n })\n .catch((error: Error&{stats?: CaptureStats}) => {\n // The counters go back with the failure, matching the in-process\n // engine: a capture that timed out after fetching forty subresources\n // has already said why, and the message alone has not.\n this.reply(socket, {\n id,\n ok: false,\n error: String(error.message || error),\n stats: error.stats,\n });\n })\n .finally(() => {\n this.inFlight -= 1;\n this.emit('response', {id});\n this.armIdleTimer();\n });\n }\n\n private reply(\n socket: net.Socket, header: DaemonReply|(DaemonStatus&{id: number|null}),\n payload?: Buffer|null): void {\n if (socket.destroyed) {\n return;\n }\n socket.write(encodeFrame(Buffer.from(JSON.stringify(header), 'utf8')));\n socket.write(encodeFrame(payload || Buffer.alloc(0)));\n }\n\n // Idle is \"nobody connected and nothing rendering\". A client that holds its\n // socket open -- a long-lived service using connect() -- keeps the daemon\n // alive without having to poll it.\n private armIdleTimer(): void {\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n if (!this.idleTimeoutMs || this.closing) {\n return;\n }\n if (this.sockets.size > 0 || this.inFlight > 0) {\n return;\n }\n this.idleTimer = setTimeout(() => {\n this.emit('idle-exit', {idleTimeoutMs: this.idleTimeoutMs});\n void this.close();\n }, this.idleTimeoutMs);\n this.idleTimer.unref();\n }\n\n async close(): Promise<void> {\n if (this.closing) {\n return;\n }\n this.closing = true;\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n for (const socket of this.sockets) {\n socket.destroy();\n }\n this.sockets.clear();\n await new Promise<void>((resolve) => this.server!.close(() => resolve()));\n // dispose() rather than stop(), and this is the one caller that should.\n // The daemon owns its process and is leaving it, so the real teardown is\n // available and worth taking: joining the engine thread unwinds the\n // network stack, which is what lets the disk cache write its index. A\n // daemon that merely stood the engine down would leave the index dirty and\n // make the next daemon rebuild it by scanning the directory.\n await this.engine.dispose();\n this.emit('close', {});\n }\n}\n\nexport {Daemon, DEFAULT_IDLE_TIMEOUT_MS};\n","// The entry point of a detached daemon process.\n//\n// The configuration arrives as one base64 argument rather than as flags,\n// because it contains paths that a Windows command line would otherwise quote\n// badly, and because the client and the daemon have to agree on it exactly:\n// the endpoint is a hash of these fields, so a value mangled in transit would\n// produce a daemon listening where nobody looks. See endpoint.ts.\n//\n// It is a build entry of its own, and not a chunk, because lib/client.ts\n// spawns it by path -- `node dist/daemon_main.js <base64 json>` -- and a name\n// the bundler chose would be a name that changes.\n\nimport {Daemon} from './lib/daemon.js';\nimport type {DaemonOptions} from './types.js';\n\nasync function main(): Promise<void> {\n const encoded = process.argv[2];\n if (!encoded) {\n process.stderr.write('shotium: daemon_main expects a base64 config\\n');\n process.exit(2);\n }\n const options = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as\n DaemonOptions;\n const daemon = new Daemon(options);\n\n daemon.on('stderr', ({worker, line}: {worker: number, line: string}) => {\n process.stderr.write(`shotium worker ${worker}: ${line}\\n`);\n });\n for (const event of ['crash', 'timeout', 'worker-restart', 'worker-error',\n 'idle-exit']) {\n daemon.on(event, (payload: {error?: unknown}) => {\n // An Error does not survive JSON.stringify -- it comes out as {} -- and\n // its message is the whole point of logging a worker that would not\n // start.\n const detail = payload && payload.error ?\n {\n ...payload,\n error: String(\n (payload.error as Error).message ?? payload.error),\n } :\n payload;\n process.stderr.write(\n `shotium daemon ${event}: ${JSON.stringify(detail)}\\n`);\n });\n }\n // An 'error' with nobody listening is thrown by EventEmitter itself, which\n // would turn a socket that failed after binding -- something the daemon can\n // survive -- into a dead pool.\n daemon.on('error', (error: Error) => {\n process.stderr.write(\n `shotium daemon error: ${(error && error.message) || error}\\n`);\n });\n\n try {\n await daemon.listen();\n } catch (error) {\n // Losing the race to bind is the ordinary outcome when two clients start a\n // daemon at the same moment: the other one is up, this one is not needed,\n // and the client that spawned it will connect to the winner. Anything else\n // is a real failure and says so.\n if ((error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE') {\n process.exit(0);\n }\n process.stderr.write(`shotium: daemon failed to start: ${error}\\n`);\n process.exit(1);\n }\n\n const shutdown = () => {\n daemon.close().then(() => process.exit(0), () => process.exit(1));\n };\n process.on('SIGINT', shutdown);\n process.on('SIGTERM', shutdown);\n daemon.on('close', () => process.exit(0));\n}\n\nvoid main();\n"],"mappings":"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"}