@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,53 @@
1
+ // The wire format shotium.exe --serve speaks, in both directions: a 4-byte
2
+ // little-endian length followed by that many bytes.
3
+ //
4
+ // Length-prefixed rather than line-delimited because the payload is binary and
5
+ // a newline inside a PNG is not a message boundary. See shot/shot_server.h for
6
+ // the same description from the other end.
7
+ //
8
+ // -> [len][{"file":"...","width":1248,...}]
9
+ // <- [len][{"ok":true,"bytes":97756}] [len][<PNG bytes>]
10
+ // <- [len][{"ok":false,"error":"..."}] [0]
11
+
12
+ const HEADER_BYTES = 4;
13
+
14
+ function encodeFrame(payload: Buffer): Buffer {
15
+ const header = Buffer.allocUnsafe(HEADER_BYTES);
16
+ header.writeUInt32LE(payload.length, 0);
17
+ return Buffer.concat([header, payload]);
18
+ }
19
+
20
+ function encodeRequest(request: unknown): Buffer {
21
+ return encodeFrame(Buffer.from(JSON.stringify(request), 'utf8'));
22
+ }
23
+
24
+ // Reassembles frames out of whatever sizes the pipe hands over.
25
+ //
26
+ // A stream is not a sequence of messages: one read can carry half a header, or
27
+ // three responses and the start of a fourth. Everything downstream assumes
28
+ // whole frames, so this is the only place that has to know that.
29
+ class FrameReader {
30
+ private buffer: Buffer = Buffer.alloc(0);
31
+
32
+ push(chunk: Buffer): void {
33
+ this.buffer = this.buffer.length === 0 ?
34
+ chunk :
35
+ Buffer.concat([this.buffer, chunk]);
36
+ }
37
+
38
+ // The next complete frame, or null when there is not one yet.
39
+ next(): Buffer|null {
40
+ if (this.buffer.length < HEADER_BYTES) {
41
+ return null;
42
+ }
43
+ const length = this.buffer.readUInt32LE(0);
44
+ if (this.buffer.length < HEADER_BYTES + length) {
45
+ return null;
46
+ }
47
+ const frame = this.buffer.subarray(HEADER_BYTES, HEADER_BYTES + length);
48
+ this.buffer = this.buffer.subarray(HEADER_BYTES + length);
49
+ return frame;
50
+ }
51
+ }
52
+
53
+ export {HEADER_BYTES, encodeFrame, encodeRequest, FrameReader};
@@ -0,0 +1,97 @@
1
+ import type {Clip, PageGotoParams, ScreenshotOptions} from '../types.js';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 30000;
4
+
5
+ // What actually goes down the pipe: ScreenshotOptions with the viewport
6
+ // flattened -- see toRequest below for why.
7
+ export interface WireRequest {
8
+ file: string;
9
+ type?: 'png'|'jpeg'|'webp';
10
+ fullPage?: boolean;
11
+ selector?: string;
12
+ quality?: number;
13
+ scale?: number;
14
+ omitBackground?: boolean;
15
+ path?: string;
16
+ pageGotoParams?: PageGotoParams;
17
+ clip?: Clip;
18
+ allowFileAccess?: boolean;
19
+ width?: number;
20
+ height?: number;
21
+ }
22
+
23
+ // Everything the worker understands, and nothing else. An unknown field is a
24
+ // typo, and a typo that is silently dropped is a screenshot that quietly
25
+ // ignored what was asked for -- so this rejects rather than filters.
26
+ //
27
+ // It is a runtime check even though the argument has a type, because the
28
+ // argument having a type says nothing about a caller who is not compiled
29
+ // against it: a JavaScript program, or a JSON body from somewhere else.
30
+ const WIRE_FIELDS = new Set([
31
+ 'file',
32
+ 'type',
33
+ 'fullPage',
34
+ 'selector',
35
+ 'quality',
36
+ 'scale',
37
+ 'omitBackground',
38
+ 'path',
39
+ 'pageGotoParams',
40
+ 'clip',
41
+ 'viewport',
42
+ 'allowFileAccess',
43
+ ]);
44
+
45
+ // One ScreenshotOptions, checked and flattened into what goes on the wire.
46
+ //
47
+ // It lives here rather than in index.ts because the engine in this process and
48
+ // the daemon both send it: a request that is valid through one entry point and
49
+ // rejected through the other would be a difference nobody asked for.
50
+ function toRequest(options: ScreenshotOptions): WireRequest {
51
+ if (!options || typeof options !== 'object') {
52
+ throw new TypeError('shotium: screenshot(options) needs an object');
53
+ }
54
+ if (typeof options.file !== 'string' || options.file.length === 0) {
55
+ throw new TypeError('shotium: options.file is required');
56
+ }
57
+
58
+ const request: Record<string, unknown> = {};
59
+ for (const [key, value] of Object.entries(options)) {
60
+ if (value === undefined) {
61
+ continue;
62
+ }
63
+ if (!WIRE_FIELDS.has(key)) {
64
+ throw new TypeError(`shotium: unknown option "${key}"`);
65
+ }
66
+ request[key] = value;
67
+ }
68
+
69
+ // The viewport is flattened because the worker takes width and height at the
70
+ // top level: it is one screenshot's frame, not a nested object on the wire.
71
+ if (request.viewport) {
72
+ const {width, height} = request.viewport as {
73
+ width?: number,
74
+ height?: number,
75
+ };
76
+ delete request.viewport;
77
+ if (width !== undefined) {
78
+ request.width = width;
79
+ }
80
+ if (height !== undefined) {
81
+ request.height = height;
82
+ }
83
+ }
84
+ return request as unknown as WireRequest;
85
+ }
86
+
87
+ function timeoutFor(options: ScreenshotOptions): number {
88
+ const timeout = options.pageGotoParams && options.pageGotoParams.timeout;
89
+ return typeof timeout === 'number' ? timeout : DEFAULT_TIMEOUT_MS;
90
+ }
91
+
92
+ export {
93
+ DEFAULT_TIMEOUT_MS,
94
+ WIRE_FIELDS,
95
+ timeoutFor,
96
+ toRequest,
97
+ };
package/src/types.ts ADDED
@@ -0,0 +1,143 @@
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 re-exports them, so
5
+ // there is one place where adding an option means adding it.
6
+
7
+ /** A region of the document, in CSS pixels. */
8
+ export interface Clip {
9
+ x: number;
10
+ y: number;
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ export interface PageGotoParams {
16
+ /** Milliseconds before the load is abandoned. Default 30000. */
17
+ timeout?: number;
18
+ /**
19
+ * `load` waits for parsing to finish, the load event to fire and every
20
+ * request to complete. `networkidle` additionally waits for a 500ms window
21
+ * with nothing in flight, which matters for documents that keep fetching
22
+ * after the load event -- CSS that pulls in more CSS, or a font a late style
23
+ * change brought in.
24
+ */
25
+ waitUntil?: 'load'|'networkidle';
26
+ }
27
+
28
+ /** The viewport the document is laid out in. */
29
+ export interface Viewport {
30
+ /** CSS pixels. Default 1280. */
31
+ width?: number;
32
+ /** CSS pixels. Default 720. */
33
+ height?: number;
34
+ }
35
+
36
+ export interface ScreenshotOptions {
37
+ /** An http/https/file URL, or a local path. */
38
+ file: string;
39
+ /** Default `png`. */
40
+ type?: 'png'|'jpeg'|'webp';
41
+ /** Capture the whole document rather than the viewport. */
42
+ fullPage?: boolean;
43
+ /**
44
+ * Capture the box of the first element matching this CSS selector. Resolved
45
+ * inside the renderer with Document::querySelector -- there is no JavaScript
46
+ * engine, so nothing is injected into the page.
47
+ */
48
+ selector?: string;
49
+ /** 1-100, `jpeg` and `webp` only. Default 90. */
50
+ quality?: number;
51
+ /** Device scale factor, 0.01-8. Default 1. */
52
+ scale?: number;
53
+ /**
54
+ * Keep the alpha channel instead of painting the page's white backdrop.
55
+ * Rejected for `jpeg`, which has no alpha channel.
56
+ */
57
+ omitBackground?: boolean;
58
+ /** Write the image here instead of returning it, saving a round trip. */
59
+ path?: string;
60
+ pageGotoParams?: PageGotoParams;
61
+ /** A region of the document, in CSS pixels. */
62
+ clip?: Clip;
63
+ /** The viewport the document is laid out in. */
64
+ viewport?: Viewport;
65
+ /**
66
+ * Let the document read `file:` subresources. Off by default: a library does
67
+ * not get to decide for its caller that a document may read the filesystem it
68
+ * is rendered on.
69
+ */
70
+ allowFileAccess?: boolean;
71
+ }
72
+
73
+ export interface StartOptions {
74
+ /**
75
+ * Root of the HTTP disk cache. `null` disables caching entirely, which is
76
+ * the default: a program holding the engine is often short-lived, and a
77
+ * cache it never reads twice is a directory it leaves behind.
78
+ */
79
+ cacheDir?: string|null;
80
+ /** Overrides the built-in user agent string. */
81
+ userAgent?: string;
82
+ /**
83
+ * Where `shotium_data.pak` and `shotium_strings.pak` are. Defaults to the
84
+ * directory the engine was loaded from, which is where they ship.
85
+ */
86
+ resourceDir?: string;
87
+ }
88
+
89
+ export interface DaemonOptions extends StartOptions {
90
+ /**
91
+ * Address the daemon by name instead of by configuration. Without it the
92
+ * endpoint is a hash of `cacheDir`, `userAgent` and `resourceDir`, so a
93
+ * client never attaches to a daemon that renders with something other than
94
+ * what it asked for.
95
+ */
96
+ name?: string;
97
+ /** The pipe or socket to use, overriding both the name and the hash. */
98
+ endpoint?: string;
99
+ /**
100
+ * Exit after this long with no connections and nothing rendering. Default
101
+ * 300000; `0` never exits.
102
+ */
103
+ idleTimeoutMs?: number;
104
+ /**
105
+ * Render one throwaway document at startup, so the first real request does
106
+ * not pay for whatever the engine initialises lazily. Default true.
107
+ */
108
+ prewarm?: boolean;
109
+ /** Fail instead of starting a daemon when none is listening. */
110
+ spawn?: boolean;
111
+ /** Where a spawned daemon's diagnostics go. Default `$SHOTIUM_DAEMON_LOG`. */
112
+ logFile?: string;
113
+ /** How long to wait for a daemon this process started to bind. */
114
+ startTimeoutMs?: number;
115
+ }
116
+
117
+ export interface DaemonStatus {
118
+ ok?: boolean;
119
+ running?: boolean;
120
+ spawned?: boolean;
121
+ pid: number;
122
+ endpoint: string;
123
+ cacheDir: string|null;
124
+ userAgent?: string;
125
+ resourceDir?: string;
126
+ /** The engine has rendered at least once. */
127
+ warm: boolean;
128
+ uptimeMs: number;
129
+ connections: number;
130
+ inFlight: number;
131
+ served: number;
132
+ idleTimeoutMs: number;
133
+ version: string;
134
+ }
135
+
136
+ export interface PurgeOptions {
137
+ /**
138
+ * Also ask the OS to take the engine's pages back. The next screenshot pays
139
+ * them back in soft page faults -- a few milliseconds -- so this is for when
140
+ * there may not be a next one soon.
141
+ */
142
+ releaseWorkingSet?: boolean;
143
+ }