@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/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 {PurgeOptions, ScreenshotOptions, StartOptions} from '../types.js';
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
- // One per process, ever. Not one at a time -- one.
17
+ // The engine this process has, held above every Engine object that uses it.
10
18
  //
11
- // This is not a rule of this file, it is what Blink is: initialising it writes
12
- // process-wide statics it has no path to undo, so shot_engine_destroy() gives
13
- // back what it can and the process still cannot make another. The C API
14
- // returns SHOT_ERR_STATE for a second create whether or not the first is
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
- // So `stop()` is final for the process, and this flag exists to say that in
18
- // words at the call site. Without it a caller who stops and starts again gets
19
- // SHOT_ERR_STATE out of the addon -- a true error, arriving one layer too deep
20
- // to explain that the answer is a second process rather than a retry.
21
- let startedInThisProcess = false;
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
- private handle: Handle|null = null;
40
- private stopped = false;
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.handle !== null;
103
+ return this.active && shared !== null;
45
104
  }
46
105
 
47
106
  /**
48
- * Starts the engine. Safe to call twice; the second call is a no-op, so that
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
- * Not safe to call after `stop()`, and not because of anything here: Blink
52
- * starts once per process and cannot be restarted. Another engine means
53
- * another process.
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
- start(options: StartOptions = {}): this {
56
- if (this.handle) {
57
- return this;
58
- }
59
- if (this.stopped) {
60
- throw new Error(
61
- 'shotium: this engine was stopped, and Blink cannot be started ' +
62
- 'again in a process that has already run it. Start another ' +
63
- 'process, or keep the engine up between screenshots.');
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 (startedInThisProcess) {
149
+ if (spent) {
66
150
  throw new Error(
67
- 'shotium: an engine has already run in this process. Blink is a ' +
68
- 'process-wide singleton -- there is one per process, ever -- so a ' +
69
- 'second Runtime cannot have one. Use the shared `runtime`, or run ' +
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
- this.handle = native.create(JSON.stringify(engineOptions));
89
- startedInThisProcess = true;
90
- return this;
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
- * Stops the engine, after whatever is queued.
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
- * Final for this process: see the note above. A program that will want
97
- * another screenshot later should leave the engine up and call `purge()`
98
- * instead, which hands back the memory without giving up the engine.
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.handle) {
219
+ if (!this.active) {
102
220
  return;
103
221
  }
104
- this.stopped = true;
105
- // After the queue, not before: destroy() waits for a capture in flight
106
- // anyway, and doing it in order means a caller's last screenshot resolves
107
- // rather than racing the shutdown.
108
- const handle = this.handle;
109
- this.handle = null;
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
- binding.load().destroy(handle);
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
- purge({releaseWorkingSet = false}: PurgeOptions = {}): void {
125
- if (!this.handle) {
263
+ releaseMemory({releaseWorkingSet = false}: ReleaseMemoryOptions = {}): void {
264
+ if (!shared) {
126
265
  return;
127
266
  }
128
- binding.load().purge(this.handle, releaseWorkingSet);
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<Buffer|null> {
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<Buffer|null> {
154
- if (!this.handle) {
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 = this.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
- const image = await result;
166
- return request.path ? null : image;
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};
@@ -1,4 +1,9 @@
1
- import type {Clip, PageGotoParams, ScreenshotOptions} from '../types.js';
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.