clients/typescript: initial TypeScript SDK for clawdforge
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
This commit is contained in:
parent
1cff9b89d2
commit
15de6e765f
10 changed files with 1538 additions and 0 deletions
4
clients/typescript/.gitignore
vendored
Normal file
4
clients/typescript/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
225
clients/typescript/README.md
Normal file
225
clients/typescript/README.md
Normal file
|
|
@ -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<HealthzResponse>`
|
||||
|
||||
```ts
|
||||
{ ok: boolean; claudePresent: boolean; claudeVersion: string | null }
|
||||
```
|
||||
|
||||
### `forge.run(req: RunRequest): Promise<RunResult>`
|
||||
|
||||
```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<FileToken>`
|
||||
|
||||
```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).
|
||||
66
clients/typescript/examples/basic.ts
Normal file
66
clients/typescript/examples/basic.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
42
clients/typescript/package.json
Normal file
42
clients/typescript/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
447
clients/typescript/src/client.ts
Normal file
447
clients/typescript/src/client.ts
Normal file
|
|
@ -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<HealthzResponse> {
|
||||
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<RunResult> {
|
||||
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<string, unknown> = { 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<FileToken> {
|
||||
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<AppTokenCreated> {
|
||||
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<AppToken[]> {
|
||||
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/<name>` — revoke. Returns true on success, false if not found. */
|
||||
async revokeToken(
|
||||
name: string,
|
||||
opts: { signal?: AbortSignal } = {}
|
||||
): Promise<boolean> {
|
||||
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<T>(
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
path: string,
|
||||
opts: {
|
||||
jsonBody?: unknown;
|
||||
body?: BodyInit;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, unknown>;
|
||||
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<typeof setTimeout> | 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
74
clients/typescript/src/errors.ts
Normal file
74
clients/typescript/src/errors.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
42
clients/typescript/src/index.ts
Normal file
42
clients/typescript/src/index.ts
Normal file
|
|
@ -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";
|
||||
149
clients/typescript/src/types.ts
Normal file
149
clients/typescript/src/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
462
clients/typescript/tests/client.test.ts
Normal file
462
clients/typescript/tests/client.test.ts
Normal file
|
|
@ -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<string, string>;
|
||||
bodyText: string | null;
|
||||
bodyForm: FormData | null;
|
||||
}
|
||||
|
||||
function makeMockFetch(
|
||||
handler: (req: CapturedRequest) => Response | Promise<Response>
|
||||
): { 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<string, string> = {};
|
||||
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<Response>((_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<void>(() => {});
|
||||
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("<!doctype html>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);
|
||||
}
|
||||
});
|
||||
27
clients/typescript/tsconfig.json
Normal file
27
clients/typescript/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue