@shotkit/shotium 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -325,6 +325,11 @@ interface CaptureStats {
325
325
  timing: {
326
326
  fetch: number; // Document retrieval: DNS, TCP, TLS, and round-trip latency
327
327
  render: number; // Rendering: parsing, subresources, styles, layout, paint
328
+ setup: number; // Page/frame creation and document installation
329
+ wait: number; // Parsing, load completion and subresource wait
330
+ lifecycle: number; // Capture selection, style, layout and lifecycle
331
+ paint: number; // PaintRecord extraction
332
+ raster: number; // Surface preparation and PaintRecord replay
328
333
  encode: number; // Image encoding duration
329
334
  total: number; // Total wall-clock duration
330
335
  };
@@ -1,4 +1,4 @@
1
- import { d as resolveStartOptions, i as Engine, n as encodeFrame, r as endpointFor, t as FrameReader } from "./protocol-BTeWJDOa.js";
1
+ import { d as resolveStartOptions, i as Engine, n as encodeFrame, r as endpointFor, t as FrameReader } from "./protocol-rQEcQPAC.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
package/dist/index.d.ts CHANGED
@@ -51,8 +51,18 @@ interface CaptureTiming {
51
51
  fetch: number;
52
52
  /** Parse, subresources, style, layout, prepaint, paint. */
53
53
  render: number;
54
+ /** Page/frame creation and synchronous document installation. */
55
+ setup: number;
56
+ /** Waiting for parsing, load completion and subresources. */
57
+ wait: number;
58
+ /** Capture selection plus style/layout/lifecycle advancement. */
59
+ lifecycle: number;
60
+ /** Extracting Blink's paint record. */
61
+ paint: number;
62
+ /** Raster-surface preparation and paint-record replay. */
63
+ raster: number;
54
64
  encode: number;
55
- /** Wall clock for the whole capture, so the three above can be checked. */
65
+ /** Wall clock for the whole capture, so the phases above can be checked. */
56
66
  total: number;
57
67
  }
58
68
  /** What one capture cost, and where its bytes came from. */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as emptyStats, c as cacheRoot, d as resolveStartOptions, f as load, i as Engine, l as defaultCacheDir, n as encodeFrame, o as timeoutFor, r as endpointFor, s as toRequest, t as FrameReader, u as normalizePath } from "./protocol-BTeWJDOa.js";
1
+ import { a as emptyStats, c as cacheRoot, d as resolveStartOptions, f as load, i as Engine, l as defaultCacheDir, n as encodeFrame, o as timeoutFor, r as endpointFor, s as toRequest, t as FrameReader, u as normalizePath } from "./protocol-rQEcQPAC.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -417,6 +417,11 @@ function emptyStats() {
417
417
  timing: {
418
418
  fetch: 0,
419
419
  render: 0,
420
+ setup: 0,
421
+ wait: 0,
422
+ lifecycle: 0,
423
+ paint: 0,
424
+ raster: 0,
420
425
  encode: 0,
421
426
  total: 0
422
427
  }
