Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/caching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ import {
import type { ProcExecutor, ProcRequest, ProcResult } from "./contract.ts";
import { procRequestSchema } from "./contract.ts";

/** A keyed store of {@link ProcResult}s backing a caching executor. */
export interface ProcCache {
/** The cached result for `key`, or `undefined` on a miss. */
get(key: string): ProcResult | undefined;
/** Cache `value` under `key`. */
set(key: string, value: ProcResult): void;
/** Drop everything. */
clear(): void;
}

/** A simple in-memory {@link ProcCache} (a `Map`); not persisted across processes. */
export function inMemoryProcCache(): ProcCache {
const map = new Map<string, ProcResult>();
return {
Expand Down Expand Up @@ -64,16 +69,21 @@ function cacheKey(req: ProcRequest): string {
});
}

/** A {@link ProcExecutor} that memoizes cacheable requests, with an explicit drop. */
export interface CachingProcExecutor extends ProcExecutor {
/** Drop all cached results — the next read of each re-derives lazily. */
invalidate(): void;
}

/** Options for {@link cachingProcExecutor}. */
export interface CachingProcOptions {
/** The backing store (defaults to {@link inMemoryProcCache}). */
cache?: ProcCache;
/** Predicate deciding which requests may be cached (defaults to {@link policyCacheable}). */
isCacheable?: (req: ProcRequest) => boolean;
}

/** Wrap an executor so cacheable requests are memoized; returns a {@link CachingProcExecutor}. */
export function cachingProcExecutor(
inner: ProcExecutor,
opts: CachingProcOptions = {},
Expand Down
11 changes: 11 additions & 0 deletions src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,37 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

/** Result of a temp-file-backed spawn capture (no in-memory output ceiling). */
export type SpawnCaptureResult = {
/** Exit code, or `null` if terminated by a signal. */
status: number | null;
/** Terminating signal, or `null`. */
signal: NodeJS.Signals | null;
/** Fully-read child stdout — no in-memory ceiling. */
stdout: string;
/** Fully-read child stderr. */
stderr: string;
/** A spawn error (e.g. ENOENT, ETIMEDOUT), if any. */
error?: Error | undefined;
};

/** Options for {@link spawnCapture}. */
export type SpawnCaptureOptions = {
/** Working directory for the child. */
cwd?: string | undefined;
/** Environment for the child. */
env?: NodeJS.ProcessEnv | undefined;
/** ms; passed through to spawnSync. */
timeout?: number | undefined;
};

/** The {@link spawnCapture} signature — an injectable capture seam. */
export type SpawnCaptureFn = (
cmd: readonly string[],
options?: SpawnCaptureOptions,
) => SpawnCaptureResult;

/** Run a command capturing stdout/stderr via temp files (no in-memory cap — for output that overflows spawnSync's buffer). */
export const spawnCapture: SpawnCaptureFn = (cmd, options = {}) => {
const [file, ...args] = cmd;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "prx-spawn-"));
Expand Down Expand Up @@ -111,6 +121,7 @@ export function captureFailureDetail(r: SpawnCaptureResult): string {
return r.error?.message ?? (r.signal ? `killed by ${r.signal}` : (r.stderr || "").trim());
}

/** {@link SpawnCaptureOptions} plus stdin + per-chunk streaming callbacks. */
export type StreamCaptureOptions = SpawnCaptureOptions & {
/** Serializable stdin written to the child then closed (no live stream). */
stdin?: string | undefined;
Expand Down
8 changes: 8 additions & 0 deletions src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { processEnv } from "@bounded-systems/env";

import { streamCapture } from "./capture.ts";

/** Zod schema validating a {@link ProcRequest} (command + args + cwd/env/stdin/timeout/stdio). */
export const procRequestSchema = z.object({
command: z.string().min(1),
args: z.array(z.string()).default([]),
Expand All @@ -30,12 +31,18 @@ export const procRequestSchema = z.object({
stdio: z.enum(["pipe", "inherit"]).default("pipe"),
});

/** A subprocess request: the command to run and how (parsed from {@link procRequestSchema}). */
export type ProcRequest = z.input<typeof procRequestSchema>;

/** The outcome of a subprocess: exit status, captured streams, and terminating signal. */
export interface ProcResult {
/** Exit code (or a signal-derived status when killed). */
readonly status: number;
/** Captured standard output. */
readonly stdout: string;
/** Captured standard error. */
readonly stderr: string;
/** The signal that terminated the process, or `null`. */
readonly signal: string | null;
}

Expand All @@ -44,6 +51,7 @@ export interface ProcResult {
* ship the request over a wire and run it elsewhere — the contract is identical.
*/
export interface ProcExecutor {
/** Run `req` and resolve its {@link ProcResult}. */
exec(req: ProcRequest): Promise<ProcResult>;
}

Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,23 @@ export {
captureFailureDetail,
} from "./capture.ts";

/** The captured result of a {@link CommandRunner} run. */
export type CommandResult = {
/** Captured standard output. */
stdout: string;
/** Captured standard error. */
stderr: string;
/** Exit code (signal-derived when killed). */
status: number;
};

/** Options for a {@link CommandRunner} run. */
export interface RunOptions {
/** Working directory for the child. */
cwd?: string;
/** When not false (default), a non-zero exit throws. */
check?: boolean;
/** Environment for the child (defaults to the sanctioned `processEnv()`). */
env?: NodeJS.ProcessEnv;
/**
* "pipe" (default) captures stdout/stderr into the result; "inherit" wires
Expand Down Expand Up @@ -72,6 +79,7 @@ function signalExitStatus(signal: NodeJS.Signals | string | null): number {
return 128 + (typeof num === "number" ? num : 0);
}

/** The default {@link CommandRunner}: a synchronous `spawnSync` with capture, timeout, and (by default) throw-on-nonzero-exit. */
export const defaultRunner: CommandRunner = (cmd, options = {}) => {
const [file, ...args] = cmd;
if (!file) {
Expand Down
4 changes: 4 additions & 0 deletions src/spawn-detached.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import { closeSync, openSync } from "node:fs";

import { processEnv } from "@bounded-systems/env";

/** Options for {@link spawnDetached} — a child that outlives the parent. */
export type SpawnDetachedOptions = {
/** Working directory for the child. */
cwd?: string;
/** Environment for the child. */
env?: NodeJS.ProcessEnv;
/**
* When set, the child's stdout+stderr are appended to this file (created if
Expand All @@ -25,6 +28,7 @@ export type SpawnDetachedOptions = {
logPath?: string;
};

/** Result of {@link spawnDetached}: the detached child's process id. */
export type SpawnDetachedResult = { pid: number };

/**
Expand Down