@shotkit/shotium 0.0.1 → 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.
@@ -0,0 +1,54 @@
1
+ {
2
+ # How the addon is built. It is not built by `npm install`: shot itself is a
3
+ # Chromium fork that takes hours and a checkout to compile, so the library
4
+ # this links against is a release artifact, and the addon is built against it
5
+ # once per platform and shipped prebuilt.
6
+ #
7
+ # SHOT_INCLUDE_DIR=/path/to/shot (the directory holding shot_api.h)
8
+ # SHOT_LIB_DIR=/path/to/out/Shot (the directory holding the library)
9
+ # npx node-gyp rebuild
10
+ "variables": {
11
+ # Not the engine's source directory: a directory holding a copy of
12
+ # shot_api.h and nothing else. An include directory pointed at shot/ makes
13
+ # libc++'s `#include <version>` resolve to shot/VERSION on any
14
+ # case-insensitive filesystem, which is macOS out of the box. See
15
+ # stage_header.js, which does the copying and explains the rest.
16
+ #
17
+ # SHOT_INCLUDE_DIR still says where shot_api.h is; stage_header.js reads it.
18
+ "shot_include_dir%": "<!(node stage_header.js)",
19
+ "shot_lib_dir%": "<!(node -p \"process.env.SHOT_LIB_DIR || require('path').resolve('../../out/Shot')\")"
20
+ },
21
+ "targets": [
22
+ {
23
+ "target_name": "shotium",
24
+ "sources": ["binding.cc"],
25
+ "include_dirs": ["<(shot_include_dir)"],
26
+ # Node-API 8 is Node 12.22 / 14.17 and up. Declaring it pins the surface
27
+ # this addon may use, so a build cannot quietly start depending on a
28
+ # newer node than the one it claims to support.
29
+ "defines": ["NAPI_VERSION=8"],
30
+ "cflags!": ["-fno-exceptions"],
31
+ "cflags_cc!": ["-fno-exceptions"],
32
+ "conditions": [
33
+ ["OS=='win'", {
34
+ # The import library GN writes beside the DLL. The DLL itself is
35
+ # found at run time in the directory the .node was loaded from.
36
+ "libraries": ["<(shot_lib_dir)/shotium.dll.lib"]
37
+ }],
38
+ ["OS=='linux'", {
39
+ "libraries": [
40
+ "-L<(shot_lib_dir)",
41
+ "-lshotium",
42
+ # $ORIGIN, escaped past make: the library ships beside the .node,
43
+ # not in a system directory, and nothing should be searching the
44
+ # host's library path for something named this generally.
45
+ "-Wl,-rpath,'$$ORIGIN'"
46
+ ]
47
+ }],
48
+ ["OS=='mac'", {
49
+ "libraries": ["-L<(shot_lib_dir)", "-lshotium", "-Wl,-rpath,@loader_path"]
50
+ }]
51
+ ]
52
+ }
53
+ ]
54
+ }
@@ -0,0 +1,53 @@
1
+ // Puts shot_api.h somewhere it is the only thing there, and prints where.
2
+ //
3
+ // binding.gyp calls this at generate time and uses the result as the addon's
4
+ // one include directory. The obvious thing -- pointing the include directory
5
+ // straight at shot/ -- does not survive a case-insensitive filesystem:
6
+ //
7
+ // src/shot/version:1:10: error: expected ';' after top level declarator
8
+ // 1 | MAJOR=153
9
+ //
10
+ // That is libc++'s <string> including <version>, the compiler searching the
11
+ // include directories before the system ones, and macOS answering `version`
12
+ // with `shot/VERSION`, which is this tree's build number file. The same trap is
13
+ // set on Windows -- NTFS is case-insensitive too -- and springs only because
14
+ // MSVC's <string> happens not to reach for <version>.
15
+ //
16
+ // Renaming the file is not available: shot/VERSION is what this fork calls
17
+ // chrome/VERSION, and //base, //build and a dozen .gni files name it. Nor is
18
+ // -iquote, which would say exactly the right thing and has a different
19
+ // spelling in each of the three generators node-gyp drives. Copying the one
20
+ // header the addon needs into a directory of its own is what is left, and it
21
+ // has the advantage that nothing about it can be undone by an STL that starts
22
+ // including one more thing.
23
+ //
24
+ // SHOT_INCLUDE_DIR still names where shot_api.h is *found*; it just is not
25
+ // handed to the compiler any more.
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import {fileURLToPath} from 'node:url';
30
+
31
+ // ESM has no __dirname. This is the same thing, from the module's own URL.
32
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
33
+
34
+ const HEADER = 'shot_api.h';
35
+
36
+ const source = process.env.SHOT_INCLUDE_DIR ||
37
+ path.resolve(HERE, '..', '..', 'shot');
38
+ const staged = path.resolve(HERE, 'build', 'include');
39
+
40
+ const from = path.join(source, HEADER);
41
+ if (!fs.existsSync(from)) {
42
+ process.stderr.write(
43
+ `stage_header.js: no ${HEADER} in ${source}\n` +
44
+ ' Set SHOT_INCLUDE_DIR to the directory holding it.\n');
45
+ process.exit(1);
46
+ }
47
+
48
+ fs.mkdirSync(staged, {recursive: true});
49
+ fs.copyFileSync(from, path.join(staged, HEADER));
50
+
51
+ // gyp takes this whole line as the variable's value, so it is the only thing
52
+ // written to stdout.
53
+ process.stdout.write(staged);
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@shotkit/shotium",
3
- "version": "0.0.1",
4
- "description": "Placeholder release to reserve the package name for Trusted Publishing.",
5
- "license": "MIT"
3
+ "version": "0.2.0",
4
+ "description": "Static screenshots from a stripped Chromium: DOM, CSS, layout, paint, no JavaScript engine.",
5
+ "keywords": [
6
+ "screenshot",
7
+ "chromium",
8
+ "blink",
9
+ "html-to-image",
10
+ "png",
11
+ "render"
12
+ ],
13
+ "homepage": "https://github.com/sj817/shotium#readme",
14
+ "bugs": "https://github.com/sj817/shotium/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sj817/shotium.git"
18
+ },
19
+ "type": "module",
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist/",
31
+ "src/",
32
+ "native/binding.cc",
33
+ "native/binding.gyp",
34
+ "native/stage_header.js",
35
+ "README.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "optionalDependencies": {
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"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "check:types": "tsc --noEmit",
51
+ "prepack": "tsdown"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "22.20.1",
55
+ "tsdown": "0.22.14",
56
+ "typescript": "5.9.3"
57
+ },
58
+ "license": "BSD-3-Clause"
6
59
  }
