@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/src/lib/client.ts CHANGED
@@ -6,12 +6,15 @@ import path from 'node:path';
6
6
  import {fileURLToPath} from 'node:url';
7
7
 
8
8
  import type {
9
+ CaptureStats,
9
10
  DaemonOptions,
10
11
  DaemonStatus,
11
12
  ScreenshotOptions,
13
+ ScreenshotResult,
12
14
  } from '../types.js';
13
15
 
14
16
  import {resolveStartOptions} from './config.js';
17
+ import {emptyStats} from './engine.js';
15
18
  import {endpointFor} from './endpoint.js';
16
19
  import {FrameReader, encodeFrame} from './protocol.js';
17
20
  import {timeoutFor, toRequest} from './request.js';
@@ -35,6 +38,10 @@ interface ClientReply {
35
38
  ok?: boolean;
36
39
  error?: string;
37
40
  path?: string;
41
+ // The daemon reports the same CaptureStats the in-process engine does, in
42
+ // its response header. It rides on the failure header too, which is why the
43
+ // rejection below carries it.
44
+ stats?: CaptureStats;
38
45
  }
39
46
 
40
47
  interface ClientResult {
@@ -126,7 +133,14 @@ class DaemonClient extends EventEmitter {
126
133
  if (header.ok) {
127
134
  pending.resolve({header, image: header.path ? null : payload});
128
135
  } else {
129
- pending.reject(new Error(header.error || 'shotium: request failed'));
136
+ const error = new Error(header.error || 'shotium: request failed');
137
+ // Attached rather than dropped: a capture that failed part of the way
138
+ // through has already measured what it did, and that is usually the
139
+ // explanation. The in-process engine does the same.
140
+ if (header.stats) {
141
+ (error as Error & {stats?: CaptureStats}).stats = header.stats;
142
+ }
143
+ pending.reject(error);
130
144
  }
131
145
  }
132
146
 
@@ -151,15 +165,20 @@ class DaemonClient extends EventEmitter {
151
165
  });
152
166
  }
153
167
 
154
- /** Resolves to the image, or to null when `path` was given. */
155
- async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
168
+ /**
169
+ * One screenshot, and what taking it cost.
170
+ *
171
+ * The same shape the in-process engine returns, so that moving a program
172
+ * between the two is an import change and nothing else.
173
+ */
174
+ async screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {
156
175
  const request = toRequest(options);
157
176
  const result = await this.send({
158
177
  op: 'screenshot',
159
178
  request,
160
179
  timeout: timeoutFor(options),
161
180
  });
162
- return result.image;
181
+ return {image: result.image, stats: result.header.stats ?? emptyStats()};
163
182
  }
164
183
 
