@shotkit/shotium 0.1.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/src/index.ts CHANGED
@@ -1,38 +1,46 @@
1
- import {EventEmitter} from 'node:events';
2
-
1
+ import {Cache} from './lib/cache.js';
3
2
  import * as client from './lib/client.js';
4
3
  import type {DaemonClient} from './lib/client.js';
5
- import {resolveStartOptions} from './lib/config.js';
6
- import {Pool} from './lib/pool.js';
7
- import {SUPERVISOR_MARGIN_MS, timeoutFor, toRequest} from './lib/request.js';
4
+ import {Engine} from './lib/engine.js';
8
5
  import type {
9
6
  DaemonOptions,
10
7
  DaemonStatus,
8
+ ReleaseMemoryOptions,
11
9
  ScreenshotOptions,
10
+ ScreenshotResult,
12
11
  StartOptions,
13
- WorkerEvent,
12
+ StartResult,
14
13
  } from './types.js';
15
14
 
16
15
  export type {
16
+ CacheClearOptions,
17
+ CacheClearResult,
18
+ CacheEntry,
19
+ CacheMode,
20
+ CacheTarget,
21
+ CaptureStats,
22
+ CaptureTiming,
17
23
  Clip,
18
24
  DaemonOptions,
19
25
  DaemonStatus,
20
26
  PageGotoParams,
21
- PurgeOptions,
27
+ ReleaseMemoryOptions,
22
28
  ScreenshotOptions,
29
+ ScreenshotResult,
23
30
  StartOptions,
31
+ StartResult,
24
32
  Viewport,
25
- WorkerEvent,
26
33
  } from './types.js';
27
34
  export type {DaemonClient} from './lib/client.js';
35
+ export {Cache} from './lib/cache.js';
28
36
 