@@ -0,0 +1,76 @@
1
+ // The entry point of a detached daemon process.
2
+ //
3
+ // The configuration arrives as one base64 argument rather than as flags,
4
+ // because it contains paths that a Windows command line would otherwise quote
5
+ // badly, and because the client and the daemon have to agree on it exactly:
6
+ // the endpoint is a hash of these fields, so a value mangled in transit would
7
+ // produce a daemon listening where nobody looks. See endpoint.ts.
8
+ //
9
+ // It is a build entry of its own, and not a chunk, because lib/client.ts
10
+ // spawns it by path -- `node dist/daemon_main.js <base64 json>` -- and a name
11
+ // the bundler chose would be a name that changes.
12
+
13
+ import {Daemon} from './lib/daemon.js';
14
+ import type {DaemonOptions} from './types.js';
15
+
16
+ async function main(): Promise<void> {
17
+ const encoded = process.argv[2];
18
+ if (!encoded) {
19
+ process.stderr.write('shotium: daemon_main expects a base64 config\n');
20
+ process.exit(2);
21
+ }
22
+ const options = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as
23
+ DaemonOptions;
24
+ const daemon = new Daemon(options);
25
+
26
+ daemon.on('stderr', ({worker, line}: {worker: number, line: string}) => {
27
+ process.stderr.write(`shotium worker ${worker}: ${line}\n`);
28
+ });
29
+ for (const event of ['crash', 'timeout', 'worker-restart', 'worker-error',
30
+ 'idle-exit']) {
31
+ daemon.on(event, (payload: {error?: unknown}) => {
32
+ // An Error does not survive JSON.stringify -- it comes out as {} -- and
33
+ // its message is the whole point of logging a worker that would not
34
+ // start.
35
+ const detail = payload && payload.error ?
36
+ {
37
+ ...payload,
38
+ error: String(
39
+ (payload.error as Error).message ?? payload.error),
40
+ } :
41
+ payload;
42
+ process.stderr.write(
43
+ `shotium daemon ${event}: ${JSON.stringify(detail)}\n`);
44
+ });
45
+ }
46
+ // An 'error' with nobody listening is thrown by EventEmitter itself, which
47
+ // would turn a socket that failed after binding -- something the daemon can
48
+ // survive -- into a dead pool.
49
+ daemon.on('error', (error: Error) => {
50
+ process.stderr.write(
51
+ `shotium daemon error: ${(error && error.message) || error}\n`);
52
+ });
53
+
54
+ try {
55
+ await daemon.listen();
56
+ } catch (error) {
57
+ // Losing the race to bind is the ordinary outcome when two clients start a
58
+ // daemon at the same moment: the other one is up, this one is not needed,
59
+ // and the client that spawned it will connect to the winner. Anything else
60
+ // is a real failure and says so.
61
+ if ((error as NodeJS.ErrnoException | null)?.code === 'EADDRINUSE') {
62
+ process.exit(0);
63
+ }
64
+ process.stderr.write(`shotium: daemon failed to start: ${error}\n`);
65
+ process.exit(1);
66
+ }
67
+
68
+ const shutdown = () => {
69
+ daemon.close().then(() => process.exit(0), () => process.exit(1));
70
+ };
71
+ process.on('SIGINT', shutdown);
72
+ process.on('SIGTERM', shutdown);
73
+ daemon.on('close', () => process.exit(0));
74
+ }
75
+
76
+ void main();
package/src/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ import * as client from './lib/client.js';
2
+ import type {DaemonClient} from './lib/client.js';
3
+ import {Engine} from './lib/engine.js';
4
+ import type {
5
+ DaemonOptions,
6
+ DaemonStatus,
7
+ PurgeOptions,
8
+ ScreenshotOptions,
9
+ StartOptions,
10
+ } from './types.js';
11
+
12
+ export type {
13
+ Clip,
14
+ DaemonOptions,
15
+ DaemonStatus,
16
+ PageGotoParams,
17
+ PurgeOptions,
18
+ ScreenshotOptions,
19
+ StartOptions,
20
+ Viewport,
21
+ } from './types.js';
22
+ export type {DaemonClient} from './lib/client.js';
23
+
24
+ /** The five things a caller does with the resident engine. */
25
+ export interface Daemon {
26
+ /** Connects, starting a daemon if none is listening. */
27
+ connect(options?: DaemonOptions): Promise<DaemonClient>;
28
+ /** One screenshot through the daemon, connection and all. */
29
+ screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
30
+ Promise<Buffer|null>;
31
+ /** Starts one if it is not up, and reports what is there either way. */
32
+ start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;
33
+ status(options?: DaemonOptions):
34
+ Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}>;
35
+ stop(options?: DaemonOptions): Promise<{stopped: boolean, endpoint: string}>;
36
+ }
37
+
38
+ /**
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.
53
+ *
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.
59
+ *
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.
63
+ */
64
+ export class Runtime {
65
+ private engine = new Engine();
66
+
67
+ get running(): boolean {
68
+ return this.engine.running;
69
+ }
70
+
71
+ /**
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.
74
+ *
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.
79
+ */
80
+ start(options: StartOptions = {}): this {
81
+ this.engine.start(options);
82
+ return this;
83
+ }
84
+
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);
103
+ }
104
+
105
+ /**
106
+ * Renders one screenshot. Resolves to the encoded image, or to `null` when
107
+ * `path` was given and the engine wrote the file itself.
108
+ */
109
+ screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
110
+ return this.engine.screenshot(options);
111
+ }
112
+ }
113
+
114
+ /** The shared engine: one per process, started on first use. */
115
+ const runtime = new Runtime();
116
+
117
+ /** One screenshot through the shared engine, starting it if it is not up. */
118
+ const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
119
+ runtime.screenshot(options);
120
+
121
+ /**
122
+ * The resident engine: a process that outlives the one that started it,
123
+ * reachable over a named pipe on Windows and a unix socket elsewhere. For
124
+ * callers that are short-lived themselves. See lib/daemon.ts.
125
+ */
126
+ const daemon: Daemon = {
127
+ connect: client.connect,
128
+ screenshot: client.screenshot,
129
+ start: client.start,
130
+ status: client.status,
131
+ stop: client.stop,
132
+ };
133
+
134
+ export {runtime, screenshot, daemon};
135
+
136
+ // A default as well as the names, because `import shotium from` is what a
137
+ // caller coming from `require` writes first, and the two have to be the same
138
+ // object rather than two views that drift.
139
+ export default {Runtime, runtime, screenshot, daemon};
@@ -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
+ }