165
184
  async status(): Promise<DaemonStatus> {
@@ -351,7 +370,7 @@ async function stop(options: DaemonOptions = {}):
351
370
  // here rather than sent, because it says which daemon to talk to and not what
352
371
  // to photograph.
353
372
  async function screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
354
- Promise<Buffer|null> {
373
+ Promise<ScreenshotResult> {
355
374
  const {daemon, ...rest} = options;
356
375
  const client = await connect(daemon || {});
357
376
  try {
package/src/lib/config.ts CHANGED
@@ -1,13 +1,114 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
1
6
  import type {StartOptions} from '../types.js';
2
7
 
3
8
  // StartOptions with every hole filled in. `cacheDir` is still nullable here
4
9
  // because null is an answer -- "no disk cache" -- and not an absent one.
5
10
  export interface ResolvedStartOptions {
6
11
  cacheDir: string|null;
12
+ cacheMaxBytes: number;
7
13
  userAgent?: string;
8
14
  resourceDir?: string;
9
15
  }
10
16
 
17
+ // One number, chosen rather than delegated.
18
+ //
19
+ // Passing 0 hands the decision to the disk cache backend, which sizes itself
20
+ // against the volume's free space -- a defensible default for a browser
21
+ // profile the user knows about, and a poor one for a directory that appears
22
+ // under ~/.shotium because somebody imported a library. 256 MB holds a large
23
+ // corpus of pages and their fonts, and is small enough that nobody has to
24
+ // think about it.
25
+ const DEFAULT_CACHE_MAX_BYTES = 256 * 1024 * 1024;
26
+
27
+ /**
28
+ * One spelling of a path: absolute, with forward slashes.
29
+ *
30
+ * Every path this module hands back goes through here. On Windows the two
31
+ * separators are interchangeable to the filesystem and not to a caller
32
+ * comparing strings or writing a glob, and a library that returns whichever
33
+ * one `path.join` happened to produce makes that the caller's problem.
34
+ */
35
+ export function normalizePath(target: string): string {
36
+ return path.resolve(target).replace(/\\/g, '/');
37
+ }
38
+
39
+ /**
40
+ * The project the current process belongs to: the nearest directory at or
41
+ * above the working directory that has a package.json.
42
+ *
43
+ * The working directory itself would be the obvious key and is the wrong one.
44
+ * It moves -- `process.chdir`, or a script run from a subdirectory -- and each
45
+ * value it takes would get a cache of its own, so a project would slowly
46
+ * accumulate directories that each know a third of its pages. The package root
47
+ * is the thing that stays put.
48
+ *
49
+ * Falls back to the working directory when there is no package.json above it,
50
+ * which is what a bare script has and is still better than nothing: it is at
51
+ * least stable for as long as the script runs from one place.
52
+ */
53
+ function projectRoot(): string {
54
+ let dir = process.cwd();
55
+ for (;;) {
56
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
57
+ return dir;
58
+ }
59
+ const parent = path.dirname(dir);
60
+ if (parent === dir) {
61
+ return process.cwd();
62
+ }
63
+ dir = parent;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Where every shotium cache directory lives. One level up from any single
69
+ * project's, which is what makes `target: 'all'` answerable.
70
+ *
71
+ * Under the home directory and not the temporary one, which is where this was
72
+ * until 0.3 was cut. $TMPDIR is defined by not surviving: /tmp is emptied on
73
+ * reboot, systemd-tmpfiles removes anything untouched for ten days, and macOS
74
+ * sweeps it on a schedule of its own. The entire value of an HTTP cache is the
75
+ * *next* run, so a default that lives somewhere designed to be cleared is a
76
+ * cache that stops working at exactly the moment it would have started paying
77
+ * for itself.
78
+ *
79
+ * `~/.shotium`, spelled the same on every platform. One place a user can look
80
+ * for it, one path to say in a bug report, and one directory to delete.
81
+ *
82
+ * $TMPDIR remains only as a fallback for a process with no home to speak of --
83
+ * some containers, some service accounts. That is a degradation and not a
84
+ * second location: there is no home directory holding a cache that would
85
+ * otherwise have been found.
86
+ */
87
+ function shotiumHome(): string {
88
+ return path.join(os.homedir() || os.tmpdir(), '.shotium');
89
+ }
90
+
91
+ export function cacheRoot(): string {
92
+ return normalizePath(path.join(shotiumHome(), 'cache'));
93
+ }
94
+
95
+ /**
96
+ * The identifier for a project's cache directory: a hash of its root path.
97
+ *
98
+ * A hash rather than the path itself because the path contains separators,
99
+ * drive letters and whatever the user called their directory, none of which
100
+ * survive being a directory name. It is not a security measure and does not
101
+ * need to be one -- it is a fixed-length name for a variable-length string.
102
+ */
103
+ export function projectKey(root: string = projectRoot()): string {
104
+ return crypto.createHash('sha1').update(normalizePath(root)).digest('hex');
105
+ }
106
+
107
+ /** This project's cache directory. */
108
+ export function defaultCacheDir(): string {
109
+ return normalizePath(path.join(cacheRoot(), projectKey()));
110
+ }
111
+
11
112
  // The one place that decides what "no options" means.
12
113
  //
13
114
  // It is shared rather than duplicated because the daemon's address is a hash of
@@ -16,16 +117,22 @@ export interface ResolvedStartOptions {
16
117
  // start a second engine next to the first one that was already warm. See
17
118
  // endpoint.ts.
18
119
  //
19
- // The default for `cacheDir` is null -- no disk cache. A program holding the
20
- // engine is often short-lived, and a cache it never reads twice is a directory
21
- // it leaves behind. The daemon, which is the case where a cache does pay for
22
- // itself, is also the case where the caller is already passing options.
120
+ // `cacheDir` defaults to this project's directory rather than to null, which
121
+ // is the reverse of 0.2. The reason is measured: without a cache every capture
122
+ // of an `https:` URL pays DNS, TLS and a round trip, which for a small page is
123
+ // most of the wall clock and all of the surprise. The objection to a default
124
+ // -- that a short-lived program leaves a directory behind -- is answered by
125
+ // the directory being per-project, size-capped, and somewhere the platform's
126
+ // own tooling knows how to clear, rather than by there being no cache.
127
+ // `cacheDir: null` still turns it off.
23
128
  function resolveStartOptions(options: StartOptions = {}): ResolvedStartOptions {
24
129
  return {
25
- cacheDir: options.cacheDir ?? null,
130
+ cacheDir: options.cacheDir === null ? null :
131
+ (options.cacheDir ?? defaultCacheDir()),
132
+ cacheMaxBytes: options.cacheMaxBytes ?? DEFAULT_CACHE_MAX_BYTES,
26
133
  userAgent: options.userAgent,
27
134
  resourceDir: options.resourceDir,
28
135
  };
29
136
  }
30
137
 
31
- export {resolveStartOptions};
138
+ export {DEFAULT_CACHE_MAX_BYTES, resolveStartOptions};
package/src/lib/daemon.ts CHANGED
@@ -4,7 +4,11 @@ import net from 'node:net';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
 
7
- import type {DaemonOptions, DaemonStatus} from '../types.js';
7
+ import type {
8
+ CaptureStats,
9
+ DaemonOptions,
10
+ DaemonStatus,
11
+ } from '../types.js';
8
12
 
9
13
  import {resolveStartOptions} from './config.js';
10
14
  import type {ResolvedStartOptions} from './config.js';
@@ -46,6 +50,11 @@ interface DaemonReply {
46
50
  bytes?: number;
47
51
  path?: string;
48
52
  stopping?: boolean;
53
+ // What the capture cost, on the success header and on the failure one. The
54
+ // client turns it back into the same CaptureStats the in-process engine
55
+ // returns, so a program moving between the two changes an import and
56
+ // nothing else.
57
+ stats?: CaptureStats;
49
58
  }
50
59
 
51
60
  // An engine that outlives the process that asked for it.
@@ -306,7 +315,7 @@ class Daemon extends EventEmitter {
306
315
  this.armIdleTimer();
307
316
  this.emit('request', {id, file: request.file});
308
317
  this.engine.capture(request)
309
- .then((image) => {
318
+ .then(({image, stats}) => {
310
319
  this.served += 1;
311
320
  this.reply(
312
321
  socket,
@@ -315,12 +324,20 @@ class Daemon extends EventEmitter {
315
324
  ok: true,
316
325
  bytes: image ? image.length : 0,
317
326
  path: request.path,
327
+ stats,
318
328
  },
319
329
  image);
320
330
  })
321
- .catch((error: Error) => {
322
- this.reply(
323
- socket, {id, ok: false, error: String(error.message || error)});
331
+ .catch((error: Error&{stats?: CaptureStats}) => {
332
+ // The counters go back with the failure, matching the in-process
333
+ // engine: a capture that timed out after fetching forty subresources
334
+ // has already said why, and the message alone has not.
335
+ this.reply(socket, {
336
+ id,
337
+ ok: false,
338
+ error: String(error.message || error),
339
+ stats: error.stats,
340
+ });
324
341
  })
325
342
  .finally(() => {
326
343
  this.inFlight -= 1;
@@ -374,7 +391,13 @@ class Daemon extends EventEmitter {
374
391
  }
375
392
  this.sockets.clear();
376
393
  await new Promise<void>((resolve) => this.server!.close(() => resolve()));
377
- await this.engine.stop();
394
+ // dispose() rather than stop(), and this is the one caller that should.
395
+ // The daemon owns its process and is leaving it, so the real teardown is
396
+ // available and worth taking: joining the engine thread unwinds the
397
+ // network stack, which is what lets the disk cache write its index. A
398
+ // daemon that merely stood the engine down would leave the index dirty and
399
+ // make the next daemon rebuild it by scanning the directory.
400
+ await this.engine.dispose();
378
401
  this.emit('close', {});
379
402
  }
380
403
  }