@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/index.ts CHANGED
@@ -1,25 +1,38 @@
1
+ import {Cache} from './lib/cache.js';
1
2
  import * as client from './lib/client.js';
2
3
  import type {DaemonClient} from './lib/client.js';
3
4
  import {Engine} from './lib/engine.js';
4
5
  import type {
5
6
  DaemonOptions,
6
7
  DaemonStatus,
7
- PurgeOptions,
8
+ ReleaseMemoryOptions,
8
9
  ScreenshotOptions,
10
+ ScreenshotResult,
9
11
  StartOptions,
12
+ StartResult,
10
13
  } from './types.js';
11
14
 
12
15
  export type {
16
+ CacheClearOptions,
17
+ CacheClearResult,
18
+ CacheEntry,
19
+ CacheMode,
20
+ CacheTarget,
21
+ CaptureStats,
22
+ CaptureTiming,
13
23
  Clip,
14
24
  DaemonOptions,
15
25
  DaemonStatus,
16
26
  PageGotoParams,
17
- PurgeOptions,
27
+ ReleaseMemoryOptions,
18
28
  ScreenshotOptions,
29
+ ScreenshotResult,
19
30
  StartOptions,
31
+ StartResult,
20
32
  Viewport,
21
33
  } from './types.js';
22
34
  export type {DaemonClient} from './lib/client.js';
35
+ export {Cache} from './lib/cache.js';
23
36
 
24
37
  /** The five things a caller does with the resident engine. */
