@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/dist/index.js CHANGED
@@ -1,11 +1,262 @@
1
- import { a as encodeFrame, i as FrameReader, n as timeoutFor, o as endpointFor, r as toRequest, s as resolveStartOptions, t as Engine } from "./engine-Xe7nH-1i.js";
2
- import { spawn } from "node:child_process";
3
- import { EventEmitter } from "node:events";
1
+ import { a as emptyStats, c as cacheRoot, d as resolveStartOptions, f as load, i as Engine, l as defaultCacheDir, n as encodeFrame, o as timeoutFor, r as endpointFor, s as toRequest, t as FrameReader, u as normalizePath } from "./protocol-rQEcQPAC.js";
4
2
  import fs from "node:fs";
5
- import net from "node:net";
6
3
  import path from "node:path";
7
4
  import { fileURLToPath } from "node:url";
5
+ import { spawn } from "node:child_process";
6
+ import { EventEmitter } from "node:events";
7
+ import net from "node:net";
8
+
9
+ //#region src/lib/cache.ts
10
+ /**
11
+ * Turns one glob into a regular expression over a URL.
12
+ *
13
+ * The dialect is the small one everybody already knows -- `*`, `**`, `?`,
14
+ * `{a,b}` -- and it is implemented here rather than depended on because this
15
+ * package has no runtime dependencies and a matcher is thirty lines. `*` stops
16
+ * at `/` and `**` does not, which is the distinction that makes
17
+ * `https://example.com/*` mean one level and `https://example.com/**` mean the
18
+ * site.
19
+ *
20
+ * Everything else is escaped, which matters more than usual here: the subjects
21
+ * are URLs, and a URL is mostly characters that mean something to a regular
22
+ * expression.
23
+ */
24
+ function globToRegExp(pattern) {
25
+ let out = "";
26
+ for (let i = 0; i < pattern.length; i++) {
27
+ const c = pattern[i];
28
+ if (c === "*") {
29
+ if (pattern[i + 1] === "*") {
30
+ out += ".*";
31
+ i++;
32
+ if (pattern[i + 1] === "/") {
33
+ out += "/?";
34
+ i++;
35
+ }
36
+ } else out += "[^/]*";
37
+ } else if (c === "?") out += "[^/]";
38
+ else if (c === "{") {
39
+ const end = pattern.indexOf("}", i);
40
+ if (end === -1) out += "\\{";
41
+ else {
42
+ const alternatives = pattern.slice(i + 1, end).split(",").map(escapeLiteral);
43
+ out += `(?:${alternatives.join("|")})`;
44
+ i = end;
45
+ }
46
+ } else out += escapeLiteral(c);
47
+ }
48
+ return new RegExp(`^${out}$`);
49
+ }
50
+ function escapeLiteral(text) {
51
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ }
53
+ /** Whether `url` matches any of `patterns`. No patterns matches nothing. */
54
+ function matchesAny(url, patterns) {
55
+ return patterns.some((pattern) => pattern.test(url));
56
+ }
57
+ /**
58
+ * What a cache directory occupies, for the one path that reports a size
59
+ * without a backend to ask.
60
+ *
61
+ * The sum of the files rather than the sum of the entries, so it will differ
62
+ * from what `clear()` reports through the backend by the index and by whatever
63
+ * rounding the filesystem does. It is the honest number for "what is about to
64
+ * be deleted", which is what it is used for.
65
+ */
66
+ function directorySize(dir) {
67
+ let total = 0;
68
+ let names = [];
69
+ try {
70
+ names = fs.readdirSync(dir, { withFileTypes: true });
71
+ } catch {
72
+ return 0;
73
+ }
74
+ for (const entry of names) {
75
+ const full = path.join(dir, entry.name);
76
+ if (entry.isDirectory()) {
77
+ total += directorySize(full);
78
+ continue;
79
+ }
80
+ try {
81
+ total += fs.statSync(full).size;
82
+ } catch {}
83
+ }
84
+ return total;
85
+ }
86
+ /**
87
+ * Which directories an operation covers.
88
+ *
89
+ * `current` is this project's, `all` is every directory under the shared root,
90
+ * and anything else is taken as a project hash. `all` reads the root rather
91
+ * than remembering what it created: another process's directory is as much
92
+ * shotium's as this one's, and a caller asking to clear them all means the
93
+ * ones on disk.
94
+ */
95
+ function resolveTargets(target) {
96
+ if (target === "all") {
97
+ const root = cacheRoot();
98
+ let names = [];
99
+ try {
100
+ names = fs.readdirSync(root);
101
+ } catch {
102
+ return [];
103
+ }
104
+ return names.map((name) => normalizePath(path.join(root, name))).filter((dir) => {
105
+ try {
106
+ return fs.statSync(dir).isDirectory();
107
+ } catch {
108
+ return false;
109
+ }
110
+ });
111
+ }
112
+ if (target === void 0 || target === "current") return [defaultCacheDir()];
113
+ if (path.isAbsolute(target)) return [normalizePath(target)];
114
+ return [normalizePath(path.join(cacheRoot(), target))];
115
+ }
116
+ /**
117
+ * The cache, from the outside.
118
+ *
119
+ * Every method takes the engine handle if there is one, and that is not an
120
+ * optimisation. Within one process a cache directory has one backend: asking
121
+ * for a second one on the directory the engine holds waits for the engine's to
122
+ * go away, which it will not do while the engine is up. Borrowing is the only
123
+ * thing that returns.
124
+ *
125
+ * "If there is one" means the process, not the lifecycle. `stop()` stands the
126
+ * engine down without tearing it down, so an engine that has been stopped
127
+ * still holds its directory and still has to be borrowed from -- which is also
128
+ * what makes the cache survive a stop, and outlive one, and be worth having.
129
+ *
130
+ * Across processes there is no such constraint -- several of them may share a
131
+ * directory and all of them cache.
132
+ *
133
+ * The engine is fetched through a callback rather than held, because this
134
+ * object is built once at import time and the engine comes and goes.
135
+ */
136
+ var Cache = class {
137
+ engineHandle;
138
+ constructor(engineHandle) {
139
+ this.engineHandle = engineHandle;
140
+ }
141
+ /**
142
+ * This project's cache directory, absolute and with forward slashes.
143
+ *
144
+ * It exists whether or not anything has been written to it -- the answer is
145
+ * "where the cache goes", not "where a cache is".
146
+ */
147
+ getDir(options = {}) {
148
+ const targets = resolveTargets(options.target);
149
+ return targets.length > 0 ? targets[0] : defaultCacheDir();
150
+ }
151
+ /** Every directory the target names. `all` can be several; the rest, one. */
152
+ getDirs(options = {}) {
153
+ return resolveTargets(options.target);
154
+ }
155
+ /**
156
+ * What the cache is holding, by URL.
157
+ *
158
+ * Named `getFiles` for the operation callers reach for, and deliberately not
159
+ * returning filenames: the files in a cache directory are called things like
160
+ * `5349fbae98c6d9a1_0`, because the name is a hash of the entry key. A list
161
+ * of those answers no question anybody has. The URLs are what the entries
162
+ * are, and they are what `clear({glob})` matches against.
163
+ *
164
+ * This opens every entry to read its key and size, so it is a diagnostic
165
+ * rather than something to put on a request path.
166
+ */
167
+ async getFiles(options = {}) {
168
+ const native = load();
169
+ const entries = [];
170
+ for (const dir of resolveTargets(options.target)) {
171
+ if (!fs.existsSync(dir)) continue;
172
+ const json = await native.cache(this.handleFor(), false, JSON.stringify({ cacheDir: dir }));
173
+ const listed = JSON.parse(json);
174
+ for (const entry of listed) entries.push({
175
+ ...entry,
176
+ dir
177
+ });
178
+ }
179
+ return entries;
180
+ }
181
+ /**
182
+ * Removes what the options select. With no options, everything.
183
+ *
184
+ * The three filters compose, and `glob` is applied here rather than in the
185
+ * engine: the entries are listed, their URLs are matched, and the ones that
186
+ * matched are what the engine is asked to remove. That keeps the pattern
187
+ * dialect in the layer whose users have opinions about pattern dialects, and
188
+ * keeps the engine's interface to exact URLs.
189
+ *
190
+ * Removal goes through the cache backend, never through the filesystem.
191
+ * Deleting the files directly would leave the backend's index naming entries
192
+ * that are no longer there, and the next process to open the directory
193
+ * either rebuilds the index from disk or, having found it inconsistent,
194
+ * discards it. That is the difference between clearing a cache and
195
+ * corrupting one.
196
+ */
197
+ async clear(options = {}) {
198
+ const native = load();
199
+ const patterns = (options.glob ?? []).map(globToRegExp);
200
+ const results = [];
201
+ if (patterns.length === 0 && !options.maxAge && !options.maxSize && !this.handleFor()) {
202
+ for (const dir of resolveTargets(options.target)) {
203
+ const before = directorySize(dir);
204
+ fs.rmSync(dir, {
205
+ recursive: true,
206
+ force: true
207
+ });
208
+ results.push({
209
+ removed: -1,
210
+ bytesBefore: before,
211
+ bytesAfter: 0,
212
+ dir
213
+ });
214
+ }
215
+ return results;
216
+ }
217
+ for (const dir of resolveTargets(options.target)) {
218
+ if (!fs.existsSync(dir)) continue;
219
+ const request = { cacheDir: dir };
220
+ if (patterns.length > 0) {
221
+ const json = await native.cache(this.handleFor(), false, JSON.stringify({ cacheDir: dir }));
222
+ const urls = JSON.parse(json).filter((entry) => matchesAny(entry.url, patterns)).map((entry) => entry.url);
223
+ if (urls.length === 0 && options.maxAge === void 0 && options.maxSize === void 0) {
224
+ results.push({
225
+ removed: 0,
226
+ bytesBefore: 0,
227
+ bytesAfter: 0,
228
+ dir
229
+ });
230
+ continue;
231
+ }
232
+ request.urls = urls;
233
+ }
234
+ if (options.maxAge) request.unusedSinceMs = Date.now() - options.maxAge * 1e3;
235
+ if (options.maxSize) request.maxBytes = options.maxSize;
236
+ const json = await native.cache(this.handleFor(), true, JSON.stringify(request));
237
+ results.push({
238
+ ...JSON.parse(json),
239
+ dir
240
+ });
241
+ }
242
+ return results;
243
+ }
244
+ /**
245
+ * The engine handle, when there is an engine.
246
+ *
247
+ * Passed for every directory and not only the engine's own. It is never
248
+ * wrong to pass it -- the engine's thread can open any directory, and for
249
+ * the one it already has open, borrowing its backend is the only thing that
250
+ * returns. It is passing `null` while an engine is up that hangs, which is
251
+ * why this is conditional on neither the directory asked for nor on whether
252
+ * the engine is currently accepting captures.
253
+ */
254
+ handleFor() {
255
+ return this.engineHandle();
256
+ }
257
+ };
8
258
 
259
+ //#endregion
9
260
  //#region src/lib/client.ts
10
261
  const HERE = path.dirname(fileURLToPath(import.meta.url));
11
262
  const DAEMON_MAIN = path.join(HERE, "daemon_main.js");
@@ -62,7 +313,11 @@ var DaemonClient = class extends EventEmitter {
62
313
  header,
63
314
  image: header.path ? null : payload
64
315
  });
65
- else pending.reject(new Error(header.error || "shotium: request failed"));
316
+ else {
317
+ const error = new Error(header.error || "shotium: request failed");
318
+ if (header.stats) error.stats = header.stats;
319
+ pending.reject(error);
320
+ }
66
321
  }
67
322
  failAll(error) {
68
323
  for (const [, pending] of this.pending) pending.reject(error);
@@ -85,14 +340,23 @@ var DaemonClient = class extends EventEmitter {
85
340
  }), "utf8")));
86
341
  });
87
342
  }
88
- /** Resolves to the image, or to null when `path` was given. */
343
+ /**
344
+ * One screenshot, and what taking it cost.
345
+ *
346
+ * The same shape the in-process engine returns, so that moving a program
347
+ * between the two is an import change and nothing else.
348
+ */
89
349
  async screenshot(options) {
90
350
  const request = toRequest(options);
91
- return (await this.send({
351
+ const result = await this.send({
92
352
  op: "screenshot",
93
353
  request,
94
354
  timeout: timeoutFor(options)
95
- })).image;
355
+ });
356
+ return {
357
+ image: result.image,
358
+ stats: result.header.stats ?? emptyStats()
359
+ };
96
360
  }
