@shotkit/shotium 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotkit/shotium",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Static screenshots from a stripped Chromium: DOM, CSS, layout, paint, no JavaScript engine.",
5
5
  "keywords": [
6
6
  "screenshot",
@@ -24,10 +24,6 @@
24
24
  "types": "./dist/index.d.ts",
25
25
  "default": "./dist/index.js"
26
26
  },
27
- "./native": {
28
- "types": "./dist/native.d.ts",
29
- "default": "./dist/native.js"
30
- },
31
27
  "./package.json": "./package.json"
32
28
  },
33
29
  "files": [
@@ -42,12 +38,12 @@
42
38
  "node": ">=18"
43
39
  },
44
40
  "optionalDependencies": {
45
- "@shotkit/shotium-darwin-arm64": "0.1.0",
46
- "@shotkit/shotium-darwin-x64": "0.1.0",
47
- "@shotkit/shotium-linux-arm64": "0.1.0",
48
- "@shotkit/shotium-linux-x64": "0.1.0",
49
- "@shotkit/shotium-win32-arm64": "0.1.0",
50
- "@shotkit/shotium-win32-x64": "0.1.0"
41
+ "@shotkit/shotium-darwin-arm64": "0.2.0",
42
+ "@shotkit/shotium-darwin-x64": "0.2.0",
43
+ "@shotkit/shotium-linux-arm64": "0.2.0",
44
+ "@shotkit/shotium-linux-x64": "0.2.0",
45
+ "@shotkit/shotium-win32-arm64": "0.2.0",
46
+ "@shotkit/shotium-win32-x64": "0.2.0"
51
47
  },