@@ -471,4 +476,4 @@ var FrameReader = class {
471
476
 
472
477
  //#endregion
473
478
  export { emptyStats as a, cacheRoot as c, resolveStartOptions as d, load as f, Engine as i, defaultCacheDir as l, encodeFrame as n, timeoutFor as o, endpointFor as r, toRequest as s, FrameReader as t, normalizePath as u };
474
- //# sourceMappingURL=protocol-BTeWJDOa.js.map
479
+ //# sourceMappingURL=protocol-rQEcQPAC.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"protocol-BTeWJDOa.js","names":["require","platformPackage.packageDir","platformPackage.packageName","binding.load","binding.directory"],"sources":["../src/lib/platform.ts","../src/lib/binding.ts","../src/lib/config.ts","../src/lib/request.ts","../src/lib/engine.ts","../src/lib/endpoint.ts","../src/lib/protocol.ts"],"sourcesContent":["import {createRequire} from 'node:module';\nimport path from 'node:path';\n\n// require.resolve is the resolver, and ESM has no synchronous equivalent\n// that answers for a package that may not be installed at all.\nconst require = createRequire(import.meta.url);\n\n// Which package carries the engine for this machine.\n//\n// The engine is not in this package and cannot be: it is a Chromium build,\n// 41 MB per platform and architecture, six of them, and `npm install` is never\n// going to produce one. So the bytes live in six packages of their own and\n// this one depends on all six as optionalDependencies with `os` and `cpu` set,\n// which is npm's way of saying \"install the one that matches this machine and\n// skip the other five\". A machine nobody builds for installs none of them and\n// still gets a working package -- it just has to be pointed at an engine.\n//\n// The alternative, a postinstall script that downloads a tarball, was not\n// chosen. It defeats a lockfile, which is supposed to pin what you get; it\n// fails behind a registry mirror, which is the one place a large dependency\n// most needs to work; and it runs code at install time in exchange for saving\n// nothing that npm was not already doing.\n//\n// Key and name are both `${process.platform}-${process.arch}`, so the table is\n// the identity map with a prefix on it. That is deliberate: the value npm\n// matches `os` and `cpu` against is process.platform, and a package named for\n// anything else makes the reader hold two spellings of one machine in their\n// head. It is also what every other package of this shape does -- esbuild,\n// swc, lightningcss all publish darwin-arm64 and win32-x64.\n//\n// The release archives spell it win/mac instead -- shotium-mac-arm64.7z --\n// and that is not going to change either. They are downloaded by people, and\n// `mac` is what people call it. So the two spellings do differ, in the one\n// place where each is right: the registry gets node's, the download page gets\n// the reader's.\nconst PACKAGES: Readonly<Record<string, string>> = {\n 'win32-x64': '@shotkit/shotium-win32-x64',\n 'win32-arm64': '@shotkit/shotium-win32-arm64',\n 'darwin-x64': '@shotkit/shotium-darwin-x64',\n 'darwin-arm64': '@shotkit/shotium-darwin-arm64',\n 'linux-x64': '@shotkit/shotium-linux-x64',\n 'linux-arm64': '@shotkit/shotium-linux-arm64',\n};\n\nfunction packageName(\n platform: string = process.platform,\n arch: string = process.arch): string|null {\n return PACKAGES[`${platform}-${arch}`] ?? null;\n}\n\n// Where the matching platform package unpacked, or null if it is not installed.\n//\n// require.resolve rather than a path built from the module's own location: the\n// package can be hoisted to a workspace root, nested under this one, or left\n// in a pnpm store with a symlink pointing at it, and the resolver is the only\n// thing that knows which of those happened.\nfunction packageDir(): string|null {\n const name = packageName();\n if (!name) {\n return null;\n }\n try {\n return path.dirname(require.resolve(`${name}/package.json`));\n } catch {\n return null;\n }\n}\n\nexport {PACKAGES, packageDir, packageName};\n","import fs from 'node:fs';\nimport {createRequire} from 'node:module';\nimport path from 'node:path';\nimport {fileURLToPath} from 'node:url';\n\nimport * as platformPackage from './platform.js';\n\n// A .node addon is a CommonJS artefact: there is no ESM loader for one.\nconst require = createRequire(import.meta.url);\n\n// ESM has no __dirname. This is the same thing, from the module's own URL.\nconst HERE = path.dirname(fileURLToPath(import.meta.url));\n\n/**\n * The engine handle the addon hands back. Opaque on purpose: everything that\n * can be done with it is a call on the binding below.\n */\nexport type Engine = unknown;\n\n/** One capture's answer, as the addon hands it over. */\nexport interface NativeCapture {\n image: Buffer;\n /**\n * CaptureStats as JSON, unparsed. The addon carries JSON between the engine\n * and this layer without reading it -- anything it understood would be a\n * third opinion about the shape, and the third opinion is the one that\n * drifts. Undefined when the engine reported none.\n */\n stats?: string;\n}\n\n/** What native/binding.cc exports. See shot/shot_api.h for the C ABI. */\nexport interface NativeBinding {\n create(optionsJson: string): Engine;\n destroy(engine: Engine): void;\n purge(engine: Engine, releaseWorkingSet: boolean): void;\n status(engine: Engine): string;\n capture(engine: Engine, requestJson: string): Promise<NativeCapture>;\n /**\n * List or clear a cache directory. `engine` is nullable and that is the\n * interface: with one, the operation runs on the engine's thread and borrows\n * the backend it already holds; without one, the library opens the directory\n * itself. Resolves to JSON.\n */\n cache(engine: Engine|null, clearing: boolean, optionsJson: string):\n Promise<string>;\n}\n\n// Where the addon and the library beside it live.\n//\n// The platform package is what ships -- the .node sits next to the shared\n// library it is linked against, which is the whole reason the two travel in\n// one package rather than two. native/build/Release is where node-gyp puts a\n// local build; it exists in a checkout and not in an install, so the two never\n// compete in practice. Both paths are relative to this file's build output,\n// which is one directory below the package root.\nfunction candidates(): string[] {\n const found: string[] = [];\n const dir = platformPackage.packageDir();\n if (dir) {\n found.push(path.join(dir, 'shotium.node'));\n }\n found.push(\n path.join(HERE, '..', 'native', 'build', 'Release', 'shotium.node'));\n return found;\n}\n\nlet binding: NativeBinding|null = null;\nlet loadedFrom: string|null = null;\n\n/**\n * The addon, loaded once. Throws if there is none for this platform, which is\n * the only failure this package cannot work around: there is nothing else to\n * fall back to.\n */\nexport function load(): NativeBinding {\n if (binding) {\n return binding;\n }\n const tried = candidates();\n for (const candidate of tried) {\n if (!fs.existsSync(candidate)) {\n continue;\n }\n // Not wrapped in a try: a .node that is there and will not load is a\n // broken installation, and the loader's own message -- a missing\n // dependency, an architecture mismatch -- says more than anything that\n // could be substituted for it.\n binding = require(candidate) as NativeBinding;\n loadedFrom = path.dirname(candidate);\n return binding;\n }\n const expected = platformPackage.packageName();\n throw new Error(\n 'shotium: no engine for this platform.\\n' +\n ` looked in:\\n ${tried.join('\\n ')}\\n` +\n (expected ?\n ` It ships in ${expected}, which npm installs as an optional ` +\n 'dependency of this package. If the install skipped optional ' +\n 'dependencies, it is not there.\\n' :\n ` There is no build for ${process.platform}-${process.arch}.\\n`));\n}\n\n/**\n * The directory the addon came from, or null before the first load(). The\n * resource packs ship beside it, which is what this is for.\n */\nexport function directory(): string|null {\n return loadedFrom;\n}\n","import crypto from 'node:crypto';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport type {StartOptions} from '../types.js';\n\n// StartOptions with every hole filled in. `cacheDir` is still nullable here\n// because null is an answer -- \"no disk cache\" -- and not an absent one.\nexport interface ResolvedStartOptions {\n cacheDir: string|null;\n cacheMaxBytes: number;\n userAgent?: string;\n resourceDir?: string;\n}\n\n// One number, chosen rather than delegated.\n//\n// Passing 0 hands the decision to the disk cache backend, which sizes itself\n// against the volume's free space -- a defensible default for a browser\n// profile the user knows about, and a poor one for a directory that appears\n// under ~/.shotium because somebody imported a library. 256 MB holds a large\n// corpus of pages and their fonts, and is small enough that nobody has to\n// think about it.\nconst DEFAULT_CACHE_MAX_BYTES = 256 * 1024 * 1024;\n\n/**\n * One spelling of a path: absolute, with forward slashes.\n *\n * Every path this module hands back goes through here. On Windows the two\n * separators are interchangeable to the filesystem and not to a caller\n * comparing strings or writing a glob, and a library that returns whichever\n * one `path.join` happened to produce makes that the caller's problem.\n */\nexport function normalizePath(target: string): string {\n return path.resolve(target).replace(/\\\\/g, '/');\n}\n\n/**\n * The project the current process belongs to: the nearest directory at or\n * above the working directory that has a package.json.\n *\n * The working directory itself would be the obvious key and is the wrong one.\n * It moves -- `process.chdir`, or a script run from a subdirectory -- and each\n * value it takes would get a cache of its own, so a project would slowly\n * accumulate directories that each know a third of its pages. The package root\n * is the thing that stays put.\n *\n * Falls back to the working directory when there is no package.json above it,\n * which is what a bare script has and is still better than nothing: it is at\n * least stable for as long as the script runs from one place.\n */\nfunction projectRoot(): string {\n let dir = process.cwd();\n for (;;) {\n if (fs.existsSync(path.join(dir, 'package.json'))) {\n return dir;\n }\n const parent = path.dirname(dir);\n if (parent === dir) {\n return process.cwd();\n }\n dir = parent;\n }\n}\n\n/**\n * Where every shotium cache directory lives. One level up from any single\n * project's, which is what makes `target: 'all'` answerable.\n *\n * Under the home directory and not the temporary one, which is where this was\n * until 0.3 was cut. $TMPDIR is defined by not surviving: /tmp is emptied on\n * reboot, systemd-tmpfiles removes anything untouched for ten days, and macOS\n * sweeps it on a schedule of its own. The entire value of an HTTP cache is the\n * *next* run, so a default that lives somewhere designed to be cleared is a\n * cache that stops working at exactly the moment it would have started paying\n * for itself.\n *\n * `~/.shotium`, spelled the same on every platform. One place a user can look\n * for it, one path to say in a bug report, and one directory to delete.\n *\n * $TMPDIR remains only as a fallback for a process with no home to speak of --\n * some containers, some service accounts. That is a degradation and not a\n * second location: there is no home directory holding a cache that would\n * otherwise have been found.\n */\nfunction shotiumHome(): string {\n return path.join(os.homedir() || os.tmpdir(), '.shotium');\n}\n\nexport function cacheRoot(): string {\n return normalizePath(path.join(shotiumHome(), 'cache'));\n}\n\n/**\n * The identifier for a project's cache directory: a hash of its root path.\n *\n * A hash rather than the path itself because the path contains separators,\n * drive letters and whatever the user called their directory, none of which\n * survive being a directory name. It is not a security measure and does not\n * need to be one -- it is a fixed-length name for a variable-length string.\n */\nexport function projectKey(root: string = projectRoot()): string {\n return crypto.createHash('sha1').update(normalizePath(root)).digest('hex');\n}\n\n/** This project's cache directory. */\nexport function defaultCacheDir(): string {\n return normalizePath(path.join(cacheRoot(), projectKey()));\n}\n\n// The one place that decides what \"no options\" means.\n//\n// It is shared rather than duplicated because the daemon's address is a hash of\n// its configuration: if two callers filled in defaults even slightly\n// differently, one would compute an address no daemon is listening on and\n// start a second engine next to the first one that was already warm. See\n// endpoint.ts.\n//\n// `cacheDir` defaults to this project's directory rather than to null, which\n// is the reverse of 0.2. The reason is measured: without a cache every capture\n// of an `https:` URL pays DNS, TLS and a round trip, which for a small page is\n// most of the wall clock and all of the surprise. The objection to a default\n// -- that a short-lived program leaves a directory behind -- is answered by\n// the directory being per-project, size-capped, and somewhere the platform's\n// own tooling knows how to clear, rather than by there being no cache.\n// `cacheDir: null` still turns it off.\nfunction resolveStartOptions(options: StartOptions = {}): ResolvedStartOptions {\n return {\n cacheDir: options.cacheDir === null ? null :\n (options.cacheDir ?? defaultCacheDir()),\n cacheMaxBytes: options.cacheMaxBytes ?? DEFAULT_CACHE_MAX_BYTES,\n userAgent: options.userAgent,\n resourceDir: options.resourceDir,\n };\n}\n\nexport {DEFAULT_CACHE_MAX_BYTES, resolveStartOptions};\n","import type {\n CacheMode,\n Clip,\n PageGotoParams,\n ScreenshotOptions,\n} from '../types.js';\n\nconst DEFAULT_TIMEOUT_MS = 30000;\n\n// What actually goes down the pipe: ScreenshotOptions with the viewport\n// flattened -- see toRequest below for why.\nexport interface WireRequest {\n file: string;\n type?: 'png'|'jpeg'|'webp';\n fullPage?: boolean;\n selector?: string;\n quality?: number;\n scale?: number;\n omitBackground?: boolean;\n path?: string;\n pageGotoParams?: PageGotoParams;\n clip?: Clip;\n allowFileAccess?: boolean;\n cache?: CacheMode;\n headers?: Record<string, string>;\n width?: number;\n height?: number;\n}\n\n// Everything the worker understands, and nothing else. An unknown field is a\n// typo, and a typo that is silently dropped is a screenshot that quietly\n// ignored what was asked for -- so this rejects rather than filters.\n//\n// It is a runtime check even though the argument has a type, because the\n// argument having a type says nothing about a caller who is not compiled\n// against it: a JavaScript program, or a JSON body from somewhere else.\nconst WIRE_FIELDS = new Set([\n 'file',\n 'type',\n 'fullPage',\n 'selector',\n 'quality',\n 'scale',\n 'omitBackground',\n 'path',\n 'pageGotoParams',\n 'clip',\n 'viewport',\n 'allowFileAccess',\n 'cache',\n 'headers',\n]);\n\n// One ScreenshotOptions, checked and flattened into what goes on the wire.\n//\n// It lives here rather than in index.ts because the engine in this process and\n// the daemon both send it: a request that is valid through one entry point and\n// rejected through the other would be a difference nobody asked for.\nfunction toRequest(options: ScreenshotOptions): WireRequest {\n if (!options || typeof options !== 'object') {\n throw new TypeError('shotium: screenshot(options) needs an object');\n }\n if (typeof options.file !== 'string' || options.file.length === 0) {\n throw new TypeError('shotium: options.file is required');\n }\n\n const request: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(options)) {\n if (value === undefined) {\n continue;\n }\n if (!WIRE_FIELDS.has(key)) {\n throw new TypeError(`shotium: unknown option \"${key}\"`);\n }\n request[key] = value;\n }\n\n // The viewport is flattened because the worker takes width and height at the\n // top level: it is one screenshot's frame, not a nested object on the wire.\n if (request.viewport) {\n const {width, height} = request.viewport as {\n width?: number,\n height?: number,\n };\n delete request.viewport;\n if (width !== undefined) {\n request.width = width;\n }\n if (height !== undefined) {\n request.height = height;\n }\n }\n return request as unknown as WireRequest;\n}\n\nfunction timeoutFor(options: ScreenshotOptions): number {\n const timeout = options.pageGotoParams && options.pageGotoParams.timeout;\n return typeof timeout === 'number' ? timeout : DEFAULT_TIMEOUT_MS;\n}\n\nexport {\n DEFAULT_TIMEOUT_MS,\n WIRE_FIELDS,\n timeoutFor,\n toRequest,\n};\n","import * as binding from './binding.js';\nimport type {Engine as Handle} from './binding.js';\nimport {toRequest} from './request.js';\nimport type {WireRequest} from './request.js';\nimport type {\n CaptureStats,\n ReleaseMemoryOptions,\n ScreenshotOptions,\n ScreenshotResult,\n StartOptions,\n StartResult,\n} from '../types.js';\n\nimport type {ResolvedStartOptions} from './config.js';\nimport {resolveStartOptions} from './config.js';\n\n// The engine this process has, held above every Engine object that uses it.\n//\n// Blink is initialised once and has no undo: it writes process-wide statics\n// that shot_engine_destroy() cannot take back, and the C API refuses a second\n// create for the lifetime of the process whether or not the first is still\n// alive.\n//\n// That fact used to be exposed directly -- `stop()` destroyed the engine and\n// every later `start()` threw. It was the wrong shape. `stop()` and `start()`\n// are a caller saying \"I am done for now\" and \"I want it again\", and a library\n// whose engine can be asked for exactly once turns an ordinary pair of calls\n// into a thing that has to be rationed. It also made the disk cache\n// nonsensical: the whole point of a cache is the *next* run, and the next run\n// could not have the engine that reads it.\n//\n// So the handle lives here rather than on the instance. `stop()` stands the\n// engine down -- the queue drains, the memory goes back, nothing more is\n// accepted -- and `start()` picks the same one up again, as many times as a\n// caller likes. The process is the engine's lifetime, which is what it always\n// was; the difference is that the API no longer pretends to offer a shorter\n// one.\nlet shared: Handle|null = null;\n\n// What `shared` was created with. Kept because those options are fixed for the\n// life of the process -- a later `start()` asking for a different cache\n// directory cannot be given one, and is told so rather than handed an engine\n// that quietly uses the first caller's.\nlet sharedOptions: ResolvedStartOptions|null = null;\n\n// Whether the one create this process gets has been spent.\n//\n// Separate from `shared` being non-null because `dispose()` clears the handle\n// and does not give the ability back: after a real teardown there is no engine\n// and there cannot be another. Nothing in the public surface calls dispose()\n// -- the daemon does, on its way out of a process it owns.\nlet spent = false;\n\n/** The engine handle this process has, or null if it has none. */\nfunction sharedHandle(): Handle|null {\n return shared;\n}\n\n// The options that are fixed at create time, and the report a mismatch gets.\n//\n// Only the ones the caller actually named are checked: `start()` with no\n// arguments is a caller saying \"whatever is there\", which is exactly what\n// adopting a running engine gives them. Naming an option that disagrees is\n// different -- it is a request that cannot be honoured, and silently rendering\n// with the other value is the failure this exists to prevent.\nfunction conflictingOption(\n options: StartOptions, current: ResolvedStartOptions): string|null {\n const wanted = resolveStartOptions(options);\n for (const key of ['cacheDir', 'cacheMaxBytes', 'userAgent', 'resourceDir'] as\n const) {\n if (options[key] === undefined || wanted[key] === current[key]) {\n continue;\n }\n return `${key} is ${JSON.stringify(current[key])}, and this start() ` +\n `asked for ${JSON.stringify(wanted[key])}`;\n }\n return null;\n}\n\n/**\n * Blink, in this process, and the queue in front of it.\n *\n * There is one renderer and there is no way to have two. Blink is a\n * process-wide singleton: it is initialised once, there is no path to a second\n * one, and `worker_threads` do not change that because they share the process.\n * So captures are serialised however many callers there are, and a program\n * that wants four at once wants four processes.\n *\n * The queue is not about fairness. Each capture occupies a libuv thread pool\n * thread for as long as the render takes, and there are four of those by\n * default, shared with fs and dns -- so letting four screenshots go at once\n * would stall the host's file reads for a fifth of a second at a time while\n * gaining nothing, since the engine serialises them anyway.\n */\nexport class Engine {\n // Whether *this* object considers itself started. The engine behind it may\n // well be up for somebody else; `running` is about this lifecycle, not about\n // whether the process has an engine.\n private active = false;\n private tail: Promise<unknown> = Promise.resolve();\n\n get running(): boolean {\n return this.active && shared !== null;\n }\n\n /**\n * The addon's engine handle, or null when this process has never had one.\n *\n * Deliberately not conditional on `running`. It is the cache that asks, and\n * what the cache needs to know is whether a backend exists in this process\n * -- because within one process a cache directory has one backend, so\n * reading or clearing the directory the engine holds means borrowing it\n * rather than opening a second one. A stood-down engine still holds its\n * directory, so a caller who calls `stop()` and then `cache.getFiles()` is\n * asking about a live backend and has to be routed to it. Nothing else\n * should reach for this.\n */\n get nativeHandle(): Handle|null {\n return sharedHandle();\n }\n\n /**\n * Starts the engine, or picks the running one back up.\n *\n * Callable as often as a caller likes, in any order with `stop()`. The first\n * call in a process builds the engine; every later one adopts it, which is\n * the same engine and the same warm cache. Library code can call it\n * defensively.\n *\n * The one thing that cannot be adopted is a different configuration. The\n * options below are fixed when the engine is built and there is no second\n * build, so naming one that disagrees with what is running throws rather\n * than rendering with a value the caller did not ask for.\n */\n start(options: StartOptions = {}): StartResult {\n if (shared) {\n const conflict = conflictingOption(options, sharedOptions!);\n if (conflict) {\n throw new Error(\n 'shotium: this process already has an engine, and its ' +\n conflict + '. Blink is initialised once per process and cannot ' +\n 'be built again, so an engine\\'s options are fixed for as long ' +\n 'as the process lives -- stop() does not undo them. Use the ' +\n 'engine that is up, or run another process.');\n }\n this.active = true;\n return this.status();\n }\n if (spent) {\n throw new Error(\n 'shotium: this process had an engine and it was disposed of. ' +\n 'Blink is initialised once per process and cannot be built again. ' +\n 'Run another process.');\n }\n\n const native = binding.load();\n const resolved = resolveStartOptions(options);\n\n const engineOptions: Record<string, unknown> = {};\n if (resolved.cacheDir !== null) {\n engineOptions.cacheDir = resolved.cacheDir;\n engineOptions.cacheMaxBytes = resolved.cacheMaxBytes;\n }\n if (resolved.userAgent !== undefined) {\n engineOptions.userAgent = resolved.userAgent;\n }\n // The packs sit beside the library, and the library cannot find itself on\n // Linux -- the path the engine resolves for \"this module\" goes through\n // /proc/self/exe, which names node. Saying it here is cheaper than\n // teaching the engine a second way to look. See shot_api.h.\n engineOptions.resourceDir = resolved.resourceDir ?? binding.directory();\n\n shared = native.create(JSON.stringify(engineOptions));\n sharedOptions = resolved;\n this.active = true;\n return this.status();\n }\n\n /**\n * What the engine came up as: whether this lifecycle is started, which cache\n * directory the engine has, and whether it actually got it.\n *\n * The last of those is the one worth reading. A directory that cannot be\n * created or written to -- no permission, no space, a path that is a file --\n * fails invisibly: the engine renders exactly as well without a cache, only\n * slower, and every capture pays the network again for a reason nothing\n * reports. The engine opens the cache during `start()` so that this is\n * answerable before the first screenshot rather than after it.\n *\n * The cache half is answered from the engine whenever this process has one,\n * including after `stop()`. A stood-down engine still holds its directory,\n * and reporting `null` for it would say the cache had gone away when what\n * went away was the willingness to render.\n */\n status(): StartResult {\n if (!shared) {\n return {running: false, cacheDir: null, cacheActive: false};\n }\n const reported =\n JSON.parse(binding.load().status(shared)) as Omit<StartResult, 'running'>;\n return {running: this.running, ...reported};\n }\n\n /**\n * Stands the engine down, after whatever is queued.\n *\n * The queue drains, the memory the engine can rebuild goes back to the OS,\n * and `running` becomes false. What does not happen is a teardown of Blink,\n * because there is no such thing -- see the note at the top of this file --\n * so the disk cache stays where it is and `start()` picks the same engine up\n * again whenever the caller wants it.\n *\n * Which makes this exactly what it says: not a destructor, a caller saying\n * they are done for now. A program that will want another screenshot in a\n * moment can equally well stay started and call `releaseMemory()`; the two\n * do the same work, and this one also stops accepting captures.\n */\n async stop(): Promise<void> {\n if (!this.active) {\n return;\n }\n this.active = false;\n // After the queue, not before: a caller's last screenshot should resolve\n // rather than race the stand-down, and the memory is not worth handing\n // back until the thing still using it has finished.\n await this.tail.catch(() => {});\n if (shared) {\n binding.load().purge(shared, /*releaseWorkingSet=*/ true);\n }\n }\n\n /**\n * The real teardown: joins the engine thread, unwinds the network stack, and\n * lets the disk cache write its index.\n *\n * Final, and final for the process rather than for this object -- which is\n * why it is not on the public surface. The daemon calls it as it exits a\n * process it owns, where the index flush is worth having and nothing is\n * going to ask for another screenshot. Everything else wants `stop()`.\n */\n async dispose(): Promise<void> {\n this.active = false;\n await this.tail.catch(() => {});\n const handle = shared;\n shared = null;\n sharedOptions = null;\n if (handle) {\n spent = true;\n binding.load().destroy(handle);\n }\n }\n\n /**\n * Hands back what the engine is holding but can rebuild.\n * `releaseWorkingSet` additionally asks the OS for the pages, which the next\n * screenshot pays back in soft faults -- worth it when there may not be a\n * next one soon.\n *\n * The daemon does this for itself on a timer because it can watch its own\n * request stream go quiet. Here the queue belongs to the caller, so the\n * caller is the one who knows a batch has ended.\n */\n releaseMemory({releaseWorkingSet = false}: ReleaseMemoryOptions = {}): void {\n if (!shared) {\n return;\n }\n binding.load().purge(shared, releaseWorkingSet);\n }\n\n /**\n * Renders one screenshot. Resolves to the encoded image, or to `null` when\n * `path` was given and the engine wrote the file itself.\n */\n // `async` and not a plain function returning capture()'s promise: toRequest()\n // throws, and a caller who wrote `screenshot(bad).catch(...)` would get the\n // throw past the catch and into the surrounding frame. The whole surface is\n // promise-shaped, so a bad request is a rejection like everything else.\n async screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {\n // Before anything else, and before the queue: a malformed request should\n // be a rejection now rather than one that waits its turn.\n return this.capture(toRequest(options));\n }\n\n /**\n * The same, for a request that is already in wire form.\n *\n * The daemon reads these off a socket, where they arrived having been\n * validated by the client that sent them. Re-deriving one from\n * ScreenshotOptions would mean the daemon validating a request it cannot see\n * the original of, and rejecting fields a newer client legitimately sent.\n */\n async capture(request: WireRequest): Promise<ScreenshotResult> {\n // Starts, or restarts, or adopts -- a screenshot after `stop()` is an\n // ordinary thing to ask for and gets the engine back.\n if (!this.running) {\n this.start();\n }\n const handle = shared!;\n const native = binding.load();\n\n // Chain onto the tail so that captures run one at a time. The catch keeps\n // one failure from poisoning everything queued behind it.\n const result = this.tail.catch(() => {}).then(\n () => native.capture(handle, JSON.stringify(request)));\n this.tail = result.catch(() => {});\n\n let captured;\n try {\n captured = await result;\n } catch (error) {\n // The addon attaches the capture's statistics to the rejection as\n // unparsed JSON, the same way it hands them back on success -- see\n // NativeCapture. Parsing them here rather than leaving a string on the\n // error is what makes `error.stats` the same CaptureStats a successful\n // call returns, which is the whole point of attaching it: the failure is\n // the case where the counters explain the most.\n const withStats = error as Error&{stats?: string | CaptureStats};\n if (typeof withStats.stats === 'string') {\n withStats.stats = JSON.parse(withStats.stats) as CaptureStats;\n }\n throw error;\n }\n\n return {\n image: request.path ? null : captured.image,\n stats: parseStats(captured.stats),\n };\n }\n}\n\n// A zeroed set of counters.\n//\n// Zeroes rather than undefined because the alternative is every caller writing\n// `stats?.timing?.total ?? 0` around a field that is present for every capture\n// that actually ran. The only case that produces none is a request rejected\n// before it started, and that path throws rather than returning.\nfunction emptyStats(): CaptureStats {\n return {\n requests: 0,\n fromCache: 0,\n failed: 0,\n bytes: 0,\n httpStatus: 0,\n finalUrl: '',\n timing: {fetch: 0, render: 0, encode: 0, total: 0},\n };\n}\n\n// The addon hands statistics over as unparsed JSON -- see NativeCapture -- so\n// this is where the string becomes an object. The daemon's client has them\n// parsed already, from its own response header, and uses emptyStats directly.\nfunction parseStats(json: string|undefined): CaptureStats {\n return json ? JSON.parse(json) as CaptureStats : emptyStats();\n}\n\nexport {emptyStats, parseStats, sharedHandle};\n","import crypto from 'node:crypto';\nimport os from 'node:os';\nimport path from 'node:path';\n\n// What endpointFor() needs to know: a resolved configuration, plus the two\n// ways of overriding the address it would derive from one.\nexport interface EndpointOptions {\n cacheDir?: string|null;\n userAgent?: string;\n resourceDir?: string;\n name?: string;\n endpoint?: string;\n}\n\n// Where a daemon listens, derived from what it was asked to be.\n//\n// The address is a hash of the configuration -- cache root, user agent,\n// resource directory -- rather than a fixed name, because attaching to\n// whatever daemon happens to be up would mean rendering with someone else's\n// settings. Two configurations are two daemons; the same configuration, from\n// any process, is one.\n//\n// Every field of EndpointOptions is optional, so nothing here fails to compile\n// when a field is dropped from the configuration -- it just stops being part\n// of the identity, and every caller collapses onto one address. That happened\n// once, when the worker pool went away and this was left hashing three fields\n// that no longer existed. If a field is added to StartOptions and it changes\n// what the engine renders, it belongs in the array below.\n//\n// A caller who wants a daemon by name instead of by configuration passes\n// `name`, which replaces the hash. That is the escape hatch for a service that\n// starts its daemon deliberately and wants clients to find it without\n// repeating the configuration.\nfunction endpointKey(options: EndpointOptions): string {\n if (options.name) {\n return String(options.name);\n }\n const identity = JSON.stringify([\n options.cacheDir === null || options.cacheDir === undefined ?\n null :\n path.resolve(options.cacheDir),\n options.userAgent ?? null,\n options.resourceDir ? path.resolve(options.resourceDir) : null,\n ]);\n return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);\n}\n\n// Windows has named pipes and no filesystem sockets; POSIX has the reverse.\n// Both are net.connect() addresses, which is the only reason the rest of the\n// daemon can ignore the difference.\n//\n// The pipe namespace is per-machine but the socket path is per-user, so the\n// uid goes in the POSIX name to keep two users on one host from colliding on a\n// path only one of them can open.\nfunction endpointFor(options: EndpointOptions = {}): string {\n if (options.endpoint) {\n return options.endpoint;\n }\n if (process.env.SHOTIUM_ENDPOINT) {\n return process.env.SHOTIUM_ENDPOINT;\n }\n const key = endpointKey(options);\n if (process.platform === 'win32') {\n return `\\\\\\\\.\\\\pipe\\\\shotium-${key}`;\n }\n const uid = typeof process.getuid === 'function' ? process.getuid() : 0;\n return path.join(os.tmpdir(), `shotium-${uid}-${key}.sock`);\n}\n\nexport {endpointFor, endpointKey};\n","// The wire format shotium.exe --serve speaks, in both directions: a 4-byte\n// little-endian length followed by that many bytes.\n//\n// Length-prefixed rather than line-delimited because the payload is binary and\n// a newline inside a PNG is not a message boundary. See shot/shot_server.h for\n// the same description from the other end.\n//\n// -> [len][{\"file\":\"...\",\"width\":1248,...}]\n// <- [len][{\"ok\":true,\"bytes\":97756}] [len][<PNG bytes>]\n// <- [len][{\"ok\":false,\"error\":\"...\"}] [0]\n\nconst HEADER_BYTES = 4;\n\nfunction encodeFrame(payload: Buffer): Buffer {\n const header = Buffer.allocUnsafe(HEADER_BYTES);\n header.writeUInt32LE(payload.length, 0);\n return Buffer.concat([header, payload]);\n}\n\nfunction encodeRequest(request: unknown): Buffer {\n return encodeFrame(Buffer.from(JSON.stringify(request), 'utf8'));\n}\n\n// Reassembles frames out of whatever sizes the pipe hands over.\n//\n// A stream is not a sequence of messages: one read can carry half a header, or\n// three responses and the start of a fourth. Everything downstream assumes\n// whole frames, so this is the only place that has to know that.\nclass FrameReader {\n private buffer: Buffer = Buffer.alloc(0);\n\n push(chunk: Buffer): void {\n this.buffer = this.buffer.length === 0 ?\n chunk :\n Buffer.concat([this.buffer, chunk]);\n }\n\n // The next complete frame, or null when there is not one yet.\n next(): Buffer|null {\n if (this.buffer.length < HEADER_BYTES) {\n return null;\n }\n const length = this.buffer.readUInt32LE(0);\n if (this.buffer.length < HEADER_BYTES + length) {\n return null;\n }\n const frame = this.buffer.subarray(HEADER_BYTES, HEADER_BYTES + length);\n this.buffer = this.buffer.subarray(HEADER_BYTES + length);\n return frame;\n }\n}\n\nexport {HEADER_BYTES, encodeFrame, encodeRequest, FrameReader};\n"],"mappings":"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"}
1
+ {"version":3,"file":"protocol-rQEcQPAC.js","names":["require","platformPackage.packageDir","platformPackage.packageName","binding.load","binding.directory"],"sources":["../src/lib/platform.ts","../src/lib/binding.ts","../src/lib/config.ts","../src/lib/request.ts","../src/lib/engine.ts","../src/lib/endpoint.ts","../src/lib/protocol.ts"],"sourcesContent":["import {createRequire} from 'node:module';\nimport path from 'node:path';\n\n// require.resolve is the resolver, and ESM has no synchronous equivalent\n// that answers for a package that may not be installed at all.\nconst require = createRequire(import.meta.url);\n\n// Which package carries the engine for this machine.\n//\n// The engine is not in this package and cannot be: it is a Chromium build,\n// 41 MB per platform and architecture, six of them, and `npm install` is never\n// going to produce one. So the bytes live in six packages of their own and\n// this one depends on all six as optionalDependencies with `os` and `cpu` set,\n// which is npm's way of saying \"install the one that matches this machine and\n// skip the other five\". A machine nobody builds for installs none of them and\n// still gets a working package -- it just has to be pointed at an engine.\n//\n// The alternative, a postinstall script that downloads a tarball, was not\n// chosen. It defeats a lockfile, which is supposed to pin what you get; it\n// fails behind a registry mirror, which is the one place a large dependency\n// most needs to work; and it runs code at install time in exchange for saving\n// nothing that npm was not already doing.\n//\n// Key and name are both `${process.platform}-${process.arch}`, so the table is\n// the identity map with a prefix on it. That is deliberate: the value npm\n// matches `os` and `cpu` against is process.platform, and a package named for\n// anything else makes the reader hold two spellings of one machine in their\n// head. It is also what every other package of this shape does -- esbuild,\n// swc, lightningcss all publish darwin-arm64 and win32-x64.\n//\n// The release archives spell it win/mac instead -- shotium-mac-arm64.7z --\n// and that is not going to change either. They are downloaded by people, and\n// `mac` is what people call it. So the two spellings do differ, in the one\n// place where each is right: the registry gets node's, the download page gets\n// the reader's.\nconst PACKAGES: Readonly<Record<string, string>> = {\n 'win32-x64': '@shotkit/shotium-win32-x64',\n 'win32-arm64': '@shotkit/shotium-win32-arm64',\n 'darwin-x64': '@shotkit/shotium-darwin-x64',\n 'darwin-arm64': '@shotkit/shotium-darwin-arm64',\n 'linux-x64': '@shotkit/shotium-linux-x64',\n 'linux-arm64': '@shotkit/shotium-linux-arm64',\n};\n\nfunction packageName(\n platform: string = process.platform,\n arch: string = process.arch): string|null {\n return PACKAGES[`${platform}-${arch}`] ?? null;\n}\n\n// Where the matching platform package unpacked, or null if it is not installed.\n//\n// require.resolve rather than a path built from the module's own location: the\n// package can be hoisted to a workspace root, nested under this one, or left\n// in a pnpm store with a symlink pointing at it, and the resolver is the only\n// thing that knows which of those happened.\nfunction packageDir(): string|null {\n const name = packageName();\n if (!name) {\n return null;\n }\n try {\n return path.dirname(require.resolve(`${name}/package.json`));\n } catch {\n return null;\n }\n}\n\nexport {PACKAGES, packageDir, packageName};\n","import fs from 'node:fs';\nimport {createRequire} from 'node:module';\nimport path from 'node:path';\nimport {fileURLToPath} from 'node:url';\n\nimport * as platformPackage from './platform.js';\n\n// A .node addon is a CommonJS artefact: there is no ESM loader for one.\nconst require = createRequire(import.meta.url);\n\n// ESM has no __dirname. This is the same thing, from the module's own URL.\nconst HERE = path.dirname(fileURLToPath(import.meta.url));\n\n/**\n * The engine handle the addon hands back. Opaque on purpose: everything that\n * can be done with it is a call on the binding below.\n */\nexport type Engine = unknown;\n\n/** One capture's answer, as the addon hands it over. */\nexport interface NativeCapture {\n image: Buffer;\n /**\n * CaptureStats as JSON, unparsed. The addon carries JSON between the engine\n * and this layer without reading it -- anything it understood would be a\n * third opinion about the shape, and the third opinion is the one that\n * drifts. Undefined when the engine reported none.\n */\n stats?: string;\n}\n\n/** What native/binding.cc exports. See shot/shot_api.h for the C ABI. */\nexport interface NativeBinding {\n create(optionsJson: string): Engine;\n destroy(engine: Engine): void;\n purge(engine: Engine, releaseWorkingSet: boolean): void;\n status(engine: Engine): string;\n capture(engine: Engine, requestJson: string): Promise<NativeCapture>;\n /**\n * List or clear a cache directory. `engine` is nullable and that is the\n * interface: with one, the operation runs on the engine's thread and borrows\n * the backend it already holds; without one, the library opens the directory\n * itself. Resolves to JSON.\n */\n cache(engine: Engine|null, clearing: boolean, optionsJson: string):\n Promise<string>;\n}\n\n// Where the addon and the library beside it live.\n//\n// The platform package is what ships -- the .node sits next to the shared\n// library it is linked against, which is the whole reason the two travel in\n// one package rather than two. native/build/Release is where node-gyp puts a\n// local build; it exists in a checkout and not in an install, so the two never\n// compete in practice. Both paths are relative to this file's build output,\n// which is one directory below the package root.\nfunction candidates(): string[] {\n const found: string[] = [];\n const dir = platformPackage.packageDir();\n if (dir) {\n found.push(path.join(dir, 'shotium.node'));\n }\n found.push(\n path.join(HERE, '..', 'native', 'build', 'Release', 'shotium.node'));\n return found;\n}\n\nlet binding: NativeBinding|null = null;\nlet loadedFrom: string|null = null;\n\n/**\n * The addon, loaded once. Throws if there is none for this platform, which is\n * the only failure this package cannot work around: there is nothing else to\n * fall back to.\n */\nexport function load(): NativeBinding {\n if (binding) {\n return binding;\n }\n const tried = candidates();\n for (const candidate of tried) {\n if (!fs.existsSync(candidate)) {\n continue;\n }\n // Not wrapped in a try: a .node that is there and will not load is a\n // broken installation, and the loader's own message -- a missing\n // dependency, an architecture mismatch -- says more than anything that\n // could be substituted for it.\n binding = require(candidate) as NativeBinding;\n loadedFrom = path.dirname(candidate);\n return binding;\n }\n const expected = platformPackage.packageName();\n throw new Error(\n 'shotium: no engine for this platform.\\n' +\n ` looked in:\\n ${tried.join('\\n ')}\\n` +\n (expected ?\n ` It ships in ${expected}, which npm installs as an optional ` +\n 'dependency of this package. If the install skipped optional ' +\n 'dependencies, it is not there.\\n' :\n ` There is no build for ${process.platform}-${process.arch}.\\n`));\n}\n\n/**\n * The directory the addon came from, or null before the first load(). The\n * resource packs ship beside it, which is what this is for.\n */\nexport function directory(): string|null {\n return loadedFrom;\n}\n","import crypto from 'node:crypto';\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport type {StartOptions} from '../types.js';\n\n// StartOptions with every hole filled in. `cacheDir` is still nullable here\n// because null is an answer -- \"no disk cache\" -- and not an absent one.\nexport interface ResolvedStartOptions {\n cacheDir: string|null;\n cacheMaxBytes: number;\n userAgent?: string;\n resourceDir?: string;\n}\n\n// One number, chosen rather than delegated.\n//\n// Passing 0 hands the decision to the disk cache backend, which sizes itself\n// against the volume's free space -- a defensible default for a browser\n// profile the user knows about, and a poor one for a directory that appears\n// under ~/.shotium because somebody imported a library. 256 MB holds a large\n// corpus of pages and their fonts, and is small enough that nobody has to\n// think about it.\nconst DEFAULT_CACHE_MAX_BYTES = 256 * 1024 * 1024;\n\n/**\n * One spelling of a path: absolute, with forward slashes.\n *\n * Every path this module hands back goes through here. On Windows the two\n * separators are interchangeable to the filesystem and not to a caller\n * comparing strings or writing a glob, and a library that returns whichever\n * one `path.join` happened to produce makes that the caller's problem.\n */\nexport function normalizePath(target: string): string {\n return path.resolve(target).replace(/\\\\/g, '/');\n}\n\n/**\n * The project the current process belongs to: the nearest directory at or\n * above the working directory that has a package.json.\n *\n * The working directory itself would be the obvious key and is the wrong one.\n * It moves -- `process.chdir`, or a script run from a subdirectory -- and each\n * value it takes would get a cache of its own, so a project would slowly\n * accumulate directories that each know a third of its pages. The package root\n * is the thing that stays put.\n *\n * Falls back to the working directory when there is no package.json above it,\n * which is what a bare script has and is still better than nothing: it is at\n * least stable for as long as the script runs from one place.\n */\nfunction projectRoot(): string {\n let dir = process.cwd();\n for (;;) {\n if (fs.existsSync(path.join(dir, 'package.json'))) {\n return dir;\n }\n const parent = path.dirname(dir);\n if (parent === dir) {\n return process.cwd();\n }\n dir = parent;\n }\n}\n\n/**\n * Where every shotium cache directory lives. One level up from any single\n * project's, which is what makes `target: 'all'` answerable.\n *\n * Under the home directory and not the temporary one, which is where this was\n * until 0.3 was cut. $TMPDIR is defined by not surviving: /tmp is emptied on\n * reboot, systemd-tmpfiles removes anything untouched for ten days, and macOS\n * sweeps it on a schedule of its own. The entire value of an HTTP cache is the\n * *next* run, so a default that lives somewhere designed to be cleared is a\n * cache that stops working at exactly the moment it would have started paying\n * for itself.\n *\n * `~/.shotium`, spelled the same on every platform. One place a user can look\n * for it, one path to say in a bug report, and one directory to delete.\n *\n * $TMPDIR remains only as a fallback for a process with no home to speak of --\n * some containers, some service accounts. That is a degradation and not a\n * second location: there is no home directory holding a cache that would\n * otherwise have been found.\n */\nfunction shotiumHome(): string {\n return path.join(os.homedir() || os.tmpdir(), '.shotium');\n}\n\nexport function cacheRoot(): string {\n return normalizePath(path.join(shotiumHome(), 'cache'));\n}\n\n/**\n * The identifier for a project's cache directory: a hash of its root path.\n *\n * A hash rather than the path itself because the path contains separators,\n * drive letters and whatever the user called their directory, none of which\n * survive being a directory name. It is not a security measure and does not\n * need to be one -- it is a fixed-length name for a variable-length string.\n */\nexport function projectKey(root: string = projectRoot()): string {\n return crypto.createHash('sha1').update(normalizePath(root)).digest('hex');\n}\n\n/** This project's cache directory. */\nexport function defaultCacheDir(): string {\n return normalizePath(path.join(cacheRoot(), projectKey()));\n}\n\n// The one place that decides what \"no options\" means.\n//\n// It is shared rather than duplicated because the daemon's address is a hash of\n// its configuration: if two callers filled in defaults even slightly\n// differently, one would compute an address no daemon is listening on and\n// start a second engine next to the first one that was already warm. See\n// endpoint.ts.\n//\n// `cacheDir` defaults to this project's directory rather than to null, which\n// is the reverse of 0.2. The reason is measured: without a cache every capture\n// of an `https:` URL pays DNS, TLS and a round trip, which for a small page is\n// most of the wall clock and all of the surprise. The objection to a default\n// -- that a short-lived program leaves a directory behind -- is answered by\n// the directory being per-project, size-capped, and somewhere the platform's\n// own tooling knows how to clear, rather than by there being no cache.\n// `cacheDir: null` still turns it off.\nfunction resolveStartOptions(options: StartOptions = {}): ResolvedStartOptions {\n return {\n cacheDir: options.cacheDir === null ? null :\n (options.cacheDir ?? defaultCacheDir()),\n cacheMaxBytes: options.cacheMaxBytes ?? DEFAULT_CACHE_MAX_BYTES,\n userAgent: options.userAgent,\n resourceDir: options.resourceDir,\n };\n}\n\nexport {DEFAULT_CACHE_MAX_BYTES, resolveStartOptions};\n","import type {\n CacheMode,\n Clip,\n PageGotoParams,\n ScreenshotOptions,\n} from '../types.js';\n\nconst DEFAULT_TIMEOUT_MS = 30000;\n\n// What actually goes down the pipe: ScreenshotOptions with the viewport\n// flattened -- see toRequest below for why.\nexport interface WireRequest {\n file: string;\n type?: 'png'|'jpeg'|'webp';\n fullPage?: boolean;\n selector?: string;\n quality?: number;\n scale?: number;\n omitBackground?: boolean;\n path?: string;\n pageGotoParams?: PageGotoParams;\n clip?: Clip;\n allowFileAccess?: boolean;\n cache?: CacheMode;\n headers?: Record<string, string>;\n width?: number;\n height?: number;\n}\n\n// Everything the worker understands, and nothing else. An unknown field is a\n// typo, and a typo that is silently dropped is a screenshot that quietly\n// ignored what was asked for -- so this rejects rather than filters.\n//\n// It is a runtime check even though the argument has a type, because the\n// argument having a type says nothing about a caller who is not compiled\n// against it: a JavaScript program, or a JSON body from somewhere else.\nconst WIRE_FIELDS = new Set([\n 'file',\n 'type',\n 'fullPage',\n 'selector',\n 'quality',\n 'scale',\n 'omitBackground',\n 'path',\n 'pageGotoParams',\n 'clip',\n 'viewport',\n 'allowFileAccess',\n 'cache',\n 'headers',\n]);\n\n// One ScreenshotOptions, checked and flattened into what goes on the wire.\n//\n// It lives here rather than in index.ts because the engine in this process and\n// the daemon both send it: a request that is valid through one entry point and\n// rejected through the other would be a difference nobody asked for.\nfunction toRequest(options: ScreenshotOptions): WireRequest {\n if (!options || typeof options !== 'object') {\n throw new TypeError('shotium: screenshot(options) needs an object');\n }\n if (typeof options.file !== 'string' || options.file.length === 0) {\n throw new TypeError('shotium: options.file is required');\n }\n\n const request: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(options)) {\n if (value === undefined) {\n continue;\n }\n if (!WIRE_FIELDS.has(key)) {\n throw new TypeError(`shotium: unknown option \"${key}\"`);\n }\n request[key] = value;\n }\n\n // The viewport is flattened because the worker takes width and height at the\n // top level: it is one screenshot's frame, not a nested object on the wire.\n if (request.viewport) {\n const {width, height} = request.viewport as {\n width?: number,\n height?: number,\n };\n delete request.viewport;\n if (width !== undefined) {\n request.width = width;\n }\n if (height !== undefined) {\n request.height = height;\n }\n }\n return request as unknown as WireRequest;\n}\n\nfunction timeoutFor(options: ScreenshotOptions): number {\n const timeout = options.pageGotoParams && options.pageGotoParams.timeout;\n return typeof timeout === 'number' ? timeout : DEFAULT_TIMEOUT_MS;\n}\n\nexport {\n DEFAULT_TIMEOUT_MS,\n WIRE_FIELDS,\n timeoutFor,\n toRequest,\n};\n","import * as binding from './binding.js';\nimport type {Engine as Handle} from './binding.js';\nimport {toRequest} from './request.js';\nimport type {WireRequest} from './request.js';\nimport type {\n CaptureStats,\n ReleaseMemoryOptions,\n ScreenshotOptions,\n ScreenshotResult,\n StartOptions,\n StartResult,\n} from '../types.js';\n\nimport type {ResolvedStartOptions} from './config.js';\nimport {resolveStartOptions} from './config.js';\n\n// The engine this process has, held above every Engine object that uses it.\n//\n// Blink is initialised once and has no undo: it writes process-wide statics\n// that shot_engine_destroy() cannot take back, and the C API refuses a second\n// create for the lifetime of the process whether or not the first is still\n// alive.\n//\n// That fact used to be exposed directly -- `stop()` destroyed the engine and\n// every later `start()` threw. It was the wrong shape. `stop()` and `start()`\n// are a caller saying \"I am done for now\" and \"I want it again\", and a library\n// whose engine can be asked for exactly once turns an ordinary pair of calls\n// into a thing that has to be rationed. It also made the disk cache\n// nonsensical: the whole point of a cache is the *next* run, and the next run\n// could not have the engine that reads it.\n//\n// So the handle lives here rather than on the instance. `stop()` stands the\n// engine down -- the queue drains, the memory goes back, nothing more is\n// accepted -- and `start()` picks the same one up again, as many times as a\n// caller likes. The process is the engine's lifetime, which is what it always\n// was; the difference is that the API no longer pretends to offer a shorter\n// one.\nlet shared: Handle|null = null;\n\n// What `shared` was created with. Kept because those options are fixed for the\n// life of the process -- a later `start()` asking for a different cache\n// directory cannot be given one, and is told so rather than handed an engine\n// that quietly uses the first caller's.\nlet sharedOptions: ResolvedStartOptions|null = null;\n\n// Whether the one create this process gets has been spent.\n//\n// Separate from `shared` being non-null because `dispose()` clears the handle\n// and does not give the ability back: after a real teardown there is no engine\n// and there cannot be another. Nothing in the public surface calls dispose()\n// -- the daemon does, on its way out of a process it owns.\nlet spent = false;\n\n/** The engine handle this process has, or null if it has none. */\nfunction sharedHandle(): Handle|null {\n return shared;\n}\n\n// The options that are fixed at create time, and the report a mismatch gets.\n//\n// Only the ones the caller actually named are checked: `start()` with no\n// arguments is a caller saying \"whatever is there\", which is exactly what\n// adopting a running engine gives them. Naming an option that disagrees is\n// different -- it is a request that cannot be honoured, and silently rendering\n// with the other value is the failure this exists to prevent.\nfunction conflictingOption(\n options: StartOptions, current: ResolvedStartOptions): string|null {\n const wanted = resolveStartOptions(options);\n for (const key of ['cacheDir', 'cacheMaxBytes', 'userAgent', 'resourceDir'] as\n const) {\n if (options[key] === undefined || wanted[key] === current[key]) {\n continue;\n }\n return `${key} is ${JSON.stringify(current[key])}, and this start() ` +\n `asked for ${JSON.stringify(wanted[key])}`;\n }\n return null;\n}\n\n/**\n * Blink, in this process, and the queue in front of it.\n *\n * There is one renderer and there is no way to have two. Blink is a\n * process-wide singleton: it is initialised once, there is no path to a second\n * one, and `worker_threads` do not change that because they share the process.\n * So captures are serialised however many callers there are, and a program\n * that wants four at once wants four processes.\n *\n * The queue is not about fairness. Each capture occupies a libuv thread pool\n * thread for as long as the render takes, and there are four of those by\n * default, shared with fs and dns -- so letting four screenshots go at once\n * would stall the host's file reads for a fifth of a second at a time while\n * gaining nothing, since the engine serialises them anyway.\n */\nexport class Engine {\n // Whether *this* object considers itself started. The engine behind it may\n // well be up for somebody else; `running` is about this lifecycle, not about\n // whether the process has an engine.\n private active = false;\n private tail: Promise<unknown> = Promise.resolve();\n\n get running(): boolean {\n return this.active && shared !== null;\n }\n\n /**\n * The addon's engine handle, or null when this process has never had one.\n *\n * Deliberately not conditional on `running`. It is the cache that asks, and\n * what the cache needs to know is whether a backend exists in this process\n * -- because within one process a cache directory has one backend, so\n * reading or clearing the directory the engine holds means borrowing it\n * rather than opening a second one. A stood-down engine still holds its\n * directory, so a caller who calls `stop()` and then `cache.getFiles()` is\n * asking about a live backend and has to be routed to it. Nothing else\n * should reach for this.\n */\n get nativeHandle(): Handle|null {\n return sharedHandle();\n }\n\n /**\n * Starts the engine, or picks the running one back up.\n *\n * Callable as often as a caller likes, in any order with `stop()`. The first\n * call in a process builds the engine; every later one adopts it, which is\n * the same engine and the same warm cache. Library code can call it\n * defensively.\n *\n * The one thing that cannot be adopted is a different configuration. The\n * options below are fixed when the engine is built and there is no second\n * build, so naming one that disagrees with what is running throws rather\n * than rendering with a value the caller did not ask for.\n */\n start(options: StartOptions = {}): StartResult {\n if (shared) {\n const conflict = conflictingOption(options, sharedOptions!);\n if (conflict) {\n throw new Error(\n 'shotium: this process already has an engine, and its ' +\n conflict + '. Blink is initialised once per process and cannot ' +\n 'be built again, so an engine\\'s options are fixed for as long ' +\n 'as the process lives -- stop() does not undo them. Use the ' +\n 'engine that is up, or run another process.');\n }\n this.active = true;\n return this.status();\n }\n if (spent) {\n throw new Error(\n 'shotium: this process had an engine and it was disposed of. ' +\n 'Blink is initialised once per process and cannot be built again. ' +\n 'Run another process.');\n }\n\n const native = binding.load();\n const resolved = resolveStartOptions(options);\n\n const engineOptions: Record<string, unknown> = {};\n if (resolved.cacheDir !== null) {\n engineOptions.cacheDir = resolved.cacheDir;\n engineOptions.cacheMaxBytes = resolved.cacheMaxBytes;\n }\n if (resolved.userAgent !== undefined) {\n engineOptions.userAgent = resolved.userAgent;\n }\n // The packs sit beside the library, and the library cannot find itself on\n // Linux -- the path the engine resolves for \"this module\" goes through\n // /proc/self/exe, which names node. Saying it here is cheaper than\n // teaching the engine a second way to look. See shot_api.h.\n engineOptions.resourceDir = resolved.resourceDir ?? binding.directory();\n\n shared = native.create(JSON.stringify(engineOptions));\n sharedOptions = resolved;\n this.active = true;\n return this.status();\n }\n\n /**\n * What the engine came up as: whether this lifecycle is started, which cache\n * directory the engine has, and whether it actually got it.\n *\n * The last of those is the one worth reading. A directory that cannot be\n * created or written to -- no permission, no space, a path that is a file --\n * fails invisibly: the engine renders exactly as well without a cache, only\n * slower, and every capture pays the network again for a reason nothing\n * reports. The engine opens the cache during `start()` so that this is\n * answerable before the first screenshot rather than after it.\n *\n * The cache half is answered from the engine whenever this process has one,\n * including after `stop()`. A stood-down engine still holds its directory,\n * and reporting `null` for it would say the cache had gone away when what\n * went away was the willingness to render.\n */\n status(): StartResult {\n if (!shared) {\n return {running: false, cacheDir: null, cacheActive: false};\n }\n const reported =\n JSON.parse(binding.load().status(shared)) as Omit<StartResult, 'running'>;\n return {running: this.running, ...reported};\n }\n\n /**\n * Stands the engine down, after whatever is queued.\n *\n * The queue drains, the memory the engine can rebuild goes back to the OS,\n * and `running` becomes false. What does not happen is a teardown of Blink,\n * because there is no such thing -- see the note at the top of this file --\n * so the disk cache stays where it is and `start()` picks the same engine up\n * again whenever the caller wants it.\n *\n * Which makes this exactly what it says: not a destructor, a caller saying\n * they are done for now. A program that will want another screenshot in a\n * moment can equally well stay started and call `releaseMemory()`; the two\n * do the same work, and this one also stops accepting captures.\n */\n async stop(): Promise<void> {\n if (!this.active) {\n return;\n }\n this.active = false;\n // After the queue, not before: a caller's last screenshot should resolve\n // rather than race the stand-down, and the memory is not worth handing\n // back until the thing still using it has finished.\n await this.tail.catch(() => {});\n if (shared) {\n binding.load().purge(shared, /*releaseWorkingSet=*/ true);\n }\n }\n\n /**\n * The real teardown: joins the engine thread, unwinds the network stack, and\n * lets the disk cache write its index.\n *\n * Final, and final for the process rather than for this object -- which is\n * why it is not on the public surface. The daemon calls it as it exits a\n * process it owns, where the index flush is worth having and nothing is\n * going to ask for another screenshot. Everything else wants `stop()`.\n */\n async dispose(): Promise<void> {\n this.active = false;\n await this.tail.catch(() => {});\n const handle = shared;\n shared = null;\n sharedOptions = null;\n if (handle) {\n spent = true;\n binding.load().destroy(handle);\n }\n }\n\n /**\n * Hands back what the engine is holding but can rebuild.\n * `releaseWorkingSet` additionally asks the OS for the pages, which the next\n * screenshot pays back in soft faults -- worth it when there may not be a\n * next one soon.\n *\n * The daemon does this for itself on a timer because it can watch its own\n * request stream go quiet. Here the queue belongs to the caller, so the\n * caller is the one who knows a batch has ended.\n */\n releaseMemory({releaseWorkingSet = false}: ReleaseMemoryOptions = {}): void {\n if (!shared) {\n return;\n }\n binding.load().purge(shared, releaseWorkingSet);\n }\n\n /**\n * Renders one screenshot. Resolves to the encoded image, or to `null` when\n * `path` was given and the engine wrote the file itself.\n */\n // `async` and not a plain function returning capture()'s promise: toRequest()\n // throws, and a caller who wrote `screenshot(bad).catch(...)` would get the\n // throw past the catch and into the surrounding frame. The whole surface is\n // promise-shaped, so a bad request is a rejection like everything else.\n async screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {\n // Before anything else, and before the queue: a malformed request should\n // be a rejection now rather than one that waits its turn.\n return this.capture(toRequest(options));\n }\n\n /**\n * The same, for a request that is already in wire form.\n *\n * The daemon reads these off a socket, where they arrived having been\n * validated by the client that sent them. Re-deriving one from\n * ScreenshotOptions would mean the daemon validating a request it cannot see\n * the original of, and rejecting fields a newer client legitimately sent.\n */\n async capture(request: WireRequest): Promise<ScreenshotResult> {\n // Starts, or restarts, or adopts -- a screenshot after `stop()` is an\n // ordinary thing to ask for and gets the engine back.\n if (!this.running) {\n this.start();\n }\n const handle = shared!;\n const native = binding.load();\n\n // Chain onto the tail so that captures run one at a time. The catch keeps\n // one failure from poisoning everything queued behind it.\n const result = this.tail.catch(() => {}).then(\n () => native.capture(handle, JSON.stringify(request)));\n this.tail = result.catch(() => {});\n\n let captured;\n try {\n captured = await result;\n } catch (error) {\n // The addon attaches the capture's statistics to the rejection as\n // unparsed JSON, the same way it hands them back on success -- see\n // NativeCapture. Parsing them here rather than leaving a string on the\n // error is what makes `error.stats` the same CaptureStats a successful\n // call returns, which is the whole point of attaching it: the failure is\n // the case where the counters explain the most.\n const withStats = error as Error&{stats?: string | CaptureStats};\n if (typeof withStats.stats === 'string') {\n withStats.stats = JSON.parse(withStats.stats) as CaptureStats;\n }\n throw error;\n }\n\n return {\n image: request.path ? null : captured.image,\n stats: parseStats(captured.stats),\n };\n }\n}\n\n// A zeroed set of counters.\n//\n// Zeroes rather than undefined because the alternative is every caller writing\n// `stats?.timing?.total ?? 0` around a field that is present for every capture\n// that actually ran. The only case that produces none is a request rejected\n// before it started, and that path throws rather than returning.\nfunction emptyStats(): CaptureStats {\n return {\n requests: 0,\n fromCache: 0,\n failed: 0,\n bytes: 0,\n httpStatus: 0,\n finalUrl: '',\n timing: {\n fetch: 0,\n render: 0,\n setup: 0,\n wait: 0,\n lifecycle: 0,\n paint: 0,\n raster: 0,\n encode: 0,\n total: 0,\n },\n };\n}\n\n// The addon hands statistics over as unparsed JSON -- see NativeCapture -- so\n// this is where the string becomes an object. The daemon's client has them\n// parsed already, from its own response header, and uses emptyStats directly.\nfunction parseStats(json: string|undefined): CaptureStats {\n return json ? JSON.parse(json) as CaptureStats : emptyStats();\n}\n\nexport {emptyStats, parseStats, sharedHandle};\n","import crypto from 'node:crypto';\nimport os from 'node:os';\nimport path from 'node:path';\n\n// What endpointFor() needs to know: a resolved configuration, plus the two\n// ways of overriding the address it would derive from one.\nexport interface EndpointOptions {\n cacheDir?: string|null;\n userAgent?: string;\n resourceDir?: string;\n name?: string;\n endpoint?: string;\n}\n\n// Where a daemon listens, derived from what it was asked to be.\n//\n// The address is a hash of the configuration -- cache root, user agent,\n// resource directory -- rather than a fixed name, because attaching to\n// whatever daemon happens to be up would mean rendering with someone else's\n// settings. Two configurations are two daemons; the same configuration, from\n// any process, is one.\n//\n// Every field of EndpointOptions is optional, so nothing here fails to compile\n// when a field is dropped from the configuration -- it just stops being part\n// of the identity, and every caller collapses onto one address. That happened\n// once, when the worker pool went away and this was left hashing three fields\n// that no longer existed. If a field is added to StartOptions and it changes\n// what the engine renders, it belongs in the array below.\n//\n// A caller who wants a daemon by name instead of by configuration passes\n// `name`, which replaces the hash. That is the escape hatch for a service that\n// starts its daemon deliberately and wants clients to find it without\n// repeating the configuration.\nfunction endpointKey(options: EndpointOptions): string {\n if (options.name) {\n return String(options.name);\n }\n const identity = JSON.stringify([\n options.cacheDir === null || options.cacheDir === undefined ?\n null :\n path.resolve(options.cacheDir),\n options.userAgent ?? null,\n options.resourceDir ? path.resolve(options.resourceDir) : null,\n ]);\n return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);\n}\n\n// Windows has named pipes and no filesystem sockets; POSIX has the reverse.\n// Both are net.connect() addresses, which is the only reason the rest of the\n// daemon can ignore the difference.\n//\n// The pipe namespace is per-machine but the socket path is per-user, so the\n// uid goes in the POSIX name to keep two users on one host from colliding on a\n// path only one of them can open.\nfunction endpointFor(options: EndpointOptions = {}): string {\n if (options.endpoint) {\n return options.endpoint;\n }\n if (process.env.SHOTIUM_ENDPOINT) {\n return process.env.SHOTIUM_ENDPOINT;\n }\n const key = endpointKey(options);\n if (process.platform === 'win32') {\n return `\\\\\\\\.\\\\pipe\\\\shotium-${key}`;\n }\n const uid = typeof process.getuid === 'function' ? process.getuid() : 0;\n return path.join(os.tmpdir(), `shotium-${uid}-${key}.sock`);\n}\n\nexport {endpointFor, endpointKey};\n","// The wire format shotium.exe --serve speaks, in both directions: a 4-byte\n// little-endian length followed by that many bytes.\n//\n// Length-prefixed rather than line-delimited because the payload is binary and\n// a newline inside a PNG is not a message boundary. See shot/shot_server.h for\n// the same description from the other end.\n//\n// -> [len][{\"file\":\"...\",\"width\":1248,...}]\n// <- [len][{\"ok\":true,\"bytes\":97756}] [len][<PNG bytes>]\n// <- [len][{\"ok\":false,\"error\":\"...\"}] [0]\n\nconst HEADER_BYTES = 4;\n\nfunction encodeFrame(payload: Buffer): Buffer {\n const header = Buffer.allocUnsafe(HEADER_BYTES);\n header.writeUInt32LE(payload.length, 0);\n return Buffer.concat([header, payload]);\n}\n\nfunction encodeRequest(request: unknown): Buffer {\n return encodeFrame(Buffer.from(JSON.stringify(request), 'utf8'));\n}\n\n// Reassembles frames out of whatever sizes the pipe hands over.\n//\n// A stream is not a sequence of messages: one read can carry half a header, or\n// three responses and the start of a fourth. Everything downstream assumes\n// whole frames, so this is the only place that has to know that.\nclass FrameReader {\n private buffer: Buffer = Buffer.alloc(0);\n\n push(chunk: Buffer): void {\n this.buffer = this.buffer.length === 0 ?\n chunk :\n Buffer.concat([this.buffer, chunk]);\n }\n\n // The next complete frame, or null when there is not one yet.\n next(): Buffer|null {\n if (this.buffer.length < HEADER_BYTES) {\n return null;\n }\n const length = this.buffer.readUInt32LE(0);\n if (this.buffer.length < HEADER_BYTES + length) {\n return null;\n }\n const frame = this.buffer.subarray(HEADER_BYTES, HEADER_BYTES + length);\n this.buffer = this.buffer.subarray(HEADER_BYTES + length);\n return frame;\n }\n}\n\nexport {HEADER_BYTES, encodeFrame, encodeRequest, FrameReader};\n"],"mappings":"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"}
package/native/binding.cc CHANGED
@@ -208,18 +208,38 @@ napi_value StatsValue(napi_env env, shot_buffer* stats) {
208
208
  return value;
209
209
  }
210
210
 
211
+ // Returns the encoded bytes without copying them across the addon boundary.
212
+ // The memory remains owned by shot: Node only decides when its Buffer dies,
213
+ // and the finalizer re-enters the DLL so the allocator that created the
214
+ // shot_buffer is also the one that destroys it.
215
+ void FinalizeImageBuffer(napi_env, void*, void* hint) {
216
+ shot_buffer_free(static_cast<shot_buffer*>(hint));
217
+ }
218
+
219
+ napi_value ImageValue(napi_env env, shot_buffer** image) {
220
+ const size_t size = shot_buffer_size(*image);
221
+ napi_value value = nullptr;
222
+ if (size > 0 &&
223
+ napi_create_external_buffer(
224
+ env, size, const_cast<uint8_t*>(shot_buffer_data(*image)),
225
+ FinalizeImageBuffer, *image, &value) == napi_ok) {
226
+ // The JS Buffer now owns the shot_buffer through FinalizeImageBuffer.
227
+ *image = nullptr;
228
+ return value;
229
+ }
230
+
231
+ // Some Node-API hosts deliberately disallow external buffers. Preserve the
232
+ // old behaviour there, and for the empty buffer returned when `path` asks
233
+ // the engine to write the image itself.
234
+ napi_create_buffer_copy(env, size, shot_buffer_data(*image), nullptr, &value);
235
+ return value;
236
+ }
237
+
211
238
  void CompleteCapture(napi_env env, napi_status status, void* data) {
212
239
  auto* task = static_cast<CaptureTask*>(data);
213
240
 
214
241
  if (status == napi_ok && task->status == SHOT_OK) {
215
- // Copied into a node Buffer rather than handed over as external memory.
216
- // An external buffer would save a memcpy of a few hundred kilobytes
217
- // against a render that took tens of milliseconds, and would put the
218
- // lifetime of shot's allocation in the hands of node's GC -- across an
219
- // allocator boundary the whole C ABI exists to keep closed.
220
- napi_value buffer = nullptr;
221
- napi_create_buffer_copy(env, shot_buffer_size(task->image),
222
- shot_buffer_data(task->image), nullptr, &buffer);
242
+ napi_value buffer = ImageValue(env, &task->image);
223
243
 
224
244
  // An object rather than the buffer alone, because there are now two
225
245
  // answers and a caller that wanted only the first should still not have to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotkit/shotium",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Static screenshots from a stripped Chromium: DOM, CSS, layout, paint, no JavaScript engine.",
5
5
  "keywords": [
6
6
  "screenshot",
@@ -38,12 +38,12 @@
38
38
  "node": ">=18"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@shotkit/shotium-darwin-arm64": "0.3.0",
42
- "@shotkit/shotium-darwin-x64": "0.3.0",
43
- "@shotkit/shotium-linux-arm64": "0.3.0",
44
- "@shotkit/shotium-linux-x64": "0.3.0",
45
- "@shotkit/shotium-win32-arm64": "0.3.0",
46
- "@shotkit/shotium-win32-x64": "0.3.0"
41
+ "@shotkit/shotium-darwin-arm64": "0.3.1",
42
+ "@shotkit/shotium-darwin-x64": "0.3.1",
43
+ "@shotkit/shotium-linux-arm64": "0.3.1",
44
+ "@shotkit/shotium-linux-x64": "0.3.1",
45
+ "@shotkit/shotium-win32-arm64": "0.3.1",
46
+ "@shotkit/shotium-win32-x64": "0.3.1"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsdown",
package/src/lib/engine.ts CHANGED
@@ -342,7 +342,17 @@ function emptyStats(): CaptureStats {
342
342
  bytes: 0,
343
343
  httpStatus: 0,
344
344
  finalUrl: '',
345
- timing: {fetch: 0, render: 0, encode: 0, total: 0},
345
+ timing: {
346
+ fetch: 0,
347
+ render: 0,
348
+ setup: 0,
349
+ wait: 0,
350
+ lifecycle: 0,
351
+ paint: 0,
352
+ raster: 0,
353
+ encode: 0,
354
+ total: 0,
355
+ },
346
356
  };
347
357
  }
348
358
 
package/src/types.ts CHANGED
@@ -58,8 +58,18 @@ export interface CaptureTiming {
58
58
  fetch: number;
59
59
  /** Parse, subresources, style, layout, prepaint, paint. */
60
60
  render: number;
61
+ /** Page/frame creation and synchronous document installation. */
62
+ setup: number;
63
+ /** Waiting for parsing, load completion and subresources. */
64
+ wait: number;
65
+ /** Capture selection plus style/layout/lifecycle advancement. */
66
+ lifecycle: number;
67
+ /** Extracting Blink's paint record. */
68
+ paint: number;
69
+ /** Raster-surface preparation and paint-record replay. */
70
+ raster: number;
61
71
  encode: number;
62
- /** Wall clock for the whole capture, so the three above can be checked. */
72
+ /** Wall clock for the whole capture, so the phases above can be checked. */
63
73
  total: number;
64
74
  }
65
75