25
38
  export interface Daemon {
@@ -27,7 +40,7 @@ export interface Daemon {
27
40
  connect(options?: DaemonOptions): Promise<DaemonClient>;
28
41
  /** One screenshot through the daemon, connection and all. */
29
42
  screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
30
- Promise<Buffer|null>;
43
+ Promise<ScreenshotResult>;
31
44
  /** Starts one if it is not up, and reports what is there either way. */
32
45
  start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;
33
46
  status(options?: DaemonOptions):
@@ -40,9 +53,11 @@ export interface Daemon {
40
53
  *
41
54
  * import shotium from '@shotkit/shotium';
42
55
  *
43
- * shotium.runtime.start();
44
- * const png = await shotium.screenshot({file: 'https://example.com'});
45
- * await shotium.runtime.stop();
56
+ * shotium.start();
57
+ * const {image, stats} = await shotium.screenshot({
58
+ * file: 'https://example.com',
59
+ * });
60
+ * await shotium.stop();
46
61
  *
47
62
  * `start` and `stop` are explicit because starting Blink is the expensive part
48
63
  * -- tens of milliseconds and a working set that stays resident -- and only
@@ -51,11 +66,24 @@ export interface Daemon {
51
66
  * What they buy is control over when that cost is paid, and the certainty that
52
67
  * it has been given back.
53
68
  *
54
- * `runtime` below is the singleton because there is nothing else it could be:
55
- * Blink starts once per process and cannot be restarted, so a second Runtime
56
- * in the same process has no engine to have. Construct one directly only to
57
- * own the lifecycle yourself instead of using `runtime`. Parallelism is more
58
- * processes, not more Runtimes.
69
+ * Neither is rationed, either. They may be called in any order and as often as
70
+ * a program likes: `stop()` stands the engine down and `start()` picks the
71
+ * same one back up, warm cache and all. What cannot happen is a *second*
72
+ * engine -- Blink is initialised once per process and there is no undo -- but
73
+ * that is a fact about how many there are, not about how many times the one
74
+ * may be asked for.
75
+ *
76
+ * The methods are on the module rather than under a `runtime` namespace, which
77
+ * they were until 0.3. There was never anything else to start, so the word
78
+ * carried nothing; and `runtime.cache` would have been the wrong place for the
79
+ * cache besides, since a cache directory outlives every engine that writes to
80
+ * it and can be read when no engine is running at all.
81
+ *
82
+ * `Runtime` is still exported for a caller who wants to own a lifecycle rather
83
+ * than share the module's. It is a lifecycle and not an engine: there is one
84
+ * engine per process, and a second Runtime that starts adopts the same one
85
+ * rather than building another. Parallelism is more processes, not more
86
+ * Runtimes.
59
87
  *
60
88
  * `daemon` is the same engine in a process of its own, behind a socket, for
61
89
  * callers whose own process does not live long enough to be worth starting
@@ -64,49 +92,91 @@ export interface Daemon {
64
92
  export class Runtime {
65
93
  private engine = new Engine();
66
94
 
95
+ /**
96
+ * The HTTP cache: where it is, what is in it, and how to empty it.
97
+ *
98
+ * On the Runtime as well as on the module because a caller holding their own
99
+ * Runtime needs the engine handle to reach a directory that engine has open:
100
+ * within one process a directory has one backend, so borrowing is the only
101
+ * way in.
102
+ */
103
+ readonly cache = new Cache(() => this.engine.nativeHandle);
104
+
67
105
  get running(): boolean {
68
106
  return this.engine.running;
69
107
  }
70
108
 
71
109
  /**
72
- * Starts the engine. Safe to call twice; the second call is a no-op, so that
73
- * library code can call it defensively. Not safe after `stop()` -- see there.
110
+ * Starts the engine, or picks the running one back up.
74
111
  *
75
- * Every option has a default. `cacheDir` is the HTTP disk cache and `null`
76
- * disables it; `resourceDir` is where `shotium_data.pak` and
112
+ * Callable as often as you like, in any order with `stop()`; library code
113
+ * can call it defensively. The first call in a process builds the engine and
114
+ * every later one adopts it -- the same engine, the same warm cache. The one
115
+ * thing it will refuse is a *different* configuration: the options below are
116
+ * fixed when the engine is built, and there is no second build, so naming
117
+ * one that disagrees with what is running throws rather than rendering with
118
+ * a value you did not ask for.
119
+ *
120
+ * Every option has a default. `cacheDir` is the HTTP disk cache and defaults
121
+ * to a per-project directory under `~/.shotium/cache`, and not under the
122
+ * temporary directory, which is defined by not surviving. `null` turns it
123
+ * off. `resourceDir` is where `shotium_data.pak` and
77
124
  * `shotium_strings.pak` are, and defaults to the directory the engine was
78
125
  * loaded from, which is where they ship.
126
+ *
127
+ * The return value is worth reading once. `cacheActive: false` with a
128
+ * `cacheDir` set means the directory could not be opened and this engine is
129
+ * running without a cache -- correctly, silently, and a round trip slower on
130
+ * everything.
79
131
  */
80
- start(options: StartOptions = {}): this {
81
- this.engine.start(options);
82
- return this;
132
+ start(options: StartOptions = {}): StartResult {
133
+ return this.engine.start(options);
134
+ }
135
+
136
+ /** What `start()` returned, asked again. */
137
+ status(): StartResult {
138
+ return this.engine.status();
83
139
  }
84
140
 
85
141
  /**
86
- * Stops the engine, after whatever is queued.
142
+ * Stands the engine down, after whatever is queued.
143
+ *
144
+ * The queue drains, the memory the engine can rebuild goes back to the OS,
145
+ * and `running` becomes false. Blink itself stays initialised, because there
146
+ * is no way to un-initialise it -- so the disk cache stays where it is, and
147
+ * `start()` or the next `screenshot()` picks the same engine back up.
87
148
  *
88
- * Final for this process. Blink writes process-wide state that it has no
89
- * path to undo, so starting again -- here or on another Runtime -- throws
90
- * rather than quietly handing back something that cannot render. A program
91
- * that wants another screenshot later should stay started and `purge()`.
149
+ * Which makes this a caller saying they are done for now rather than a
150
+ * destructor. It does the same work as `releaseMemory({releaseWorkingSet:
151
+ * true})` and additionally stops accepting captures.
92
152
  */
93
153
  stop(): Promise<void> {
94
154
  return this.engine.stop();
95
155
  }
96
156
 
97
157
  /**
98
- * Hands back what the engine is holding but can rebuild. Worth calling when
99
- * a batch has ended and the next one may be a while away.
158
+ * Hands back what the engine is holding but can rebuild: Blink's heap,
159
+ * skia's caches, PartitionAlloc's free lists. Worth calling when a batch has
160
+ * ended and the next one may be a while away.
161
+ *
162
+ * This is memory and nothing else. It was called `purge()` until 0.3, which
163
+ * next to `cache.clear()` read as though it emptied the HTTP cache; it does
164
+ * not touch the disk at all.
100
165
  */
101
- purge(options: PurgeOptions = {}): void {
102
- this.engine.purge(options);
166
+ releaseMemory(options: ReleaseMemoryOptions = {}): void {
167
+ this.engine.releaseMemory(options);
103
168
  }
104
169
 
105
170
  /**
106
- * Renders one screenshot. Resolves to the encoded image, or to `null` when
107
- * `path` was given and the engine wrote the file itself.
171
+ * Renders one screenshot, and reports what it cost.
172
+ *
173
+ * `image` is the encoded bytes, or `null` when `path` was given and the
174
+ * engine wrote the file itself. `stats` says how many resources were
175
+ * fetched, how many came from the cache, and where the milliseconds went --
176
+ * which for an `https:` URL is usually the answer to "why did this take so
177
+ * long", because a cold connection costs more than the render does.
108
178
  */
109
- screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
179
+ screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {
110
180
  return this.engine.screenshot(options);
111
181
  }
112
182
  }
@@ -115,13 +185,37 @@ export class Runtime {
115
185
  const runtime = new Runtime();
116
186
 
117
187
  /** One screenshot through the shared engine, starting it if it is not up. */
118
- const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
188
+ const screenshot = (options: ScreenshotOptions): Promise<ScreenshotResult> =>
119
189
  runtime.screenshot(options);
120
190
 
191
+ const start = (options?: StartOptions): StartResult => runtime.start(options);
192
+ const status = (): StartResult => runtime.status();
193
+ const stop = (): Promise<void> => runtime.stop();
194
+ const releaseMemory = (options?: ReleaseMemoryOptions): void =>
195
+ runtime.releaseMemory(options);
196
+
197
+ /**
198
+ * The HTTP cache.
199
+ *
200
+ * At the top level rather than under the engine because it outlives one: the
201
+ * directory is on disk whether or not anything is running, `getDir()` answers
202
+ * before the first `start()`, and clearing it is something a program may want
203
+ * to do without bringing Blink up at all. When an engine *is* up, these
204
+ * borrow its cache backend, because within one process a directory has one
205
+ * backend and that is the only way in.
206
+ */
207
+ const cache = runtime.cache;
208
+
121
209
  /**
122
210
  * The resident engine: a process that outlives the one that started it,
123
211
  * reachable over a named pipe on Windows and a unix socket elsewhere. For
124
212
  * callers that are short-lived themselves. See lib/daemon.ts.
213
+ *
214
+ * It has no `cache` of its own. A daemon's cache directory is reported by
215
+ * `daemon.status()`, and clearing it is done by pointing `cache.clear()` at
216
+ * that directory or by stopping the daemon -- a cross-process cache protocol
217
+ * would be a second implementation of this module for something nobody does on
218
+ * a request path.
125
219
  */
126
220
  const daemon: Daemon = {
127
221
  connect: client.connect,
@@ -131,9 +225,27 @@ const daemon: Daemon = {
131
225
  stop: client.stop,
132
226
  };
133
227
 
134
- export {runtime, screenshot, daemon};
228
+ export {cache, daemon, releaseMemory, runtime, screenshot, start, status, stop};
135
229
 
136
230
  // A default as well as the names, because `import shotium from` is what a
137
231
  // caller coming from `require` writes first, and the two have to be the same
138
232
  // object rather than two views that drift.
139
- export default {Runtime, runtime, screenshot, daemon};
233
+ export default {
234
+ Runtime,
235
+ cache,
236
+ daemon,
237
+ releaseMemory,
238
+ runtime,
239
+ screenshot,
240
+ start,
241
+ status,
242
+ stop,
243
+ // A getter and not a value, because it changes. It is only on the default
244
+ // export: a named `running` would have to be a live binding that something
245
+ // remembered to update, and the two would disagree the first time anybody
246
+ // forgot. Callers who import by name have `status().running`, which is the
247
+ // same answer with the cache directory attached.
248
+ get running(): boolean {
249
+ return runtime.running;
250
+ },
251
+ };
@@ -17,12 +17,33 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
17
  */
18
18
  export type Engine = unknown;
19
19
 
20
+ /** One capture's answer, as the addon hands it over. */
21
+ export interface NativeCapture {
22
+ image: Buffer;
23
+ /**
24
+ * CaptureStats as JSON, unparsed. The addon carries JSON between the engine
25
+ * and this layer without reading it -- anything it understood would be a
26
+ * third opinion about the shape, and the third opinion is the one that
27
+ * drifts. Undefined when the engine reported none.
28
+ */
29
+ stats?: string;
30
+ }
31
+
20
32
  /** What native/binding.cc exports. See shot/shot_api.h for the C ABI. */
21
33
  export interface NativeBinding {
22
34
  create(optionsJson: string): Engine;
23
35
  destroy(engine: Engine): void;
24
36
  purge(engine: Engine, releaseWorkingSet: boolean): void;
25
- capture(engine: Engine, requestJson: string): Promise<Buffer>;
37
+ status(engine: Engine): string;
38
+ capture(engine: Engine, requestJson: string): Promise<NativeCapture>;
39
+ /**
40
+ * List or clear a cache directory. `engine` is nullable and that is the
41
+ * interface: with one, the operation runs on the engine's thread and borrows
42
+ * the backend it already holds; without one, the library opens the directory
43
+ * itself. Resolves to JSON.
44
+ */
45
+ cache(engine: Engine|null, clearing: boolean, optionsJson: string):
46
+ Promise<string>;
26
47
  }
27
48
 
28
49
  // Where the addon and the library beside it live.
@@ -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
+ }