52
48
  "scripts": {
53
49
  "build": "tsdown",
package/src/index.ts CHANGED
@@ -1,16 +1,12 @@
1
- import {EventEmitter} from 'node:events';
2
-
3
1
  import * as client from './lib/client.js';
4
2
  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';
3
+ import {Engine} from './lib/engine.js';
8
4
  import type {
9
5
  DaemonOptions,
10
6
  DaemonStatus,
7
+ PurgeOptions,
11
8
  ScreenshotOptions,
12
9
  StartOptions,
13
- WorkerEvent,
14
10
  } from './types.js';
15
11
 
16
12
  export type {
@@ -22,11 +18,10 @@ export type {
22
18
  ScreenshotOptions,
23
19
  StartOptions,
24
20
  Viewport,
25
- WorkerEvent,
26
21
  } from './types.js';
27
22
  export type {DaemonClient} from './lib/client.js';
28
23
 
29
- /** The five things a caller does with the resident pool. */
24
+ /** The five things a caller does with the resident engine. */
30
25
  export interface Daemon {
31
26
  /** Connects, starting a daemon if none is listening. */
32
27
  connect(options?: DaemonOptions): Promise<DaemonClient>;
@@ -40,108 +35,91 @@ export interface Daemon {
40
35
  stop(options?: DaemonOptions): Promise<{stopped: boolean, endpoint: string}>;
41
36
  }
42
37
 
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
38
  /**
64
- * The library's one runtime: a pool of worker processes plus its lifecycle.
39
+ * The engine, and its lifecycle, in this process.
40
+ *
41
+ * import shotium from '@shotkit/shotium';
42
+ *
43
+ * shotium.runtime.start();
44
+ * const png = await shotium.screenshot({file: 'https://example.com'});
45
+ * await shotium.runtime.stop();
46
+ *
47
+ * `start` and `stop` are explicit because starting Blink is the expensive part
48
+ * -- tens of milliseconds and a working set that stays resident -- and only
49
+ * the caller knows whether the next screenshot is coming in a moment or never.
50
+ * Neither call is required: a screenshot starts the engine if it is not up.
51
+ * What they buy is control over when that cost is paid, and the certainty that
52
+ * it has been given back.
65
53
  *
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.
54
+ * `runtime` below is the singleton because there is nothing else it could be:
55
+ * Blink starts once per process and cannot be restarted, so a second Runtime
56
+ * in the same process has no engine to have. Construct one directly only to
57
+ * own the lifecycle yourself instead of using `runtime`. Parallelism is more
58
+ * processes, not more Runtimes.
69
59
  *
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.
60
+ * `daemon` is the same engine in a process of its own, behind a socket, for
61
+ * callers whose own process does not live long enough to be worth starting
62
+ * one.
73
63
  */
74
- export class Runtime extends EventEmitter {
75
- private pool: Pool|null = null;
64
+ export class Runtime {
65
+ private engine = new Engine();
76
66
 
77
67
  get running(): boolean {
78
- return this.pool !== null;
68
+ return this.engine.running;
79
69
  }
80
70
 
81
71
  /**
82
- * Starts the pool. Safe to call twice; the second call is a no-op, so that
83
- * library code can call it defensively.
72
+ * Starts the engine. Safe to call twice; the second call is a no-op, so that
73
+ * library code can call it defensively. Not safe after `stop()` -- see there.
84
74
  *
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.
75
+ * Every option has a default. `cacheDir` is the HTTP disk cache and `null`
76
+ * disables it; `resourceDir` is where `shotium_data.pak` and
77
+ * `shotium_strings.pak` are, and defaults to the directory the engine was
78
+ * loaded from, which is where they ship.
89
79
  */
90
80
  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();
81
+ this.engine.start(options);
102
82
  return this;
103
83
  }
104
84
 
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();
85
+ /**
86
+ * Stops the engine, after whatever is queued.
87
+ *
88
+ * Final for this process. Blink writes process-wide state that it has no
89
+ * path to undo, so starting again -- here or on another Runtime -- throws
90
+ * rather than quietly handing back something that cannot render. A program
91
+ * that wants another screenshot later should stay started and `purge()`.
92
+ */
93
+ stop(): Promise<void> {
94
+ return this.engine.stop();
95
+ }
96
+
97
+ /**
98
+ * Hands back what the engine is holding but can rebuild. Worth calling when
99
+ * a batch has ended and the next one may be a while away.
100
+ */
101
+ purge(options: PurgeOptions = {}): void {
102
+ this.engine.purge(options);
113
103
  }
114
104
 
115
105
  /**
116
106
  * Renders one screenshot. Resolves to the encoded image, or to `null` when
117
- * `path` was given and the worker wrote the file itself.
107
+ * `path` was given and the engine wrote the file itself.
118
108
  */
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;
109
+ screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
110
+ return this.engine.screenshot(options);
133
111
  }
134
112
  }
135
113
 
136
- /** The shared pool: one per process, started on first use. */
114
+ /** The shared engine: one per process, started on first use. */
137
115
  const runtime = new Runtime();
138
116
 
139
- /** One screenshot through the shared pool, starting it if it is not up. */
117
+ /** One screenshot through the shared engine, starting it if it is not up. */
140
118
  const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
141
119
  runtime.screenshot(options);
142
120
 
143
121
  /**
144
- * The resident pool: workers that outlive the process that started them,
122
+ * The resident engine: a process that outlives the one that started it,
145
123
  * reachable over a named pipe on Windows and a unix socket elsewhere. For
146
124
  * callers that are short-lived themselves. See lib/daemon.ts.
147
125
  */
@@ -0,0 +1,89 @@
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
+ /** What native/binding.cc exports. See shot/shot_api.h for the C ABI. */
21
+ export interface NativeBinding {
22
+ create(optionsJson: string): Engine;
23
+ destroy(engine: Engine): void;
24
+ purge(engine: Engine, releaseWorkingSet: boolean): void;
25
+ capture(engine: Engine, requestJson: string): Promise<Buffer>;
26
+ }
27
+
28
+ // Where the addon and the library beside it live.
29
+ //
30
+ // The platform package is what ships -- the .node sits next to the shared
31
+ // library it is linked against, which is the whole reason the two travel in
32
+ // one package rather than two. native/build/Release is where node-gyp puts a
33
+ // local build; it exists in a checkout and not in an install, so the two never
34
+ // compete in practice. Both paths are relative to this file's build output,
35
+ // which is one directory below the package root.
36
+ function candidates(): string[] {
37
+ const found: string[] = [];
38
+ const dir = platformPackage.packageDir();
39
+ if (dir) {
40
+ found.push(path.join(dir, 'shotium.node'));
41
+ }
42
+ found.push(
43
+ path.join(HERE, '..', 'native', 'build', 'Release', 'shotium.node'));
44
+ return found;
45
+ }
46
+
47
+ let binding: NativeBinding|null = null;
48
+ let loadedFrom: string|null = null;
49
+
50
+ /**
51
+ * The addon, loaded once. Throws if there is none for this platform, which is
52
+ * the only failure this package cannot work around: there is nothing else to
53
+ * fall back to.
54
+ */
55
+ export function load(): NativeBinding {
56
+ if (binding) {
57
+ return binding;
58
+ }
59
+ const tried = candidates();
60
+ for (const candidate of tried) {
61
+ if (!fs.existsSync(candidate)) {
62
+ continue;
63
+ }
64
+ // Not wrapped in a try: a .node that is there and will not load is a
65
+ // broken installation, and the loader's own message -- a missing
66
+ // dependency, an architecture mismatch -- says more than anything that
67
+ // could be substituted for it.
68
+ binding = require(candidate) as NativeBinding;
69
+ loadedFrom = path.dirname(candidate);
70
+ return binding;
71
+ }
72
+ const expected = platformPackage.packageName();
73
+ throw new Error(
74
+ 'shotium: no engine for this platform.\n' +
75
+ ` looked in:\n ${tried.join('\n ')}\n` +
76
+ (expected ?
77
+ ` It ships in ${expected}, which npm installs as an optional ` +
78
+ 'dependency of this package. If the install skipped optional ' +
79
+ 'dependencies, it is not there.\n' :
80
+ ` There is no build for ${process.platform}-${process.arch}.\n`));
81
+ }
82
+
83
+ /**
84
+ * The directory the addon came from, or null before the first load(). The
85
+ * resource packs ship beside it, which is what this is for.
86
+ */
87
+ export function directory(): string|null {
88
+ return loadedFrom;
89
+ }
package/src/lib/client.ts CHANGED
@@ -48,10 +48,9 @@ interface Pending {
48
48
  }
49
49
 
50
50
  interface ResolvedDaemonOptions {
51
- binary: string;
52
- workers: number;
53
51
  cacheDir: string|null;
54
- args: string[];
52
+ userAgent?: string;
53
+ resourceDir?: string;
55
54
  name: string|undefined;
56
55
  endpoint: string;
57
56
  idleTimeoutMs: number|undefined;
@@ -61,11 +60,11 @@ interface ResolvedDaemonOptions {
61
60
 
62
61
  // The client half of the resident daemon.
63
62
  //
64
- // One connection can carry several requests at once, which is the difference
65
- // between this and the worker protocol underneath: every message carries an
63
+ // One connection can carry several requests at once: every message carries an
66
64
  // `id` and the answers are matched back by it, so a caller can fire ten
67
- // screenshots down one socket and let the pool on the other side spread them
68
- // across workers.
65
+ // screenshots down one socket without waiting between them. They still come
66
+ // back one at a time -- there is one renderer on the other side -- so this
67
+ // saves the round trips, not the renders.
69
68
  class DaemonClient extends EventEmitter {
70
69
  private readonly socket: net.Socket;
71
70
  private readonly endpointPath: string;
@@ -155,12 +154,10 @@ class DaemonClient extends EventEmitter {
155
154
  /** Resolves to the image, or to null when `path` was given. */
156
155
  async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
157
156
  const request = toRequest(options);
158
- const retry = typeof options.retry === 'number' ? options.retry : 0;
159
157
  const result = await this.send({
160
158
  op: 'screenshot',
161
159
  request,
162
160
  timeout: timeoutFor(options),
163
- retry,
164
161
  });
165
162
  return result.image;
166
163
  }
@@ -219,10 +216,9 @@ function resolveDaemonOptions(options: DaemonOptions = {}):
219
216
 
220
217
  function spawnDaemon(options: ResolvedDaemonOptions): void {
221
218
  const config = {
222
- binary: options.binary,
223
- workers: options.workers,
224
219
  cacheDir: options.cacheDir,
225
- args: options.args,
220
+ userAgent: options.userAgent,
221
+ resourceDir: options.resourceDir,
226
222
  endpoint: options.endpoint,
227
223
  idleTimeoutMs: options.idleTimeoutMs,
228
224
  prewarm: options.prewarm,
package/src/lib/config.ts CHANGED
@@ -1,21 +1,11 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import {fileURLToPath} from 'node:url';
4
-
5
1
  import type {StartOptions} from '../types.js';
6
2
 
7
- import * as platform from './platform.js';
8
-
9
- // ESM has no __dirname. This is the same thing, from the module's own URL.
10
- const HERE = path.dirname(fileURLToPath(import.meta.url));
11
-
12
3
  // StartOptions with every hole filled in. `cacheDir` is still nullable here
13
4
  // because null is an answer -- "no disk cache" -- and not an absent one.
14
5
  export interface ResolvedStartOptions {
15
- binary: string;
16
- workers: number;
17
6
  cacheDir: string|null;
18
- args: string[];
7
+ userAgent?: string;
8
+ resourceDir?: string;
19
9
  }
20
10
 
21
11
  // The one place that decides what "no options" means.
@@ -23,64 +13,19 @@ export interface ResolvedStartOptions {
23
13
  // It is shared rather than duplicated because the daemon's address is a hash of
24
14
  // its configuration: if two callers filled in defaults even slightly
25
15
  // differently, one would compute an address no daemon is listening on and
26
- // start a second pool next to the first one that was already warm. See
16
+ // start a second engine next to the first one that was already warm. See
27
17
  // endpoint.ts.
28
18
  //
29
- // Three places, in the order a caller means them: what they said, what npm
30
- // installed, and what they unpacked by hand. The middle one is the normal case
31
- // and the only one that needs no instructions.
32
- function defaultBinary(): string {
33
- if (process.env.SHOTIUM_BINARY) {
34
- return process.env.SHOTIUM_BINARY;
35
- }
36
- const dir = platform.packageDir();
37
- if (dir) {
38
- return path.join(dir, platform.binaryName());
39
- }
40
- // No platform package: an archive from the releases page, unpacked into
41
- // bin/ beside this file. This is also the path a checkout takes, where
42
- // nothing was installed from a registry at all.
43
- return path.join(HERE, '..', 'bin', platform.binaryName());
44
- }
45
-
46
- // How many worker processes, when nobody said.
47
- //
48
- // Half the cores, capped. The cap is there because a worker is a process with
49
- // blink in it, not a thread: measured on this tree it settles around 14 MB of
50
- // private working set and holds a further ~30 MB of shotium.exe resident, so
51
- // "half the cores" on a 32-core machine is sixteen of them and most of a
52
- // gigabyte for a queue that is almost never sixteen deep. Four is past the
53
- // point where a screenshot workload gets much from another one -- the corpus
54
- // runs at 41 pages/s on four -- and anyone who has measured otherwise passes
55
- // `workers`.
56
- const MAXIMUM_DEFAULT_WORKERS = 4;
57
-
58
- function defaultWorkers(): number {
59
- const half = Math.floor((os.cpus().length || 2) / 2);
60
- return Math.max(1, Math.min(MAXIMUM_DEFAULT_WORKERS, half));
61
- }
62
-
63
- function defaultCacheDir(): string {
64
- return path.join(os.tmpdir(), 'shotium-cache');
65
- }
66
-
67
- // binary / workers / cacheDir / args, filled in and normalised. `cacheDir:
68
- // null` survives as null -- it means "no disk cache", which is not the same
69
- // request as "use the default one".
19
+ // The default for `cacheDir` is null -- no disk cache. A program holding the
20
+ // engine is often short-lived, and a cache it never reads twice is a directory
21
+ // it leaves behind. The daemon, which is the case where a cache does pay for
22
+ // itself, is also the case where the caller is already passing options.
70
23
  function resolveStartOptions(options: StartOptions = {}): ResolvedStartOptions {
71
24
  return {
72
- binary: options.binary || defaultBinary(),
73
- workers: options.workers || defaultWorkers(),
74
- cacheDir: options.cacheDir === null ?
75
- null :
76
- (options.cacheDir || defaultCacheDir()),
77
- args: options.args || [],
25
+ cacheDir: options.cacheDir ?? null,
26
+ userAgent: options.userAgent,
27
+ resourceDir: options.resourceDir,
78
28
  };
79
29
  }
80
30
 
81
- export {
82
- defaultBinary,
83
- defaultCacheDir,
84
- defaultWorkers,
85
- resolveStartOptions,
86
- };
31
+ export {resolveStartOptions};