From 15de6e765f3182d5ef322c0050a09d0973f3239d Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 22:42:26 -0700 Subject: [PATCH] clients/typescript: initial TypeScript SDK for clawdforge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop-in Node 18+ client with strict types, native fetch, AbortSignal support, and a typed error hierarchy (ForgeAuthError / ForgeAPIError / ForgeTransportError). Mirrors the existing Python client surface but stays generic — no Sulkta-specific assumptions, suitable for anyone running their own clawdforge instance. - camelCase TS, snake_case wire — converted at the boundary - node:test suite (17 tests) covering healthz, run success/error paths, 502 envelopes, abort/timeout, file upload, and admin token CRUD - tsc --noEmit clean with strict mode + Node16 module resolution --- clients/typescript/.gitignore | 4 + clients/typescript/README.md | 225 ++++++++++++ clients/typescript/examples/basic.ts | 66 ++++ clients/typescript/package.json | 42 +++ clients/typescript/src/client.ts | 447 +++++++++++++++++++++++ clients/typescript/src/errors.ts | 74 ++++ clients/typescript/src/index.ts | 42 +++ clients/typescript/src/types.ts | 149 ++++++++ clients/typescript/tests/client.test.ts | 462 ++++++++++++++++++++++++ clients/typescript/tsconfig.json | 27 ++ 10 files changed, 1538 insertions(+) create mode 100644 clients/typescript/.gitignore create mode 100644 clients/typescript/README.md create mode 100644 clients/typescript/examples/basic.ts create mode 100644 clients/typescript/package.json create mode 100644 clients/typescript/src/client.ts create mode 100644 clients/typescript/src/errors.ts create mode 100644 clients/typescript/src/index.ts create mode 100644 clients/typescript/src/types.ts create mode 100644 clients/typescript/tests/client.test.ts create mode 100644 clients/typescript/tsconfig.json diff --git a/clients/typescript/.gitignore b/clients/typescript/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/clients/typescript/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/clients/typescript/README.md b/clients/typescript/README.md new file mode 100644 index 0000000..dd3e9d5 --- /dev/null +++ b/clients/typescript/README.md @@ -0,0 +1,225 @@ +# clawdforge — TypeScript SDK + +A small, dependency-free TypeScript / Node.js SDK for [clawdforge](../../README.md): +a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a +bearer-token-gated REST API. + +- **Node 18+** — uses native `fetch` and `AbortController` +- **No runtime dependencies** — `typescript` is a dev dep only +- **Strict TypeScript** — public types are explicit, `result` is `unknown` + so you narrow it where you know the shape +- **camelCase TS / snake_case wire** — converted at the boundary, you never + see `timeout_secs` or `duration_ms` in your code +- **Typed errors** — `ForgeAuthError`, `ForgeAPIError`, `ForgeTransportError` +- **Cancellable** — every method takes an optional `signal: AbortSignal` + +## Install + +For now, the SDK is consumed straight from this repo subtree (no npm publish): + +```bash +# from your app's repo +npm install file:/path/to/clawdforge/clients/typescript +``` + +Or, if you check the clawdforge repo out alongside your app: + +```bash +npm install file:../clawdforge/clients/typescript +``` + +You can also point at a Git URL, since `package.json` lives at this path: + +```bash +npm install "git+https://your-git-host/your-org/clawdforge.git#main:clients/typescript" +``` + +## Quickstart + +```typescript +import { Forge, ForgeAPIError, ForgeError } from "clawdforge"; + +const forge = new Forge({ + baseUrl: "http://localhost:8800", + token: process.env.CLAWDFORGE_TOKEN!, // a per-app `cf_...` token +}); + +const h = await forge.healthz(); +console.log(h.claudeVersion); // "1.2.3 (Claude Code)" (or null) + +const r = await forge.run({ + prompt: 'Reply with JSON: {"hello":"world"}', + model: "sonnet", + system: "Be terse.", + timeoutSecs: 60, +}); +console.log(r.result, r.durationMs, r.stopReason); + +// Narrow the result if you trust the prompt: +type Hello = { hello: string }; +const data = r.result as Hello; +console.log(data.hello); +``` + +## API + +All methods are `async` and return Promises. + +### `new Forge(options: ForgeOptions)` + +| field | type | default | notes | +|--------------------|----------------|-----------------------|--------------------------------------------------| +| `baseUrl` | `string` | required | trailing slashes are stripped | +| `token` | `string` | required | per-app `cf_...` for `/run`+`/files`, admin for `/admin/*` | +| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables | +| `fetch` | `typeof fetch` | `globalThis.fetch` | inject a custom fetch (testing, proxies, etc.) | + +### `forge.healthz(): Promise` + +```ts +{ ok: boolean; claudePresent: boolean; claudeVersion: string | null } +``` + +### `forge.run(req: RunRequest): Promise` + +```ts +interface RunRequest { + prompt: string; // required, non-empty + model?: string; // e.g. "sonnet" + system?: string; // append-system-prompt + files?: string[]; // file_tokens from uploadFile() + timeoutSecs?: number; // 5..600, server-side subprocess timeout + signal?: AbortSignal; // cancel the request +} + +interface RunResult { + ok: true; + result: unknown; // narrow at call site (object or string) + durationMs: number; + stopReason: string | null; // typically "end_turn" +} +``` + +On a server-reported subprocess failure (HTTP 502), the SDK throws a +`ForgeAPIError` whose `body` is a `RunErrorBody`: + +```ts +interface RunErrorBody { + ok: false; + error: string | null; + stderr: string; + durationMs: number; + stopReason: string | null; // "timeout" | "error" | ... +} +``` + +### `forge.uploadFile(source, opts?): Promise` + +```ts +const ft = await forge.uploadFile("./recipe.png", { ttlSecs: 3600 }); + +await forge.run({ + prompt: "extract recipe data", + files: [ft.fileToken], +}); +``` + +`source` can be a string path, a `Uint8Array` / `Buffer`, or a `Blob`. Pass +`filename` and/or `contentType` in `opts` to override defaults. + +```ts +interface FileToken { + fileToken: string; // "ff_..." + ttlSecs: number; // 60..86400, default 3600 + size: number; +} +``` + +### Admin: `createToken`, `listTokens`, `revokeToken` + +These require an admin-bootstrap token in the `Forge` constructor, not a +per-app token. + +```ts +const admin = new Forge({ baseUrl, token: process.env.CLAWDFORGE_ADMIN! }); + +const minted = await admin.createToken({ + name: "my-app", + ipCidrs: ["10.0.0.0/8"], +}); +console.log(minted.token); // shown ONCE — save it now + +for (const t of await admin.listTokens()) { + console.log(t.name, t.enabled, t.lastUsed); +} + +await admin.revokeToken("my-app"); // returns true; false if not found +``` + +## Errors + +All SDK errors extend `ForgeError`. Catch the leaf you care about, or the +base for blanket handling: + +```ts +import { + ForgeError, + ForgeAuthError, + ForgeAPIError, + ForgeTransportError, +} from "clawdforge"; + +try { + await forge.run({ prompt: "..." }); +} catch (e) { + if (e instanceof ForgeAuthError) { + // 401 / 403 — bearer wrong/revoked, or IP not allow-listed + } else if (e instanceof ForgeAPIError) { + // any other 4xx/5xx; e.status, e.body + if (e.status === 502) { + // subprocess failure: e.body is RunErrorBody + } + } else if (e instanceof ForgeTransportError) { + // network failure, DNS, TLS, abort, or timeout + if (e.aborted) { + // either caller's AbortSignal fired or the SDK's defaultTimeoutMs did + } + } else if (e instanceof ForgeError) { + // input-validation errors thrown before the network + } +} +``` + +## Cancellation + +Every method accepts an optional `signal: AbortSignal`. On abort the SDK +throws `ForgeTransportError` with `aborted: true`: + +```ts +const ac = new AbortController(); +const p = forge.run({ prompt: "long task", signal: ac.signal }); +setTimeout(() => ac.abort(), 5_000); +``` + +The SDK also enforces a client-side `defaultTimeoutMs` (120s by default). For +`run()`, when you pass `timeoutSecs`, the network timeout is automatically +set to `timeoutSecs * 1000 + 30_000`, so the HTTP socket outlives the +server-side subprocess timeout — that way the server gets a chance to send +back a clean 502 envelope rather than the client tearing down first. + +## Develop + +```bash +cd clients/typescript +npm install +npm run typecheck # tsc --noEmit +npm test # node --test --import tsx tests/*.test.ts +npm run build # emit dist/ +``` + +The test suite uses `node:test` with a mock `fetch` injected via the +`fetch` constructor option — no live server needed. + +## License + +MIT (see repo root). diff --git a/clients/typescript/examples/basic.ts b/clients/typescript/examples/basic.ts new file mode 100644 index 0000000..c4252f1 --- /dev/null +++ b/clients/typescript/examples/basic.ts @@ -0,0 +1,66 @@ +/** + * Minimal end-to-end example. + * + * CLAWDFORGE_URL=http://localhost:8800 \ + * CLAWDFORGE_TOKEN=cf_xxx \ + * npx tsx examples/basic.ts + */ +import { Forge, ForgeAPIError, ForgeError } from "../src/index.js"; + +async function main(): Promise { + const baseUrl = process.env["CLAWDFORGE_URL"] ?? "http://localhost:8800"; + const token = process.env["CLAWDFORGE_TOKEN"]; + if (!token) { + console.error("Set CLAWDFORGE_TOKEN to a per-app token (cf_...)"); + process.exit(1); + } + + const forge = new Forge({ baseUrl, token }); + + // 1) liveness + const h = await forge.healthz(); + console.log(`healthz: ok=${h.ok} present=${h.claudePresent} version=${h.claudeVersion}`); + + // 2) plain prompt — JSON result + try { + const r = await forge.run({ + prompt: 'Reply with valid JSON: {"hello":"world"}', + model: "sonnet", + timeoutSecs: 60, + }); + console.log(`run: durationMs=${r.durationMs} stopReason=${r.stopReason}`); + console.log("result:", r.result); + + // Type-narrow the result if you know the prompt returns this shape. + type HelloResult = { hello: string }; + const data = r.result as HelloResult; + console.log("hello field:", data.hello); + } catch (e) { + if (e instanceof ForgeAPIError) { + console.error(`API error ${e.status}:`, e.body); + } else if (e instanceof ForgeError) { + console.error("Forge error:", e.message); + } else { + throw e; + } + } + + // 3) cancellable run + const ac = new AbortController(); + const slow = forge.run({ + prompt: "summarize anything; this might take a while", + signal: ac.signal, + }); + setTimeout(() => ac.abort(), 2_000); // give up after 2s + try { + const r2 = await slow; + console.log("slow run finished:", r2.durationMs, "ms"); + } catch (e) { + console.log("slow run cancelled:", e instanceof Error ? e.message : e); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/clients/typescript/package.json b/clients/typescript/package.json new file mode 100644 index 0000000..3cd9619 --- /dev/null +++ b/clients/typescript/package.json @@ -0,0 +1,42 @@ +{ + "name": "clawdforge", + "version": "0.1.0", + "description": "TypeScript SDK for the clawdforge HTTP service — a token-gated wrapper around `claude -p`.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "node --test --import tsx tests/*.test.ts", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "claude", + "claude-code", + "anthropic", + "sdk", + "clawdforge" + ], + "license": "MIT", + "devDependencies": { + "@types/node": "^20.19.39", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts new file mode 100644 index 0000000..1b31df0 --- /dev/null +++ b/clients/typescript/src/client.ts @@ -0,0 +1,447 @@ +import { readFile, stat } from "node:fs/promises"; +import { basename } from "node:path"; + +import { + ForgeAPIError, + ForgeAuthError, + ForgeError, + ForgeTransportError, +} from "./errors.js"; +import type { + AppToken, + AppTokenCreated, + CreateTokenOptions, + FileToken, + ForgeOptions, + HealthzResponse, + RunErrorBody, + RunRequest, + RunResult, + UploadFileOptions, +} from "./types.js"; + +/** + * Thin HTTP client for clawdforge. + * + * @example + * ```ts + * const forge = new Forge({ baseUrl: "http://localhost:8800", token: "cf_..." }); + * const r = await forge.run({ prompt: "Reply with JSON: {\"hello\":\"world\"}" }); + * console.log(r.result); + * ``` + */ +export class Forge { + private readonly baseUrl: string; + private readonly token: string; + private readonly defaultTimeoutMs: number; + private readonly fetchImpl: typeof fetch; + + constructor(options: ForgeOptions) { + if (!options || typeof options !== "object") { + throw new ForgeError("Forge constructor requires an options object"); + } + if (!options.baseUrl) { + throw new ForgeError("Forge: baseUrl is required"); + } + if (!options.token) { + throw new ForgeError("Forge: token is required"); + } + + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.token = options.token; + this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000; + + const fetchFn = options.fetch ?? globalThis.fetch; + if (typeof fetchFn !== "function") { + throw new ForgeError( + "Forge: no fetch implementation found. On Node < 18, pass `fetch` explicitly." + ); + } + this.fetchImpl = fetchFn; + } + + // ------------------------------------------------------------------ healthz + + /** + * `GET /healthz` — returns liveness info plus a `claude --version` smoke check. + * + * The server allows healthz from any IP in the global CIDR allowlist (no + * bearer required), but the SDK still sends one for symmetry. + */ + async healthz(opts: { signal?: AbortSignal } = {}): Promise { + const raw = await this.request<{ + ok: boolean; + claude_present: boolean; + claude_version: string | null; + }>("GET", "/healthz", { signal: opts.signal }); + return { + ok: raw.ok, + claudePresent: raw.claude_present, + claudeVersion: raw.claude_version, + }; + } + + // ---------------------------------------------------------------------- run + + /** + * `POST /run` — execute a prompt against `claude -p`. + * + * On success returns a {@link RunResult} with the parsed `result` (object + * or string). On a server-reported subprocess failure (HTTP 502) throws + * {@link ForgeAPIError} whose `body` is a {@link RunErrorBody}. + */ + async run(req: RunRequest): Promise { + if (!req || typeof req.prompt !== "string" || req.prompt.length === 0) { + throw new ForgeError("run: prompt is required and must be non-empty"); + } + + const wire: Record = { prompt: req.prompt }; + if (req.model !== undefined) wire["model"] = req.model; + if (req.system !== undefined) wire["system"] = req.system; + if (req.files !== undefined) wire["files"] = req.files; + if (req.timeoutSecs !== undefined) wire["timeout_secs"] = req.timeoutSecs; + + // Network timeout = server-side timeout + 30s headroom, matching the + // Python client convention. + const networkTimeoutMs = + req.timeoutSecs !== undefined + ? req.timeoutSecs * 1000 + 30_000 + : this.defaultTimeoutMs; + + const raw = await this.request<{ + ok: true; + result: unknown; + duration_ms: number; + stop_reason: string | null; + }>("POST", "/run", { + jsonBody: wire, + signal: req.signal, + timeoutMs: networkTimeoutMs, + }); + + return { + ok: true, + result: raw.result, + durationMs: raw.duration_ms, + stopReason: raw.stop_reason, + }; + } + + // -------------------------------------------------------------- file upload + + /** + * `POST /files` — upload a file from disk and receive a `file_token`. + * + * The first argument may be: + * - a string path to a local file + * - a Node `Buffer` / `Uint8Array` + * - a Web `Blob` / `File` + * + * For Buffers and Blobs, pass `filename` in `opts` to control the on-disk name. + */ + async uploadFile( + source: string | Uint8Array | Blob, + opts: UploadFileOptions = {} + ): Promise { + const ttlSecs = opts.ttlSecs ?? 3600; + if (ttlSecs < 60 || ttlSecs > 86400) { + throw new ForgeError("uploadFile: ttlSecs must be between 60 and 86400"); + } + + const form = new FormData(); + let blob: Blob; + let filename: string; + + if (typeof source === "string") { + const data = await readFile(source); + // Verify the path actually existed; readFile would throw otherwise, + // but stat clarifies the error message vs a transport-level surprise. + await stat(source); + blob = new Blob([new Uint8Array(data)], { + type: opts.contentType ?? "application/octet-stream", + }); + filename = opts.filename ?? basename(source); + } else if (source instanceof Uint8Array) { + blob = new Blob([new Uint8Array(source)], { + type: opts.contentType ?? "application/octet-stream", + }); + filename = opts.filename ?? "upload"; + } else if (typeof Blob !== "undefined" && source instanceof Blob) { + // Re-wrap to override the content type if requested. + blob = opts.contentType + ? new Blob([source], { type: opts.contentType }) + : source; + filename = + opts.filename ?? + (typeof (source as { name?: unknown }).name === "string" + ? ((source as { name?: string }).name as string) + : "upload"); + } else { + throw new ForgeError( + "uploadFile: source must be a string path, Uint8Array, or Blob" + ); + } + + form.append("file", blob, filename); + form.append("ttl_secs", String(ttlSecs)); + + const raw = await this.request<{ + file_token: string; + ttl_secs: number; + size: number; + }>("POST", "/files", { body: form, signal: opts.signal }); + + return { + fileToken: raw.file_token, + ttlSecs: raw.ttl_secs, + size: raw.size, + }; + } + + // ----------------------------------------------------------------- /admin/* + + /** + * `POST /admin/tokens` — mint a new per-app bearer token. + * + * Requires the bearer to be the admin bootstrap token (configure on the + * server via `ADMIN_BOOTSTRAP_TOKEN`). The plaintext token is returned + * **once**; the server only stores a SHA-256 hash thereafter. + */ + async createToken(opts: CreateTokenOptions): Promise { + if (!opts.name) { + throw new ForgeError("createToken: name is required"); + } + const wire = { + name: opts.name, + ip_cidrs: opts.ipCidrs ?? [], + }; + const raw = await this.request<{ + name: string; + token: string; + ip_cidrs: string[]; + }>("POST", "/admin/tokens", { jsonBody: wire, signal: opts.signal }); + return { + name: raw.name, + token: raw.token, + ipCidrs: raw.ip_cidrs, + }; + } + + /** `GET /admin/tokens` — list all minted tokens (without plaintext). */ + async listTokens(opts: { signal?: AbortSignal } = {}): Promise { + const raw = await this.request<{ + tokens: Array<{ + name: string; + ip_cidrs: string; + created_at: number; + last_used: number | null; + enabled: number; + }>; + }>("GET", "/admin/tokens", { signal: opts.signal }); + return raw.tokens.map((t) => ({ + name: t.name, + ipCidrs: t.ip_cidrs, + createdAt: t.created_at, + lastUsed: t.last_used, + enabled: t.enabled, + })); + } + + /** `DELETE /admin/tokens/` — revoke. Returns true on success, false if not found. */ + async revokeToken( + name: string, + opts: { signal?: AbortSignal } = {} + ): Promise { + if (!name) { + throw new ForgeError("revokeToken: name is required"); + } + try { + const raw = await this.request<{ ok: boolean }>( + "DELETE", + `/admin/tokens/${encodeURIComponent(name)}`, + { signal: opts.signal } + ); + return raw.ok === true; + } catch (e) { + if (e instanceof ForgeAPIError && e.status === 404) { + return false; + } + throw e; + } + } + + // ------------------------------------------------------------- internals + + /** + * Low-level request helper. + * + * Routes both JSON and FormData bodies, applies the bearer header, wires + * up an AbortSignal (combining the caller's with our timeout), and maps + * non-2xx responses to typed errors. + */ + private async request( + method: "GET" | "POST" | "DELETE", + path: string, + opts: { + jsonBody?: unknown; + body?: BodyInit; + signal?: AbortSignal; + timeoutMs?: number; + } = {} + ): Promise { + const headers: Record = { + Authorization: `Bearer ${this.token}`, + Accept: "application/json", + }; + + let body: BodyInit | undefined; + if (opts.jsonBody !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.jsonBody); + } else if (opts.body !== undefined) { + // FormData — let fetch set the multipart boundary itself. + body = opts.body; + } + + const timeoutMs = opts.timeoutMs ?? this.defaultTimeoutMs; + const signal = combineSignals(opts.signal, timeoutMs); + + const url = `${this.baseUrl}${path}`; + let res: Response; + try { + res = await this.fetchImpl(url, { + method, + headers, + body, + signal: signal.signal, + }); + } catch (e: unknown) { + const aborted = + (e instanceof Error && e.name === "AbortError") || + opts.signal?.aborted === true; + if (aborted) { + throw new ForgeTransportError( + opts.signal?.aborted + ? "request aborted by caller" + : `request timed out after ${timeoutMs}ms`, + { cause: e, aborted: true } + ); + } + const msg = e instanceof Error ? e.message : String(e); + throw new ForgeTransportError(`fetch failed: ${msg}`, { cause: e }); + } finally { + signal.cleanup(); + } + + if (res.status >= 200 && res.status < 300) { + // Empty body? Return an empty object — only happens on synthetic responses. + const text = await res.text(); + if (!text) { + return {} as T; + } + try { + return JSON.parse(text) as T; + } catch (e) { + throw new ForgeAPIError( + res.status, + text, + `clawdforge returned non-JSON body: ${truncate(text, 200)}` + ); + } + } + + // Error path — try to parse the body as JSON, fall back to text. + const rawText = await res.text(); + let parsed: unknown = rawText; + try { + parsed = rawText ? JSON.parse(rawText) : null; + } catch { + // keep rawText + } + + // 502 from /run carries the snake_case failure envelope; camelCase it + // before exposing to the caller via ForgeAPIError.body. + const camelBody = camelCaseRunFailure(parsed); + + if (res.status === 401 || res.status === 403) { + throw new ForgeAuthError(res.status, camelBody); + } + throw new ForgeAPIError(res.status, camelBody); + } +} + +// ---------------------------------------------------------- helpers (private) + +/** Truncate a string to N chars, appending an ellipsis. */ +function truncate(s: string, n: number): string { + return s.length <= n ? s : `${s.slice(0, n)}...`; +} + +/** + * If the server returned a `/run` failure envelope `{ok:false, error, + * stderr, duration_ms, stop_reason}`, convert the snake_case keys to + * camelCase. Returns the input untouched otherwise. + */ +function camelCaseRunFailure(body: unknown): unknown { + if ( + body && + typeof body === "object" && + "ok" in body && + (body as { ok: unknown }).ok === false + ) { + const b = body as Record; + const out: RunErrorBody = { + ok: false, + error: typeof b["error"] === "string" ? (b["error"] as string) : null, + stderr: typeof b["stderr"] === "string" ? (b["stderr"] as string) : "", + durationMs: + typeof b["duration_ms"] === "number" + ? (b["duration_ms"] as number) + : 0, + stopReason: + typeof b["stop_reason"] === "string" + ? (b["stop_reason"] as string) + : null, + }; + return out; + } + return body; +} + +/** + * Combine an optional caller-supplied AbortSignal with a timeout-driven one, + * returning a single signal plus a cleanup function to clear the timer. + */ +function combineSignals( + caller: AbortSignal | undefined, + timeoutMs: number +): { signal: AbortSignal; cleanup: () => void } { + const ac = new AbortController(); + let timer: ReturnType | null = null; + const onCallerAbort = () => { + ac.abort(caller?.reason); + }; + + if (caller) { + if (caller.aborted) { + ac.abort(caller.reason); + } else { + caller.addEventListener("abort", onCallerAbort, { once: true }); + } + } + + if (timeoutMs > 0) { + timer = setTimeout(() => ac.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs); + // Don't keep the event loop alive just for our timeout — if every other + // handle has unwound, the request is effectively done. + (timer as unknown as { unref?: () => void }).unref?.(); + } + + return { + signal: ac.signal, + cleanup: () => { + if (timer) clearTimeout(timer); + caller?.removeEventListener("abort", onCallerAbort); + }, + }; +} diff --git a/clients/typescript/src/errors.ts b/clients/typescript/src/errors.ts new file mode 100644 index 0000000..f9b66ef --- /dev/null +++ b/clients/typescript/src/errors.ts @@ -0,0 +1,74 @@ +/** + * Error hierarchy for the clawdforge SDK. + * + * All errors thrown by the {@link Forge} client extend {@link ForgeError}, + * so a single `catch (e: unknown) { if (e instanceof ForgeError) ... }` is + * sufficient at most call sites. + */ + +/** Base class. Catch this to handle anything thrown by the SDK. */ +export class ForgeError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ForgeError"; + // Preserve prototype chain across transpilers / older targets. + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * 401 / 403 — bearer token missing, wrong, revoked, or IP not allow-listed. + * + * The server returns 401 for "missing bearer" and 403 for "unknown or + * disabled token" / "ip not in allowlist" / "admin auth failed". Both map + * to this class so callers can treat them uniformly. + */ +export class ForgeAuthError extends ForgeError { + public readonly status: number; + public readonly body: unknown; + + constructor(status: number, body: unknown, message?: string) { + super(message ?? `clawdforge auth failed (${status})`); + this.name = "ForgeAuthError"; + this.status = status; + this.body = body; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Any other non-2xx response from the server (4xx not auth, or 5xx). + * + * Notably: `/run` returns **502** with `{ok:false, error, stderr, ...}` when + * the underlying `claude -p` subprocess fails or times out. The SDK exposes + * those failures as `ForgeAPIError` so callers can react to `error.body`. + */ +export class ForgeAPIError extends ForgeError { + public readonly status: number; + public readonly body: unknown; + + constructor(status: number, body: unknown, message?: string) { + super(message ?? `clawdforge API error (${status})`); + this.name = "ForgeAPIError"; + this.status = status; + this.body = body; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Thrown when the request never completed: network failure, DNS, TLS, + * connection refused, or the caller's `AbortSignal` fired. + * + * `aborted` is true when the cause was an `AbortSignal` cancellation. + */ +export class ForgeTransportError extends ForgeError { + public readonly aborted: boolean; + + constructor(message: string, options?: { cause?: unknown; aborted?: boolean }) { + super(message, options); + this.name = "ForgeTransportError"; + this.aborted = options?.aborted ?? false; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts new file mode 100644 index 0000000..a56245d --- /dev/null +++ b/clients/typescript/src/index.ts @@ -0,0 +1,42 @@ +/** + * clawdforge — TypeScript SDK. + * + * Wrap a clawdforge HTTP service in a typed, Promise-based client. Compatible + * with Node 18+ (uses native `fetch`). + * + * @example + * ```ts + * import { Forge, ForgeError } from "clawdforge"; + * + * const forge = new Forge({ + * baseUrl: "http://localhost:8800", + * token: process.env.CLAWDFORGE_TOKEN!, + * }); + * + * const r = await forge.run({ + * prompt: 'Reply with JSON: {"hello":"world"}', + * model: "sonnet", + * timeoutSecs: 60, + * }); + * console.log(r.result, r.durationMs, r.stopReason); + * ``` + */ +export { Forge } from "./client.js"; +export { + ForgeError, + ForgeAuthError, + ForgeAPIError, + ForgeTransportError, +} from "./errors.js"; +export type { + AppToken, + AppTokenCreated, + CreateTokenOptions, + FileToken, + ForgeOptions, + HealthzResponse, + RunErrorBody, + RunRequest, + RunResult, + UploadFileOptions, +} from "./types.js"; diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts new file mode 100644 index 0000000..42aac77 --- /dev/null +++ b/clients/typescript/src/types.ts @@ -0,0 +1,149 @@ +/** + * Public types for the clawdforge TypeScript SDK. + * + * Naming convention: TypeScript code is camelCase, the wire format is + * snake_case. The {@link Forge} client converts at the boundary so consumers + * never see snake_case keys. + */ + +/** Construction options for the {@link Forge} client. */ +export interface ForgeOptions { + /** Base URL of the clawdforge service, e.g. `http://localhost:8800`. Trailing slash is optional. */ + baseUrl: string; + /** + * Bearer token. For `/run` and `/files` use a per-app token (`cf_...`). + * For `/admin/*` use the admin bootstrap token configured on the server. + */ + token: string; + /** + * Default request timeout in milliseconds, used when the caller doesn't + * pass `signal`. Defaults to 120_000 (2 minutes). Set to 0 to disable. + * + * Note: `/run` has its own server-side `timeoutSecs`; this is a separate, + * client-side network timeout. The SDK adds 30s of headroom so the HTTP + * timeout outlives the subprocess timeout. + */ + defaultTimeoutMs?: number; + /** + * Override the underlying `fetch` implementation. Useful for testing or + * for custom user-agents / proxies. Defaults to `globalThis.fetch`. + */ + fetch?: typeof fetch; +} + +/** Response shape from `GET /healthz`. */ +export interface HealthzResponse { + ok: boolean; + /** Whether the `claude` binary was located on the server's PATH. */ + claudePresent: boolean; + /** First line of `claude --version`, or null if the smoke check failed. */ + claudeVersion: string | null; +} + +/** Request body for {@link Forge.run}. */ +export interface RunRequest { + /** Prompt text. Must be non-empty. */ + prompt: string; + /** Model alias, e.g. `"sonnet"`, `"opus"`, `"haiku"`. Server has its own default. */ + model?: string; + /** System prompt. Forwarded to `claude -p --append-system-prompt`. */ + system?: string; + /** + * File tokens previously returned by {@link Forge.uploadFile}. + * Each token is scoped to the uploading app — token A cannot reference + * token B's files. + */ + files?: string[]; + /** Server-side timeout in seconds for the underlying `claude -p` subprocess. 5..600. */ + timeoutSecs?: number; + /** Optional `AbortSignal` to cancel this request. */ + signal?: AbortSignal; +} + +/** + * Successful response from `POST /run`. + * + * `result` is whatever the server parsed out of the inner `claude -p` + * JSON envelope: usually an object (when the prompt asked for JSON), often + * a plain string. Consumers should narrow it themselves — the SDK does not + * guess the shape. + */ +export interface RunResult { + ok: true; + /** Parsed JSON value or fallback string. Narrow at the call site. */ + result: unknown; + /** Wall-clock duration of the subprocess, in milliseconds. */ + durationMs: number; + /** Typically `"end_turn"` on success. */ + stopReason: string | null; +} + +/** + * Body shape returned by the server on a 502 from `/run` (subprocess + * failed or timed out). Surfaced via `ForgeAPIError.body` after camel-casing. + */ +export interface RunErrorBody { + ok: false; + error: string | null; + stderr: string; + durationMs: number; + stopReason: string | null; +} + +/** Options for {@link Forge.uploadFile}. */ +export interface UploadFileOptions { + /** Time-to-live in seconds for the file token. 60..86400, default 3600. */ + ttlSecs?: number; + /** Override the filename sent to the server (defaults to basename of the path / Blob name). */ + filename?: string; + /** Override the MIME type sent in the multipart part. */ + contentType?: string; + /** Optional `AbortSignal` to cancel this request. */ + signal?: AbortSignal; +} + +/** Response from `POST /files`. */ +export interface FileToken { + /** Opaque token, prefix `ff_`. Pass into `RunRequest.files`. */ + fileToken: string; + /** TTL the server actually applied. */ + ttlSecs: number; + /** Stored size in bytes. */ + size: number; +} + +/** Options for {@link Forge.createToken}. */ +export interface CreateTokenOptions { + /** + * App slug. The server requires `^[a-z0-9][a-z0-9_-]*$` (1..64 chars). + * Two tokens cannot share a name; revoke first if reusing. + */ + name: string; + /** + * Optional per-app CIDR allowlist on top of the global one. Empty means + * "fall back to global allowlist only". + */ + ipCidrs?: string[]; + /** Optional `AbortSignal`. */ + signal?: AbortSignal; +} + +/** Response from `POST /admin/tokens` — contains the **plaintext token**, shown ONCE. */ +export interface AppTokenCreated { + name: string; + /** Plaintext bearer token. Save it now — server only stores a SHA-256 hash. */ + token: string; + ipCidrs: string[]; +} + +/** Row shape returned from `GET /admin/tokens`. */ +export interface AppToken { + name: string; + ipCidrs: string; + /** Unix epoch seconds. */ + createdAt: number; + /** Unix epoch seconds, or null if never used. */ + lastUsed: number | null; + /** 1 = active, 0 = revoked. */ + enabled: number; +} diff --git a/clients/typescript/tests/client.test.ts b/clients/typescript/tests/client.test.ts new file mode 100644 index 0000000..c06c6fb --- /dev/null +++ b/clients/typescript/tests/client.test.ts @@ -0,0 +1,462 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + Forge, + ForgeAPIError, + ForgeAuthError, + ForgeError, + ForgeTransportError, +} from "../src/index.js"; +import type { RunErrorBody } from "../src/index.js"; + +// -------------------------------------------------------------- mock plumbing + +interface CapturedRequest { + url: string; + method: string; + headers: Record; + bodyText: string | null; + bodyForm: FormData | null; +} + +function makeMockFetch( + handler: (req: CapturedRequest) => Response | Promise +): { fetch: typeof fetch; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fn: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : (input as URL).toString(); + const method = (init?.method ?? "GET").toUpperCase(); + const headers: Record = {}; + const rawHeaders = init?.headers; + if (rawHeaders) { + if (rawHeaders instanceof Headers) { + rawHeaders.forEach((v, k) => (headers[k.toLowerCase()] = v)); + } else if (Array.isArray(rawHeaders)) { + for (const [k, v] of rawHeaders) headers[k.toLowerCase()] = v; + } else { + for (const [k, v] of Object.entries(rawHeaders)) { + headers[k.toLowerCase()] = v as string; + } + } + } + + let bodyText: string | null = null; + let bodyForm: FormData | null = null; + if (init?.body !== undefined && init.body !== null) { + if (typeof init.body === "string") { + bodyText = init.body; + } else if (init.body instanceof FormData) { + bodyForm = init.body; + } else { + bodyText = String(init.body); + } + } + + const captured: CapturedRequest = { url, method, headers, bodyText, bodyForm }; + calls.push(captured); + + // Honor abort like real fetch does — raise AbortError synchronously if + // the signal is already aborted, or asynchronously if it fires while + // the handler is still pending. + const sig = init?.signal; + if (sig?.aborted) { + const err = new Error("aborted"); + err.name = "AbortError"; + throw err; + } + return await Promise.race([ + handler(captured), + new Promise((_resolve, reject) => { + sig?.addEventListener( + "abort", + () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }, + { once: true } + ); + }), + ]); + }; + return { fetch: fn, calls }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const BASE = { baseUrl: "http://forge.test:8800", token: "cf_unit_test" }; + +// ---------------------------------------------------------------------- tests + +test("constructor rejects missing baseUrl/token", () => { + // @ts-expect-error — we want to verify runtime guard + assert.throws(() => new Forge({ token: "x" }), ForgeError); + // @ts-expect-error + assert.throws(() => new Forge({ baseUrl: "http://x" }), ForgeError); +}); + +test("healthz: GET, bearer header, snake→camel mapping", async () => { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { + ok: true, + claude_present: true, + claude_version: "1.2.3 (Claude Code)", + }) + ); + const forge = new Forge({ ...BASE, fetch }); + const h = await forge.healthz(); + + assert.equal(calls.length, 1); + assert.equal(calls[0]!.method, "GET"); + assert.equal(calls[0]!.url, "http://forge.test:8800/healthz"); + assert.equal(calls[0]!.headers["authorization"], "Bearer cf_unit_test"); + + assert.equal(h.ok, true); + assert.equal(h.claudePresent, true); + assert.equal(h.claudeVersion, "1.2.3 (Claude Code)"); +}); + +test("healthz: trailing slashes in baseUrl are stripped", async () => { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { ok: true, claude_present: false, claude_version: null }) + ); + const forge = new Forge({ + ...BASE, + baseUrl: "http://forge.test:8800///", + fetch, + }); + await forge.healthz(); + assert.equal(calls[0]!.url, "http://forge.test:8800/healthz"); +}); + +test("run: POST, JSON body camel→snake, parses JSON result", async () => { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { + ok: true, + result: { hello: "world" }, + duration_ms: 4321, + stop_reason: "end_turn", + }) + ); + const forge = new Forge({ ...BASE, fetch }); + const r = await forge.run({ + prompt: "Reply with JSON", + model: "sonnet", + system: "Be terse.", + timeoutSecs: 60, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]!.method, "POST"); + assert.equal(calls[0]!.url, "http://forge.test:8800/run"); + assert.equal(calls[0]!.headers["content-type"], "application/json"); + const sent = JSON.parse(calls[0]!.bodyText!); + assert.deepEqual(sent, { + prompt: "Reply with JSON", + model: "sonnet", + system: "Be terse.", + timeout_secs: 60, + }); + + assert.equal(r.ok, true); + assert.deepEqual(r.result, { hello: "world" }); + assert.equal(r.durationMs, 4321); + assert.equal(r.stopReason, "end_turn"); +}); + +test("run: result can also be a plain string", async () => { + const { fetch } = makeMockFetch(() => + jsonResponse(200, { + ok: true, + result: "just text, no json this time", + duration_ms: 100, + stop_reason: "end_turn", + }) + ); + const forge = new Forge({ ...BASE, fetch }); + const r = await forge.run({ prompt: "say hi" }); + assert.equal(typeof r.result, "string"); + assert.equal(r.result, "just text, no json this time"); +}); + +test("run: empty prompt rejected client-side", async () => { + const { fetch, calls } = makeMockFetch(() => jsonResponse(200, {})); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.run({ prompt: "" }), ForgeError); + assert.equal(calls.length, 0, "must not hit the network"); +}); + +test("run: 502 → ForgeAPIError with camelCased body", async () => { + const { fetch } = makeMockFetch(() => + jsonResponse(502, { + ok: false, + error: "subprocess timeout", + stderr: "...claude killed by timeout...", + duration_ms: 60_000, + stop_reason: "timeout", + }) + ); + const forge = new Forge({ ...BASE, fetch }); + + await assert.rejects( + forge.run({ prompt: "hello", timeoutSecs: 60 }), + (e: unknown) => { + assert.ok(e instanceof ForgeAPIError); + assert.equal((e as ForgeAPIError).status, 502); + const body = (e as ForgeAPIError).body as RunErrorBody; + assert.equal(body.ok, false); + assert.equal(body.error, "subprocess timeout"); + assert.equal(body.durationMs, 60_000); + assert.equal(body.stopReason, "timeout"); + assert.match(body.stderr, /killed by timeout/); + return true; + } + ); +}); + +test("run: 403 → ForgeAuthError", async () => { + const { fetch } = makeMockFetch( + () => + new Response(JSON.stringify({ detail: "unknown or disabled token" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => { + assert.ok(e instanceof ForgeAuthError); + assert.equal((e as ForgeAuthError).status, 403); + return true; + }); +}); + +test("run: AbortSignal cancellation surfaces as ForgeTransportError(aborted=true)", async () => { + const { fetch } = makeMockFetch(async () => { + // Never resolve naturally — only abort can end this. + await new Promise(() => {}); + return jsonResponse(200, {}); + }); + const forge = new Forge({ ...BASE, fetch }); + const ac = new AbortController(); + const p = forge.run({ prompt: "long task", signal: ac.signal }); + setImmediate(() => ac.abort()); + + await assert.rejects(p, (e: unknown) => { + assert.ok(e instanceof ForgeTransportError); + assert.equal((e as ForgeTransportError).aborted, true); + return true; + }); +}); + +test("run: file_token list is forwarded under snake_case key", async () => { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { + ok: true, + result: "ok", + duration_ms: 1, + stop_reason: "end_turn", + }) + ); + const forge = new Forge({ ...BASE, fetch }); + await forge.run({ prompt: "extract", files: ["ff_one", "ff_two"] }); + const sent = JSON.parse(calls[0]!.bodyText!); + assert.deepEqual(sent.files, ["ff_one", "ff_two"]); +}); + +test("uploadFile: from disk path, multipart includes file + ttl_secs", async () => { + const tmp = join(tmpdir(), `clawdforge-ts-test-${Date.now()}.bin`); + writeFileSync(tmp, Buffer.from("fake recipe pixels")); + try { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { + file_token: "ff_abc123", + ttl_secs: 3600, + size: 18, + }) + ); + const forge = new Forge({ ...BASE, fetch }); + const ft = await forge.uploadFile(tmp); + + assert.equal(ft.fileToken, "ff_abc123"); + assert.equal(ft.ttlSecs, 3600); + assert.equal(ft.size, 18); + + const c = calls[0]!; + assert.equal(c.method, "POST"); + assert.equal(c.url, "http://forge.test:8800/files"); + assert.ok(c.bodyForm, "expected multipart form body"); + assert.equal(c.bodyForm!.get("ttl_secs"), "3600"); + const filePart = c.bodyForm!.get("file"); + assert.ok( + filePart && typeof filePart === "object" && "size" in filePart, + "expected a Blob/File under field 'file'" + ); + } finally { + try { + unlinkSync(tmp); + } catch { + /* ignore */ + } + } +}); + +test("uploadFile: ttlSecs out of range rejected client-side", async () => { + const { fetch, calls } = makeMockFetch(() => jsonResponse(200, {})); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects( + forge.uploadFile(new Uint8Array([1, 2, 3]), { ttlSecs: 30 }), + ForgeError + ); + await assert.rejects( + forge.uploadFile(new Uint8Array([1, 2, 3]), { ttlSecs: 99999 }), + ForgeError + ); + assert.equal(calls.length, 0); +}); + +test("uploadFile: from Uint8Array, default filename 'upload'", async () => { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 3 }) + ); + const forge = new Forge({ ...BASE, fetch }); + await forge.uploadFile(new Uint8Array([1, 2, 3]), { filename: "foo.png" }); + const filePart = calls[0]!.bodyForm!.get("file"); + // FormData.get on a File-like returns something with a `name` property. + assert.ok(filePart && typeof filePart === "object"); + if ("name" in filePart) { + assert.equal((filePart as { name: string }).name, "foo.png"); + } +}); + +test("admin: createToken / listTokens / revokeToken round-trip", async () => { + const { fetch, calls } = makeMockFetch((req) => { + if (req.method === "POST" && req.url.endsWith("/admin/tokens")) { + const sent = JSON.parse(req.bodyText!); + return jsonResponse(200, { + name: sent.name, + token: "cf_freshly_minted", + ip_cidrs: sent.ip_cidrs, + }); + } + if (req.method === "GET" && req.url.endsWith("/admin/tokens")) { + return jsonResponse(200, { + tokens: [ + { + name: "alpha", + ip_cidrs: "10.0.0.0/8", + created_at: 1700000000, + last_used: 1700000500, + enabled: 1, + }, + { + name: "beta", + ip_cidrs: "", + created_at: 1700001000, + last_used: null, + enabled: 0, + }, + ], + }); + } + if (req.method === "DELETE" && req.url.endsWith("/admin/tokens/alpha")) { + return jsonResponse(200, { ok: true }); + } + if (req.method === "DELETE" && req.url.endsWith("/admin/tokens/missing")) { + return jsonResponse(404, { detail: "no such token" }); + } + return jsonResponse(500, { detail: "unhandled" }); + }); + + const forge = new Forge({ ...BASE, fetch }); + + const created = await forge.createToken({ + name: "alpha", + ipCidrs: ["10.0.0.0/8"], + }); + assert.equal(created.name, "alpha"); + assert.equal(created.token, "cf_freshly_minted"); + assert.deepEqual(created.ipCidrs, ["10.0.0.0/8"]); + + const tokens = await forge.listTokens(); + assert.equal(tokens.length, 2); + assert.equal(tokens[0]!.name, "alpha"); + assert.equal(tokens[0]!.createdAt, 1700000000); + assert.equal(tokens[0]!.lastUsed, 1700000500); + assert.equal(tokens[0]!.enabled, 1); + assert.equal(tokens[1]!.lastUsed, null); + assert.equal(tokens[1]!.enabled, 0); + + const revoked = await forge.revokeToken("alpha"); + assert.equal(revoked, true); + + const missing = await forge.revokeToken("missing"); + assert.equal(missing, false); + + // Ensure URL encoding works on names. + assert.ok( + calls.some((c) => c.method === "DELETE" && c.url.endsWith("/admin/tokens/alpha")) + ); +}); + +test("non-JSON 200 body → ForgeAPIError", async () => { + const { fetch } = makeMockFetch( + () => + new Response("oops", { + status: 200, + headers: { "Content-Type": "text/html" }, + }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.healthz(), ForgeAPIError); +}); + +test("network error → ForgeTransportError(aborted=false)", async () => { + const fetchFn: typeof fetch = async () => { + throw new TypeError("fetch failed: ECONNREFUSED"); + }; + const forge = new Forge({ ...BASE, fetch: fetchFn }); + await assert.rejects(forge.healthz(), (e: unknown) => { + assert.ok(e instanceof ForgeTransportError); + assert.equal((e as ForgeTransportError).aborted, false); + return true; + }); +}); + +test("default timeout fires → ForgeTransportError(aborted=true)", async () => { + const fetchFn: typeof fetch = async (_input, init) => { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }, + { once: true } + ); + }); + }; + const forge = new Forge({ ...BASE, fetch: fetchFn, defaultTimeoutMs: 25 }); + // The SDK's internal timer is unref'd so it doesn't keep the process + // alive in the wild. In a test the event loop would unwind too quickly, + // so we hold a timer to keep things ticking until the abort fires. + const keepalive = setInterval(() => {}, 5); + try { + await assert.rejects(forge.healthz(), (e: unknown) => { + assert.ok(e instanceof ForgeTransportError); + assert.equal((e as ForgeTransportError).aborted, true); + return true; + }); + } finally { + clearInterval(keepalive); + } +}); diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json new file mode 100644 index 0000000..82d41ae --- /dev/null +++ b/clients/typescript/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022", "DOM"], + "strict": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests", "examples"] +}