@shotkit/shotium 0.2.0 → 0.3.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 @@
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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"}
package/native/binding.cc CHANGED
@@ -55,6 +55,23 @@ struct CaptureTask {
55
55
  std::string request;
56
56
  shot_status status = SHOT_ERR_CAPTURE;
57
57
  shot_buffer* image = nullptr;
58
+ shot_buffer* stats = nullptr;
59
+ shot_buffer* error = nullptr;
60
+ };
61
+
62
+ // One cache operation in flight. The same shape as a capture and deliberately
63
+ // not shared with it: a capture resolves to bytes and this resolves to JSON,
64
+ // and the one field they would have in common is the promise.
65
+ struct CacheTask {
66
+ napi_deferred deferred = nullptr;
67
+ napi_async_work work = nullptr;
68
+ // Null is meaningful here rather than an error: it says there is no engine
69
+ // in this process, and the library should open the directory itself.
70
+ shot_engine* engine = nullptr;
71
+ bool clearing = false;
72
+ std::string options;
73
+ shot_status status = SHOT_ERR_STATE;
74
+ shot_buffer* json = nullptr;
58
75
  shot_buffer* error = nullptr;
59
76
  };
60
77
 
@@ -169,8 +186,26 @@ napi_value Destroy(napi_env env, napi_callback_info info) {
169
186
  // no env, which is why everything it needs was copied out first.
170
187
  void ExecuteCapture(napi_env env, void* data) {
171
188
  auto* task = static_cast<CaptureTask*>(data);
172
- task->status = shot_engine_capture(task->engine, task->request.c_str(),
173
- &task->image, &task->error);
189
+ task->status =
190
+ shot_engine_capture(task->engine, task->request.c_str(), &task->image,
191
+ &task->stats, &task->error);
192
+ }
193
+
194
+ // The statistics buffer as a JS string, or undefined when there is none.
195
+ //
196
+ // Left as a string rather than parsed here: this file carries JSON between the
197
+ // engine and the JS layer without reading it, and a JSON.parse on this side
198
+ // would be a second opinion about the shape -- the thing the comment at the
199
+ // top of this file exists to rule out.
200
+ napi_value StatsValue(napi_env env, shot_buffer* stats) {
201
+ if (!stats || shot_buffer_size(stats) == 0) {
202
+ return Undefined(env);
203
+ }
204
+ napi_value value = nullptr;
205
+ napi_create_string_utf8(env,
206
+ reinterpret_cast<const char*>(shot_buffer_data(stats)),
207
+ shot_buffer_size(stats), &value);
208
+ return value;
174
209
  }
175
210
 
176
211
  void CompleteCapture(napi_env env, napi_status status, void* data) {
@@ -185,7 +220,16 @@ void CompleteCapture(napi_env env, napi_status status, void* data) {
185
220
  napi_value buffer = nullptr;
186
221
  napi_create_buffer_copy(env, shot_buffer_size(task->image),
187
222
  shot_buffer_data(task->image), nullptr, &buffer);
188
- napi_resolve_deferred(env, task->deferred, buffer);
223
+
224
+ // An object rather than the buffer alone, because there are now two
225
+ // answers and a caller that wanted only the first should still not have to
226
+ // ask for a second call to get the other.
227
+ napi_value result = nullptr;
228
+ napi_create_object(env, &result);
229
+ napi_set_named_property(env, result, "image", buffer);
230
+ napi_set_named_property(env, result, "stats",
231
+ StatsValue(env, task->stats));
232
+ napi_resolve_deferred(env, task->deferred, result);
189
233
  } else {
190
234
  const char* text = "shotium: the capture failed";
191
235
  if (task->error && shot_buffer_size(task->error) > 0) {
@@ -195,10 +239,53 @@ void CompleteCapture(napi_env env, napi_status status, void* data) {
195
239
  napi_value error_value = nullptr;
196
240
  napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &message);
197
241
  napi_create_error(env, nullptr, message, &error_value);
242
+ // A capture that failed part of the way through still measured what it
243
+ // did, and that is usually the explanation -- forty subresources fetched
244
+ // and the one that mattered timed out. Attached to the error because a
245
+ // rejection has nowhere else to carry it.
246
+ napi_set_named_property(env, error_value, "stats",
247
+ StatsValue(env, task->stats));
198
248
  napi_reject_deferred(env, task->deferred, error_value);
199
249
  }
200
250
 
201
251
  shot_buffer_free(task->image);
252
+ shot_buffer_free(task->stats);
253
+ shot_buffer_free(task->error);
254
+ napi_delete_async_work(env, task->work);
255
+ delete task;
256
+ }
257
+
258
+ void ExecuteCache(napi_env env, void* data) {
259
+ auto* task = static_cast<CacheTask*>(data);
260
+ task->status = task->clearing
261
+ ? shot_cache_clear(task->engine, task->options.c_str(),
262
+ &task->json, &task->error)
263
+ : shot_cache_list(task->engine, task->options.c_str(),
264
+ &task->json, &task->error);
265
+ }
266
+
267
+ void CompleteCache(napi_env env, napi_status status, void* data) {
268
+ auto* task = static_cast<CacheTask*>(data);
269
+
270
+ if (status == napi_ok && task->status == SHOT_OK) {
271
+ napi_value json = nullptr;
272
+ napi_create_string_utf8(
273
+ env, reinterpret_cast<const char*>(shot_buffer_data(task->json)),
274
+ shot_buffer_size(task->json), &json);
275
+ napi_resolve_deferred(env, task->deferred, json);
276
+ } else {
277
+ const char* text = "shotium: the cache operation failed";
278
+ if (task->error && shot_buffer_size(task->error) > 0) {
279
+ text = reinterpret_cast<const char*>(shot_buffer_data(task->error));
280
+ }
281
+ napi_value message = nullptr;
282
+ napi_value error_value = nullptr;
283
+ napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &message);
284
+ napi_create_error(env, nullptr, message, &error_value);
285
+ napi_reject_deferred(env, task->deferred, error_value);
286
+ }
287
+
288
+ shot_buffer_free(task->json);
202
289
  shot_buffer_free(task->error);
203
290
  napi_delete_async_work(env, task->work);
204
291
  delete task;
@@ -239,6 +326,96 @@ napi_value Capture(napi_env env, napi_callback_info info) {
239
326
  return promise;
240
327
  }
241
328
 
329
+ // status(engine) -> string
330
+ //
331
+ // Synchronous, like purge: it reads two fields the engine already knows and
332
+ // the caller is holding a promise open for nothing if it waits on the event
333
+ // loop for them.
334
+ napi_value Status(napi_env env, napi_callback_info info) {
335
+ size_t argc = 1;
336
+ napi_value argv[1] = {};
337
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
338
+
339
+ EngineHandle* handle = nullptr;
340
+ if (argc < 1 || !ReadHandle(env, argv[0], &handle)) {
341
+ return nullptr;
342
+ }
343
+
344
+ shot_buffer* json = nullptr;
345
+ shot_buffer* error = nullptr;
346
+ if (shot_engine_status(handle->engine, &json, &error) != SHOT_OK) {
347
+ ThrowFromBuffer(env, error, "shotium: could not read the engine's status");
348
+ return nullptr;
349
+ }
350
+
351
+ napi_value value = nullptr;
352
+ napi_create_string_utf8(env,
353
+ reinterpret_cast<const char*>(shot_buffer_data(json)),
354
+ shot_buffer_size(json), &value);
355
+ shot_buffer_free(json);
356
+ shot_buffer_free(error);
357
+ return value;
358
+ }
359
+
360
+ // cache(engineOrNull, clearing, optionsJson) -> Promise<string>
361
+ //
362
+ // The engine argument is nullable and that is the whole interface: with one,
363
+ // the operation runs on the engine's thread and borrows the backend it already
364
+ // holds; without one, the library builds a small environment and opens the
365
+ // directory itself. The JS layer knows which it has and this does not have to
366
+ // guess.
367
+ napi_value Cache(napi_env env, napi_callback_info info) {
368
+ size_t argc = 3;
369
+ napi_value argv[3] = {};
370
+ napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
371
+
372
+ if (argc < 3) {
373
+ napi_throw_type_error(
374
+ env, nullptr, "shotium: cache(engine, clearing, optionsJson) wants "
375
+ "three arguments");
376
+ return nullptr;
377
+ }
378
+
379
+ auto* task = new CacheTask;
380
+
381
+ // Null and undefined both mean "no engine". Anything else has to be a
382
+ // handle, and a handle that has been destroyed is an error rather than a
383
+ // silent fallback to opening the directory -- the directory may still be
384
+ // locked by an engine that is on its way down.
385
+ napi_valuetype type = napi_undefined;
386
+ napi_typeof(env, argv[0], &type);
387
+ if (type != napi_null && type != napi_undefined) {
388
+ EngineHandle* handle = nullptr;
389
+ if (!ReadHandle(env, argv[0], &handle)) {
390
+ delete task;
391
+ return nullptr;
392
+ }
393
+ task->engine = handle->engine;
394
+ }
395
+
396
+ napi_get_value_bool(env, argv[1], &task->clearing);
397
+ if (!ReadUtf8(env, argv[2], &task->options)) {
398
+ delete task;
399
+ napi_throw_type_error(env, nullptr,
400
+ "shotium: cache() wants an options string");
401
+ return nullptr;
402
+ }
403
+
404
+ napi_value promise = nullptr;
405
+ if (napi_create_promise(env, &task->deferred, &promise) != napi_ok) {
406
+ delete task;
407
+ napi_throw_error(env, nullptr, "shotium: could not make a promise");
408
+ return nullptr;
409
+ }
410
+
411
+ napi_value name = nullptr;
412
+ napi_create_string_utf8(env, "shot:cache", NAPI_AUTO_LENGTH, &name);
413
+ napi_create_async_work(env, nullptr, name, ExecuteCache, CompleteCache, task,
414
+ &task->work);
415
+ napi_queue_async_work(env, task->work);
416
+ return promise;
417
+ }
418
+
242
419
  // Synchronous on purpose. A purge is milliseconds and happens when the caller
243
420
  // has decided it has nothing else to do; queuing it behind the event loop
244
421
  // would mean the process that just went idle stays large until something wakes
@@ -271,6 +448,10 @@ napi_value Init(napi_env env, napi_value exports) {
271
448
  nullptr},
272
449
  {"purge", nullptr, Purge, nullptr, nullptr, nullptr, napi_default,
273
450
  nullptr},
451
+ {"cache", nullptr, Cache, nullptr, nullptr, nullptr, napi_default,
452
+ nullptr},
453
+ {"status", nullptr, Status, nullptr, nullptr, nullptr, napi_default,
454
+ nullptr},
274
455
  };
275
456
  napi_define_properties(env, exports,
276
457
  sizeof(properties) / sizeof(properties[0]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotkit/shotium",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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.2.0",
42
- "@shotkit/shotium-darwin-x64": "0.2.0",
43
- "@shotkit/shotium-linux-arm64": "0.2.0",
44
- "@shotkit/shotium-linux-x64": "0.2.0",
45
- "@shotkit/shotium-win32-arm64": "0.2.0",
46
- "@shotkit/shotium-win32-x64": "0.2.0"
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"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsdown",
package/src/index.ts CHANGED
@@ -1,25 +1,38 @@
1
+ import {Cache} from './lib/cache.js';
1
2
  import * as client from './lib/client.js';
2
3
  import type {DaemonClient} from './lib/client.js';
3
4
  import {Engine} from './lib/engine.js';
4
5
  import type {
5
6
  DaemonOptions,
6
7
  DaemonStatus,
7
- PurgeOptions,
8
+ ReleaseMemoryOptions,
8
9
  ScreenshotOptions,
10
+ ScreenshotResult,
9
11
  StartOptions,
12
+ StartResult,
10
13
  } from './types.js';
11
14
 
12
15
  export type {
16
+ CacheClearOptions,
17
+ CacheClearResult,
18
+ CacheEntry,
19
+ CacheMode,
20
+ CacheTarget,
21
+ CaptureStats,
22
+ CaptureTiming,
13
23
  Clip,
14
24
  DaemonOptions,
15
25
  DaemonStatus,
16
26
  PageGotoParams,
17
- PurgeOptions,
27
+ ReleaseMemoryOptions,
18
28
  ScreenshotOptions,
29
+ ScreenshotResult,
19
30
  StartOptions,
31
+ StartResult,
20
32
  Viewport,
21
33
  } from './types.js';
22
34
  export type {DaemonClient} from './lib/client.js';
35
+ export {Cache} from './lib/cache.js';
23
36
 
24
37
  /** The five things a caller does with the resident engine. */
25
38
  export interface Daemon {
@@ -27,7 +40,7 @@ export interface Daemon {
27
40
  connect(options?: DaemonOptions): Promise<DaemonClient>;
28
41
  /** One screenshot through the daemon, connection and all. */
29
42
  screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):
30
- Promise<Buffer|null>;
43
+ Promise<ScreenshotResult>;
31
44
  /** Starts one if it is not up, and reports what is there either way. */
32
45
  start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;
33
46
  status(options?: DaemonOptions):
@@ -40,9 +53,11 @@ export interface Daemon {
40
53
  *
41
54
  * import shotium from '@shotkit/shotium';
42
55
  *
43
- * shotium.runtime.start();
44
- * const png = await shotium.screenshot({file: 'https://example.com'});
45
- * await shotium.runtime.stop();
56
+ * shotium.start();
57
+ * const {image, stats} = await shotium.screenshot({
58
+ * file: 'https://example.com',
59
+ * });
60
+ * await shotium.stop();
46
61
  *
47
62
  * `start` and `stop` are explicit because starting Blink is the expensive part
48
63
  * -- tens of milliseconds and a working set that stays resident -- and only
@@ -51,11 +66,24 @@ export interface Daemon {
51
66
  * What they buy is control over when that cost is paid, and the certainty that
52
67
  * it has been given back.
53
68
  *
54
- * `runtime` below is the singleton because there is nothing else it could be:
55
- * Blink starts once per process and cannot be restarted, so a second Runtime
56
- * in the same process has no engine to have. Construct one directly only to
57
- * own the lifecycle yourself instead of using `runtime`. Parallelism is more
58
- * processes, not more Runtimes.
69
+ * Neither is rationed, either. They may be called in any order and as often as
70
+ * a program likes: `stop()` stands the engine down and `start()` picks the
71
+ * same one back up, warm cache and all. What cannot happen is a *second*
72
+ * engine -- Blink is initialised once per process and there is no undo -- but
73
+ * that is a fact about how many there are, not about how many times the one
74
+ * may be asked for.
75
+ *
76
+ * The methods are on the module rather than under a `runtime` namespace, which
77
+ * they were until 0.3. There was never anything else to start, so the word
78
+ * carried nothing; and `runtime.cache` would have been the wrong place for the
79
+ * cache besides, since a cache directory outlives every engine that writes to
80
+ * it and can be read when no engine is running at all.
81
+ *
82
+ * `Runtime` is still exported for a caller who wants to own a lifecycle rather
83
+ * than share the module's. It is a lifecycle and not an engine: there is one
84
+ * engine per process, and a second Runtime that starts adopts the same one
85
+ * rather than building another. Parallelism is more processes, not more
86
+ * Runtimes.
59
87
  *
60
88
  * `daemon` is the same engine in a process of its own, behind a socket, for
61
89
  * callers whose own process does not live long enough to be worth starting
@@ -64,49 +92,91 @@ export interface Daemon {
64
92
  export class Runtime {
65
93
  private engine = new Engine();
66
94
 
95
+ /**
96
+ * The HTTP cache: where it is, what is in it, and how to empty it.
97
+ *
98
+ * On the Runtime as well as on the module because a caller holding their own
99
+ * Runtime needs the engine handle to reach a directory that engine has open:
100
+ * within one process a directory has one backend, so borrowing is the only
101
+ * way in.
102
+ */
103
+ readonly cache = new Cache(() => this.engine.nativeHandle);
104
+
67
105
  get running(): boolean {
68
106
  return this.engine.running;
69
107
  }
70
108
 
71
109
  /**
72
- * Starts the engine. Safe to call twice; the second call is a no-op, so that
73
- * library code can call it defensively. Not safe after `stop()` -- see there.
110
+ * Starts the engine, or picks the running one back up.
74
111
  *
75
- * Every option has a default. `cacheDir` is the HTTP disk cache and `null`
76
- * disables it; `resourceDir` is where `shotium_data.pak` and
112
+ * Callable as often as you like, in any order with `stop()`; library code
113
+ * can call it defensively. The first call in a process builds the engine and
114
+ * every later one adopts it -- the same engine, the same warm cache. The one
115
+ * thing it will refuse is a *different* configuration: the options below are
116
+ * fixed when the engine is built, and there is no second build, so naming
117
+ * one that disagrees with what is running throws rather than rendering with
118
+ * a value you did not ask for.
119
+ *
120
+ * Every option has a default. `cacheDir` is the HTTP disk cache and defaults
121
+ * to a per-project directory under `~/.shotium/cache`, and not under the
122
+ * temporary directory, which is defined by not surviving. `null` turns it
123
+ * off. `resourceDir` is where `shotium_data.pak` and
77
124
  * `shotium_strings.pak` are, and defaults to the directory the engine was
78
125
  * loaded from, which is where they ship.
126
+ *
127
+ * The return value is worth reading once. `cacheActive: false` with a
128
+ * `cacheDir` set means the directory could not be opened and this engine is
129
+ * running without a cache -- correctly, silently, and a round trip slower on
130
+ * everything.
79
131
  */
80
- start(options: StartOptions = {}): this {
81
- this.engine.start(options);
82
- return this;
132
+ start(options: StartOptions = {}): StartResult {
133
+ return this.engine.start(options);
134
+ }
135
+
136
+ /** What `start()` returned, asked again. */
137
+ status(): StartResult {
138
+ return this.engine.status();
83
139
  }
84
140
 
85
141
  /**
86
- * Stops the engine, after whatever is queued.
142
+ * Stands the engine down, after whatever is queued.
143
+ *
144
+ * The queue drains, the memory the engine can rebuild goes back to the OS,
145
+ * and `running` becomes false. Blink itself stays initialised, because there
146
+ * is no way to un-initialise it -- so the disk cache stays where it is, and
147
+ * `start()` or the next `screenshot()` picks the same engine back up.
87
148
  *
88
- * Final for this process. Blink writes process-wide state that it has no
89
- * path to undo, so starting again -- here or on another Runtime -- throws
90
- * rather than quietly handing back something that cannot render. A program
91
- * that wants another screenshot later should stay started and `purge()`.
149
+ * Which makes this a caller saying they are done for now rather than a
150
+ * destructor. It does the same work as `releaseMemory({releaseWorkingSet:
151
+ * true})` and additionally stops accepting captures.
92
152
  */
93
153
  stop(): Promise<void> {
94
154
  return this.engine.stop();
95
155
  }
96
156
 
97
157
  /**
98
- * Hands back what the engine is holding but can rebuild. Worth calling when
99
- * a batch has ended and the next one may be a while away.
158
+ * Hands back what the engine is holding but can rebuild: Blink's heap,
159
+ * skia's caches, PartitionAlloc's free lists. Worth calling when a batch has
160
+ * ended and the next one may be a while away.
161
+ *
162
+ * This is memory and nothing else. It was called `purge()` until 0.3, which
163
+ * next to `cache.clear()` read as though it emptied the HTTP cache; it does
164
+ * not touch the disk at all.
100
165
  */
101
- purge(options: PurgeOptions = {}): void {
102
- this.engine.purge(options);
166
+ releaseMemory(options: ReleaseMemoryOptions = {}): void {
167
+ this.engine.releaseMemory(options);
103
168
  }
104
169
 
105
170
  /**
106
- * Renders one screenshot. Resolves to the encoded image, or to `null` when
107
- * `path` was given and the engine wrote the file itself.
171
+ * Renders one screenshot, and reports what it cost.
172
+ *
173
+ * `image` is the encoded bytes, or `null` when `path` was given and the
174
+ * engine wrote the file itself. `stats` says how many resources were
175
+ * fetched, how many came from the cache, and where the milliseconds went --
176
+ * which for an `https:` URL is usually the answer to "why did this take so
177
+ * long", because a cold connection costs more than the render does.
108
178
  */
109
- screenshot(options: ScreenshotOptions): Promise<Buffer|null> {
179
+ screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {
110
180
  return this.engine.screenshot(options);
111
181
  }
112
182
  }
@@ -115,13 +185,37 @@ export class Runtime {
115
185
  const runtime = new Runtime();
116
186
 
117
187
  /** One screenshot through the shared engine, starting it if it is not up. */
118
- const screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>
188
+ const screenshot = (options: ScreenshotOptions): Promise<ScreenshotResult> =>
119
189
  runtime.screenshot(options);
120
190
 
191
+ const start = (options?: StartOptions): StartResult => runtime.start(options);
192
+ const status = (): StartResult => runtime.status();
193
+ const stop = (): Promise<void> => runtime.stop();
194
+ const releaseMemory = (options?: ReleaseMemoryOptions): void =>
195
+ runtime.releaseMemory(options);
196
+
197
+ /**
198
+ * The HTTP cache.
199
+ *
200
+ * At the top level rather than under the engine because it outlives one: the
201
+ * directory is on disk whether or not anything is running, `getDir()` answers
202
+ * before the first `start()`, and clearing it is something a program may want
203
+ * to do without bringing Blink up at all. When an engine *is* up, these
204
+ * borrow its cache backend, because within one process a directory has one
205
+ * backend and that is the only way in.
206
+ */
207
+ const cache = runtime.cache;
208
+
121
209
  /**
122
210
  * The resident engine: a process that outlives the one that started it,
123
211
  * reachable over a named pipe on Windows and a unix socket elsewhere. For
124
212
  * callers that are short-lived themselves. See lib/daemon.ts.
213
+ *
214
+ * It has no `cache` of its own. A daemon's cache directory is reported by
215
+ * `daemon.status()`, and clearing it is done by pointing `cache.clear()` at
216
+ * that directory or by stopping the daemon -- a cross-process cache protocol
217
+ * would be a second implementation of this module for something nobody does on
218
+ * a request path.
125
219
  */
126
220
  const daemon: Daemon = {
127
221
  connect: client.connect,
@@ -131,9 +225,27 @@ const daemon: Daemon = {
131
225
  stop: client.stop,
132
226
  };
133
227
 
134
- export {runtime, screenshot, daemon};
228
+ export {cache, daemon, releaseMemory, runtime, screenshot, start, status, stop};
135
229
 
136
230
  // A default as well as the names, because `import shotium from` is what a
137
231
  // caller coming from `require` writes first, and the two have to be the same
138
232
  // object rather than two views that drift.
139
- export default {Runtime, runtime, screenshot, daemon};
233
+ export default {
234
+ Runtime,
235
+ cache,
236
+ daemon,
237
+ releaseMemory,
238
+ runtime,
239
+ screenshot,
240
+ start,
241
+ status,
242
+ stop,
243
+ // A getter and not a value, because it changes. It is only on the default
244
+ // export: a named `running` would have to be a live binding that something
245
+ // remembered to update, and the two would disagree the first time anybody
246
+ // forgot. Callers who import by name have `status().running`, which is the
247
+ // same answer with the cache directory attached.
248
+ get running(): boolean {
249
+ return runtime.running;
250
+ },
251
+ };