@shotkit/shotium 0.2.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.
- package/README.md +317 -60
- package/dist/daemon_main.js +9 -7
- package/dist/daemon_main.js.map +1 -1
- package/dist/index.d.ts +396 -35
- package/dist/index.js +389 -41
- package/dist/index.js.map +1 -1
- package/dist/protocol-BTeWJDOa.js +474 -0
- package/dist/protocol-BTeWJDOa.js.map +1 -0
- package/native/binding.cc +184 -3
- package/package.json +7 -7
- package/src/index.ts +145 -33
- package/src/lib/binding.ts +22 -1
- package/src/lib/cache.ts +323 -0
- package/src/lib/client.ts +24 -5
- package/src/lib/config.ts +113 -6
- package/src/lib/daemon.ts +29 -6
- package/src/lib/engine.ts +246 -58
- package/src/lib/request.ts +10 -1
- package/src/types.ts +204 -4
- package/dist/engine-Xe7nH-1i.js +0 -267
- package/dist/engine-Xe7nH-1i.js.map +0 -1
package/src/lib/binding.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/lib/cache.ts
ADDED
|
@@ -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 {
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
155
|
-
|
|
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<
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
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 {
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
}
|