97
361
  async status() {
98
362
  const { header } = await this.send({ op: "status" });
@@ -192,7 +456,7 @@ async function connect(options = {}) {
192
456
  const { client } = await ensureClient(options);
193
457
  return client;
194
458
  }
195
- async function start(options = {}) {
459
+ async function start$1(options = {}) {
196
460
  const { client, spawned, endpoint } = await ensureClient(options);
197
461
  try {
198
462
  return {
@@ -204,7 +468,7 @@ async function start(options = {}) {
204
468
  client.close();
205
469
  }
206
470
  }
207
- async function status(options = {}) {
471
+ async function status$1(options = {}) {
208
472
  const resolved = resolveDaemonOptions(options);
209
473
  let client;
210
474
  try {
@@ -224,7 +488,7 @@ async function status(options = {}) {
224
488
  client.close();
225
489
  }
226
490
  }
227
- async function stop(options = {}) {
491
+ async function stop$1(options = {}) {
228
492
  const resolved = resolveDaemonOptions(options);
229
493
  let client;
230
494
  try {
@@ -262,9 +526,11 @@ async function screenshot$1(options) {
262
526
  *
263
527
  * import shotium from '@shotkit/shotium';
264
528
  *
265
- * shotium.runtime.start();
266
- * const png = await shotium.screenshot({file: 'https://example.com'});
267
- * await shotium.runtime.stop();
529
+ * shotium.start();
530
+ * const {image, stats} = await shotium.screenshot({
531
+ * file: 'https://example.com',
532
+ * });
533
+ * await shotium.stop();
268
534
  *
269
535
  * `start` and `stop` are explicit because starting Blink is the expensive part
270
536
  * -- tens of milliseconds and a working set that stays resident -- and only
@@ -273,11 +539,24 @@ async function screenshot$1(options) {
273
539
  * What they buy is control over when that cost is paid, and the certainty that
274
540
  * it has been given back.
275
541
  *
276
- * `runtime` below is the singleton because there is nothing else it could be:
277
- * Blink starts once per process and cannot be restarted, so a second Runtime
278
- * in the same process has no engine to have. Construct one directly only to
279
- * own the lifecycle yourself instead of using `runtime`. Parallelism is more
280
- * processes, not more Runtimes.
542
+ * Neither is rationed, either. They may be called in any order and as often as
543
+ * a program likes: `stop()` stands the engine down and `start()` picks the
544
+ * same one back up, warm cache and all. What cannot happen is a *second*
545
+ * engine -- Blink is initialised once per process and there is no undo -- but
546
+ * that is a fact about how many there are, not about how many times the one
547
+ * may be asked for.
548
+ *
549
+ * The methods are on the module rather than under a `runtime` namespace, which
550
+ * they were until 0.3. There was never anything else to start, so the word
551
+ * carried nothing; and `runtime.cache` would have been the wrong place for the
552
+ * cache besides, since a cache directory outlives every engine that writes to
553
+ * it and can be read when no engine is running at all.
554
+ *
555
+ * `Runtime` is still exported for a caller who wants to own a lifecycle rather
556
+ * than share the module's. It is a lifecycle and not an engine: there is one
557
+ * engine per process, and a second Runtime that starts adopts the same one
558
+ * rather than building another. Parallelism is more processes, not more
559
+ * Runtimes.
281
560
  *
282
561
  * `daemon` is the same engine in a process of its own, behind a socket, for
283
562
  * callers whose own process does not live long enough to be worth starting
@@ -285,43 +564,83 @@ async function screenshot$1(options) {
285
564
  */
286
565
  var Runtime = class {
287
566
  engine = new Engine();
567
+ /**
568
+ * The HTTP cache: where it is, what is in it, and how to empty it.
569
+ *
570
+ * On the Runtime as well as on the module because a caller holding their own
571
+ * Runtime needs the engine handle to reach a directory that engine has open:
572
+ * within one process a directory has one backend, so borrowing is the only
573
+ * way in.
574
+ */
575
+ cache = new Cache(() => this.engine.nativeHandle);
288
576
  get running() {
289
577
  return this.engine.running;
290
578
  }
291
579
  /**
292
- * Starts the engine. Safe to call twice; the second call is a no-op, so that
293
- * library code can call it defensively. Not safe after `stop()` -- see there.
580
+ * Starts the engine, or picks the running one back up.
581
+ *
582
+ * Callable as often as you like, in any order with `stop()`; library code
583
+ * can call it defensively. The first call in a process builds the engine and
584
+ * every later one adopts it -- the same engine, the same warm cache. The one
585
+ * thing it will refuse is a *different* configuration: the options below are
586
+ * fixed when the engine is built, and there is no second build, so naming
587
+ * one that disagrees with what is running throws rather than rendering with
588
+ * a value you did not ask for.
294
589
  *
295
- * Every option has a default. `cacheDir` is the HTTP disk cache and `null`
296
- * disables it; `resourceDir` is where `shotium_data.pak` and
590
+ * Every option has a default. `cacheDir` is the HTTP disk cache and defaults
591
+ * to a per-project directory under `~/.shotium/cache`, and not under the
592
+ * temporary directory, which is defined by not surviving. `null` turns it
593
+ * off. `resourceDir` is where `shotium_data.pak` and
297
594
  * `shotium_strings.pak` are, and defaults to the directory the engine was
298
595
  * loaded from, which is where they ship.
596
+ *
597
+ * The return value is worth reading once. `cacheActive: false` with a
598
+ * `cacheDir` set means the directory could not be opened and this engine is
599
+ * running without a cache -- correctly, silently, and a round trip slower on
600
+ * everything.
299
601
  */
300
602
  start(options = {}) {
301
- this.engine.start(options);
302
- return this;
603
+ return this.engine.start(options);
604
+ }
605
+ /** What `start()` returned, asked again. */
606
+ status() {
607
+ return this.engine.status();
303
608
  }
304
609
  /**
305
- * Stops the engine, after whatever is queued.
610
+ * Stands the engine down, after whatever is queued.
611
+ *
612
+ * The queue drains, the memory the engine can rebuild goes back to the OS,
613
+ * and `running` becomes false. Blink itself stays initialised, because there
614
+ * is no way to un-initialise it -- so the disk cache stays where it is, and
615
+ * `start()` or the next `screenshot()` picks the same engine back up.
306
616
  *
307
- * Final for this process. Blink writes process-wide state that it has no
308
- * path to undo, so starting again -- here or on another Runtime -- throws
309
- * rather than quietly handing back something that cannot render. A program
310
- * that wants another screenshot later should stay started and `purge()`.
617
+ * Which makes this a caller saying they are done for now rather than a
618
+ * destructor. It does the same work as `releaseMemory({releaseWorkingSet:
619
+ * true})` and additionally stops accepting captures.
311
620
  */
312
621
  stop() {
313
622
  return this.engine.stop();
314
623
  }
315
624
  /**
316
- * Hands back what the engine is holding but can rebuild. Worth calling when
317
- * a batch has ended and the next one may be a while away.
625
+ * Hands back what the engine is holding but can rebuild: Blink's heap,
626
+ * skia's caches, PartitionAlloc's free lists. Worth calling when a batch has
627
+ * ended and the next one may be a while away.
628
+ *
629
+ * This is memory and nothing else. It was called `purge()` until 0.3, which
630
+ * next to `cache.clear()` read as though it emptied the HTTP cache; it does
631
+ * not touch the disk at all.
318
632
  */
319
- purge(options = {}) {
320
- this.engine.purge(options);
633
+ releaseMemory(options = {}) {
634
+ this.engine.releaseMemory(options);
321
635
  }
322
636
  /**
323
- * Renders one screenshot. Resolves to the encoded image, or to `null` when
324
- * `path` was given and the engine wrote the file itself.
637
+ * Renders one screenshot, and reports what it cost.
638
+ *
639
+ * `image` is the encoded bytes, or `null` when `path` was given and the
640
+ * engine wrote the file itself. `stats` says how many resources were
641
+ * fetched, how many came from the cache, and where the milliseconds went --
642
+ * which for an `https:` URL is usually the answer to "why did this take so
643
+ * long", because a cold connection costs more than the render does.
325
644
  */
326
645
  screenshot(options) {
327
646
  return this.engine.screenshot(options);
@@ -331,25 +650,54 @@ var Runtime = class {
331
650
  const runtime = new Runtime();
332
651
  /** One screenshot through the shared engine, starting it if it is not up. */
333
652
  const screenshot = (options) => runtime.screenshot(options);
653
+ const start = (options) => runtime.start(options);
654
+ const status = () => runtime.status();
655
+ const stop = () => runtime.stop();
656
+ const releaseMemory = (options) => runtime.releaseMemory(options);
657
+ /**
658
+ * The HTTP cache.
659
+ *
660
+ * At the top level rather than under the engine because it outlives one: the
661
+ * directory is on disk whether or not anything is running, `getDir()` answers
662
+ * before the first `start()`, and clearing it is something a program may want
663
+ * to do without bringing Blink up at all. When an engine *is* up, these
664
+ * borrow its cache backend, because within one process a directory has one
665
+ * backend and that is the only way in.
666
+ */
667
+ const cache = runtime.cache;
334
668
  /**
335
669
  * The resident engine: a process that outlives the one that started it,
336
670
  * reachable over a named pipe on Windows and a unix socket elsewhere. For
337
671
  * callers that are short-lived themselves. See lib/daemon.ts.
672
+ *
673
+ * It has no `cache` of its own. A daemon's cache directory is reported by
674
+ * `daemon.status()`, and clearing it is done by pointing `cache.clear()` at
675
+ * that directory or by stopping the daemon -- a cross-process cache protocol
676
+ * would be a second implementation of this module for something nobody does on
677
+ * a request path.
338
678
  */
339
679
  const daemon = {
340
680
  connect,
341
681
  screenshot: screenshot$1,
342
- start,
343
- status,
344
- stop
682
+ start: start$1,
683
+ status: status$1,
684
+ stop: stop$1
345
685
  };
346
686
  var src_default = {
347
687
  Runtime,
688
+ cache,
689
+ daemon,
690
+ releaseMemory,
348
691
  runtime,
349
692
  screenshot,
350
- daemon
693
+ start,
694
+ status,
695
+ stop,
696
+ get running() {
697
+ return runtime.running;
698
+ }
351
699
  };
352
700
 
353
701
  //#endregion
354
- export { Runtime, daemon, src_default as default, runtime, screenshot };
702
+ export { Cache, Runtime, cache, daemon, src_default as default, releaseMemory, runtime, screenshot, start, status, stop };
355
703
  //# sourceMappingURL=index.js.map