@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.
@@ -0,0 +1,283 @@
1
+ // Copyright 2026 The Chromium Authors
2
+ // Use of this source code is governed by a BSD-style license that can be
3
+ // found in the LICENSE file.
4
+
5
+ // shotium's node addon: shot in this process, over shot_api.h.
6
+ //
7
+ // It is deliberately thin. Everything about what a screenshot means -- which
8
+ // fields exist, what they default to, what an unknown one is -- lives in
9
+ // shotium/src/lib/request.ts and shot/shot_request.cc, and this file carries
10
+ // JSON between them without reading it. Anything it understood would be a
11
+ // third opinion about the request format, and the third opinion is always the
12
+ // one that drifts.
13
+ //
14
+ // Node-API rather than V8: the ABI is stable across node versions, so one
15
+ // prebuilt .node per platform is enough and the addon does not have to be
16
+ // rebuilt every time node's internals move.
17
+
18
+ #include <node_api.h>
19
+
20
+ #include <cstring>
21
+ #include <string>
22
+ #include <utility>
23
+
24
+ #include "shot_api.h"
25
+
26
+ namespace {
27
+
28
+ // A shot_engine, as node sees it.
29
+ //
30
+ // Wrapped rather than handed over as a bare pointer so that a script dropping
31
+ // the handle on the floor still shuts the engine down: the finalizer runs when
32
+ // the object is collected. `engine` is cleared by an explicit destroy() so the
33
+ // finalizer does not do it twice.
34
+ struct EngineHandle {
35
+ shot_engine* engine = nullptr;
36
+ };
37
+
38
+ void FinalizeEngine(napi_env env, void* data, void* hint) {
39
+ auto* handle = static_cast<EngineHandle*>(data);
40
+ if (handle->engine) {
41
+ shot_engine_destroy(handle->engine);
42
+ }
43
+ delete handle;
44
+ }
45
+
46
+ // One capture in flight: a promise, and the strings on both sides of it.
47
+ //
48
+ // The request is copied rather than referenced because Execute runs on a
49
+ // libuv thread where no JS value may be touched, and the JS string it came
50
+ // from can be collected before then.
51
+ struct CaptureTask {
52
+ napi_deferred deferred = nullptr;
53
+ napi_async_work work = nullptr;
54
+ shot_engine* engine = nullptr;
55
+ std::string request;
56
+ shot_status status = SHOT_ERR_CAPTURE;
57
+ shot_buffer* image = nullptr;
58
+ shot_buffer* error = nullptr;
59
+ };
60
+
61
+ bool ReadUtf8(napi_env env, napi_value value, std::string* out) {
62
+ size_t length = 0;
63
+ if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) {
64
+ return false;
65
+ }
66
+ // Room for the NUL node insists on writing, then cut back to what it says
67
+ // it wrote.
68
+ std::string text(length + 1, '\0');
69
+ size_t written = 0;
70
+ if (napi_get_value_string_utf8(env, value, text.data(), length + 1,
71
+ &written) != napi_ok) {
72
+ return false;
73
+ }
74
+ text.resize(written);
75
+ *out = std::move(text);
76
+ return true;
77
+ }
78
+
79
+ napi_value Undefined(napi_env env) {
80
+ napi_value value = nullptr;
81
+ napi_get_undefined(env, &value);
82
+ return value;
83
+ }
84
+
85
+ // Turns a shot_buffer carrying a message into a thrown JS error. Freeing it is
86
+ // this function's job either way, because every caller is on its way out.
87
+ void ThrowFromBuffer(napi_env env, shot_buffer* message, const char* fallback) {
88
+ const char* text = fallback;
89
+ if (message && shot_buffer_size(message) > 0) {
90
+ text = reinterpret_cast<const char*>(shot_buffer_data(message));
91
+ }
92
+ napi_throw_error(env, nullptr, text);
93
+ shot_buffer_free(message);
94
+ }
95
+
96
+ bool ReadHandle(napi_env env, napi_value value, EngineHandle** out) {
97
+ void* data = nullptr;
98
+ if (napi_get_value_external(env, value, &data) != napi_ok || !data) {
99
+ napi_throw_type_error(env, nullptr, "shotium: expected an engine handle");
100
+ return false;
101
+ }
102
+ *out = static_cast<EngineHandle*>(data);
103
+ if (!(*out)->engine) {
104
+ napi_throw_error(env, nullptr, "shotium: this engine has been destroyed");
105
+ return false;
106
+ }
107
+ return true;
108
+ }
109
+
110
+ napi_value Create(napi_env env, napi_callback_info info) {
111
+ size_t argc = 1;
112
+ napi_value argv[1] = {};
113
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
114
+
115
+ std::string options;
116
+ if (argc > 0 && !ReadUtf8(env, argv[0], &options)) {
117
+ napi_throw_type_error(env, nullptr,
118
+ "shotium: create(optionsJson) wants a string");
119
+ return nullptr;
120
+ }
121
+
122
+ if (shot_abi_version() != SHOT_ABI_VERSION) {
123
+ napi_throw_error(env, nullptr,
124
+ "shotium: the shot library beside this addon speaks a "
125
+ "different ABI version; they ship together and one of "
126
+ "them has been replaced");
127
+ return nullptr;
128
+ }
129
+
130
+ shot_engine* engine = nullptr;
131
+ shot_buffer* error = nullptr;
132
+ if (shot_engine_create(options.c_str(), &engine, &error) != SHOT_OK) {
133
+ ThrowFromBuffer(env, error, "shotium: the engine would not start");
134
+ return nullptr;
135
+ }
136
+
137
+ auto* handle = new EngineHandle{engine};
138
+ napi_value external = nullptr;
139
+ if (napi_create_external(env, handle, FinalizeEngine, nullptr, &external) !=
140
+ napi_ok) {
141
+ shot_engine_destroy(engine);
142
+ delete handle;
143
+ napi_throw_error(env, nullptr, "shotium: could not wrap the engine");
144
+ return nullptr;
145
+ }
146
+ return external;
147
+ }
148
+
149
+ napi_value Destroy(napi_env env, napi_callback_info info) {
150
+ size_t argc = 1;
151
+ napi_value argv[1] = {};
152
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
153
+
154
+ void* data = nullptr;
155
+ if (argc < 1 || napi_get_value_external(env, argv[0], &data) != napi_ok ||
156
+ !data) {
157
+ napi_throw_type_error(env, nullptr, "shotium: expected an engine handle");
158
+ return nullptr;
159
+ }
160
+ auto* handle = static_cast<EngineHandle*>(data);
161
+ if (handle->engine) {
162
+ shot_engine_destroy(handle->engine);
163
+ handle->engine = nullptr;
164
+ }
165
+ return Undefined(env);
166
+ }
167
+
168
+ // Runs on a libuv thread. No napi call is legal here beyond the ones that take
169
+ // no env, which is why everything it needs was copied out first.
170
+ void ExecuteCapture(napi_env env, void* data) {
171
+ auto* task = static_cast<CaptureTask*>(data);
172
+ task->status = shot_engine_capture(task->engine, task->request.c_str(),
173
+ &task->image, &task->error);
174
+ }
175
+
176
+ void CompleteCapture(napi_env env, napi_status status, void* data) {
177
+ auto* task = static_cast<CaptureTask*>(data);
178
+
179
+ if (status == napi_ok && task->status == SHOT_OK) {
180
+ // Copied into a node Buffer rather than handed over as external memory.
181
+ // An external buffer would save a memcpy of a few hundred kilobytes
182
+ // against a render that took tens of milliseconds, and would put the
183
+ // lifetime of shot's allocation in the hands of node's GC -- across an
184
+ // allocator boundary the whole C ABI exists to keep closed.
185
+ napi_value buffer = nullptr;
186
+ napi_create_buffer_copy(env, shot_buffer_size(task->image),
187
+ shot_buffer_data(task->image), nullptr, &buffer);
188
+ napi_resolve_deferred(env, task->deferred, buffer);
189
+ } else {
190
+ const char* text = "shotium: the capture failed";
191
+ if (task->error && shot_buffer_size(task->error) > 0) {
192
+ text = reinterpret_cast<const char*>(shot_buffer_data(task->error));
193
+ }
194
+ napi_value message = nullptr;
195
+ napi_value error_value = nullptr;
196
+ napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &message);
197
+ napi_create_error(env, nullptr, message, &error_value);
198
+ napi_reject_deferred(env, task->deferred, error_value);
199
+ }
200
+
201
+ shot_buffer_free(task->image);
202
+ shot_buffer_free(task->error);
203
+ napi_delete_async_work(env, task->work);
204
+ delete task;
205
+ }
206
+
207
+ napi_value Capture(napi_env env, napi_callback_info info) {
208
+ size_t argc = 2;
209
+ napi_value argv[2] = {};
210
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
211
+
212
+ EngineHandle* handle = nullptr;
213
+ if (argc < 2 || !ReadHandle(env, argv[0], &handle)) {
214
+ return nullptr;
215
+ }
216
+
217
+ auto* task = new CaptureTask;
218
+ task->engine = handle->engine;
219
+ if (!ReadUtf8(env, argv[1], &task->request)) {
220
+ delete task;
221
+ napi_throw_type_error(env, nullptr,
222
+ "shotium: capture(engine, requestJson) wants a "
223
+ "string");
224
+ return nullptr;
225
+ }
226
+
227
+ napi_value promise = nullptr;
228
+ if (napi_create_promise(env, &task->deferred, &promise) != napi_ok) {
229
+ delete task;
230
+ napi_throw_error(env, nullptr, "shotium: could not make a promise");
231
+ return nullptr;
232
+ }
233
+
234
+ napi_value name = nullptr;
235
+ napi_create_string_utf8(env, "shot:capture", NAPI_AUTO_LENGTH, &name);
236
+ napi_create_async_work(env, nullptr, name, ExecuteCapture, CompleteCapture,
237
+ task, &task->work);
238
+ napi_queue_async_work(env, task->work);
239
+ return promise;
240
+ }
241
+
242
+ // Synchronous on purpose. A purge is milliseconds and happens when the caller
243
+ // has decided it has nothing else to do; queuing it behind the event loop
244
+ // would mean the process that just went idle stays large until something wakes
245
+ // it up.
246
+ napi_value Purge(napi_env env, napi_callback_info info) {
247
+ size_t argc = 2;
248
+ napi_value argv[2] = {};
249
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
250
+
251
+ EngineHandle* handle = nullptr;
252
+ if (argc < 1 || !ReadHandle(env, argv[0], &handle)) {
253
+ return nullptr;
254
+ }
255
+
256
+ bool release = false;
257
+ if (argc > 1) {
258
+ napi_get_value_bool(env, argv[1], &release);
259
+ }
260
+ shot_engine_purge(handle->engine, release ? 1 : 0);
261
+ return Undefined(env);
262
+ }
263
+
264
+ napi_value Init(napi_env env, napi_value exports) {
265
+ const napi_property_descriptor properties[] = {
266
+ {"create", nullptr, Create, nullptr, nullptr, nullptr, napi_default,
267
+ nullptr},
268
+ {"destroy", nullptr, Destroy, nullptr, nullptr, nullptr, napi_default,
269
+ nullptr},
270
+ {"capture", nullptr, Capture, nullptr, nullptr, nullptr, napi_default,
271
+ nullptr},
272
+ {"purge", nullptr, Purge, nullptr, nullptr, nullptr, napi_default,
273
+ nullptr},
274
+ };
275
+ napi_define_properties(env, exports,
276
+ sizeof(properties) / sizeof(properties[0]),
277
+ properties);
278
+ return exports;
279
+ }
280
+
281
+ } // namespace
282
+
283
+ NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
@@ -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,63 @@
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.1.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
+ "./native": {
28
+ "types": "./dist/native.d.ts",
29
+ "default": "./dist/native.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "dist/",
35
+ "src/",
36
+ "native/binding.cc",
37
+ "native/binding.gyp",
38
+ "native/stage_header.js",
39
+ "README.md"
40
+ ],
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "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"
51
+ },
52
+ "scripts": {
53
+ "build": "tsdown",
54
+ "check:types": "tsc --noEmit",
55
+ "prepack": "tsdown"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "22.20.1",
59
+ "tsdown": "0.22.14",
60
+ "typescript": "5.9.3"
61
+ },
62
+ "license": "BSD-3-Clause"
6
63
  }
@@ -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();