@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/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["screenshot","client.connect","client.screenshot","client.start","client.status","client.stop"],"sources":["../src/lib/client.ts","../src/index.ts"],"sourcesContent":["import {spawn} from 'node:child_process';\nimport {EventEmitter} from 'node:events';\nimport fs from 'node:fs';\nimport net from 'node:net';\nimport path from 'node:path';\nimport {fileURLToPath} from 'node:url';\n\nimport type {\n DaemonOptions,\n DaemonStatus,\n ScreenshotOptions,\n} from '../types.js';\n\nimport {resolveStartOptions} from './config.js';\nimport {endpointFor} from './endpoint.js';\nimport {FrameReader, encodeFrame} from './protocol.js';\nimport {timeoutFor, toRequest} from './request.js';\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// The detached daemon's entry point, which is a build output beside this one.\n// It is spawned as `node <path>`, so it has to be a file on disk with a name\n// that does not move -- see tsdown.config.ts, where it is an entry of its own\n// for exactly that reason.\nconst DAEMON_MAIN = path.join(HERE, 'daemon_main.js');\n// How long to wait for a daemon this process just started to bind its\n// endpoint. Binding happens after the workers are spawned but before they are\n// warm, so this covers process startup and nothing else.\nconst START_TIMEOUT_MS = 20000;\nconst CONNECT_RETRY_MS = 20;\n\ninterface ClientReply {\n id: number;\n ok?: boolean;\n error?: string;\n path?: string;\n}\n\ninterface ClientResult {\n header: ClientReply;\n image: Buffer|null;\n}\n\ninterface Pending {\n resolve: (result: ClientResult) => void;\n reject: (error: Error) => void;\n}\n\ninterface ResolvedDaemonOptions {\n cacheDir: string|null;\n userAgent?: string;\n resourceDir?: string;\n name: string|undefined;\n endpoint: string;\n idleTimeoutMs: number|undefined;\n prewarm: boolean|undefined;\n logFile: string|null;\n}\n\n// The client half of the resident daemon.\n//\n// One connection can carry several requests at once: every message carries an\n// `id` and the answers are matched back by it, so a caller can fire ten\n// screenshots down one socket without waiting between them. They still come\n// back one at a time -- there is one renderer on the other side -- so this\n// saves the round trips, not the renders.\nclass DaemonClient extends EventEmitter {\n private readonly socket: net.Socket;\n private readonly endpointPath: string;\n private readonly pending = new Map<number, Pending>();\n private nextId = 1;\n private header: ClientReply|null = null;\n private reader = new FrameReader();\n\n constructor(socket: net.Socket, endpoint: string) {\n super();\n this.socket = socket;\n this.endpointPath = endpoint;\n\n socket.on('data', (chunk: Buffer) => this.onData(chunk));\n socket.on('error', (error: Error) => this.failAll(error));\n socket.on('close', () => {\n this.failAll(new Error('shotium: the daemon closed the connection'));\n this.emit('close', {});\n });\n }\n\n get endpoint(): string {\n return this.endpointPath;\n }\n\n get closed(): boolean {\n return this.socket.destroyed;\n }\n\n private onData(chunk: Buffer): void {\n this.reader.push(chunk);\n for (;;) {\n const frame = this.reader.next();\n if (frame === null) {\n return;\n }\n if (this.header === null) {\n try {\n this.header = JSON.parse(frame.toString('utf8')) as ClientReply;\n } catch {\n this.failAll(\n new Error('shotium: the daemon sent a header that is not JSON'));\n return;\n }\n continue;\n }\n const header = this.header;\n this.header = null;\n this.settle(header, frame);\n }\n }\n\n private settle(header: ClientReply, payload: Buffer): void {\n const pending = this.pending.get(header.id);\n if (!pending) {\n return;\n }\n this.pending.delete(header.id);\n if (header.ok) {\n pending.resolve({header, image: header.path ? null : payload});\n } else {\n pending.reject(new Error(header.error || 'shotium: request failed'));\n }\n }\n\n private failAll(error: Error): void {\n for (const [, pending] of this.pending) {\n pending.reject(error);\n }\n this.pending.clear();\n }\n\n // Sends one message and resolves with {header, image}.\n send(message: Record<string, unknown>): Promise<ClientResult> {\n return new Promise<ClientResult>((resolve, reject) => {\n if (this.socket.destroyed) {\n reject(new Error('shotium: not connected to a daemon'));\n return;\n }\n const id = this.nextId++;\n this.pending.set(id, {resolve, reject});\n this.socket.write(\n encodeFrame(Buffer.from(JSON.stringify({...message, id}), 'utf8')));\n });\n }\n\n /** Resolves to the image, or to null when `path` was given. */\n async screenshot(options: ScreenshotOptions): Promise<Buffer|null> {\n const request = toRequest(options);\n const result = await this.send({\n op: 'screenshot',\n request,\n timeout: timeoutFor(options),\n });\n return result.image;\n }\n\n async status(): Promise<DaemonStatus> {\n const {header} = await this.send({op: 'status'});\n return header as unknown as DaemonStatus;\n }\n\n async shutdown(): Promise<{ok: boolean}> {\n const {header} = await this.send({op: 'shutdown'});\n return {ok: header.ok === true};\n }\n\n close(): void {\n this.socket.end();\n this.socket.destroy();\n }\n}\n\n// Opens a connection to a daemon that is already listening, and fails if there\n// is not one. Nothing is spawned here: a caller that wants a daemon started\n// says so, because starting one is a side effect on the machine and not the\n// sort of thing a status query should do.\nfunction connectOnly(endpoint: string): Promise<DaemonClient> {\n return new Promise<DaemonClient>((resolve, reject) => {\n const socket = net.connect(endpoint);\n const onError = (error: Error) => {\n socket.destroy();\n reject(error);\n };\n socket.once('error', onError);\n socket.once('connect', () => {\n socket.removeListener('error', onError);\n resolve(new DaemonClient(socket, endpoint));\n });\n });\n}\n\nfunction resolveDaemonOptions(options: DaemonOptions = {}):\n ResolvedDaemonOptions {\n const resolved = resolveStartOptions(options);\n return {\n ...resolved,\n name: options.name,\n endpoint: endpointFor({\n ...resolved,\n name: options.name,\n endpoint: options.endpoint,\n }),\n idleTimeoutMs: options.idleTimeoutMs,\n prewarm: options.prewarm,\n logFile: options.logFile || process.env.SHOTIUM_DAEMON_LOG || null,\n };\n}\n\nfunction spawnDaemon(options: ResolvedDaemonOptions): void {\n const config = {\n cacheDir: options.cacheDir,\n userAgent: options.userAgent,\n resourceDir: options.resourceDir,\n endpoint: options.endpoint,\n idleTimeoutMs: options.idleTimeoutMs,\n prewarm: options.prewarm,\n };\n const encoded =\n Buffer.from(JSON.stringify(config), 'utf8').toString('base64');\n\n // Detached, with the standard streams let go of: the daemon has to outlive\n // the process that started it, and a child still holding this process's pipes\n // would keep it from exiting -- the exact failure that makes a \"background\"\n // daemon hang a shell.\n let stdio: 'ignore'|['ignore', number, number] = 'ignore';\n let logFd: number|null = null;\n if (options.logFile) {\n logFd = fs.openSync(options.logFile, 'a');\n stdio = ['ignore', logFd, logFd];\n }\n const child = spawn(process.execPath, [DAEMON_MAIN, encoded], {\n detached: true,\n stdio,\n windowsHide: true,\n });\n child.unref();\n if (logFd !== null) {\n fs.closeSync(logFd);\n }\n}\n\nconst sleep = (ms: number) =>\n new Promise<void>((resolve) => setTimeout(resolve, ms));\n\nexport interface EnsuredClient {\n client: DaemonClient;\n spawned: boolean;\n endpoint: string;\n}\n\n// Connects, starting a daemon if none answers.\n//\n// The endpoint existing is the readiness signal, so this is a connect loop\n// rather than a handshake: a daemon that has bound can be talked to, and one\n// that has not is indistinguishable from one that was never started. Several\n// processes racing here is fine -- the losers' daemons exit on EADDRINUSE and\n// everyone ends up on the winner.\nasync function ensureClient(options: DaemonOptions = {}):\n Promise<EnsuredClient> {\n const resolved = resolveDaemonOptions(options);\n try {\n const client = await connectOnly(resolved.endpoint);\n return {client, spawned: false, endpoint: resolved.endpoint};\n } catch {\n if (options.spawn === false) {\n throw new Error(`shotium: no daemon at ${resolved.endpoint}`);\n }\n }\n\n spawnDaemon(resolved);\n const deadline = Date.now() +\n (options.startTimeoutMs === undefined ? START_TIMEOUT_MS :\n options.startTimeoutMs);\n for (;;) {\n try {\n const client = await connectOnly(resolved.endpoint);\n return {client, spawned: true, endpoint: resolved.endpoint};\n } catch {\n if (Date.now() >= deadline) {\n throw new Error(\n `shotium: the daemon did not come up at ${resolved.endpoint}`);\n }\n await sleep(CONNECT_RETRY_MS);\n }\n }\n}\n\n// The five things a caller does with a daemon. Each opens a connection, does\n// one thing and closes it, which is the shape a short-lived process wants; a\n// service that will send more than one request calls connect() and keeps the\n// client.\nasync function connect(options: DaemonOptions = {}): Promise<DaemonClient> {\n const {client} = await ensureClient(options);\n return client;\n}\n\nasync function start(options: DaemonOptions = {}):\n Promise<DaemonStatus&{spawned: boolean}> {\n const {client, spawned, endpoint} = await ensureClient(options);\n try {\n const status = await client.status();\n return {...status, endpoint, spawned};\n } finally {\n client.close();\n }\n}\n\nasync function status(options: DaemonOptions = {}):\n Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}> {\n const resolved = resolveDaemonOptions(options);\n let client: DaemonClient;\n try {\n client = await connectOnly(resolved.endpoint);\n } catch {\n return {running: false, endpoint: resolved.endpoint};\n }\n try {\n return {...(await client.status()), running: true};\n } finally {\n client.close();\n }\n}\n\nasync function stop(options: DaemonOptions = {}):\n Promise<{stopped: boolean, endpoint: string}> {\n const resolved = resolveDaemonOptions(options);\n let client: DaemonClient;\n try {\n client = await connectOnly(resolved.endpoint);\n } catch {\n return {stopped: false, endpoint: resolved.endpoint};\n }\n try {\n await client.shutdown();\n return {stopped: true, endpoint: resolved.endpoint};\n } finally {\n client.close();\n }\n}\n\n// One screenshot through the daemon, connection and all. `daemon` carries the\n// pool's configuration -- binary, workers, cache root -- and is stripped out\n// here rather than sent, because it says which daemon to talk to and not what\n// to photograph.\nasync function screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):\n Promise<Buffer|null> {\n const {daemon, ...rest} = options;\n const client = await connect(daemon || {});\n try {\n return await client.screenshot(rest);\n } finally {\n client.close();\n }\n}\n\nexport {\n DaemonClient,\n connect,\n ensureClient,\n resolveDaemonOptions,\n screenshot,\n start,\n status,\n stop,\n};\n","import * as client from './lib/client.js';\nimport type {DaemonClient} from './lib/client.js';\nimport {Engine} from './lib/engine.js';\nimport type {\n DaemonOptions,\n DaemonStatus,\n PurgeOptions,\n ScreenshotOptions,\n StartOptions,\n} from './types.js';\n\nexport type {\n Clip,\n DaemonOptions,\n DaemonStatus,\n PageGotoParams,\n PurgeOptions,\n ScreenshotOptions,\n StartOptions,\n Viewport,\n} from './types.js';\nexport type {DaemonClient} from './lib/client.js';\n\n/** The five things a caller does with the resident engine. */\nexport interface Daemon {\n /** Connects, starting a daemon if none is listening. */\n connect(options?: DaemonOptions): Promise<DaemonClient>;\n /** One screenshot through the daemon, connection and all. */\n screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):\n Promise<Buffer|null>;\n /** Starts one if it is not up, and reports what is there either way. */\n start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;\n status(options?: DaemonOptions):\n Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}>;\n stop(options?: DaemonOptions): Promise<{stopped: boolean, endpoint: string}>;\n}\n\n/**\n * The engine, and its lifecycle, in this process.\n *\n * import shotium from '@shotkit/shotium';\n *\n * shotium.runtime.start();\n * const png = await shotium.screenshot({file: 'https://example.com'});\n * await shotium.runtime.stop();\n *\n * `start` and `stop` are explicit because starting Blink is the expensive part\n * -- tens of milliseconds and a working set that stays resident -- and only\n * the caller knows whether the next screenshot is coming in a moment or never.\n * Neither call is required: a screenshot starts the engine if it is not up.\n * What they buy is control over when that cost is paid, and the certainty that\n * it has been given back.\n *\n * `runtime` below is the singleton because there is nothing else it could be:\n * Blink starts once per process and cannot be restarted, so a second Runtime\n * in the same process has no engine to have. Construct one directly only to\n * own the lifecycle yourself instead of using `runtime`. Parallelism is more\n * processes, not more Runtimes.\n *\n * `daemon` is the same engine in a process of its own, behind a socket, for\n * callers whose own process does not live long enough to be worth starting\n * one.\n */\nexport class Runtime {\n private engine = new Engine();\n\n get running(): boolean {\n return this.engine.running;\n }\n\n /**\n * Starts the engine. Safe to call twice; the second call is a no-op, so that\n * library code can call it defensively. Not safe after `stop()` -- see there.\n *\n * Every option has a default. `cacheDir` is the HTTP disk cache and `null`\n * disables it; `resourceDir` is where `shotium_data.pak` and\n * `shotium_strings.pak` are, and defaults to the directory the engine was\n * loaded from, which is where they ship.\n */\n start(options: StartOptions = {}): this {\n this.engine.start(options);\n return this;\n }\n\n /**\n * Stops the engine, after whatever is queued.\n *\n * Final for this process. Blink writes process-wide state that it has no\n * path to undo, so starting again -- here or on another Runtime -- throws\n * rather than quietly handing back something that cannot render. A program\n * that wants another screenshot later should stay started and `purge()`.\n */\n stop(): Promise<void> {\n return this.engine.stop();\n }\n\n /**\n * Hands back what the engine is holding but can rebuild. Worth calling when\n * a batch has ended and the next one may be a while away.\n */\n purge(options: PurgeOptions = {}): void {\n this.engine.purge(options);\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 screenshot(options: ScreenshotOptions): Promise<Buffer|null> {\n return this.engine.screenshot(options);\n }\n}\n\n/** The shared engine: one per process, started on first use. */\nconst runtime = new Runtime();\n\n/** One screenshot through the shared engine, starting it if it is not up. */\nconst screenshot = (options: ScreenshotOptions): Promise<Buffer|null> =>\n runtime.screenshot(options);\n\n/**\n * The resident engine: a process that outlives the one that started it,\n * reachable over a named pipe on Windows and a unix socket elsewhere. For\n * callers that are short-lived themselves. See lib/daemon.ts.\n */\nconst daemon: Daemon = {\n connect: client.connect,\n screenshot: client.screenshot,\n start: client.start,\n status: client.status,\n stop: client.stop,\n};\n\nexport {runtime, screenshot, daemon};\n\n// A default as well as the names, because `import shotium from` is what a\n// caller coming from `require` writes first, and the two have to be the same\n// object rather than two views that drift.\nexport default {Runtime, runtime, screenshot, daemon};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["binding.load","start","status","stop","screenshot","client.connect","client.screenshot","client.start","client.status","client.stop"],"sources":["../src/lib/cache.ts","../src/lib/client.ts","../src/index.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type {\n CacheClearOptions,\n CacheClearResult,\n CacheEntry,\n CacheTarget,\n} from '../types.js';\n\nimport * as binding from './binding.js';\nimport type {Engine as Handle} from './binding.js';\nimport {cacheRoot, defaultCacheDir, normalizePath} from './config.js';\n\n/**\n * Turns one glob into a regular expression over a URL.\n *\n * The dialect is the small one everybody already knows -- `*`, `**`, `?`,\n * `{a,b}` -- and it is implemented here rather than depended on because this\n * package has no runtime dependencies and a matcher is thirty lines. `*` stops\n * at `/` and `**` does not, which is the distinction that makes\n * `https://example.com/*` mean one level and `https://example.com/**` mean the\n * site.\n *\n * Everything else is escaped, which matters more than usual here: the subjects\n * are URLs, and a URL is mostly characters that mean something to a regular\n * expression.\n */\nfunction globToRegExp(pattern: string): RegExp {\n let out = '';\n for (let i = 0; i < pattern.length; i++) {\n const c = pattern[i];\n if (c === '*') {\n if (pattern[i + 1] === '*') {\n out += '.*';\n i++;\n // `/**/` should also match the zero-segment case, so that\n // `https://x/**/y` matches `https://x/y`.\n if (pattern[i + 1] === '/') {\n out += '/?';\n i++;\n }\n } else {\n out += '[^/]*';\n }\n } else if (c === '?') {\n out += '[^/]';\n } else if (c === '{') {\n const end = pattern.indexOf('}', i);\n if (end === -1) {\n out += '\\\\{';\n } else {\n const alternatives =\n pattern.slice(i + 1, end).split(',').map(escapeLiteral);\n out += `(?:${alternatives.join('|')})`;\n i = end;\n }\n } else {\n out += escapeLiteral(c);\n }\n }\n return new RegExp(`^${out}$`);\n}\n\nfunction escapeLiteral(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/** Whether `url` matches any of `patterns`. No patterns matches nothing. */\nfunction matchesAny(url: string, patterns: RegExp[]): boolean {\n return patterns.some((pattern) => pattern.test(url));\n}\n\n/**\n * What a cache directory occupies, for the one path that reports a size\n * without a backend to ask.\n *\n * The sum of the files rather than the sum of the entries, so it will differ\n * from what `clear()` reports through the backend by the index and by whatever\n * rounding the filesystem does. It is the honest number for \"what is about to\n * be deleted\", which is what it is used for.\n */\nfunction directorySize(dir: string): number {\n let total = 0;\n let names: fs.Dirent[] = [];\n try {\n names = fs.readdirSync(dir, {withFileTypes: true});\n } catch {\n return 0;\n }\n for (const entry of names) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n total += directorySize(full);\n continue;\n }\n try {\n total += fs.statSync(full).size;\n } catch {\n // Raced with something else clearing the same directory. Not an error:\n // a file that is already gone contributes nothing to what is left.\n }\n }\n return total;\n}\n\n/**\n * Which directories an operation covers.\n *\n * `current` is this project's, `all` is every directory under the shared root,\n * and anything else is taken as a project hash. `all` reads the root rather\n * than remembering what it created: another process's directory is as much\n * shotium's as this one's, and a caller asking to clear them all means the\n * ones on disk.\n */\nfunction resolveTargets(target: CacheTarget['target']): string[] {\n if (target === 'all') {\n const root = cacheRoot();\n let names: string[] = [];\n try {\n names = fs.readdirSync(root);\n } catch {\n // No root means nothing has been cached yet, which is an empty list and\n // not an error: a caller clearing an empty cache asked for a state that\n // already holds.\n return [];\n }\n return names.map((name) => normalizePath(path.join(root, name)))\n .filter((dir) => {\n try {\n return fs.statSync(dir).isDirectory();\n } catch {\n return false;\n }\n });\n }\n if (target === undefined || target === 'current') {\n return [defaultCacheDir()];\n }\n // A directory, if it looks like one. `start({cacheDir})` takes any path, so\n // a caller who chose their own has to be able to name it here -- otherwise\n // the cache they configured is the one cache these methods cannot see.\n if (path.isAbsolute(target)) {\n return [normalizePath(target)];\n }\n // Otherwise a project hash. Resolved against the root rather than used as a\n // path, so that a relative string cannot reach outside it by accident.\n return [normalizePath(path.join(cacheRoot(), target))];\n}\n\n/**\n * The cache, from the outside.\n *\n * Every method takes the engine handle if there is one, and that is not an\n * optimisation. Within one process a cache directory has one backend: asking\n * for a second one on the directory the engine holds waits for the engine's to\n * go away, which it will not do while the engine is up. Borrowing is the only\n * thing that returns.\n *\n * \"If there is one\" means the process, not the lifecycle. `stop()` stands the\n * engine down without tearing it down, so an engine that has been stopped\n * still holds its directory and still has to be borrowed from -- which is also\n * what makes the cache survive a stop, and outlive one, and be worth having.\n *\n * Across processes there is no such constraint -- several of them may share a\n * directory and all of them cache.\n *\n * The engine is fetched through a callback rather than held, because this\n * object is built once at import time and the engine comes and goes.\n */\nexport class Cache {\n constructor(private readonly engineHandle: () => Handle | null) {}\n\n /**\n * This project's cache directory, absolute and with forward slashes.\n *\n * It exists whether or not anything has been written to it -- the answer is\n * \"where the cache goes\", not \"where a cache is\".\n */\n getDir(options: CacheTarget = {}): string {\n const targets = resolveTargets(options.target);\n return targets.length > 0 ? targets[0] : defaultCacheDir();\n }\n\n /** Every directory the target names. `all` can be several; the rest, one. */\n getDirs(options: CacheTarget = {}): string[] {\n return resolveTargets(options.target);\n }\n\n /**\n * What the cache is holding, by URL.\n *\n * Named `getFiles` for the operation callers reach for, and deliberately not\n * returning filenames: the files in a cache directory are called things like\n * `5349fbae98c6d9a1_0`, because the name is a hash of the entry key. A list\n * of those answers no question anybody has. The URLs are what the entries\n * are, and they are what `clear({glob})` matches against.\n *\n * This opens every entry to read its key and size, so it is a diagnostic\n * rather than something to put on a request path.\n */\n async getFiles(options: CacheTarget = {}): Promise<CacheEntry[]> {\n const native = binding.load();\n const entries: CacheEntry[] = [];\n for (const dir of resolveTargets(options.target)) {\n if (!fs.existsSync(dir)) {\n continue;\n }\n const json = await native.cache(\n this.handleFor(), /*clearing=*/ false, JSON.stringify({\n cacheDir: dir,\n }));\n const listed = JSON.parse(json) as Array<Omit<CacheEntry, 'dir'>>;\n for (const entry of listed) {\n entries.push({...entry, dir});\n }\n }\n return entries;\n }\n\n /**\n * Removes what the options select. With no options, everything.\n *\n * The three filters compose, and `glob` is applied here rather than in the\n * engine: the entries are listed, their URLs are matched, and the ones that\n * matched are what the engine is asked to remove. That keeps the pattern\n * dialect in the layer whose users have opinions about pattern dialects, and\n * keeps the engine's interface to exact URLs.\n *\n * Removal goes through the cache backend, never through the filesystem.\n * Deleting the files directly would leave the backend's index naming entries\n * that are no longer there, and the next process to open the directory\n * either rebuilds the index from disk or, having found it inconsistent,\n * discards it. That is the difference between clearing a cache and\n * corrupting one.\n */\n async clear(options: CacheClearOptions = {}): Promise<CacheClearResult[]> {\n const native = binding.load();\n const patterns = (options.glob ?? []).map(globToRegExp);\n const results: CacheClearResult[] = [];\n\n // Clearing everything, in a process that has no engine at all: remove the\n // directory.\n //\n // This is the one case where touching the filesystem is correct rather\n // than reckless. The danger in deleting cache files by hand is a partial\n // delete -- an index left naming entries that are gone -- and there is no\n // such thing when the index goes with them. What is left is a directory\n // that does not exist, which is exactly what an empty cache looks like\n // before anything has written to it.\n //\n // It is also the fast path a short script gets: emptying a cache without\n // starting Blink to do it costs a few milliseconds instead of the tens\n // that building an engine does.\n const unfiltered = patterns.length === 0 && !options.maxAge &&\n !options.maxSize;\n if (unfiltered && !this.handleFor()) {\n for (const dir of resolveTargets(options.target)) {\n const before = directorySize(dir);\n fs.rmSync(dir, {recursive: true, force: true});\n results.push(\n {removed: -1, bytesBefore: before, bytesAfter: 0, dir});\n }\n return results;\n }\n\n for (const dir of resolveTargets(options.target)) {\n if (!fs.existsSync(dir)) {\n continue;\n }\n const request: Record<string, unknown> = {cacheDir: dir};\n\n if (patterns.length > 0) {\n const json = await native.cache(\n this.handleFor(), /*clearing=*/ false,\n JSON.stringify({cacheDir: dir}));\n const listed = JSON.parse(json) as Array<Omit<CacheEntry, 'dir'>>;\n const urls =\n listed.filter((entry) => matchesAny(entry.url, patterns))\n .map((entry) => entry.url);\n // Nothing matched, so nothing is asked for. Falling through with an\n // empty `urls` would be read by the engine as \"no URL filter\", which\n // combined with no other filter empties the directory -- the opposite\n // of what a pattern that matched nothing means.\n if (urls.length === 0 && options.maxAge === undefined &&\n options.maxSize === undefined) {\n results.push({removed: 0, bytesBefore: 0, bytesAfter: 0, dir});\n continue;\n }\n request.urls = urls;\n }\n\n if (options.maxAge) {\n request.unusedSinceMs = Date.now() - options.maxAge * 1000;\n }\n if (options.maxSize) {\n request.maxBytes = options.maxSize;\n }\n\n const json = await native.cache(\n this.handleFor(), /*clearing=*/ true, JSON.stringify(request));\n results.push({\n ...(JSON.parse(json) as Omit<CacheClearResult, 'dir'>),\n dir,\n });\n }\n return results;\n }\n\n /**\n * The engine handle, when there is an engine.\n *\n * Passed for every directory and not only the engine's own. It is never\n * wrong to pass it -- the engine's thread can open any directory, and for\n * the one it already has open, borrowing its backend is the only thing that\n * returns. It is passing `null` while an engine is up that hangs, which is\n * why this is conditional on neither the directory asked for nor on whether\n * the engine is currently accepting captures.\n */\n private handleFor(): Handle|null {\n return this.engineHandle();\n }\n}\n","import {spawn} from 'node:child_process';\nimport {EventEmitter} from 'node:events';\nimport fs from 'node:fs';\nimport net from 'node:net';\nimport path from 'node:path';\nimport {fileURLToPath} from 'node:url';\n\nimport type {\n CaptureStats,\n DaemonOptions,\n DaemonStatus,\n ScreenshotOptions,\n ScreenshotResult,\n} from '../types.js';\n\nimport {resolveStartOptions} from './config.js';\nimport {emptyStats} from './engine.js';\nimport {endpointFor} from './endpoint.js';\nimport {FrameReader, encodeFrame} from './protocol.js';\nimport {timeoutFor, toRequest} from './request.js';\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// The detached daemon's entry point, which is a build output beside this one.\n// It is spawned as `node <path>`, so it has to be a file on disk with a name\n// that does not move -- see tsdown.config.ts, where it is an entry of its own\n// for exactly that reason.\nconst DAEMON_MAIN = path.join(HERE, 'daemon_main.js');\n// How long to wait for a daemon this process just started to bind its\n// endpoint. Binding happens after the workers are spawned but before they are\n// warm, so this covers process startup and nothing else.\nconst START_TIMEOUT_MS = 20000;\nconst CONNECT_RETRY_MS = 20;\n\ninterface ClientReply {\n id: number;\n ok?: boolean;\n error?: string;\n path?: string;\n // The daemon reports the same CaptureStats the in-process engine does, in\n // its response header. It rides on the failure header too, which is why the\n // rejection below carries it.\n stats?: CaptureStats;\n}\n\ninterface ClientResult {\n header: ClientReply;\n image: Buffer|null;\n}\n\ninterface Pending {\n resolve: (result: ClientResult) => void;\n reject: (error: Error) => void;\n}\n\ninterface ResolvedDaemonOptions {\n cacheDir: string|null;\n userAgent?: string;\n resourceDir?: string;\n name: string|undefined;\n endpoint: string;\n idleTimeoutMs: number|undefined;\n prewarm: boolean|undefined;\n logFile: string|null;\n}\n\n// The client half of the resident daemon.\n//\n// One connection can carry several requests at once: every message carries an\n// `id` and the answers are matched back by it, so a caller can fire ten\n// screenshots down one socket without waiting between them. They still come\n// back one at a time -- there is one renderer on the other side -- so this\n// saves the round trips, not the renders.\nclass DaemonClient extends EventEmitter {\n private readonly socket: net.Socket;\n private readonly endpointPath: string;\n private readonly pending = new Map<number, Pending>();\n private nextId = 1;\n private header: ClientReply|null = null;\n private reader = new FrameReader();\n\n constructor(socket: net.Socket, endpoint: string) {\n super();\n this.socket = socket;\n this.endpointPath = endpoint;\n\n socket.on('data', (chunk: Buffer) => this.onData(chunk));\n socket.on('error', (error: Error) => this.failAll(error));\n socket.on('close', () => {\n this.failAll(new Error('shotium: the daemon closed the connection'));\n this.emit('close', {});\n });\n }\n\n get endpoint(): string {\n return this.endpointPath;\n }\n\n get closed(): boolean {\n return this.socket.destroyed;\n }\n\n private onData(chunk: Buffer): void {\n this.reader.push(chunk);\n for (;;) {\n const frame = this.reader.next();\n if (frame === null) {\n return;\n }\n if (this.header === null) {\n try {\n this.header = JSON.parse(frame.toString('utf8')) as ClientReply;\n } catch {\n this.failAll(\n new Error('shotium: the daemon sent a header that is not JSON'));\n return;\n }\n continue;\n }\n const header = this.header;\n this.header = null;\n this.settle(header, frame);\n }\n }\n\n private settle(header: ClientReply, payload: Buffer): void {\n const pending = this.pending.get(header.id);\n if (!pending) {\n return;\n }\n this.pending.delete(header.id);\n if (header.ok) {\n pending.resolve({header, image: header.path ? null : payload});\n } else {\n const error = new Error(header.error || 'shotium: request failed');\n // Attached rather than dropped: a capture that failed part of the way\n // through has already measured what it did, and that is usually the\n // explanation. The in-process engine does the same.\n if (header.stats) {\n (error as Error & {stats?: CaptureStats}).stats = header.stats;\n }\n pending.reject(error);\n }\n }\n\n private failAll(error: Error): void {\n for (const [, pending] of this.pending) {\n pending.reject(error);\n }\n this.pending.clear();\n }\n\n // Sends one message and resolves with {header, image}.\n send(message: Record<string, unknown>): Promise<ClientResult> {\n return new Promise<ClientResult>((resolve, reject) => {\n if (this.socket.destroyed) {\n reject(new Error('shotium: not connected to a daemon'));\n return;\n }\n const id = this.nextId++;\n this.pending.set(id, {resolve, reject});\n this.socket.write(\n encodeFrame(Buffer.from(JSON.stringify({...message, id}), 'utf8')));\n });\n }\n\n /**\n * One screenshot, and what taking it cost.\n *\n * The same shape the in-process engine returns, so that moving a program\n * between the two is an import change and nothing else.\n */\n async screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {\n const request = toRequest(options);\n const result = await this.send({\n op: 'screenshot',\n request,\n timeout: timeoutFor(options),\n });\n return {image: result.image, stats: result.header.stats ?? emptyStats()};\n }\n\n async status(): Promise<DaemonStatus> {\n const {header} = await this.send({op: 'status'});\n return header as unknown as DaemonStatus;\n }\n\n async shutdown(): Promise<{ok: boolean}> {\n const {header} = await this.send({op: 'shutdown'});\n return {ok: header.ok === true};\n }\n\n close(): void {\n this.socket.end();\n this.socket.destroy();\n }\n}\n\n// Opens a connection to a daemon that is already listening, and fails if there\n// is not one. Nothing is spawned here: a caller that wants a daemon started\n// says so, because starting one is a side effect on the machine and not the\n// sort of thing a status query should do.\nfunction connectOnly(endpoint: string): Promise<DaemonClient> {\n return new Promise<DaemonClient>((resolve, reject) => {\n const socket = net.connect(endpoint);\n const onError = (error: Error) => {\n socket.destroy();\n reject(error);\n };\n socket.once('error', onError);\n socket.once('connect', () => {\n socket.removeListener('error', onError);\n resolve(new DaemonClient(socket, endpoint));\n });\n });\n}\n\nfunction resolveDaemonOptions(options: DaemonOptions = {}):\n ResolvedDaemonOptions {\n const resolved = resolveStartOptions(options);\n return {\n ...resolved,\n name: options.name,\n endpoint: endpointFor({\n ...resolved,\n name: options.name,\n endpoint: options.endpoint,\n }),\n idleTimeoutMs: options.idleTimeoutMs,\n prewarm: options.prewarm,\n logFile: options.logFile || process.env.SHOTIUM_DAEMON_LOG || null,\n };\n}\n\nfunction spawnDaemon(options: ResolvedDaemonOptions): void {\n const config = {\n cacheDir: options.cacheDir,\n userAgent: options.userAgent,\n resourceDir: options.resourceDir,\n endpoint: options.endpoint,\n idleTimeoutMs: options.idleTimeoutMs,\n prewarm: options.prewarm,\n };\n const encoded =\n Buffer.from(JSON.stringify(config), 'utf8').toString('base64');\n\n // Detached, with the standard streams let go of: the daemon has to outlive\n // the process that started it, and a child still holding this process's pipes\n // would keep it from exiting -- the exact failure that makes a \"background\"\n // daemon hang a shell.\n let stdio: 'ignore'|['ignore', number, number] = 'ignore';\n let logFd: number|null = null;\n if (options.logFile) {\n logFd = fs.openSync(options.logFile, 'a');\n stdio = ['ignore', logFd, logFd];\n }\n const child = spawn(process.execPath, [DAEMON_MAIN, encoded], {\n detached: true,\n stdio,\n windowsHide: true,\n });\n child.unref();\n if (logFd !== null) {\n fs.closeSync(logFd);\n }\n}\n\nconst sleep = (ms: number) =>\n new Promise<void>((resolve) => setTimeout(resolve, ms));\n\nexport interface EnsuredClient {\n client: DaemonClient;\n spawned: boolean;\n endpoint: string;\n}\n\n// Connects, starting a daemon if none answers.\n//\n// The endpoint existing is the readiness signal, so this is a connect loop\n// rather than a handshake: a daemon that has bound can be talked to, and one\n// that has not is indistinguishable from one that was never started. Several\n// processes racing here is fine -- the losers' daemons exit on EADDRINUSE and\n// everyone ends up on the winner.\nasync function ensureClient(options: DaemonOptions = {}):\n Promise<EnsuredClient> {\n const resolved = resolveDaemonOptions(options);\n try {\n const client = await connectOnly(resolved.endpoint);\n return {client, spawned: false, endpoint: resolved.endpoint};\n } catch {\n if (options.spawn === false) {\n throw new Error(`shotium: no daemon at ${resolved.endpoint}`);\n }\n }\n\n spawnDaemon(resolved);\n const deadline = Date.now() +\n (options.startTimeoutMs === undefined ? START_TIMEOUT_MS :\n options.startTimeoutMs);\n for (;;) {\n try {\n const client = await connectOnly(resolved.endpoint);\n return {client, spawned: true, endpoint: resolved.endpoint};\n } catch {\n if (Date.now() >= deadline) {\n throw new Error(\n `shotium: the daemon did not come up at ${resolved.endpoint}`);\n }\n await sleep(CONNECT_RETRY_MS);\n }\n }\n}\n\n// The five things a caller does with a daemon. Each opens a connection, does\n// one thing and closes it, which is the shape a short-lived process wants; a\n// service that will send more than one request calls connect() and keeps the\n// client.\nasync function connect(options: DaemonOptions = {}): Promise<DaemonClient> {\n const {client} = await ensureClient(options);\n return client;\n}\n\nasync function start(options: DaemonOptions = {}):\n Promise<DaemonStatus&{spawned: boolean}> {\n const {client, spawned, endpoint} = await ensureClient(options);\n try {\n const status = await client.status();\n return {...status, endpoint, spawned};\n } finally {\n client.close();\n }\n}\n\nasync function status(options: DaemonOptions = {}):\n Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}> {\n const resolved = resolveDaemonOptions(options);\n let client: DaemonClient;\n try {\n client = await connectOnly(resolved.endpoint);\n } catch {\n return {running: false, endpoint: resolved.endpoint};\n }\n try {\n return {...(await client.status()), running: true};\n } finally {\n client.close();\n }\n}\n\nasync function stop(options: DaemonOptions = {}):\n Promise<{stopped: boolean, endpoint: string}> {\n const resolved = resolveDaemonOptions(options);\n let client: DaemonClient;\n try {\n client = await connectOnly(resolved.endpoint);\n } catch {\n return {stopped: false, endpoint: resolved.endpoint};\n }\n try {\n await client.shutdown();\n return {stopped: true, endpoint: resolved.endpoint};\n } finally {\n client.close();\n }\n}\n\n// One screenshot through the daemon, connection and all. `daemon` carries the\n// pool's configuration -- binary, workers, cache root -- and is stripped out\n// here rather than sent, because it says which daemon to talk to and not what\n// to photograph.\nasync function screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):\n Promise<ScreenshotResult> {\n const {daemon, ...rest} = options;\n const client = await connect(daemon || {});\n try {\n return await client.screenshot(rest);\n } finally {\n client.close();\n }\n}\n\nexport {\n DaemonClient,\n connect,\n ensureClient,\n resolveDaemonOptions,\n screenshot,\n start,\n status,\n stop,\n};\n","import {Cache} from './lib/cache.js';\nimport * as client from './lib/client.js';\nimport type {DaemonClient} from './lib/client.js';\nimport {Engine} from './lib/engine.js';\nimport type {\n DaemonOptions,\n DaemonStatus,\n ReleaseMemoryOptions,\n ScreenshotOptions,\n ScreenshotResult,\n StartOptions,\n StartResult,\n} from './types.js';\n\nexport type {\n CacheClearOptions,\n CacheClearResult,\n CacheEntry,\n CacheMode,\n CacheTarget,\n CaptureStats,\n CaptureTiming,\n Clip,\n DaemonOptions,\n DaemonStatus,\n PageGotoParams,\n ReleaseMemoryOptions,\n ScreenshotOptions,\n ScreenshotResult,\n StartOptions,\n StartResult,\n Viewport,\n} from './types.js';\nexport type {DaemonClient} from './lib/client.js';\nexport {Cache} from './lib/cache.js';\n\n/** The five things a caller does with the resident engine. */\nexport interface Daemon {\n /** Connects, starting a daemon if none is listening. */\n connect(options?: DaemonOptions): Promise<DaemonClient>;\n /** One screenshot through the daemon, connection and all. */\n screenshot(options: ScreenshotOptions&{daemon?: DaemonOptions}):\n Promise<ScreenshotResult>;\n /** Starts one if it is not up, and reports what is there either way. */\n start(options?: DaemonOptions): Promise<DaemonStatus&{spawned: boolean}>;\n status(options?: DaemonOptions):\n Promise<Partial<DaemonStatus>&{running: boolean, endpoint: string}>;\n stop(options?: DaemonOptions): Promise<{stopped: boolean, endpoint: string}>;\n}\n\n/**\n * The engine, and its lifecycle, in this process.\n *\n * import shotium from '@shotkit/shotium';\n *\n * shotium.start();\n * const {image, stats} = await shotium.screenshot({\n * file: 'https://example.com',\n * });\n * await shotium.stop();\n *\n * `start` and `stop` are explicit because starting Blink is the expensive part\n * -- tens of milliseconds and a working set that stays resident -- and only\n * the caller knows whether the next screenshot is coming in a moment or never.\n * Neither call is required: a screenshot starts the engine if it is not up.\n * What they buy is control over when that cost is paid, and the certainty that\n * it has been given back.\n *\n * Neither is rationed, either. They may be called in any order and as often as\n * a program likes: `stop()` stands the engine down and `start()` picks the\n * same one back up, warm cache and all. What cannot happen is a *second*\n * engine -- Blink is initialised once per process and there is no undo -- but\n * that is a fact about how many there are, not about how many times the one\n * may be asked for.\n *\n * The methods are on the module rather than under a `runtime` namespace, which\n * they were until 0.3. There was never anything else to start, so the word\n * carried nothing; and `runtime.cache` would have been the wrong place for the\n * cache besides, since a cache directory outlives every engine that writes to\n * it and can be read when no engine is running at all.\n *\n * `Runtime` is still exported for a caller who wants to own a lifecycle rather\n * than share the module's. It is a lifecycle and not an engine: there is one\n * engine per process, and a second Runtime that starts adopts the same one\n * rather than building another. Parallelism is more processes, not more\n * Runtimes.\n *\n * `daemon` is the same engine in a process of its own, behind a socket, for\n * callers whose own process does not live long enough to be worth starting\n * one.\n */\nexport class Runtime {\n private engine = new Engine();\n\n /**\n * The HTTP cache: where it is, what is in it, and how to empty it.\n *\n * On the Runtime as well as on the module because a caller holding their own\n * Runtime needs the engine handle to reach a directory that engine has open:\n * within one process a directory has one backend, so borrowing is the only\n * way in.\n */\n readonly cache = new Cache(() => this.engine.nativeHandle);\n\n get running(): boolean {\n return this.engine.running;\n }\n\n /**\n * Starts the engine, or picks the running one back up.\n *\n * Callable as often as you like, in any order with `stop()`; library code\n * can call it defensively. The first call in a process builds the engine and\n * every later one adopts it -- the same engine, the same warm cache. The one\n * thing it will refuse is a *different* configuration: the options below are\n * fixed when the engine is built, and there is no second build, so naming\n * one that disagrees with what is running throws rather than rendering with\n * a value you did not ask for.\n *\n * Every option has a default. `cacheDir` is the HTTP disk cache and defaults\n * to a per-project directory under `~/.shotium/cache`, and not under the\n * temporary directory, which is defined by not surviving. `null` turns it\n * off. `resourceDir` is where `shotium_data.pak` and\n * `shotium_strings.pak` are, and defaults to the directory the engine was\n * loaded from, which is where they ship.\n *\n * The return value is worth reading once. `cacheActive: false` with a\n * `cacheDir` set means the directory could not be opened and this engine is\n * running without a cache -- correctly, silently, and a round trip slower on\n * everything.\n */\n start(options: StartOptions = {}): StartResult {\n return this.engine.start(options);\n }\n\n /** What `start()` returned, asked again. */\n status(): StartResult {\n return this.engine.status();\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. Blink itself stays initialised, because there\n * is no way to un-initialise it -- so the disk cache stays where it is, and\n * `start()` or the next `screenshot()` picks the same engine back up.\n *\n * Which makes this a caller saying they are done for now rather than a\n * destructor. It does the same work as `releaseMemory({releaseWorkingSet:\n * true})` and additionally stops accepting captures.\n */\n stop(): Promise<void> {\n return this.engine.stop();\n }\n\n /**\n * Hands back what the engine is holding but can rebuild: Blink's heap,\n * skia's caches, PartitionAlloc's free lists. Worth calling when a batch has\n * ended and the next one may be a while away.\n *\n * This is memory and nothing else. It was called `purge()` until 0.3, which\n * next to `cache.clear()` read as though it emptied the HTTP cache; it does\n * not touch the disk at all.\n */\n releaseMemory(options: ReleaseMemoryOptions = {}): void {\n this.engine.releaseMemory(options);\n }\n\n /**\n * Renders one screenshot, and reports what it cost.\n *\n * `image` is the encoded bytes, or `null` when `path` was given and the\n * engine wrote the file itself. `stats` says how many resources were\n * fetched, how many came from the cache, and where the milliseconds went --\n * which for an `https:` URL is usually the answer to \"why did this take so\n * long\", because a cold connection costs more than the render does.\n */\n screenshot(options: ScreenshotOptions): Promise<ScreenshotResult> {\n return this.engine.screenshot(options);\n }\n}\n\n/** The shared engine: one per process, started on first use. */\nconst runtime = new Runtime();\n\n/** One screenshot through the shared engine, starting it if it is not up. */\nconst screenshot = (options: ScreenshotOptions): Promise<ScreenshotResult> =>\n runtime.screenshot(options);\n\nconst start = (options?: StartOptions): StartResult => runtime.start(options);\nconst status = (): StartResult => runtime.status();\nconst stop = (): Promise<void> => runtime.stop();\nconst releaseMemory = (options?: ReleaseMemoryOptions): void =>\n runtime.releaseMemory(options);\n\n/**\n * The HTTP cache.\n *\n * At the top level rather than under the engine because it outlives one: the\n * directory is on disk whether or not anything is running, `getDir()` answers\n * before the first `start()`, and clearing it is something a program may want\n * to do without bringing Blink up at all. When an engine *is* up, these\n * borrow its cache backend, because within one process a directory has one\n * backend and that is the only way in.\n */\nconst cache = runtime.cache;\n\n/**\n * The resident engine: a process that outlives the one that started it,\n * reachable over a named pipe on Windows and a unix socket elsewhere. For\n * callers that are short-lived themselves. See lib/daemon.ts.\n *\n * It has no `cache` of its own. A daemon's cache directory is reported by\n * `daemon.status()`, and clearing it is done by pointing `cache.clear()` at\n * that directory or by stopping the daemon -- a cross-process cache protocol\n * would be a second implementation of this module for something nobody does on\n * a request path.\n */\nconst daemon: Daemon = {\n connect: client.connect,\n screenshot: client.screenshot,\n start: client.start,\n status: client.status,\n stop: client.stop,\n};\n\nexport {cache, daemon, releaseMemory, runtime, screenshot, start, status, stop};\n\n// A default as well as the names, because `import shotium from` is what a\n// caller coming from `require` writes first, and the two have to be the same\n// object rather than two views that drift.\nexport default {\n Runtime,\n cache,\n daemon,\n releaseMemory,\n runtime,\n screenshot,\n start,\n status,\n stop,\n // A getter and not a value, because it changes. It is only on the default\n // export: a named `running` would have to be a live binding that something\n // remembered to update, and the two would disagree the first time anybody\n // forgot. Callers who import by name have `status().running`, which is the\n // same answer with the cache directory attached.\n get running(): boolean {\n return runtime.running;\n },\n};\n"],"mappings":"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"}
@@ -0,0 +1,479 @@
1
+ import { createRequire } from "node:module";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import crypto from "node:crypto";
6
+ import os from "node:os";
7
+
8
+ //#region src/lib/platform.ts
9
+ const require$1 = createRequire(import.meta.url);
10
+ const PACKAGES = {
11
+ "win32-x64": "@shotkit/shotium-win32-x64",
12
+ "win32-arm64": "@shotkit/shotium-win32-arm64",
13
+ "darwin-x64": "@shotkit/shotium-darwin-x64",
14
+ "darwin-arm64": "@shotkit/shotium-darwin-arm64",
15
+ "linux-x64": "@shotkit/shotium-linux-x64",
16
+ "linux-arm64": "@shotkit/shotium-linux-arm64"
17
+ };
18
+ function packageName(platform = process.platform, arch = process.arch) {
19
+ return PACKAGES[`${platform}-${arch}`] ?? null;
20
+ }
21
+ function packageDir() {
22
+ const name = packageName();
23
+ if (!name) return null;
24
+ try {
25
+ return path.dirname(require$1.resolve(`${name}/package.json`));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ //#endregion
32
+ //#region src/lib/binding.ts
33
+ const require = createRequire(import.meta.url);
34
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
35
+ function candidates() {
36
+ const found = [];
37
+ const dir = packageDir();
38
+ if (dir) found.push(path.join(dir, "shotium.node"));
39
+ found.push(path.join(HERE, "..", "native", "build", "Release", "shotium.node"));
40
+ return found;
41
+ }
42
+ let binding = null;
43
+ let loadedFrom = null;
44
+ /**
45
+ * The addon, loaded once. Throws if there is none for this platform, which is
46
+ * the only failure this package cannot work around: there is nothing else to
47
+ * fall back to.
48
+ */
49
+ function load() {
50
+ if (binding) return binding;
51
+ const tried = candidates();
52
+ for (const candidate of tried) {
53
+ if (!fs.existsSync(candidate)) continue;
54
+ binding = require(candidate);
55
+ loadedFrom = path.dirname(candidate);
56
+ return binding;
57
+ }
58
+ const expected = packageName();
59
+ throw new Error(`shotium: no engine for this platform.
60
+ looked in:\n ${tried.join("\n ")}\n` + (expected ? ` It ships in ${expected}, which npm installs as an optional dependency of this package. If the install skipped optional dependencies, it is not there.
61
+ ` : ` There is no build for ${process.platform}-${process.arch}.\n`));
62
+ }
63
+ /**
64
+ * The directory the addon came from, or null before the first load(). The
65
+ * resource packs ship beside it, which is what this is for.
66
+ */
67
+ function directory() {
68
+ return loadedFrom;
69
+ }
70
+
71
+ //#endregion
72
+ //#region src/lib/config.ts
73
+ const DEFAULT_CACHE_MAX_BYTES = 268435456;
74
+ /**
75
+ * One spelling of a path: absolute, with forward slashes.
76
+ *
77
+ * Every path this module hands back goes through here. On Windows the two
78
+ * separators are interchangeable to the filesystem and not to a caller
79
+ * comparing strings or writing a glob, and a library that returns whichever
80
+ * one `path.join` happened to produce makes that the caller's problem.
81
+ */
82
+ function normalizePath(target) {
83
+ return path.resolve(target).replace(/\\/g, "/");
84
+ }
85
+ /**
86
+ * The project the current process belongs to: the nearest directory at or
87
+ * above the working directory that has a package.json.
88
+ *
89
+ * The working directory itself would be the obvious key and is the wrong one.
90
+ * It moves -- `process.chdir`, or a script run from a subdirectory -- and each
91
+ * value it takes would get a cache of its own, so a project would slowly
92
+ * accumulate directories that each know a third of its pages. The package root
93
+ * is the thing that stays put.
94
+ *
95
+ * Falls back to the working directory when there is no package.json above it,
96
+ * which is what a bare script has and is still better than nothing: it is at
97
+ * least stable for as long as the script runs from one place.
98
+ */
99
+ function projectRoot() {
100
+ let dir = process.cwd();
101
+ for (;;) {
102
+ if (fs.existsSync(path.join(dir, "package.json"))) return dir;
103
+ const parent = path.dirname(dir);
104
+ if (parent === dir) return process.cwd();
105
+ dir = parent;
106
+ }
107
+ }
108
+ /**
109
+ * Where every shotium cache directory lives. One level up from any single
110
+ * project's, which is what makes `target: 'all'` answerable.
111
+ *
112
+ * Under the home directory and not the temporary one, which is where this was
113
+ * until 0.3 was cut. $TMPDIR is defined by not surviving: /tmp is emptied on
114
+ * reboot, systemd-tmpfiles removes anything untouched for ten days, and macOS
115
+ * sweeps it on a schedule of its own. The entire value of an HTTP cache is the
116
+ * *next* run, so a default that lives somewhere designed to be cleared is a
117
+ * cache that stops working at exactly the moment it would have started paying
118
+ * for itself.
119
+ *
120
+ * `~/.shotium`, spelled the same on every platform. One place a user can look
121
+ * for it, one path to say in a bug report, and one directory to delete.
122
+ *
123
+ * $TMPDIR remains only as a fallback for a process with no home to speak of --
124
+ * some containers, some service accounts. That is a degradation and not a
125
+ * second location: there is no home directory holding a cache that would
126
+ * otherwise have been found.
127
+ */
128
+ function shotiumHome() {
129
+ return path.join(os.homedir() || os.tmpdir(), ".shotium");
130
+ }
131
+ function cacheRoot() {
132
+ return normalizePath(path.join(shotiumHome(), "cache"));
133
+ }
134
+ /**
135
+ * The identifier for a project's cache directory: a hash of its root path.
136
+ *
137
+ * A hash rather than the path itself because the path contains separators,
138
+ * drive letters and whatever the user called their directory, none of which
139
+ * survive being a directory name. It is not a security measure and does not
140
+ * need to be one -- it is a fixed-length name for a variable-length string.
141
+ */
142
+ function projectKey(root = projectRoot()) {
143
+ return crypto.createHash("sha1").update(normalizePath(root)).digest("hex");
144
+ }
145
+ /** This project's cache directory. */
146
+ function defaultCacheDir() {
147
+ return normalizePath(path.join(cacheRoot(), projectKey()));
148
+ }
149
+ function resolveStartOptions(options = {}) {
150
+ return {
151
+ cacheDir: options.cacheDir === null ? null : options.cacheDir ?? defaultCacheDir(),
152
+ cacheMaxBytes: options.cacheMaxBytes ?? 268435456,
153
+ userAgent: options.userAgent,
154
+ resourceDir: options.resourceDir
155
+ };
156
+ }
157
+
158
+ //#endregion
159
+ //#region src/lib/request.ts
160
+ const DEFAULT_TIMEOUT_MS = 3e4;
161
+ const WIRE_FIELDS = /* @__PURE__ */ new Set([
162
+ "file",
163
+ "type",
164
+ "fullPage",
165
+ "selector",
166
+ "quality",
167
+ "scale",
168
+ "omitBackground",
169
+ "path",
170
+ "pageGotoParams",
171
+ "clip",
172
+ "viewport",
173
+ "allowFileAccess",
174
+ "cache",
175
+ "headers"
176
+ ]);
177
+ function toRequest(options) {
178
+ if (!options || typeof options !== "object") throw new TypeError("shotium: screenshot(options) needs an object");
179
+ if (typeof options.file !== "string" || options.file.length === 0) throw new TypeError("shotium: options.file is required");
180
+ const request = {};
181
+ for (const [key, value] of Object.entries(options)) {
182
+ if (value === void 0) continue;
183
+ if (!WIRE_FIELDS.has(key)) throw new TypeError(`shotium: unknown option "${key}"`);
184
+ request[key] = value;
185
+ }
186
+ if (request.viewport) {
187
+ const { width, height } = request.viewport;
188
+ delete request.viewport;
189
+ if (width !== void 0) request.width = width;
190
+ if (height !== void 0) request.height = height;
191
+ }
192
+ return request;
193
+ }
194
+ function timeoutFor(options) {
195
+ const timeout = options.pageGotoParams && options.pageGotoParams.timeout;
196
+ return typeof timeout === "number" ? timeout : DEFAULT_TIMEOUT_MS;
197
+ }
198
+
199
+ //#endregion
200
+ //#region src/lib/engine.ts
201
+ let shared = null;
202
+ let sharedOptions = null;
203
+ let spent = false;
204
+ /** The engine handle this process has, or null if it has none. */
205
+ function sharedHandle() {
206
+ return shared;
207
+ }
208
+ function conflictingOption(options, current) {
209
+ const wanted = resolveStartOptions(options);
210
+ for (const key of [
211
+ "cacheDir",
212
+ "cacheMaxBytes",
213
+ "userAgent",
214
+ "resourceDir"
215
+ ]) {
216
+ if (options[key] === void 0 || wanted[key] === current[key]) continue;
217
+ return `${key} is ${JSON.stringify(current[key])}, and this start() asked for ${JSON.stringify(wanted[key])}`;
218
+ }
219
+ return null;
220
+ }
221
+ /**
222
+ * Blink, in this process, and the queue in front of it.
223
+ *
224
+ * There is one renderer and there is no way to have two. Blink is a
225
+ * process-wide singleton: it is initialised once, there is no path to a second
226
+ * one, and `worker_threads` do not change that because they share the process.
227
+ * So captures are serialised however many callers there are, and a program
228
+ * that wants four at once wants four processes.
229
+ *
230
+ * The queue is not about fairness. Each capture occupies a libuv thread pool
231
+ * thread for as long as the render takes, and there are four of those by
232
+ * default, shared with fs and dns -- so letting four screenshots go at once
233
+ * would stall the host's file reads for a fifth of a second at a time while
234
+ * gaining nothing, since the engine serialises them anyway.
235
+ */
236
+ var Engine = class {
237
+ active = false;
238
+ tail = Promise.resolve();
239
+ get running() {
240
+ return this.active && shared !== null;
241
+ }
242
+ /**
243
+ * The addon's engine handle, or null when this process has never had one.
244
+ *
245
+ * Deliberately not conditional on `running`. It is the cache that asks, and
246
+ * what the cache needs to know is whether a backend exists in this process
247
+ * -- because within one process a cache directory has one backend, so
248
+ * reading or clearing the directory the engine holds means borrowing it
249
+ * rather than opening a second one. A stood-down engine still holds its
250
+ * directory, so a caller who calls `stop()` and then `cache.getFiles()` is
251
+ * asking about a live backend and has to be routed to it. Nothing else
252
+ * should reach for this.
253
+ */
254
+ get nativeHandle() {
255
+ return sharedHandle();
256
+ }
257
+ /**
258
+ * Starts the engine, or picks the running one back up.
259
+ *
260
+ * Callable as often as a caller likes, in any order with `stop()`. The first
261
+ * call in a process builds the engine; every later one adopts it, which is
262
+ * the same engine and the same warm cache. Library code can call it
263
+ * defensively.
264
+ *
265
+ * The one thing that cannot be adopted is a different configuration. The
266
+ * options below are fixed when the engine is built and there is no second
267
+ * build, so naming one that disagrees with what is running throws rather
268
+ * than rendering with a value the caller did not ask for.
269
+ */
270
+ start(options = {}) {
271
+ if (shared) {
272
+ const conflict = conflictingOption(options, sharedOptions);
273
+ if (conflict) throw new Error("shotium: this process already has an engine, and its " + conflict + ". Blink is initialised once per process and cannot be built again, so an engine's options are fixed for as long as the process lives -- stop() does not undo them. Use the engine that is up, or run another process.");
274
+ this.active = true;
275
+ return this.status();
276
+ }
277
+ if (spent) throw new Error("shotium: this process had an engine and it was disposed of. Blink is initialised once per process and cannot be built again. Run another process.");
278
+ const native = load();
279
+ const resolved = resolveStartOptions(options);
280
+ const engineOptions = {};
281
+ if (resolved.cacheDir !== null) {
282
+ engineOptions.cacheDir = resolved.cacheDir;
283
+ engineOptions.cacheMaxBytes = resolved.cacheMaxBytes;
284
+ }
285
+ if (resolved.userAgent !== void 0) engineOptions.userAgent = resolved.userAgent;
286
+ engineOptions.resourceDir = resolved.resourceDir ?? directory();
287
+ shared = native.create(JSON.stringify(engineOptions));
288
+ sharedOptions = resolved;
289
+ this.active = true;
290
+ return this.status();
291
+ }
292
+ /**
293
+ * What the engine came up as: whether this lifecycle is started, which cache
294
+ * directory the engine has, and whether it actually got it.
295
+ *
296
+ * The last of those is the one worth reading. A directory that cannot be
297
+ * created or written to -- no permission, no space, a path that is a file --
298
+ * fails invisibly: the engine renders exactly as well without a cache, only
299
+ * slower, and every capture pays the network again for a reason nothing
300
+ * reports. The engine opens the cache during `start()` so that this is
301
+ * answerable before the first screenshot rather than after it.
302
+ *
303
+ * The cache half is answered from the engine whenever this process has one,
304
+ * including after `stop()`. A stood-down engine still holds its directory,
305
+ * and reporting `null` for it would say the cache had gone away when what
306
+ * went away was the willingness to render.
307
+ */
308
+ status() {
309
+ if (!shared) return {
310
+ running: false,
311
+ cacheDir: null,
312
+ cacheActive: false
313
+ };
314
+ const reported = JSON.parse(load().status(shared));
315
+ return {
316
+ running: this.running,
317
+ ...reported
318
+ };
319
+ }
320
+ /**
321
+ * Stands the engine down, after whatever is queued.
322
+ *
323
+ * The queue drains, the memory the engine can rebuild goes back to the OS,
324
+ * and `running` becomes false. What does not happen is a teardown of Blink,
325
+ * because there is no such thing -- see the note at the top of this file --
326
+ * so the disk cache stays where it is and `start()` picks the same engine up
327
+ * again whenever the caller wants it.
328
+ *
329
+ * Which makes this exactly what it says: not a destructor, a caller saying
330
+ * they are done for now. A program that will want another screenshot in a
331
+ * moment can equally well stay started and call `releaseMemory()`; the two
332
+ * do the same work, and this one also stops accepting captures.
333
+ */
334
+ async stop() {
335
+ if (!this.active) return;
336
+ this.active = false;
337
+ await this.tail.catch(() => {});
338
+ if (shared) load().purge(shared, true);
339
+ }
340
+ /**
341
+ * The real teardown: joins the engine thread, unwinds the network stack, and
342
+ * lets the disk cache write its index.
343
+ *
344
+ * Final, and final for the process rather than for this object -- which is
345
+ * why it is not on the public surface. The daemon calls it as it exits a
346
+ * process it owns, where the index flush is worth having and nothing is
347
+ * going to ask for another screenshot. Everything else wants `stop()`.
348
+ */
349
+ async dispose() {
350
+ this.active = false;
351
+ await this.tail.catch(() => {});
352
+ const handle = shared;
353
+ shared = null;
354
+ sharedOptions = null;
355
+ if (handle) {
356
+ spent = true;
357
+ load().destroy(handle);
358
+ }
359
+ }
360
+ /**
361
+ * Hands back what the engine is holding but can rebuild.
362
+ * `releaseWorkingSet` additionally asks the OS for the pages, which the next
363
+ * screenshot pays back in soft faults -- worth it when there may not be a
364
+ * next one soon.
365
+ *
366
+ * The daemon does this for itself on a timer because it can watch its own
367
+ * request stream go quiet. Here the queue belongs to the caller, so the
368
+ * caller is the one who knows a batch has ended.
369
+ */
370
+ releaseMemory({ releaseWorkingSet = false } = {}) {
371
+ if (!shared) return;
372
+ load().purge(shared, releaseWorkingSet);
373
+ }
374
+ /**
375
+ * Renders one screenshot. Resolves to the encoded image, or to `null` when
376
+ * `path` was given and the engine wrote the file itself.
377
+ */
378
+ async screenshot(options) {
379
+ return this.capture(toRequest(options));
380
+ }
381
+ /**
382
+ * The same, for a request that is already in wire form.
383
+ *
384
+ * The daemon reads these off a socket, where they arrived having been
385
+ * validated by the client that sent them. Re-deriving one from
386
+ * ScreenshotOptions would mean the daemon validating a request it cannot see
387
+ * the original of, and rejecting fields a newer client legitimately sent.
388
+ */
389
+ async capture(request) {
390
+ if (!this.running) this.start();
391
+ const handle = shared;
392
+ const native = load();
393
+ const result = this.tail.catch(() => {}).then(() => native.capture(handle, JSON.stringify(request)));
394
+ this.tail = result.catch(() => {});
395
+ let captured;
396
+ try {
397
+ captured = await result;
398
+ } catch (error) {
399
+ const withStats = error;
400
+ if (typeof withStats.stats === "string") withStats.stats = JSON.parse(withStats.stats);
401
+ throw error;
402
+ }
403
+ return {
404
+ image: request.path ? null : captured.image,
405
+ stats: parseStats(captured.stats)
406
+ };
407
+ }
408
+ };
409
+ function emptyStats() {
410
+ return {
411
+ requests: 0,
412
+ fromCache: 0,
413
+ failed: 0,
414
+ bytes: 0,
415
+ httpStatus: 0,
416
+ finalUrl: "",
417
+ timing: {
418
+ fetch: 0,
419
+ render: 0,
420
+ setup: 0,
421
+ wait: 0,
422
+ lifecycle: 0,
423
+ paint: 0,
424
+ raster: 0,
425
+ encode: 0,
426
+ total: 0
427
+ }
428
+ };
429
+ }
430
+ function parseStats(json) {
431
+ return json ? JSON.parse(json) : emptyStats();
432
+ }
433
+
434
+ //#endregion
435
+ //#region src/lib/endpoint.ts
436
+ function endpointKey(options) {
437
+ if (options.name) return String(options.name);
438
+ const identity = JSON.stringify([
439
+ options.cacheDir === null || options.cacheDir === void 0 ? null : path.resolve(options.cacheDir),
440
+ options.userAgent ?? null,
441
+ options.resourceDir ? path.resolve(options.resourceDir) : null
442
+ ]);
443
+ return crypto.createHash("sha256").update(identity).digest("hex").slice(0, 16);
444
+ }
445
+ function endpointFor(options = {}) {
446
+ if (options.endpoint) return options.endpoint;
447
+ if (process.env.SHOTIUM_ENDPOINT) return process.env.SHOTIUM_ENDPOINT;
448
+ const key = endpointKey(options);
449
+ if (process.platform === "win32") return `\\\\.\\pipe\\shotium-${key}`;
450
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
451
+ return path.join(os.tmpdir(), `shotium-${uid}-${key}.sock`);
452
+ }
453
+
454
+ //#endregion
455
+ //#region src/lib/protocol.ts
456
+ const HEADER_BYTES = 4;
457
+ function encodeFrame(payload) {
458
+ const header = Buffer.allocUnsafe(4);
459
+ header.writeUInt32LE(payload.length, 0);
460
+ return Buffer.concat([header, payload]);
461
+ }
462
+ var FrameReader = class {
463
+ buffer = Buffer.alloc(0);
464
+ push(chunk) {
465
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
466
+ }
467
+ next() {
468
+ if (this.buffer.length < 4) return null;
469
+ const length = this.buffer.readUInt32LE(0);
470
+ if (this.buffer.length < 4 + length) return null;
471
+ const frame = this.buffer.subarray(4, 4 + length);
472
+ this.buffer = this.buffer.subarray(4 + length);
473
+ return frame;
474
+ }
475
+ };
476
+
477
+ //#endregion
478
+ export { emptyStats as a, cacheRoot as c, resolveStartOptions as d, load as f, Engine as i, defaultCacheDir as l, encodeFrame as n, timeoutFor as o, endpointFor as r, toRequest as s, FrameReader as t, normalizePath as u };
479
+ //# sourceMappingURL=protocol-rQEcQPAC.js.map