@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/README.md +322 -60
- package/dist/daemon_main.js +9 -7
- package/dist/daemon_main.js.map +1 -1
- package/dist/index.d.ts +406 -35
- package/dist/index.js +389 -41
- package/dist/index.js.map +1 -1
- package/dist/protocol-rQEcQPAC.js +479 -0
- package/dist/protocol-rQEcQPAC.js.map +1 -0
- package/native/binding.cc +212 -11
- 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 +256 -58
- package/src/lib/request.ts +10 -1
- package/src/types.ts +214 -4
- package/dist/engine-Xe7nH-1i.js +0 -267
- package/dist/engine-Xe7nH-1i.js.map +0 -1
package/src/lib/engine.ts
CHANGED
|
@@ -2,23 +2,80 @@ import * as binding from './binding.js';
|
|
|
2
2
|
import type {Engine as Handle} from './binding.js';
|
|
3
3
|
import {toRequest} from './request.js';
|
|
4
4
|
import type {WireRequest} from './request.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
CaptureStats,
|
|
7
|
+
ReleaseMemoryOptions,
|
|
8
|
+
ScreenshotOptions,
|
|
9
|
+
ScreenshotResult,
|
|
10
|
+
StartOptions,
|
|
11
|
+
StartResult,
|
|
12
|
+
} from '../types.js';
|
|
6
13
|
|
|
14
|
+
import type {ResolvedStartOptions} from './config.js';
|
|
7
15
|
import {resolveStartOptions} from './config.js';
|
|
8
16
|
|
|
9
|
-
//
|
|
17
|
+
// The engine this process has, held above every Engine object that uses it.
|
|
10
18
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// still alive. See shot/shot_api.h.
|
|
19
|
+
// Blink is initialised once and has no undo: it writes process-wide statics
|
|
20
|
+
// that shot_engine_destroy() cannot take back, and the C API refuses a second
|
|
21
|
+
// create for the lifetime of the process whether or not the first is still
|
|
22
|
+
// alive.
|
|
16
23
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
24
|
+
// That fact used to be exposed directly -- `stop()` destroyed the engine and
|
|
25
|
+
// every later `start()` threw. It was the wrong shape. `stop()` and `start()`
|
|
26
|
+
// are a caller saying "I am done for now" and "I want it again", and a library
|
|
27
|
+
// whose engine can be asked for exactly once turns an ordinary pair of calls
|
|
28
|
+
// into a thing that has to be rationed. It also made the disk cache
|
|
29
|
+
// nonsensical: the whole point of a cache is the *next* run, and the next run
|
|
30
|
+
// could not have the engine that reads it.
|
|
31
|
+
//
|
|
32
|
+
// So the handle lives here rather than on the instance. `stop()` stands the
|
|
33
|
+
// engine down -- the queue drains, the memory goes back, nothing more is
|
|
34
|
+
// accepted -- and `start()` picks the same one up again, as many times as a
|
|
35
|
+
// caller likes. The process is the engine's lifetime, which is what it always
|
|
36
|
+
// was; the difference is that the API no longer pretends to offer a shorter
|
|
37
|
+
// one.
|
|
38
|
+
let shared: Handle|null = null;
|
|
39
|
+
|
|
40
|
+
// What `shared` was created with. Kept because those options are fixed for the
|
|
41
|
+
// life of the process -- a later `start()` asking for a different cache
|
|
42
|
+
// directory cannot be given one, and is told so rather than handed an engine
|
|
43
|
+
// that quietly uses the first caller's.
|
|
44
|
+
let sharedOptions: ResolvedStartOptions|null = null;
|
|
45
|
+
|
|
46
|
+
// Whether the one create this process gets has been spent.
|
|
47
|
+
//
|
|
48
|
+
// Separate from `shared` being non-null because `dispose()` clears the handle
|
|
49
|
+
// and does not give the ability back: after a real teardown there is no engine
|
|
50
|
+
// and there cannot be another. Nothing in the public surface calls dispose()
|
|
51
|
+
// -- the daemon does, on its way out of a process it owns.
|
|
52
|
+
let spent = false;
|
|
53
|
+
|
|
54
|
+
/** The engine handle this process has, or null if it has none. */
|
|
55
|
+
function sharedHandle(): Handle|null {
|
|
56
|
+
return shared;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The options that are fixed at create time, and the report a mismatch gets.
|
|
60
|
+
//
|
|
61
|
+
// Only the ones the caller actually named are checked: `start()` with no
|
|
62
|
+
// arguments is a caller saying "whatever is there", which is exactly what
|
|
63
|
+
// adopting a running engine gives them. Naming an option that disagrees is
|
|
64
|
+
// different -- it is a request that cannot be honoured, and silently rendering
|
|
65
|
+
// with the other value is the failure this exists to prevent.
|
|
66
|
+
function conflictingOption(
|
|
67
|
+
options: StartOptions, current: ResolvedStartOptions): string|null {
|
|
68
|
+
const wanted = resolveStartOptions(options);
|
|
69
|
+
for (const key of ['cacheDir', 'cacheMaxBytes', 'userAgent', 'resourceDir'] as
|
|
70
|
+
const) {
|
|
71
|
+
if (options[key] === undefined || wanted[key] === current[key]) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
return `${key} is ${JSON.stringify(current[key])}, and this start() ` +
|
|
75
|
+
`asked for ${JSON.stringify(wanted[key])}`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
22
79
|
|
|
23
80
|
/**
|
|
24
81
|
* Blink, in this process, and the queue in front of it.
|
|
@@ -36,45 +93,73 @@ let startedInThisProcess = false;
|
|
|
36
93
|
* gaining nothing, since the engine serialises them anyway.
|
|
37
94
|
*/
|
|
38
95
|
export class Engine {
|
|
39
|
-
|
|
40
|
-
|
|
96
|
+
// Whether *this* object considers itself started. The engine behind it may
|
|
97
|
+
// well be up for somebody else; `running` is about this lifecycle, not about
|
|
98
|
+
// whether the process has an engine.
|
|
99
|
+
private active = false;
|
|
41
100
|
private tail: Promise<unknown> = Promise.resolve();
|
|
42
101
|
|
|
43
102
|
get running(): boolean {
|
|
44
|
-
return this.
|
|
103
|
+
return this.active && shared !== null;
|
|
45
104
|
}
|
|
46
105
|
|
|
47
106
|
/**
|
|
48
|
-
*
|
|
49
|
-
* library code can call it defensively.
|
|
107
|
+
* The addon's engine handle, or null when this process has never had one.
|
|
50
108
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
109
|
+
* Deliberately not conditional on `running`. It is the cache that asks, and
|
|
110
|
+
* what the cache needs to know is whether a backend exists in this process
|
|
111
|
+
* -- because within one process a cache directory has one backend, so
|
|
112
|
+
* reading or clearing the directory the engine holds means borrowing it
|
|
113
|
+
* rather than opening a second one. A stood-down engine still holds its
|
|
114
|
+
* directory, so a caller who calls `stop()` and then `cache.getFiles()` is
|
|
115
|
+
* asking about a live backend and has to be routed to it. Nothing else
|
|
116
|
+
* should reach for this.
|
|
54
117
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
118
|
+
get nativeHandle(): Handle|null {
|
|
119
|
+
return sharedHandle();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Starts the engine, or picks the running one back up.
|
|
124
|
+
*
|
|
125
|
+
* Callable as often as a caller likes, in any order with `stop()`. The first
|
|
126
|
+
* call in a process builds the engine; every later one adopts it, which is
|
|
127
|
+
* the same engine and the same warm cache. Library code can call it
|
|
128
|
+
* defensively.
|
|
129
|
+
*
|
|
130
|
+
* The one thing that cannot be adopted is a different configuration. The
|
|
131
|
+
* options below are fixed when the engine is built and there is no second
|
|
132
|
+
* build, so naming one that disagrees with what is running throws rather
|
|
133
|
+
* than rendering with a value the caller did not ask for.
|
|
134
|
+
*/
|
|
135
|
+
start(options: StartOptions = {}): StartResult {
|
|
136
|
+
if (shared) {
|
|
137
|
+
const conflict = conflictingOption(options, sharedOptions!);
|
|
138
|
+
if (conflict) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'shotium: this process already has an engine, and its ' +
|
|
141
|
+
conflict + '. Blink is initialised once per process and cannot ' +
|
|
142
|
+
'be built again, so an engine\'s options are fixed for as long ' +
|
|
143
|
+
'as the process lives -- stop() does not undo them. Use the ' +
|
|
144
|
+
'engine that is up, or run another process.');
|
|
145
|
+
}
|
|
146
|
+
this.active = true;
|
|
147
|
+
return this.status();
|
|
64
148
|
}
|
|
65
|
-
if (
|
|
149
|
+
if (spent) {
|
|
66
150
|
throw new Error(
|
|
67
|
-
'shotium: an engine
|
|
68
|
-
'
|
|
69
|
-
'
|
|
70
|
-
'another process.');
|
|
151
|
+
'shotium: this process had an engine and it was disposed of. ' +
|
|
152
|
+
'Blink is initialised once per process and cannot be built again. ' +
|
|
153
|
+
'Run another process.');
|
|
71
154
|
}
|
|
155
|
+
|
|
72
156
|
const native = binding.load();
|
|
73
157
|
const resolved = resolveStartOptions(options);
|
|
74
158
|
|
|
75
159
|
const engineOptions: Record<string, unknown> = {};
|
|
76
160
|
if (resolved.cacheDir !== null) {
|
|
77
161
|
engineOptions.cacheDir = resolved.cacheDir;
|
|
162
|
+
engineOptions.cacheMaxBytes = resolved.cacheMaxBytes;
|
|
78
163
|
}
|
|
79
164
|
if (resolved.userAgent !== undefined) {
|
|
80
165
|
engineOptions.userAgent = resolved.userAgent;
|
|
@@ -85,30 +170,84 @@ export class Engine {
|
|
|
85
170
|
// teaching the engine a second way to look. See shot_api.h.
|
|
86
171
|
engineOptions.resourceDir = resolved.resourceDir ?? binding.directory();
|
|
87
172
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
173
|
+
shared = native.create(JSON.stringify(engineOptions));
|
|
174
|
+
sharedOptions = resolved;
|
|
175
|
+
this.active = true;
|
|
176
|
+
return this.status();
|
|
91
177
|
}
|
|
92
178
|
|
|
93
179
|
/**
|
|
94
|
-
*
|
|
180
|
+
* What the engine came up as: whether this lifecycle is started, which cache
|
|
181
|
+
* directory the engine has, and whether it actually got it.
|
|
182
|
+
*
|
|
183
|
+
* The last of those is the one worth reading. A directory that cannot be
|
|
184
|
+
* created or written to -- no permission, no space, a path that is a file --
|
|
185
|
+
* fails invisibly: the engine renders exactly as well without a cache, only
|
|
186
|
+
* slower, and every capture pays the network again for a reason nothing
|
|
187
|
+
* reports. The engine opens the cache during `start()` so that this is
|
|
188
|
+
* answerable before the first screenshot rather than after it.
|
|
95
189
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
190
|
+
* The cache half is answered from the engine whenever this process has one,
|
|
191
|
+
* including after `stop()`. A stood-down engine still holds its directory,
|
|
192
|
+
* and reporting `null` for it would say the cache had gone away when what
|
|
193
|
+
* went away was the willingness to render.
|
|
194
|
+
*/
|
|
195
|
+
status(): StartResult {
|
|
196
|
+
if (!shared) {
|
|
197
|
+
return {running: false, cacheDir: null, cacheActive: false};
|
|
198
|
+
}
|
|
199
|
+
const reported =
|
|
200
|
+
JSON.parse(binding.load().status(shared)) as Omit<StartResult, 'running'>;
|
|
201
|
+
return {running: this.running, ...reported};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Stands the engine down, after whatever is queued.
|
|
206
|
+
*
|
|
207
|
+
* The queue drains, the memory the engine can rebuild goes back to the OS,
|
|
208
|
+
* and `running` becomes false. What does not happen is a teardown of Blink,
|
|
209
|
+
* because there is no such thing -- see the note at the top of this file --
|
|
210
|
+
* so the disk cache stays where it is and `start()` picks the same engine up
|
|
211
|
+
* again whenever the caller wants it.
|
|
212
|
+
*
|
|
213
|
+
* Which makes this exactly what it says: not a destructor, a caller saying
|
|
214
|
+
* they are done for now. A program that will want another screenshot in a
|
|
215
|
+
* moment can equally well stay started and call `releaseMemory()`; the two
|
|
216
|
+
* do the same work, and this one also stops accepting captures.
|
|
99
217
|
*/
|
|
100
218
|
async stop(): Promise<void> {
|
|
101
|
-
if (!this.
|
|
219
|
+
if (!this.active) {
|
|
102
220
|
return;
|
|
103
221
|
}
|
|
104
|
-
this.
|
|
105
|
-
// After the queue, not before:
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
222
|
+
this.active = false;
|
|
223
|
+
// After the queue, not before: a caller's last screenshot should resolve
|
|
224
|
+
// rather than race the stand-down, and the memory is not worth handing
|
|
225
|
+
// back until the thing still using it has finished.
|
|
226
|
+
await this.tail.catch(() => {});
|
|
227
|
+
if (shared) {
|
|
228
|
+
binding.load().purge(shared, /*releaseWorkingSet=*/ true);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The real teardown: joins the engine thread, unwinds the network stack, and
|
|
234
|
+
* lets the disk cache write its index.
|
|
235
|
+
*
|
|
236
|
+
* Final, and final for the process rather than for this object -- which is
|
|
237
|
+
* why it is not on the public surface. The daemon calls it as it exits a
|
|
238
|
+
* process it owns, where the index flush is worth having and nothing is
|
|
239
|
+
* going to ask for another screenshot. Everything else wants `stop()`.
|
|
240
|
+
*/
|
|
241
|
+
async dispose(): Promise<void> {
|
|
242
|
+
this.active = false;
|
|
110
243
|
await this.tail.catch(() => {});
|
|
111
|
-
|
|
244
|
+
const handle = shared;
|
|
245
|
+
shared = null;
|
|
246
|
+
sharedOptions = null;
|
|
247
|
+
if (handle) {
|
|
248
|
+
spent = true;
|
|
249
|
+
binding.load().destroy(handle);
|
|
250
|
+
}
|
|
112
251
|
}
|
|
113
252
|
|
|
114
253
|
/**
|
|
@@ -121,11 +260,11 @@ export class Engine {
|
|
|
121
260
|
* request stream go quiet. Here the queue belongs to the caller, so the
|
|
122
261
|
* caller is the one who knows a batch has ended.
|
|
123
262
|
*/
|
|
124
|
-
|
|
125
|
-
if (!
|
|
263
|
+
releaseMemory({releaseWorkingSet = false}: ReleaseMemoryOptions = {}): void {
|
|
264
|
+
if (!shared) {
|
|
126
265
|
return;
|
|
127
266
|
}
|
|
128
|
-
binding.load().purge(
|
|
267
|
+
binding.load().purge(shared, releaseWorkingSet);
|
|
129
268
|
}
|
|
130
269
|
|
|
131
270
|
/**
|
|
@@ -136,7 +275,7 @@ export class Engine {
|
|
|
136
275
|
// throws, and a caller who wrote `screenshot(bad).catch(...)` would get the
|
|
137
276
|
// throw past the catch and into the surrounding frame. The whole surface is
|
|
138
277
|
// promise-shaped, so a bad request is a rejection like everything else.
|
|
139
|
-
async screenshot(options: ScreenshotOptions): Promise<
|
|
278
|
+
async screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {
|
|
140
279
|
// Before anything else, and before the queue: a malformed request should
|
|
141
280
|
// be a rejection now rather than one that waits its turn.
|
|
142
281
|
return this.capture(toRequest(options));
|
|
@@ -150,11 +289,13 @@ export class Engine {
|
|
|
150
289
|
* ScreenshotOptions would mean the daemon validating a request it cannot see
|
|
151
290
|
* the original of, and rejecting fields a newer client legitimately sent.
|
|
152
291
|
*/
|
|
153
|
-
async capture(request: WireRequest): Promise<
|
|
154
|
-
|
|
292
|
+
async capture(request: WireRequest): Promise<ScreenshotResult> {
|
|
293
|
+
// Starts, or restarts, or adopts -- a screenshot after `stop()` is an
|
|
294
|
+
// ordinary thing to ask for and gets the engine back.
|
|
295
|
+
if (!this.running) {
|
|
155
296
|
this.start();
|
|
156
297
|
}
|
|
157
|
-
const handle =
|
|
298
|
+
const handle = shared!;
|
|
158
299
|
const native = binding.load();
|
|
159
300
|
|
|
160
301
|
// Chain onto the tail so that captures run one at a time. The catch keeps
|
|
@@ -162,7 +303,64 @@ export class Engine {
|
|
|
162
303
|
const result = this.tail.catch(() => {}).then(
|
|
163
304
|
() => native.capture(handle, JSON.stringify(request)));
|
|
164
305
|
this.tail = result.catch(() => {});
|
|
165
|
-
|
|
166
|
-
|
|
306
|
+
|
|
307
|
+
let captured;
|
|
308
|
+
try {
|
|
309
|
+
captured = await result;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
// The addon attaches the capture's statistics to the rejection as
|
|
312
|
+
// unparsed JSON, the same way it hands them back on success -- see
|
|
313
|
+
// NativeCapture. Parsing them here rather than leaving a string on the
|
|
314
|
+
// error is what makes `error.stats` the same CaptureStats a successful
|
|
315
|
+
// call returns, which is the whole point of attaching it: the failure is
|
|
316
|
+
// the case where the counters explain the most.
|
|
317
|
+
const withStats = error as Error&{stats?: string | CaptureStats};
|
|
318
|
+
if (typeof withStats.stats === 'string') {
|
|
319
|
+
withStats.stats = JSON.parse(withStats.stats) as CaptureStats;
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
image: request.path ? null : captured.image,
|
|
326
|
+
stats: parseStats(captured.stats),
|
|
327
|
+
};
|
|
167
328
|
}
|
|
168
329
|
}
|
|
330
|
+
|
|
331
|
+
// A zeroed set of counters.
|
|
332
|
+
//
|
|
333
|
+
// Zeroes rather than undefined because the alternative is every caller writing
|
|
334
|
+
// `stats?.timing?.total ?? 0` around a field that is present for every capture
|
|
335
|
+
// that actually ran. The only case that produces none is a request rejected
|
|
336
|
+
// before it started, and that path throws rather than returning.
|
|
337
|
+
function emptyStats(): CaptureStats {
|
|
338
|
+
return {
|
|
339
|
+
requests: 0,
|
|
340
|
+
fromCache: 0,
|
|
341
|
+
failed: 0,
|
|
342
|
+
bytes: 0,
|
|
343
|
+
httpStatus: 0,
|
|
344
|
+
finalUrl: '',
|
|
345
|
+
timing: {
|
|
346
|
+
fetch: 0,
|
|
347
|
+
render: 0,
|
|
348
|
+
setup: 0,
|
|
349
|
+
wait: 0,
|
|
350
|
+
lifecycle: 0,
|
|
351
|
+
paint: 0,
|
|
352
|
+
raster: 0,
|
|
353
|
+
encode: 0,
|
|
354
|
+
total: 0,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The addon hands statistics over as unparsed JSON -- see NativeCapture -- so
|
|
360
|
+
// this is where the string becomes an object. The daemon's client has them
|
|
361
|
+
// parsed already, from its own response header, and uses emptyStats directly.
|
|
362
|
+
function parseStats(json: string|undefined): CaptureStats {
|
|
363
|
+
return json ? JSON.parse(json) as CaptureStats : emptyStats();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export {emptyStats, parseStats, sharedHandle};
|
package/src/lib/request.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CacheMode,
|
|
3
|
+
Clip,
|
|
4
|
+
PageGotoParams,
|
|
5
|
+
ScreenshotOptions,
|
|
6
|
+
} from '../types.js';
|
|
2
7
|
|
|
3
8
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
4
9
|
|
|
@@ -16,6 +21,8 @@ export interface WireRequest {
|
|
|
16
21
|
pageGotoParams?: PageGotoParams;
|
|
17
22
|
clip?: Clip;
|
|
18
23
|
allowFileAccess?: boolean;
|
|
24
|
+
cache?: CacheMode;
|
|
25
|
+
headers?: Record<string, string>;
|
|
19
26
|
width?: number;
|
|
20
27
|
height?: number;
|
|
21
28
|
}
|
|
@@ -40,6 +47,8 @@ const WIRE_FIELDS = new Set([
|
|
|
40
47
|
'clip',
|
|
41
48
|
'viewport',
|
|
42
49
|
'allowFileAccess',
|
|
50
|
+
'cache',
|
|
51
|
+
'headers',
|
|
43
52
|
]);
|
|
44
53
|
|
|
45
54
|
// One ScreenshotOptions, checked and flattened into what goes on the wire.
|