@shotkit/shotium 0.0.1 → 0.1.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/native.ts ADDED
@@ -0,0 +1,234 @@
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 './lib/platform.js';
7
+ import {toRequest} from './lib/request.js';
8
+ import type {
9
+ NativeStartOptions,
10
+ PurgeOptions,
11
+ ScreenshotOptions,
12
+ } from './types.js';
13
+
14
+ export type {
15
+ NativeStartOptions,
16
+ PurgeOptions,
17
+ ScreenshotOptions,
18
+ } from './types.js';
19
+
20
+ // A .node addon is a CommonJS artefact: there is no ESM loader for one.
21
+ const require = createRequire(import.meta.url);
22
+
23
+ // ESM has no __dirname. This is the same thing, from the module's own URL.
24
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ // The engine handle the addon hands back. It is opaque on purpose: everything
27
+ // that can be done with it is a call on the binding below.
28
+ type Engine = unknown;
29
+
30
+ // What native/binding.cc exports. See shot/shot_api.h for the C ABI under it.
31
+ interface NativeBinding {
32
+ create(optionsJson: string): Engine;
33
+ destroy(engine: Engine): void;
34
+ purge(engine: Engine, releaseWorkingSet: boolean): void;
35
+ capture(engine: Engine, requestJson: string): Promise<Buffer>;
36
+ }
37
+
38
+ // shot in this process, instead of in workers beside it.
39
+ //
40
+ // The difference from `runtime` is not the API, which is the same
41
+ // screenshot(options), and not the request format, which is byte for byte the
42
+ // same JSON. It is where blink is:
43
+ //
44
+ // runtime N worker processes, one screenshot each at a time, a crash is a
45
+ // retry, memory is N copies of an engine
46
+ // native one engine in this process, one screenshot at a time ever, a
47
+ // crash takes the program with it, memory is one copy
48
+ //
49
+ // One at a time is not a limitation of this file. Blink is a process-wide
50
+ // singleton -- it is initialised once and there is no path to a second one --
51
+ // so an in-process engine is one renderer no matter how it is driven, and
52
+ // worker_threads do not change that because they share the process. A caller
53
+ // who wants four screenshots at once wants four processes, which is what the
54
+ // pool is for.
55
+ //
56
+ // What it buys is that there is no process to start, nothing to find on disk,
57
+ // no pipe, and no supervisor: a program that takes a handful of screenshots
58
+ // and exits pays for one engine and talks to it directly.
59
+
60
+ // Where the addon and the library beside it live.
61
+ //
62
+ // The platform package is what ships -- the .node sits next to the shared
63
+ // library it is linked against, which is the whole reason the two travel in
64
+ // one package rather than two. native/build/Release is where node-gyp puts a
65
+ // local build; it exists in a checkout and not in an install, so the two never
66
+ // compete in practice. Both paths are relative to this file's build output,
67
+ // which is one directory below the package root.
68
+ function candidates(): string[] {
69
+ const found: string[] = [];
70
+ const dir = platformPackage.packageDir();
71
+ if (dir) {
72
+ found.push(path.join(dir, 'shotium.node'));
73
+ }
74
+ found.push(
75
+ path.join(HERE, '..', 'native', 'build', 'Release', 'shotium.node'));
76
+ return found;
77
+ }
78
+
79
+ let binding: NativeBinding|null = null;
80
+ let bindingDir: string|null = null;
81
+
82
+ function load(): NativeBinding {
83
+ if (binding) {
84
+ return binding;
85
+ }
86
+ const tried = candidates();
87
+ for (const candidate of tried) {
88
+ if (!fs.existsSync(candidate)) {
89
+ continue;
90
+ }
91
+ // Not wrapped in a try: a .node that is there and will not load is a
92
+ // broken installation, and the loader's own message -- a missing
93
+ // dependency, an architecture mismatch -- says more than anything that
94
+ // could be substituted for it.
95
+ binding = require(candidate) as NativeBinding;
96
+ bindingDir = path.dirname(candidate);
97
+ return binding;
98
+ }
99
+ const expected = platformPackage.packageName();
100
+ throw new Error(
101
+ 'shotium: no native engine for this platform.\n' +
102
+ ` looked in:\n ${tried.join('\n ')}\n` +
103
+ (expected ?
104
+ ` It ships in ${expected}, which npm installs as an optional ` +
105
+ 'dependency of this package.\n' :
106
+ ` There is no build for ${process.platform}-${process.arch}.\n`) +
107
+ ' import("@shotkit/shotium") uses worker processes instead and needs ' +
108
+ 'no addon.');
109
+ }
110
+
111
+ /**
112
+ * The engine, in this process, and the queue in front of it.
113
+ *
114
+ * Same options and same output as `runtime`, and a different set of trades.
115
+ * There is no worker process, so there is nothing to start, nothing to find on
116
+ * disk and no pipe: a screenshot costs about a third less than through the
117
+ * pool, and the whole thing is one process instead of five.
118
+ *
119
+ * What it gives up is what a separate process was providing for free. One
120
+ * renderer, because blink is a process-wide singleton and `worker_threads`
121
+ * share the process, so requests are serialised however many callers there
122
+ * are. And no crash isolation: a renderer that dies takes the host program
123
+ * with it, where the pool would have retried.
124
+ *
125
+ * The queue is not about fairness. Each capture occupies a libuv thread pool
126
+ * thread for as long as the render takes, and there are four of those by
127
+ * default, shared with fs and dns -- so letting four screenshots go at once
128
+ * would stall the host's file reads for a fifth of a second at a time while
129
+ * gaining nothing, since the engine serialises them anyway.
130
+ */
131
+ export class NativeRuntime {
132
+ private engine: Engine|null = null;
133
+ private tail: Promise<unknown> = Promise.resolve();
134
+
135
+ get running(): boolean {
136
+ return this.engine !== null;
137
+ }
138
+
139
+ /**
140
+ * Starts the engine. Safe to call twice; the second call is a no-op.
141
+ *
142
+ * `cacheDir` is the HTTP disk cache and `null` disables it, which is the
143
+ * default here. `resourceDir` is where `shotium_data.pak` and
144
+ * `shotium_strings.pak` are, and defaults to the directory the addon was
145
+ * loaded from, which is where they ship.
146
+ */
147
+ start(options: NativeStartOptions = {}): this {
148
+ if (this.engine) {
149
+ return this;
150
+ }
151
+ const native = load();
152
+
153
+ const engineOptions: Record<string, unknown> = {};
154
+ if (options.cacheDir !== null && options.cacheDir !== undefined) {
155
+ engineOptions.cacheDir = options.cacheDir;
156
+ }
157
+ if (options.userAgent !== undefined) {
158
+ engineOptions.userAgent = options.userAgent;
159
+ }
160
+ // The packs sit beside the library, and the library cannot find itself on
161
+ // Linux -- the path the engine resolves for "this module" goes through
162
+ // /proc/self/exe, which names node. Saying it here is cheaper than
163
+ // teaching the engine a second way to look. See shot_api.h.
164
+ engineOptions.resourceDir = options.resourceDir || bindingDir;
165
+
166
+ this.engine = native.create(JSON.stringify(engineOptions));
167
+ return this;
168
+ }
169
+
170
+ /** Stops the engine, after whatever is queued. */
171
+ async stop(): Promise<void> {
172
+ if (!this.engine) {
173
+ return;
174
+ }
175
+ // After the queue, not before: destroy() waits for a capture in flight
176
+ // anyway, and doing it in order means a caller's last screenshot resolves
177
+ // rather than racing the shutdown.
178
+ const engine = this.engine;
179
+ this.engine = null;
180
+ await this.tail.catch(() => {});
181
+ load().destroy(engine);
182
+ }
183
+
184
+ /**
185
+ * Hands back what the engine is holding but can rebuild.
186
+ * `releaseWorkingSet` additionally asks the OS for the pages, which the next
187
+ * screenshot pays back in soft faults -- worth it when there may not be a
188
+ * next one soon.
189
+ *
190
+ * The resident worker does this for itself on a timer because it can watch
191
+ * its own request stream go quiet. Here the queue belongs to the caller, so
192
+ * the caller is the one who knows a batch has ended.
193
+ */
194
+ purge({releaseWorkingSet = false}: PurgeOptions = {}): void {
195
+ if (!this.engine) {
196
+ return;
197
+ }
198
+ load().purge(this.engine, releaseWorkingSet);
199
+ }
200
+
201
+ /**
202
+ * Renders one screenshot. Resolves to the encoded image, or to `null` when
203
+ * `path` was given and the engine wrote the file itself.
204
+ */
205
+ async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
206
+ // Before anything else, and before the queue: a malformed request should
207
+ // be a rejection now rather than one that waits its turn.
208
+ const request = toRequest(options);
209
+ if (!this.engine) {
210
+ this.start();
211
+ }
212
+ const engine = this.engine;
213
+ const native = load();
214
+
215
+ // Chain onto the tail so that captures run one at a time. The catch keeps
216
+ // one failure from poisoning everything queued behind it.
217
+ const result = this.tail.catch(() => {}).then(
218
+ () => native.capture(engine, JSON.stringify(request)));
219
+ this.tail = result.catch(() => {});
220
+ const image = await result;
221
+ return request.path ? null : image;
222
+ }
223
+ }
224
+
225
+ /** The shared in-process engine, started on first use. */
226
+ const native = new NativeRuntime();
227
+
228
+ /** One screenshot through the shared in-process engine. */
229
+ const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
230
+ native.screenshot(options);
231
+
232
+ export {native, screenshot};
233
+
234
+ export default {NativeRuntime, native, screenshot};
package/src/types.ts ADDED
@@ -0,0 +1,169 @@
1
+ // The vocabulary of the package: what a caller passes in and what comes back.
2
+ //
3
+ // It lives in one file rather than beside the code that reads each field
4
+ // because these types are the published API. index.ts and native.ts both
5
+ // re-export them, so a consumer sees one `ScreenshotOptions` whichever entry
6
+ // point they came through -- and, more to the point, so there is one place
7
+ // where adding an option means adding it.
8
+
9
+ /** A region of the document, in CSS pixels. */
10
+ export interface Clip {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ export interface PageGotoParams {
18
+ /** Milliseconds before the load is abandoned. Default 30000. */
19
+ timeout?: number;
20
+ /**
21
+ * `load` waits for parsing to finish, the load event to fire and every
22
+ * request to complete. `networkidle` additionally waits for a 500ms window
23
+ * with nothing in flight, which matters for documents that keep fetching
24
+ * after the load event -- CSS that pulls in more CSS, or a font a late style
25
+ * change brought in.
26
+ */
27
+ waitUntil?: 'load'|'networkidle';
28
+ }
29
+
30
+ /** The viewport the document is laid out in. */
31
+ export interface Viewport {
32
+ /** CSS pixels. Default 1280. */
33
+ width?: number;
34
+ /** CSS pixels. Default 720. */
35
+ height?: number;
36
+ }
37
+
38
+ export interface ScreenshotOptions {
39
+ /** An http/https/file URL, or a local path. */
40
+ file: string;
41
+ /** Default `png`. */
42
+ type?: 'png'|'jpeg'|'webp';
43
+ /** Capture the whole document rather than the viewport. */
44
+ fullPage?: boolean;
45
+ /**
46
+ * Capture the box of the first element matching this CSS selector. Resolved
47
+ * inside the renderer with Document::querySelector -- there is no JavaScript
48
+ * engine, so nothing is injected into the page.
49
+ */
50
+ selector?: string;
51
+ /** 1-100, `jpeg` and `webp` only. Default 90. */
52
+ quality?: number;
53
+ /** Device scale factor, 0.01-8. Default 1. */
54
+ scale?: number;
55
+ /**
56
+ * Keep the alpha channel instead of painting the page's white backdrop.
57
+ * Rejected for `jpeg`, which has no alpha channel.
58
+ */
59
+ omitBackground?: boolean;
60
+ /** Write the image here instead of returning it, saving a round trip. */
61
+ path?: string;
62
+ pageGotoParams?: PageGotoParams;
63
+ /** A region of the document, in CSS pixels. */
64
+ clip?: Clip;
65
+ /** The viewport the document is laid out in. */
66
+ viewport?: Viewport;
67
+ /**
68
+ * Let the document read `file:` subresources. Off by default: a library does
69
+ * not get to decide for its caller that a document may read the filesystem it
70
+ * is rendered on.
71
+ */
72
+ allowFileAccess?: boolean;
73
+ /** How many times to re-send after a crash or a timeout. Default 0. */
74
+ retry?: number;
75
+ }
76
+
77
+ export interface StartOptions {
78
+ /**
79
+ * Path to `shotium.exe`. Default `$SHOTIUM_BINARY`, then the platform
80
+ * package for this machine, then `./bin/shotium.exe`.
81
+ */
82
+ binary?: string;
83
+ /** Worker processes. Default half the cores, at least one, at most four. */
84
+ workers?: number;
85
+ /** Root of the per-worker HTTP disk caches. `null` disables caching. */
86
+ cacheDir?: string|null;
87
+ /** Extra flags passed to every worker. */
88
+ args?: string[];
89
+ }
90
+
91
+ export interface WorkerEvent {
92
+ worker: number;
93
+ code?: number|null;
94
+ signal?: NodeJS.Signals|null;
95
+ }
96
+
97
+ export interface DaemonOptions extends StartOptions {
98
+ /**
99
+ * Address the daemon by name instead of by configuration. Without it the
100
+ * endpoint is a hash of `binary`, `workers`, `cacheDir` and `args`, so a
101
+ * client never attaches to a pool that renders with something other than
102
+ * what it asked for.
103
+ */
104
+ name?: string;
105
+ /** The pipe or socket to use, overriding both the name and the hash. */
106
+ endpoint?: string;
107
+ /**
108
+ * Exit after this long with no connections and nothing rendering. Default
109
+ * 300000; `0` never exits.
110
+ */
111
+ idleTimeoutMs?: number;
112
+ /**
113
+ * Render one throwaway document per worker at startup, so the first real
114
+ * request does not pay for whatever a worker initialises lazily. Default
115
+ * true.
116
+ */
117
+ prewarm?: boolean;
118
+ /** Fail instead of starting a daemon when none is listening. */
119
+ spawn?: boolean;
120
+ /** Where a spawned daemon's diagnostics go. Default `$SHOTIUM_DAEMON_LOG`. */
121
+ logFile?: string;
122
+ /** How long to wait for a daemon this process started to bind. */
123
+ startTimeoutMs?: number;
124
+ }
125
+
126
+ export interface DaemonStatus {
127
+ ok?: boolean;
128
+ running?: boolean;
129
+ spawned?: boolean;
130
+ pid: number;
131
+ endpoint: string;
132
+ binary: string;
133
+ workers: number;
134
+ cacheDir: string|null;
135
+ args: string[];
136
+ /** Every worker has rendered at least once. */
137
+ warm: boolean;
138
+ uptimeMs: number;
139
+ connections: number;
140
+ inFlight: number;
141
+ served: number;
142
+ idleTimeoutMs: number;
143
+ version: string;
144
+ }
145
+
146
+ export interface NativeStartOptions {
147
+ /**
148
+ * Root of the HTTP disk cache. `null` disables caching entirely, which is
149
+ * the default here: an in-process engine is often a short-lived program, and
150
+ * a cache it never reads twice is a directory it leaves behind.
151
+ */
152
+ cacheDir?: string|null;
153
+ /** Overrides the built-in user agent string. */
154
+ userAgent?: string;
155
+ /**
156
+ * Where `shotium_data.pak` and `shotium_strings.pak` are. Defaults to the
157
+ * directory the native engine was loaded from, which is where they ship.
158
+ */
159
+ resourceDir?: string;
160
+ }
161
+
162
+ export interface PurgeOptions {
163
+ /**
164
+ * Also ask the OS to take the engine's pages back. The next screenshot pays
165
+ * them back in soft page faults -- a few milliseconds -- so this is for when
166
+ * there may not be a next one soon.
167
+ */
168
+ releaseWorkingSet?: boolean;
169
+ }