@shotkit/shotium 0.1.0 → 0.3.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.
@@ -0,0 +1,323 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type {
5
+ CacheClearOptions,
6
+ CacheClearResult,
7
+ CacheEntry,
8
+ CacheTarget,
9
+ } from '../types.js';
10
+
11
+ import * as binding from './binding.js';
12
+ import type {Engine as Handle} from './binding.js';
13
+ import {cacheRoot, defaultCacheDir, normalizePath} from './config.js';
14
+
15
+ /**
16
+ * Turns one glob into a regular expression over a URL.
17
+ *
18
+ * The dialect is the small one everybody already knows -- `*`, `**`, `?`,
19
+ * `{a,b}` -- and it is implemented here rather than depended on because this
20
+ * package has no runtime dependencies and a matcher is thirty lines. `*` stops
21
+ * at `/` and `**` does not, which is the distinction that makes
22
+ * `https://example.com/*` mean one level and `https://example.com/**` mean the
23
+ * site.
24
+ *
25
+ * Everything else is escaped, which matters more than usual here: the subjects
26
+ * are URLs, and a URL is mostly characters that mean something to a regular
27
+ * expression.
28
+ */
29
+ function globToRegExp(pattern: string): RegExp {
30
+ let out = '';
31
+ for (let i = 0; i < pattern.length; i++) {
32
+ const c = pattern[i];
33
+ if (c === '*') {
34
+ if (pattern[i + 1] === '*') {
35
+ out += '.*';
36
+ i++;
37
+ // `/**/` should also match the zero-segment case, so that
38
+ // `https://x/**/y` matches `https://x/y`.
39
+ if (pattern[i + 1] === '/') {
40
+ out += '/?';
41
+ i++;
42
+ }
43
+ } else {
44
+ out += '[^/]*';
45
+ }
46
+ } else if (c === '?') {
47
+ out += '[^/]';
48
+ } else if (c === '{') {
49
+ const end = pattern.indexOf('}', i);
50
+ if (end === -1) {
51
+ out += '\\{';
52
+ } else {
53
+ const alternatives =
54
+ pattern.slice(i + 1, end).split(',').map(escapeLiteral);
55
+ out += `(?:${alternatives.join('|')})`;
56
+ i = end;
57
+ }
58
+ } else {
59
+ out += escapeLiteral(c);
60
+ }
61
+ }
62
+ return new RegExp(`^${out}$`);
63
+ }
64
+
65
+ function escapeLiteral(text: string): string {
66
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ }
68
+
69
+ /** Whether `url` matches any of `patterns`. No patterns matches nothing. */
70
+ function matchesAny(url: string, patterns: RegExp[]): boolean {
71
+ return patterns.some((pattern) => pattern.test(url));
72
+ }
73
+
74
+ /**
75
+ * What a cache directory occupies, for the one path that reports a size
76
+ * without a backend to ask.
77
+ *
78
+ * The sum of the files rather than the sum of the entries, so it will differ
79
+ * from what `clear()` reports through the backend by the index and by whatever
80
+ * rounding the filesystem does. It is the honest number for "what is about to
81
+ * be deleted", which is what it is used for.
82
+ */
83
+ function directorySize(dir: string): number {
84
+ let total = 0;
85
+ let names: fs.Dirent[] = [];
86
+ try {
87
+ names = fs.readdirSync(dir, {withFileTypes: true});
88
+ } catch {
89
+ return 0;
90
+ }
91
+ for (const entry of names) {
92
+ const full = path.join(dir, entry.name);
93
+ if (entry.isDirectory()) {
94
+ total += directorySize(full);
95
+ continue;
96
+ }
97
+ try {
98
+ total += fs.statSync(full).size;
99
+ } catch {
100
+ // Raced with something else clearing the same directory. Not an error:
101
+ // a file that is already gone contributes nothing to what is left.
102
+ }
103
+ }
104
+ return total;
105
+ }
106
+
107
+ /**
108
+ * Which directories an operation covers.
109
+ *
110
+ * `current` is this project's, `all` is every directory under the shared root,
111
+ * and anything else is taken as a project hash. `all` reads the root rather
112
+ * than remembering what it created: another process's directory is as much
113
+ * shotium's as this one's, and a caller asking to clear them all means the
114
+ * ones on disk.
115
+ */
116
+ function resolveTargets(target: CacheTarget['target']): string[] {
117
+ if (target === 'all') {
118
+ const root = cacheRoot();
119
+ let names: string[] = [];
120
+ try {
121
+ names = fs.readdirSync(root);
122
+ } catch {
123
+ // No root means nothing has been cached yet, which is an empty list and
124
+ // not an error: a caller clearing an empty cache asked for a state that
125
+ // already holds.
126
+ return [];
127
+ }
128
+ return names.map((name) => normalizePath(path.join(root, name)))
129
+ .filter((dir) => {
130
+ try {
131
+ return fs.statSync(dir).isDirectory();
132
+ } catch {
133
+ return false;
134
+ }
135
+ });
136
+ }
137
+ if (target === undefined || target === 'current') {
138
+ return [defaultCacheDir()];
139
+ }
140
+ // A directory, if it looks like one. `start({cacheDir})` takes any path, so
141
+ // a caller who chose their own has to be able to name it here -- otherwise
142
+ // the cache they configured is the one cache these methods cannot see.
143
+ if (path.isAbsolute(target)) {
144
+ return [normalizePath(target)];
145
+ }
146
+ // Otherwise a project hash. Resolved against the root rather than used as a
147
+ // path, so that a relative string cannot reach outside it by accident.
148
+ return [normalizePath(path.join(cacheRoot(), target))];
149
+ }
150
+
151
+ /**
152
+ * The cache, from the outside.
153
+ *
154
+ * Every method takes the engine handle if there is one, and that is not an
155
+ * optimisation. Within one process a cache directory has one backend: asking
156
+ * for a second one on the directory the engine holds waits for the engine's to
157
+ * go away, which it will not do while the engine is up. Borrowing is the only
158
+ * thing that returns.
159
+ *
160
+ * "If there is one" means the process, not the lifecycle. `stop()` stands the
161
+ * engine down without tearing it down, so an engine that has been stopped
162
+ * still holds its directory and still has to be borrowed from -- which is also
163
+ * what makes the cache survive a stop, and outlive one, and be worth having.
164
+ *
165
+ * Across processes there is no such constraint -- several of them may share a
166
+ * directory and all of them cache.
167
+ *
168
+ * The engine is fetched through a callback rather than held, because this
169
+ * object is built once at import time and the engine comes and goes.
170
+ */
171
+ export class Cache {
172
+ constructor(private readonly engineHandle: () => Handle | null) {}
173
+
174
+ /**
175
+ * This project's cache directory, absolute and with forward slashes.
176
+ *
177
+ * It exists whether or not anything has been written to it -- the answer is
178
+ * "where the cache goes", not "where a cache is".
179
+ */
180
+ getDir(options: CacheTarget = {}): string {
181
+ const targets = resolveTargets(options.target);
182
+ return targets.length > 0 ? targets[0] : defaultCacheDir();
183
+ }
184
+
185
+ /** Every directory the target names. `all` can be several; the rest, one. */
186
+ getDirs(options: CacheTarget = {}): string[] {
187
+ return resolveTargets(options.target);
188
+ }
189
+
190
+ /**
191
+ * What the cache is holding, by URL.
192
+ *
193
+ * Named `getFiles` for the operation callers reach for, and deliberately not
194
+ * returning filenames: the files in a cache directory are called things like
195
+ * `5349fbae98c6d9a1_0`, because the name is a hash of the entry key. A list
196
+ * of those answers no question anybody has. The URLs are what the entries
197
+ * are, and they are what `clear({glob})` matches against.
198
+ *
199
+ * This opens every entry to read its key and size, so it is a diagnostic
200
+ * rather than something to put on a request path.
201
+ */
202
+ async getFiles(options: CacheTarget = {}): Promise<CacheEntry[]> {
203
+ const native = binding.load();
204
+ const entries: CacheEntry[] = [];
205
+ for (const dir of resolveTargets(options.target)) {
206
+ if (!fs.existsSync(dir)) {
207
+ continue;
208
+ }
209
+ const json = await native.cache(
210
+ this.handleFor(), /*clearing=*/ false, JSON.stringify({
211
+ cacheDir: dir,
212
+ }));
213
+ const listed = JSON.parse(json) as Array<Omit<CacheEntry, 'dir'>>;
214
+ for (const entry of listed) {
215
+ entries.push({...entry, dir});
216
+ }
217
+ }
218
+ return entries;
219
+ }
220
+
221
+ /**
222
+ * Removes what the options select. With no options, everything.
223
+ *
224
+ * The three filters compose, and `glob` is applied here rather than in the
225
+ * engine: the entries are listed, their URLs are matched, and the ones that
226
+ * matched are what the engine is asked to remove. That keeps the pattern
227
+ * dialect in the layer whose users have opinions about pattern dialects, and
228
+ * keeps the engine's interface to exact URLs.
229
+ *
230
+ * Removal goes through the cache backend, never through the filesystem.
231
+ * Deleting the files directly would leave the backend's index naming entries
232
+ * that are no longer there, and the next process to open the directory
233
+ * either rebuilds the index from disk or, having found it inconsistent,
234
+ * discards it. That is the difference between clearing a cache and
235
+ * corrupting one.
236
+ */
237
+ async clear(options: CacheClearOptions = {}): Promise<CacheClearResult[]> {
238
+ const native = binding.load();
239
+ const patterns = (options.glob ?? []).map(globToRegExp);
240
+ const results: CacheClearResult[] = [];
241
+
242
+ // Clearing everything, in a process that has no engine at all: remove the
243
+ // directory.
244
+ //
245
+ // This is the one case where touching the filesystem is correct rather
246
+ // than reckless. The danger in deleting cache files by hand is a partial
247
+ // delete -- an index left naming entries that are gone -- and there is no
248
+ // such thing when the index goes with them. What is left is a directory
249
+ // that does not exist, which is exactly what an empty cache looks like
250
+ // before anything has written to it.
251
+ //
252
+ // It is also the fast path a short script gets: emptying a cache without
253
+ // starting Blink to do it costs a few milliseconds instead of the tens
254
+ // that building an engine does.
255
+ const unfiltered = patterns.length === 0 && !options.maxAge &&
256
+ !options.maxSize;
257
+ if (unfiltered && !this.handleFor()) {
258
+ for (const dir of resolveTargets(options.target)) {
259
+ const before = directorySize(dir);
260
+ fs.rmSync(dir, {recursive: true, force: true});
261
+ results.push(
262
+ {removed: -1, bytesBefore: before, bytesAfter: 0, dir});
263
+ }
264
+ return results;
265
+ }
266
+
267
+ for (const dir of resolveTargets(options.target)) {
268
+ if (!fs.existsSync(dir)) {
269
+ continue;
270
+ }
271
+ const request: Record<string, unknown> = {cacheDir: dir};
272
+
273
+ if (patterns.length > 0) {
274
+ const json = await native.cache(
275
+ this.handleFor(), /*clearing=*/ false,
276
+ JSON.stringify({cacheDir: dir}));
277
+ const listed = JSON.parse(json) as Array<Omit<CacheEntry, 'dir'>>;
278
+ const urls =
279
+ listed.filter((entry) => matchesAny(entry.url, patterns))
280
+ .map((entry) => entry.url);
281
+ // Nothing matched, so nothing is asked for. Falling through with an
282
+ // empty `urls` would be read by the engine as "no URL filter", which
283
+ // combined with no other filter empties the directory -- the opposite
284
+ // of what a pattern that matched nothing means.
285
+ if (urls.length === 0 && options.maxAge === undefined &&
286
+ options.maxSize === undefined) {
287
+ results.push({removed: 0, bytesBefore: 0, bytesAfter: 0, dir});
288
+ continue;
289
+ }
290
+ request.urls = urls;
291
+ }
292
+
293
+ if (options.maxAge) {
294
+ request.unusedSinceMs = Date.now() - options.maxAge * 1000;
295
+ }
296
+ if (options.maxSize) {
297
+ request.maxBytes = options.maxSize;
298
+ }
299
+
300
+ const json = await native.cache(
301
+ this.handleFor(), /*clearing=*/ true, JSON.stringify(request));
302
+ results.push({
303
+ ...(JSON.parse(json) as Omit<CacheClearResult, 'dir'>),
304
+ dir,
305
+ });
306
+ }
307
+ return results;
308
+ }
309
+
310
+ /**
311
+ * The engine handle, when there is an engine.
312
+ *
313
+ * Passed for every directory and not only the engine's own. It is never
314
+ * wrong to pass it -- the engine's thread can open any directory, and for
315
+ * the one it already has open, borrowing its backend is the only thing that
316
+ * returns. It is passing `null` while an engine is up that hangs, which is
317
+ * why this is conditional on neither the directory asked for nor on whether
318
+ * the engine is currently accepting captures.
319
+ */
320
+ private handleFor(): Handle|null {
321
+ return this.engineHandle();
322
+ }
323
+ }
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 {
@@ -48,10 +55,9 @@ interface Pending {
48
55
  }
49
56
 
50
57
  interface ResolvedDaemonOptions {
51
- binary: string;
52
- workers: number;
53
58
  cacheDir: string|null;
54
- args: string[];
59
+ userAgent?: string;
60
+ resourceDir?: string;
55
61
  name: string|undefined;
56
62
  endpoint: string;
57
63
  idleTimeoutMs: number|undefined;
@@ -61,11 +67,11 @@ interface ResolvedDaemonOptions {
61
67
 
62
68
  // The client half of the resident daemon.
63
69
  //
64
- // One connection can carry several requests at once, which is the difference
65
- // between this and the worker protocol underneath: every message carries an
70
+ // One connection can carry several requests at once: every message carries an
66
71
  // `id` and the answers are matched back by it, so a caller can fire ten
67
- // screenshots down one socket and let the pool on the other side spread them
68
- // across workers.
72
+ // screenshots down one socket without waiting between them. They still come
73
+ // back one at a time -- there is one renderer on the other side -- so this
74
+ // saves the round trips, not the renders.
69
75
  class DaemonClient extends EventEmitter {
70
76
  private readonly socket: net.Socket;
71
77
  private readonly endpointPath: string;
@@ -127,7 +133,14 @@ class DaemonClient extends EventEmitter {
127
133
  if (header.ok) {
128
134
  pending.resolve({header, image: header.path ? null : payload});
129
135
  } else {
130
- 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);
131
144
  }
132
145
  }
133
146
 
@@ -152,17 +165,20 @@ class DaemonClient extends EventEmitter {
152
165
  });
153
166
  }
154
167
 
155
- /** Resolves to the image, or to null when `path` was given. */
156
- 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> {
157
175
  const request = toRequest(options);
158
- const retry = typeof options.retry === 'number' ? options.retry : 0;
159
176
  const result = await this.send({
160
177
  op: 'screenshot',
161
178
  request,
162
179
  timeout: timeoutFor(options),
163
- retry,
164
180
  });
165
- return result.image;
181
+ return {image: result.image, stats: result.header.stats ?? emptyStats()};
166
182
  }
167
183
 
168
184
  async status(): Promise<DaemonStatus> {
@@ -219,10 +235,9 @@ function resolveDaemonOptions(options: DaemonOptions = {}):
219
235
 
220
236
  function spawnDaemon(options: ResolvedDaemonOptions): void {
221
237
  const config = {
222
- binary: options.binary,
223
- workers: options.workers,
224
238
  cacheDir: options.cacheDir,
225
- args: options.args,
239
+ userAgent: options.userAgent,
240
+ resourceDir: options.resourceDir,
226
241
  endpoint: options.endpoint,
227
242
  idleTimeoutMs: options.idleTimeoutMs,
228
243
  prewarm: options.prewarm,
@@ -355,7 +370,7 @@ async function stop(options: DaemonOptions = {}):
355
370
  // here rather than sent, because it says which daemon to talk to and not what
356
371
  // to photograph.
357
372
  async function screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
358
- Promise<Buffer|null> {
373
+ Promise<ScreenshotResult> {
359
374
  const {daemon, ...rest} = options;
360
375
  const client = await connect(daemon || {});
361
376
  try {
package/src/lib/config.ts CHANGED
@@ -1,86 +1,138 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
1
3
  import os from 'node:os';
2
4
  import path from 'node:path';
3
- import {fileURLToPath} from 'node:url';
4
5
 
5
6
  import type {StartOptions} from '../types.js';
6
7
 
7
- import * as platform from './platform.js';
8
-
9
- // ESM has no __dirname. This is the same thing, from the module's own URL.
10
- const HERE = path.dirname(fileURLToPath(import.meta.url));
11
-
12
8
  // StartOptions with every hole filled in. `cacheDir` is still nullable here
13
9
  // because null is an answer -- "no disk cache" -- and not an absent one.
14
10
  export interface ResolvedStartOptions {
15
- binary: string;
16
- workers: number;
17
11
  cacheDir: string|null;
18
- args: string[];
12
+ cacheMaxBytes: number;
13
+ userAgent?: string;
14
+ resourceDir?: string;
19
15
  }
20
16
 
21
- // The one place that decides what "no options" means.
22
- //
23
- // It is shared rather than duplicated because the daemon's address is a hash of
24
- // its configuration: if two callers filled in defaults even slightly
25
- // differently, one would compute an address no daemon is listening on and
26
- // start a second pool next to the first one that was already warm. See
27
- // endpoint.ts.
17
+ // One number, chosen rather than delegated.
28
18
  //
29
- // Three places, in the order a caller means them: what they said, what npm
30
- // installed, and what they unpacked by hand. The middle one is the normal case
31
- // and the only one that needs no instructions.
32
- function defaultBinary(): string {
33
- if (process.env.SHOTIUM_BINARY) {
34
- return process.env.SHOTIUM_BINARY;
35
- }
36
- const dir = platform.packageDir();
37
- if (dir) {
38
- return path.join(dir, platform.binaryName());
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;
39
64
  }
40
- // No platform package: an archive from the releases page, unpacked into
41
- // bin/ beside this file. This is also the path a checkout takes, where
42
- // nothing was installed from a registry at all.
43
- return path.join(HERE, '..', 'bin', platform.binaryName());
44
65
  }
45
66
 
46
- // How many worker processes, when nobody said.
47
- //
48
- // Half the cores, capped. The cap is there because a worker is a process with
49
- // blink in it, not a thread: measured on this tree it settles around 14 MB of
50
- // private working set and holds a further ~30 MB of shotium.exe resident, so
51
- // "half the cores" on a 32-core machine is sixteen of them and most of a
52
- // gigabyte for a queue that is almost never sixteen deep. Four is past the
53
- // point where a screenshot workload gets much from another one -- the corpus
54
- // runs at 41 pages/s on four -- and anyone who has measured otherwise passes
55
- // `workers`.
56
- const MAXIMUM_DEFAULT_WORKERS = 4;
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
+ }
57
94
 
58
- function defaultWorkers(): number {
59
- const half = Math.floor((os.cpus().length || 2) / 2);
60
- return Math.max(1, Math.min(MAXIMUM_DEFAULT_WORKERS, half));
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');
61
105
  }
62
106
 
63
- function defaultCacheDir(): string {
64
- return path.join(os.tmpdir(), 'shotium-cache');
107
+ /** This project's cache directory. */
108
+ export function defaultCacheDir(): string {
109
+ return normalizePath(path.join(cacheRoot(), projectKey()));
65
110
  }
66
111
 
67
- // binary / workers / cacheDir / args, filled in and normalised. `cacheDir:
68
- // null` survives as null -- it means "no disk cache", which is not the same
69
- // request as "use the default one".
112
+ // The one place that decides what "no options" means.
113
+ //
114
+ // It is shared rather than duplicated because the daemon's address is a hash of
115
+ // its configuration: if two callers filled in defaults even slightly
116
+ // differently, one would compute an address no daemon is listening on and
117
+ // start a second engine next to the first one that was already warm. See
118
+ // endpoint.ts.
119
+ //
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.
70
128
  function resolveStartOptions(options: StartOptions = {}): ResolvedStartOptions {
71
129
  return {
72
- binary: options.binary || defaultBinary(),
73
- workers: options.workers || defaultWorkers(),
74
- cacheDir: options.cacheDir === null ?
75
- null :
76
- (options.cacheDir || defaultCacheDir()),
77
- args: options.args || [],
130
+ cacheDir: options.cacheDir === null ? null :
131
+ (options.cacheDir ?? defaultCacheDir()),
132
+ cacheMaxBytes: options.cacheMaxBytes ?? DEFAULT_CACHE_MAX_BYTES,
133
+ userAgent: options.userAgent,
134
+ resourceDir: options.resourceDir,
78
135
  };
79
136
  }
80
137
 
81
- export {
82
- defaultBinary,
83
- defaultCacheDir,
84
- defaultWorkers,
85
- resolveStartOptions,
86
- };
138
+ export {DEFAULT_CACHE_MAX_BYTES, resolveStartOptions};