29
- /** The five things a caller does with the resident pool. */
37
+ /** The five things a caller does with the resident engine. */
30
38
  export interface Daemon {
31
39
  /** Connects, starting a daemon if none is listening. */
32
40
  connect(options?: DaemonOptions): Promise<DaemonClient>;
33
41
  /** One screenshot through the daemon, connection and all. */
34
42
  screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
35
- Promise<Buffer|null>;
43
+ Promise<ScreenshotResult>;
36
44
  /** Starts one if it is not up, and reports what is there either way. */
37
45
  start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;
38
46
  status(options?: DaemonOptions):
@@ -40,110 +48,174 @@ export interface Daemon {
40
48
  stop(options?: DaemonOptions): Promise<{stopped: boolean, endpoint: string}>;
41
49
  }
42
50
 
43
- // The events the pool forwards, and the only ones. Declared as an interface
44
- // merged into the class below rather than as a catch-all `on(string, ...)`,
45
- // so that a listener for an event this runtime never emits is a compile error
46
- // rather than a callback nobody ever calls.
47
- export interface Runtime {
48
- on(event: 'ready', listener: (info: {workers: number}) => void): this;
49
- on(event: 'exit', listener: (event: WorkerEvent) => void): this;
50
- on(event: 'crash', listener: (event: WorkerEvent) => void): this;
51
- on(event: 'timeout',
52
- listener: (event: {worker: number, timeout: number}) => void): this;
53
- on(event: 'worker-restart',
54
- listener: (event: {worker: number, reason: string, delay: number}) => void):
55
- this;
56
- /** A worker could not be started at all -- a missing or unusable binary. */
57
- on(event: 'worker-error',
58
- listener: (event: {worker: number, error: Error}) => void): this;
59
- on(event: 'stderr',
60
- listener: (event: {worker: number, line: string}) => void): this;
61
- }
62
-
63
51
  /**
64
- * The library's one runtime: a pool of worker processes plus its lifecycle.
52
+ * The engine, and its lifecycle, in this process.
53
+ *
54
+ * import shotium from '@shotkit/shotium';
55
+ *
56
+ * shotium.start();
57
+ * const {image, stats} = await shotium.screenshot({
58
+ * file: 'https://example.com',
59
+ * });
60
+ * await shotium.stop();
65
61
  *
66
- * `runtime` below is the singleton, because the expensive part is the
67
- * processes and a second runtime would double them for no gain. Anyone who
68
- * genuinely wants two constructs a Runtime directly.
62
+ * `start` and `stop` are explicit because starting Blink is the expensive part
63
+ * -- tens of milliseconds and a working set that stays resident -- and only
64
+ * the caller knows whether the next screenshot is coming in a moment or never.
65
+ * Neither call is required: a screenshot starts the engine if it is not up.
66
+ * What they buy is control over when that cost is paid, and the certainty that
67
+ * it has been given back.
69
68
  *
70
- * Its pool lives and dies with this process. `daemon` is the same pool behind
71
- * a socket, for callers whose process does not live long enough to be worth
72
- * starting one.
69
+ * Neither is rationed, either. They may be called in any order and as often as
70
+ * a program likes: `stop()` stands the engine down and `start()` picks the
71
+ * same one back up, warm cache and all. What cannot happen is a *second*
72
+ * engine -- Blink is initialised once per process and there is no undo -- but
73
+ * that is a fact about how many there are, not about how many times the one
74
+ * may be asked for.
75
+ *
76
+ * The methods are on the module rather than under a `runtime` namespace, which
77
+ * they were until 0.3. There was never anything else to start, so the word
78
+ * carried nothing; and `runtime.cache` would have been the wrong place for the
79
+ * cache besides, since a cache directory outlives every engine that writes to
80
+ * it and can be read when no engine is running at all.
81
+ *
82
+ * `Runtime` is still exported for a caller who wants to own a lifecycle rather
83
+ * than share the module's. It is a lifecycle and not an engine: there is one
84
+ * engine per process, and a second Runtime that starts adopts the same one
85
+ * rather than building another. Parallelism is more processes, not more
86
+ * Runtimes.
87
+ *
88
+ * `daemon` is the same engine in a process of its own, behind a socket, for
89
+ * callers whose own process does not live long enough to be worth starting
90
+ * one.
73
91
  */
74
- export class Runtime extends EventEmitter {
75
- private pool: Pool|null = null;
92
+ export class Runtime {
93
+ private engine = new Engine();
94
+
95
+ /**
96
+ * The HTTP cache: where it is, what is in it, and how to empty it.
97
+ *
98
+ * On the Runtime as well as on the module because a caller holding their own
99
+ * Runtime needs the engine handle to reach a directory that engine has open:
100
+ * within one process a directory has one backend, so borrowing is the only
101
+ * way in.
102
+ */
103
+ readonly cache = new Cache(() => this.engine.nativeHandle);
76
104
 
77
105
  get running(): boolean {
78
- return this.pool !== null;
106
+ return this.engine.running;
107
+ }
108
+
109
+ /**
110
+ * Starts the engine, or picks the running one back up.
111
+ *
112
+ * Callable as often as you like, in any order with `stop()`; library code
113
+ * can call it defensively. The first call in a process builds the engine and
114
+ * every later one adopts it -- the same engine, the same warm cache. The one
115
+ * thing it will refuse is a *different* configuration: the options below are
116
+ * fixed when the engine is built, and there is no second build, so naming
117
+ * one that disagrees with what is running throws rather than rendering with
118
+ * a value you did not ask for.
119
+ *
120
+ * Every option has a default. `cacheDir` is the HTTP disk cache and defaults
121
+ * to a per-project directory under `~/.shotium/cache`, and not under the
122
+ * temporary directory, which is defined by not surviving. `null` turns it
123
+ * off. `resourceDir` is where `shotium_data.pak` and
124
+ * `shotium_strings.pak` are, and defaults to the directory the engine was
125
+ * loaded from, which is where they ship.
126
+ *
127
+ * The return value is worth reading once. `cacheActive: false` with a
128
+ * `cacheDir` set means the directory could not be opened and this engine is
129
+ * running without a cache -- correctly, silently, and a round trip slower on
130
+ * everything.
131
+ */
132
+ start(options: StartOptions = {}): StartResult {
133
+ return this.engine.start(options);
134
+ }
135
+
136
+ /** What `start()` returned, asked again. */
137
+ status(): StartResult {
138
+ return this.engine.status();
79
139
  }
80
140
 
81
141
  /**
82
- * Starts the pool. Safe to call twice; the second call is a no-op, so that
83
- * library code can call it defensively.
142
+ * Stands the engine down, after whatever is queued.
143
+ *
144
+ * The queue drains, the memory the engine can rebuild goes back to the OS,
145
+ * and `running` becomes false. Blink itself stays initialised, because there
146
+ * is no way to un-initialise it -- so the disk cache stays where it is, and
147
+ * `start()` or the next `screenshot()` picks the same engine back up.
84
148
  *
85
- * Every option has a default: the binary is `$SHOTIUM_BINARY`, then the
86
- * platform package, then `./bin/shotium.exe`; the worker count is half the
87
- * cores, at least one and at most four; the cache root is a directory under
88
- * the system temp, and `null` disables caching.
149
+ * Which makes this a caller saying they are done for now rather than a
150
+ * destructor. It does the same work as `releaseMemory({releaseWorkingSet:
151
+ * true})` and additionally stops accepting captures.
89
152
  */
90
- start(options: StartOptions = {}): this {
91
- if (this.pool) {
92
- return this;
93
- }
94
- const pool = new Pool(resolveStartOptions(options));
95
- this.pool = pool;
96
- for (const event
97
- of ['ready', 'exit', 'crash', 'timeout', 'worker-restart',
98
- 'worker-error', 'stderr']) {
99
- pool.on(event, (payload) => this.emit(event, payload));
100
- }
101
- pool.start();
102
- return this;
153
+ stop(): Promise<void> {
154
+ return this.engine.stop();
103
155
  }
104
156
 
105
- /** Stops every worker. The pool can be started again afterwards. */
106
- async stop(): Promise<void> {
107
- if (!this.pool) {
108
- return;
109
- }
110
- const pool = this.pool;
111
- this.pool = null;
112
- await pool.stop();
157
+ /**
158
+ * Hands back what the engine is holding but can rebuild: Blink's heap,
159
+ * skia's caches, PartitionAlloc's free lists. Worth calling when a batch has
160
+ * ended and the next one may be a while away.
161
+ *
162
+ * This is memory and nothing else. It was called `purge()` until 0.3, which
163
+ * next to `cache.clear()` read as though it emptied the HTTP cache; it does
164
+ * not touch the disk at all.
165
+ */
166
+ releaseMemory(options: ReleaseMemoryOptions = {}): void {
167
+ this.engine.releaseMemory(options);
113
168
  }
114
169
 
115
170
  /**
116
- * Renders one screenshot. Resolves to the encoded image, or to `null` when
117
- * `path` was given and the worker wrote the file itself.
171
+ * Renders one screenshot, and reports what it cost.
172
+ *
173
+ * `image` is the encoded bytes, or `null` when `path` was given and the
174
+ * engine wrote the file itself. `stats` says how many resources were
175
+ * fetched, how many came from the cache, and where the milliseconds went --
176
+ * which for an `https:` URL is usually the answer to "why did this take so
177
+ * long", because a cold connection costs more than the render does.
118
178
  */
119
- async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
120
- // Validate before starting anything. A malformed request should not cost a
121
- // pool of worker processes to discover, and toRequest() is the only check
122
- // that can be made without one.
123
- const request = toRequest(options);
124
- if (!this.pool) {
125
- this.start();
126
- }
127
- const retry = typeof options.retry === 'number' ? options.retry : 0;
128
- const result = await this.pool!.submit(request, {
129
- timeout: timeoutFor(options) + SUPERVISOR_MARGIN_MS,
130
- retry,
131
- });
132
- return result.image;
179
+ screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {
180
+ return this.engine.screenshot(options);
133
181
  }
134
182
  }
135
183
 
136
- /** The shared pool: one per process, started on first use. */
184
+ /** The shared engine: one per process, started on first use. */
137
185
  const runtime = new Runtime();
138
186
 
139
- /** One screenshot through the shared pool, starting it if it is not up. */
140
- const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
187
+ /** One screenshot through the shared engine, starting it if it is not up. */
188
+ const screenshot = (options: ScreenshotOptions): Promise<ScreenshotResult> =>
141
189
  runtime.screenshot(options);
142
190
 
191
+ const start = (options?: StartOptions): StartResult => runtime.start(options);
192
+ const status = (): StartResult => runtime.status();
193
+ const stop = (): Promise<void> => runtime.stop();
194
+ const releaseMemory = (options?: ReleaseMemoryOptions): void =>
195
+ runtime.releaseMemory(options);
196
+
197
+ /**
198
+ * The HTTP cache.
199
+ *
200
+ * At the top level rather than under the engine because it outlives one: the
201
+ * directory is on disk whether or not anything is running, `getDir()` answers
202
+ * before the first `start()`, and clearing it is something a program may want
203
+ * to do without bringing Blink up at all. When an engine *is* up, these
204
+ * borrow its cache backend, because within one process a directory has one
205
+ * backend and that is the only way in.
206
+ */
207
+ const cache = runtime.cache;
208
+
143
209
  /**
144
- * The resident pool: workers that outlive the process that started them,
210
+ * The resident engine: a process that outlives the one that started it,
145
211
  * reachable over a named pipe on Windows and a unix socket elsewhere. For
146
212
  * callers that are short-lived themselves. See lib/daemon.ts.
213
+ *
214
+ * It has no `cache` of its own. A daemon's cache directory is reported by
215
+ * `daemon.status()`, and clearing it is done by pointing `cache.clear()` at
216
+ * that directory or by stopping the daemon -- a cross-process cache protocol
217
+ * would be a second implementation of this module for something nobody does on
218
+ * a request path.
147
219
  */
148
220
  const daemon: Daemon = {
149
221
  connect: client.connect,
@@ -153,9 +225,27 @@ const daemon: Daemon = {
153
225
  stop: client.stop,
154
226
  };
155
227
 
156
- export {runtime, screenshot, daemon};
228
+ export {cache, daemon, releaseMemory, runtime, screenshot, start, status, stop};
157
229
 
158
230
  // A default as well as the names, because `import shotium from` is what a
159
231
  // caller coming from `require` writes first, and the two have to be the same
160
232
  // object rather than two views that drift.
161
- export default {Runtime, runtime, screenshot, daemon};
233
+ export default {
234
+ Runtime,
235
+ cache,
236
+ daemon,
237
+ releaseMemory,
238
+ runtime,
239
+ screenshot,
240
+ start,
241
+ status,
242
+ stop,
243
+ // A getter and not a value, because it changes. It is only on the default
244
+ // export: a named `running` would have to be a live binding that something
245
+ // remembered to update, and the two would disagree the first time anybody
246
+ // forgot. Callers who import by name have `status().running`, which is the
247
+ // same answer with the cache directory attached.
248
+ get running(): boolean {
249
+ return runtime.running;
250
+ },
251
+ };
@@ -0,0 +1,110 @@
1
+ import fs from 'node:fs';
2
+ import {createRequire} from 'node:module';
3
+ import path from 'node:path';
4
+ import {fileURLToPath} from 'node:url';
5
+
6
+ import * as platformPackage from './platform.js';
7
+
8
+ // A .node addon is a CommonJS artefact: there is no ESM loader for one.
9
+ const require = createRequire(import.meta.url);
10
+
11
+ // ESM has no __dirname. This is the same thing, from the module's own URL.
12
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ /**
15
+ * The engine handle the addon hands back. Opaque on purpose: everything that
16
+ * can be done with it is a call on the binding below.
17
+ */
18
+ export type Engine = unknown;
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
+
32
+ /** What native/binding.cc exports. See shot/shot_api.h for the C ABI. */
33
+ export interface NativeBinding {
34
+ create(optionsJson: string): Engine;
35
+ destroy(engine: Engine): void;
36
+ purge(engine: Engine, releaseWorkingSet: boolean): void;
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>;
47
+ }
48
+
49
+ // Where the addon and the library beside it live.
50
+ //
51
+ // The platform package is what ships -- the .node sits next to the shared
52
+ // library it is linked against, which is the whole reason the two travel in
53
+ // one package rather than two. native/build/Release is where node-gyp puts a
54
+ // local build; it exists in a checkout and not in an install, so the two never
55
+ // compete in practice. Both paths are relative to this file's build output,
56
+ // which is one directory below the package root.
57
+ function candidates(): string[] {
58
+ const found: string[] = [];
59
+ const dir = platformPackage.packageDir();
60
+ if (dir) {
61
+ found.push(path.join(dir, 'shotium.node'));
62
+ }
63
+ found.push(
64
+ path.join(HERE, '..', 'native', 'build', 'Release', 'shotium.node'));
65
+ return found;
66
+ }
67
+
68
+ let binding: NativeBinding|null = null;
69
+ let loadedFrom: string|null = null;
70
+
71
+ /**
72
+ * The addon, loaded once. Throws if there is none for this platform, which is
73
+ * the only failure this package cannot work around: there is nothing else to
74
+ * fall back to.
75
+ */
76
+ export function load(): NativeBinding {
77
+ if (binding) {
78
+ return binding;
79
+ }
80
+ const tried = candidates();
81
+ for (const candidate of tried) {
82
+ if (!fs.existsSync(candidate)) {
83
+ continue;
84
+ }
85
+ // Not wrapped in a try: a .node that is there and will not load is a
86
+ // broken installation, and the loader's own message -- a missing
87
+ // dependency, an architecture mismatch -- says more than anything that
88
+ // could be substituted for it.
89
+ binding = require(candidate) as NativeBinding;
90
+ loadedFrom = path.dirname(candidate);
91
+ return binding;
92
+ }
93
+ const expected = platformPackage.packageName();
94
+ throw new Error(
95
+ 'shotium: no engine for this platform.\n' +
96
+ ` looked in:\n ${tried.join('\n ')}\n` +
97
+ (expected ?
98
+ ` It ships in ${expected}, which npm installs as an optional ` +
99
+ 'dependency of this package. If the install skipped optional ' +
100
+ 'dependencies, it is not there.\n' :
101
+ ` There is no build for ${process.platform}-${process.arch}.\n`));
102
+ }
103
+
104
+ /**
105
+ * The directory the addon came from, or null before the first load(). The
106
+ * resource packs ship beside it, which is what this is for.
107
+ */
108
+ export function directory(): string|null {
109
+ return loadedFrom;
110
+ }