@shotkit/shotium 0.2.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 +322 -60
- package/dist/daemon_main.js +9 -7
- package/dist/daemon_main.js.map +1 -1
- package/dist/index.d.ts +406 -35
- package/dist/index.js +389 -41
- package/dist/index.js.map +1 -1
- package/dist/protocol-rQEcQPAC.js +479 -0
- package/dist/protocol-rQEcQPAC.js.map +1 -0
- package/native/binding.cc +212 -11
- package/package.json +7 -7
- package/src/index.ts +145 -33
- package/src/lib/binding.ts +22 -1
- package/src/lib/cache.ts +323 -0
- package/src/lib/client.ts +24 -5
- package/src/lib/config.ts +113 -6
- package/src/lib/daemon.ts +29 -6
- package/src/lib/engine.ts +256 -58
- package/src/lib/request.ts +10 -1
- package/src/types.ts +214 -4
- package/dist/engine-Xe7nH-1i.js +0 -267
- package/dist/engine-Xe7nH-1i.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
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
|
@@ -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,23 +186,70 @@ 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 =
|
|
173
|
-
|
|
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;
|
|
209
|
+
}
|
|
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;
|
|
174
236
|
}
|
|
175
237
|
|
|
176
238
|
void CompleteCapture(napi_env env, napi_status status, void* data) {
|
|
177
239
|
auto* task = static_cast<CaptureTask*>(data);
|
|
178
240
|
|
|
179
241
|
if (status == napi_ok && task->status == SHOT_OK) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
napi_value
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
242
|
+
napi_value buffer = ImageValue(env, &task->image);
|
|
243
|
+
|
|
244
|
+
// An object rather than the buffer alone, because there are now two
|
|
245
|
+
// answers and a caller that wanted only the first should still not have to
|
|
246
|
+
// ask for a second call to get the other.
|
|
247
|
+
napi_value result = nullptr;
|
|
248
|
+
napi_create_object(env, &result);
|
|
249
|
+
napi_set_named_property(env, result, "image", buffer);
|
|
250
|
+
napi_set_named_property(env, result, "stats",
|
|
251
|
+
StatsValue(env, task->stats));
|
|
252
|
+
napi_resolve_deferred(env, task->deferred, result);
|
|
189
253
|
} else {
|
|
190
254
|
const char* text = "shotium: the capture failed";
|
|
191
255
|
if (task->error && shot_buffer_size(task->error) > 0) {
|
|
@@ -195,10 +259,53 @@ void CompleteCapture(napi_env env, napi_status status, void* data) {
|
|
|
195
259
|
napi_value error_value = nullptr;
|
|
196
260
|
napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &message);
|
|
197
261
|
napi_create_error(env, nullptr, message, &error_value);
|
|
262
|
+
// A capture that failed part of the way through still measured what it
|
|
263
|
+
// did, and that is usually the explanation -- forty subresources fetched
|
|
264
|
+
// and the one that mattered timed out. Attached to the error because a
|
|
265
|
+
// rejection has nowhere else to carry it.
|
|
266
|
+
napi_set_named_property(env, error_value, "stats",
|
|
267
|
+
StatsValue(env, task->stats));
|
|
198
268
|
napi_reject_deferred(env, task->deferred, error_value);
|
|
199
269
|
}
|
|
200
270
|
|
|
201
271
|
shot_buffer_free(task->image);
|
|
272
|
+
shot_buffer_free(task->stats);
|
|
273
|
+
shot_buffer_free(task->error);
|
|
274
|
+
napi_delete_async_work(env, task->work);
|
|
275
|
+
delete task;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
void ExecuteCache(napi_env env, void* data) {
|
|
279
|
+
auto* task = static_cast<CacheTask*>(data);
|
|
280
|
+
task->status = task->clearing
|
|
281
|
+
? shot_cache_clear(task->engine, task->options.c_str(),
|
|
282
|
+
&task->json, &task->error)
|
|
283
|
+
: shot_cache_list(task->engine, task->options.c_str(),
|
|
284
|
+
&task->json, &task->error);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
void CompleteCache(napi_env env, napi_status status, void* data) {
|
|
288
|
+
auto* task = static_cast<CacheTask*>(data);
|
|
289
|
+
|
|
290
|
+
if (status == napi_ok && task->status == SHOT_OK) {
|
|
291
|
+
napi_value json = nullptr;
|
|
292
|
+
napi_create_string_utf8(
|
|
293
|
+
env, reinterpret_cast<const char*>(shot_buffer_data(task->json)),
|
|
294
|
+
shot_buffer_size(task->json), &json);
|
|
295
|
+
napi_resolve_deferred(env, task->deferred, json);
|
|
296
|
+
} else {
|
|
297
|
+
const char* text = "shotium: the cache operation failed";
|
|
298
|
+
if (task->error && shot_buffer_size(task->error) > 0) {
|
|
299
|
+
text = reinterpret_cast<const char*>(shot_buffer_data(task->error));
|
|
300
|
+
}
|
|
301
|
+
napi_value message = nullptr;
|
|
302
|
+
napi_value error_value = nullptr;
|
|
303
|
+
napi_create_string_utf8(env, text, NAPI_AUTO_LENGTH, &message);
|
|
304
|
+
napi_create_error(env, nullptr, message, &error_value);
|
|
305
|
+
napi_reject_deferred(env, task->deferred, error_value);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
shot_buffer_free(task->json);
|
|
202
309
|
shot_buffer_free(task->error);
|
|
203
310
|
napi_delete_async_work(env, task->work);
|
|
204
311
|
delete task;
|
|
@@ -239,6 +346,96 @@ napi_value Capture(napi_env env, napi_callback_info info) {
|
|
|
239
346
|
return promise;
|
|
240
347
|
}
|
|
241
348
|
|
|
349
|
+
// status(engine) -> string
|
|
350
|
+
//
|
|
351
|
+
// Synchronous, like purge: it reads two fields the engine already knows and
|
|
352
|
+
// the caller is holding a promise open for nothing if it waits on the event
|
|
353
|
+
// loop for them.
|
|
354
|
+
napi_value Status(napi_env env, napi_callback_info info) {
|
|
355
|
+
size_t argc = 1;
|
|
356
|
+
napi_value argv[1] = {};
|
|
357
|
+
napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
|
|
358
|
+
|
|
359
|
+
EngineHandle* handle = nullptr;
|
|
360
|
+
if (argc < 1 || !ReadHandle(env, argv[0], &handle)) {
|
|
361
|
+
return nullptr;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
shot_buffer* json = nullptr;
|
|
365
|
+
shot_buffer* error = nullptr;
|
|
366
|
+
if (shot_engine_status(handle->engine, &json, &error) != SHOT_OK) {
|
|
367
|
+
ThrowFromBuffer(env, error, "shotium: could not read the engine's status");
|
|
368
|
+
return nullptr;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
napi_value value = nullptr;
|
|
372
|
+
napi_create_string_utf8(env,
|
|
373
|
+
reinterpret_cast<const char*>(shot_buffer_data(json)),
|
|
374
|
+
shot_buffer_size(json), &value);
|
|
375
|
+
shot_buffer_free(json);
|
|
376
|
+
shot_buffer_free(error);
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// cache(engineOrNull, clearing, optionsJson) -> Promise<string>
|
|
381
|
+
//
|
|
382
|
+
// The engine argument is nullable and that is the whole interface: with one,
|
|
383
|
+
// the operation runs on the engine's thread and borrows the backend it already
|
|
384
|
+
// holds; without one, the library builds a small environment and opens the
|
|
385
|
+
// directory itself. The JS layer knows which it has and this does not have to
|
|
386
|
+
// guess.
|
|
387
|
+
napi_value Cache(napi_env env, napi_callback_info info) {
|
|
388
|
+
size_t argc = 3;
|
|
389
|
+
napi_value argv[3] = {};
|
|
390
|
+
napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
|
|
391
|
+
|
|
392
|
+
if (argc < 3) {
|
|
393
|
+
napi_throw_type_error(
|
|
394
|
+
env, nullptr, "shotium: cache(engine, clearing, optionsJson) wants "
|
|
395
|
+
"three arguments");
|
|
396
|
+
return nullptr;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
auto* task = new CacheTask;
|
|
400
|
+
|
|
401
|
+
// Null and undefined both mean "no engine". Anything else has to be a
|
|
402
|
+
// handle, and a handle that has been destroyed is an error rather than a
|
|
403
|
+
// silent fallback to opening the directory -- the directory may still be
|
|
404
|
+
// locked by an engine that is on its way down.
|
|
405
|
+
napi_valuetype type = napi_undefined;
|
|
406
|
+
napi_typeof(env, argv[0], &type);
|
|
407
|
+
if (type != napi_null && type != napi_undefined) {
|
|
408
|
+
EngineHandle* handle = nullptr;
|
|
409
|
+
if (!ReadHandle(env, argv[0], &handle)) {
|
|
410
|
+
delete task;
|
|
411
|
+
return nullptr;
|
|
412
|
+
}
|
|
413
|
+
task->engine = handle->engine;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
napi_get_value_bool(env, argv[1], &task->clearing);
|
|
417
|
+
if (!ReadUtf8(env, argv[2], &task->options)) {
|
|
418
|
+
delete task;
|
|
419
|
+
napi_throw_type_error(env, nullptr,
|
|
420
|
+
"shotium: cache() wants an options string");
|
|
421
|
+
return nullptr;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
napi_value promise = nullptr;
|
|
425
|
+
if (napi_create_promise(env, &task->deferred, &promise) != napi_ok) {
|
|
426
|
+
delete task;
|
|
427
|
+
napi_throw_error(env, nullptr, "shotium: could not make a promise");
|
|
428
|
+
return nullptr;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
napi_value name = nullptr;
|
|
432
|
+
napi_create_string_utf8(env, "shot:cache", NAPI_AUTO_LENGTH, &name);
|
|
433
|
+
napi_create_async_work(env, nullptr, name, ExecuteCache, CompleteCache, task,
|
|
434
|
+
&task->work);
|
|
435
|
+
napi_queue_async_work(env, task->work);
|
|
436
|
+
return promise;
|
|
437
|
+
}
|
|
438
|
+
|
|
242
439
|
// Synchronous on purpose. A purge is milliseconds and happens when the caller
|
|
243
440
|
// has decided it has nothing else to do; queuing it behind the event loop
|
|
244
441
|
// would mean the process that just went idle stays large until something wakes
|
|
@@ -271,6 +468,10 @@ napi_value Init(napi_env env, napi_value exports) {
|
|
|
271
468
|
nullptr},
|
|
272
469
|
{"purge", nullptr, Purge, nullptr, nullptr, nullptr, napi_default,
|
|
273
470
|
nullptr},
|
|
471
|
+
{"cache", nullptr, Cache, nullptr, nullptr, nullptr, napi_default,
|
|
472
|
+
nullptr},
|
|
473
|
+
{"status", nullptr, Status, nullptr, nullptr, nullptr, napi_default,
|
|
474
|
+
nullptr},
|
|
274
475
|
};
|
|
275
476
|
napi_define_properties(env, exports,
|
|
276
477
|
sizeof(properties) / sizeof(properties[0]),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shotkit/shotium",
|
|
3
|
-
"version": "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.
|
|
42
|
-
"@shotkit/shotium-darwin-x64": "0.
|
|
43
|
-
"@shotkit/shotium-linux-arm64": "0.
|
|
44
|
-
"@shotkit/shotium-linux-x64": "0.
|
|
45
|
-
"@shotkit/shotium-win32-arm64": "0.
|
|
46
|
-
"@shotkit/shotium-win32-x64": "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",
|