clients/typescript: apply audit findings — uploadFile streaming + metadata + validation (15de6e7cc54cfb)

HIGH:
- H1: uploadFile streams via createReadStream, validates is_file, caps size (configurable, default 100MB)

MEDIUM:
- M1: LICENSE file added
- M2: package.json repository/bugs/homepage/author fields
- M3: ESM-only doc + engines.node>=18
- M4: defaultTimeoutMs negative validation
- M5: baseUrl validated as URL in constructor

LOW:
- L1: empty 200 body throws ForgeAPIError instead of {} as T
- L2: DOMException("timeout", "TimeoutError") for symmetry with AbortSignal.timeout()
- L4: package-lock.json committed
- L5: 6 new tests (500-not-502, Blob upload, invalid source, empty revokeToken, JSON error body, double-signal race)
- L7: defensive raw.ok === true check in run()

Audit: memory/clawdforge-audits/typescript-15de6e7.md
This commit is contained in:
Kayos 2026-04-28 23:11:57 -07:00
parent 7e878e6f45
commit e9d5e0ea16
8 changed files with 1203 additions and 22 deletions

View file

@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 Sulkta-Coop
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -13,6 +13,21 @@ bearer-token-gated REST API.
- **Typed errors**`ForgeAuthError`, `ForgeAPIError`, `ForgeTransportError`
- **Cancellable** — every method takes an optional `signal: AbortSignal`
## Module format: ESM-only
This SDK ships **ESM only**. `package.json` is `"type": "module"`, the
`exports` map only resolves an `import` condition, and there is no CJS
build. Consumers must:
- Use a project with `"type": "module"` in their own `package.json`, or
- Import from a `.mts` / `.ts` file and let their bundler (esbuild, tsup,
Vite, etc.) handle interop, or
- Use the dynamic `await import("clawdforge")` form from a CJS module.
`require("clawdforge")` will throw `ERR_REQUIRE_ESM` on Node. If a CJS
build becomes necessary, file an issue — the fix is a second `tsc` pass
plus a dual `exports` map, not a deep refactor.
## Install
For now, the SDK is consumed straight from this repo subtree (no npm publish):
@ -69,9 +84,10 @@ All methods are `async` and return Promises.
| field | type | default | notes |
|--------------------|----------------|-----------------------|--------------------------------------------------|
| `baseUrl` | `string` | required | trailing slashes are stripped |
| `baseUrl` | `string` | required | trailing slashes are stripped; validated via `new URL()` |
| `token` | `string` | required | per-app `cf_...` for `/run`+`/files`, admin for `/admin/*` |
| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables |
| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables; negative rejected |
| `uploadMaxBytes` | `number` | `104857600` (100 MB) | size cap for `uploadFile(path)`; 0 disables |
| `fetch` | `typeof fetch` | `globalThis.fetch` | inject a custom fetch (testing, proxies, etc.) |
### `forge.healthz(): Promise<HealthzResponse>`
@ -124,8 +140,21 @@ await forge.run({
});
```
`source` can be a string path, a `Uint8Array` / `Buffer`, or a `Blob`. Pass
`filename` and/or `contentType` in `opts` to override defaults.
`source` can be:
- a **string path** to a local **regular file**. Symlinks are followed to
their target via `fs.stat`. Non-regular files (directories, FIFOs,
sockets, block/char devices) are rejected with `ForgeError`. The file
is streamed via `fs.createReadStream` — its full contents are **not**
buffered in memory.
- a Node `Uint8Array` / `Buffer`
- a Web `Blob` / `File`
For string-path uploads, the file size is checked via `fs.stat` against
`ForgeOptions.uploadMaxBytes` (default 100 MB) **before** any bytes are
read; oversized files fail fast with `ForgeError` and never touch memory
or the network. Set `uploadMaxBytes: 0` to disable the cap.
Pass `filename` and/or `contentType` in `opts` to override defaults.
```ts
interface FileToken {

594
clients/typescript/package-lock.json generated Normal file
View file

@ -0,0 +1,594 @@
{
"name": "clawdforge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clawdforge",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.19.39",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View file

@ -24,7 +24,7 @@
"prepublishOnly": "npm run build"
},
"engines": {
"node": ">=18.0.0"
"node": ">=18"
},
"keywords": [
"claude",
@ -33,7 +33,17 @@
"sdk",
"clawdforge"
],
"author": "Sulkta-Coop",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge.git",
"directory": "clients/typescript"
},
"bugs": {
"url": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge/issues"
},
"homepage": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge/src/branch/main/clients/typescript",
"devDependencies": {
"@types/node": "^20.19.39",
"tsx": "^4.21.0",

View file

@ -1,5 +1,7 @@
import { readFile, stat } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { basename } from "node:path";
import { Readable } from "node:stream";
import {
ForgeAPIError,
@ -20,6 +22,9 @@ import type {
UploadFileOptions,
} from "./types.js";
/** Default cap on `uploadFile(path)` size before any bytes are read: 100 MB. */
const DEFAULT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
/**
* Thin HTTP client for clawdforge.
*
@ -34,6 +39,7 @@ export class Forge {
private readonly baseUrl: string;
private readonly token: string;
private readonly defaultTimeoutMs: number;
private readonly uploadMaxBytes: number;
private readonly fetchImpl: typeof fetch;
constructor(options: ForgeOptions) {
@ -47,9 +53,50 @@ export class Forge {
throw new ForgeError("Forge: token is required");
}
// M5: validate baseUrl is parseable as a URL — reject newline injection,
// spaces, and other malformed input up-front instead of letting fetch
// surface a cryptic error later.
try {
// The result is intentionally discarded — we only care about throw/no-throw.
new URL(options.baseUrl);
} catch (e) {
throw new ForgeError(
`Forge: baseUrl is not a valid URL: ${options.baseUrl}`,
{ cause: e }
);
}
// M4: a negative `defaultTimeoutMs` would silently behave as "no timeout"
// because the internal `if (timeoutMs > 0)` check skips it. That's a
// footgun. 0 explicitly disables; anything below that is rejected.
if (options.defaultTimeoutMs !== undefined) {
if (
typeof options.defaultTimeoutMs !== "number" ||
!Number.isFinite(options.defaultTimeoutMs) ||
options.defaultTimeoutMs < 0
) {
throw new ForgeError(
"Forge: defaultTimeoutMs must be a non-negative finite number (0 disables)"
);
}
}
if (options.uploadMaxBytes !== undefined) {
if (
typeof options.uploadMaxBytes !== "number" ||
!Number.isFinite(options.uploadMaxBytes) ||
options.uploadMaxBytes < 0
) {
throw new ForgeError(
"Forge: uploadMaxBytes must be a non-negative finite number (0 disables)"
);
}
}
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
this.token = options.token;
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
this.uploadMaxBytes = options.uploadMaxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
const fetchFn = options.fetch ?? globalThis.fetch;
if (typeof fetchFn !== "function") {
@ -109,7 +156,7 @@ export class Forge {
: this.defaultTimeoutMs;
const raw = await this.request<{
ok: true;
ok: boolean;
result: unknown;
duration_ms: number;
stop_reason: string | null;
@ -119,6 +166,17 @@ export class Forge {
timeoutMs: networkTimeoutMs,
});
// L7: a buggy server returning {ok:false} with a 200 status would
// otherwise hand back a `RunResult` claiming `ok: true`. Defend against
// it explicitly.
if (raw.ok !== true) {
throw new ForgeAPIError(
200,
camelCaseRunFailure(raw),
"clawdforge /run returned 200 with ok!=true"
);
}
return {
ok: true,
result: raw.result,
@ -133,10 +191,18 @@ export class Forge {
* `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 string path to a local **regular file** (symlinks are followed to
* their target via `fs.stat`; non-regular files such as directories,
* FIFOs, sockets, or block/char devices are rejected with
* {@link ForgeError})
* - a Node `Buffer` / `Uint8Array`
* - a Web `Blob` / `File`
*
* String-path uploads are streamed via `fs.createReadStream` the entire
* file is **not** buffered in memory. Size is checked against
* `ForgeOptions.uploadMaxBytes` (default 100 MB) using `fs.stat` BEFORE any
* bytes are read.
*
* For Buffers and Blobs, pass `filename` in `opts` to control the on-disk name.
*/
async uploadFile(
@ -153,13 +219,37 @@ export class Forge {
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",
});
// H1: stat FIRST so we can validate type + size before reading any
// bytes. The previous code did `readFile` first, making the stat
// dead-code; pointing at /etc/passwd via a symlink would happily
// upload it. Pointing at a directory would surface a cryptic EISDIR;
// pointing at a FIFO would hang the process.
let s;
try {
s = await stat(source);
} catch (e) {
throw new ForgeError(
`uploadFile: cannot stat source path "${source}"`,
{ cause: e }
);
}
if (!s.isFile()) {
throw new ForgeError(
`uploadFile: source path "${source}" is not a regular file`
);
}
if (this.uploadMaxBytes > 0 && s.size > this.uploadMaxBytes) {
throw new ForgeError(
`uploadFile: source size ${s.size} exceeds uploadMaxBytes ${this.uploadMaxBytes}`
);
}
// Stream the file through `Readable.toWeb` rather than slurping the
// whole thing into a Buffer. Node's undici-based fetch consumes
// ReadableStream bodies natively.
const nodeStream = createReadStream(source);
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
blob = streamToBlob(webStream, s.size, opts.contentType);
filename = opts.filename ?? basename(source);
} else if (source instanceof Uint8Array) {
blob = new Blob([new Uint8Array(source)], {
@ -334,10 +424,16 @@ export class Forge {
}
if (res.status >= 200 && res.status < 300) {
// Empty body? Return an empty object — only happens on synthetic responses.
// L1: every endpoint the SDK calls contractually returns a JSON body
// on 2xx. An empty body is a server-side bug — surface it as an API
// error rather than a runtime lie of `{} as T`.
const text = await res.text();
if (!text) {
return {} as T;
throw new ForgeAPIError(
res.status,
null,
`clawdforge returned an empty body on ${res.status} ${method} ${path}`
);
}
try {
return JSON.parse(text) as T;
@ -345,7 +441,8 @@ export class Forge {
throw new ForgeAPIError(
res.status,
text,
`clawdforge returned non-JSON body: ${truncate(text, 200)}`
`clawdforge returned non-JSON body: ${truncate(text, 200)}`,
{ cause: e }
);
}
}
@ -377,6 +474,46 @@ function truncate(s: string, n: number): string {
return s.length <= n ? s : `${s.slice(0, n)}...`;
}
/**
* Wrap a `ReadableStream<Uint8Array>` of known length in a `Blob`-shaped
* object that can be appended to a `FormData` part. We can't use the native
* `Blob` constructor because it eagerly materializes its input into memory,
* defeating the streaming intent. Instead we expose just the surface that
* undici's FormData serializer needs (`size`, `type`, `stream()`, plus the
* `Symbol.toStringTag` it sniffs to identify Blob-likes).
*/
function streamToBlob(
stream: ReadableStream<Uint8Array>,
size: number,
type: string | undefined
): Blob {
const blobType = type ?? "application/octet-stream";
// The cast is safe: undici's multipart serializer calls `.stream()`,
// `.size`, and `.type`; everything else (`arrayBuffer`, `slice`, `text`)
// is unused on the upload path.
return {
size,
type: blobType,
stream: () => stream,
arrayBuffer: () => {
throw new Error(
"stream-backed Blob does not support arrayBuffer(); upload path only"
);
},
text: () => {
throw new Error(
"stream-backed Blob does not support text(); upload path only"
);
},
slice: () => {
throw new Error(
"stream-backed Blob does not support slice(); upload path only"
);
},
[Symbol.toStringTag]: "Blob",
} as unknown as Blob;
}
/**
* If the server returned a `/run` failure envelope `{ok:false, error,
* stderr, duration_ms, stop_reason}`, convert the snake_case keys to
@ -431,7 +568,14 @@ function combineSignals(
}
if (timeoutMs > 0) {
timer = setTimeout(() => ac.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
// L2: use DOMException("timeout","TimeoutError") for symmetry with
// `AbortSignal.timeout()`. Caller chains that match on reason.name will
// see the same shape regardless of whether we or the platform timed it
// out.
timer = setTimeout(
() => ac.abort(new DOMException(`timeout ${timeoutMs}ms`, "TimeoutError")),
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?.();

View file

@ -47,8 +47,13 @@ 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})`);
constructor(
status: number,
body: unknown,
message?: string,
options?: { cause?: unknown }
) {
super(message ?? `clawdforge API error (${status})`, options);
this.name = "ForgeAPIError";
this.status = status;
this.body = body;

View file

@ -29,6 +29,15 @@ export interface ForgeOptions {
* for custom user-agents / proxies. Defaults to `globalThis.fetch`.
*/
fetch?: typeof fetch;
/**
* Maximum size in bytes that {@link Forge.uploadFile} will accept when
* given a string path. Defaults to 100 MB (100 * 1024 * 1024). The size
* check happens against `fs.stat` BEFORE any bytes are read or buffered,
* so an oversized file fails fast without memory pressure.
*
* Set to 0 to disable the cap entirely (not recommended).
*/
uploadMaxBytes?: number;
}
/** Response shape from `GET /healthz`. */

View file

@ -1,6 +1,12 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { writeFileSync, unlinkSync } from "node:fs";
import {
mkdirSync,
rmSync,
truncateSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -460,3 +466,369 @@ test("default timeout fires → ForgeTransportError(aborted=true)", async () =>
clearInterval(keepalive);
}
});
// -------------------------------------------------------- audit fix tests (H1)
test("uploadFile: rejects a directory path with ForgeError (no EISDIR)", async () => {
const dir = join(tmpdir(), `clawdforge-ts-dir-${Date.now()}`);
mkdirSync(dir);
try {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 })
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(
forge.uploadFile(dir),
(e: unknown) => {
assert.ok(e instanceof ForgeError);
assert.match((e as Error).message, /not a regular file/);
return true;
}
);
assert.equal(calls.length, 0, "must not hit the network for a directory");
} finally {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
});
test("uploadFile: rejects a missing path with ForgeError carrying cause", async () => {
const missing = join(tmpdir(), `clawdforge-ts-nope-${Date.now()}-${Math.random()}.bin`);
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 })
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.uploadFile(missing), (e: unknown) => {
assert.ok(e instanceof ForgeError);
assert.match((e as Error).message, /cannot stat source path/);
return true;
});
assert.equal(calls.length, 0);
});
test("uploadFile: file size > uploadMaxBytes is rejected before any bytes read", async () => {
const tmp = join(tmpdir(), `clawdforge-ts-big-${Date.now()}.bin`);
// Sparse-truncate to 5 MB so we don't actually allocate the bytes; stat
// still reports the full size, which is what the cap checks against.
writeFileSync(tmp, Buffer.alloc(0));
truncateSync(tmp, 5 * 1024 * 1024);
try {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 })
);
const forge = new Forge({
...BASE,
fetch,
uploadMaxBytes: 1024, // 1 KB cap, file is 5 MB
});
await assert.rejects(forge.uploadFile(tmp), (e: unknown) => {
assert.ok(e instanceof ForgeError);
assert.match(
(e as Error).message,
/exceeds uploadMaxBytes/,
"must call out the cap, not surface a transport-level surprise"
);
return true;
});
assert.equal(calls.length, 0, "must not hit the network");
} finally {
try {
unlinkSync(tmp);
} catch {
/* ignore */
}
}
});
test("uploadFile: streams large file without buffering whole thing", async () => {
// 8 MB file, sparse-allocated so the test stays cheap. The streaming code
// path is exercised by `createReadStream` + `Readable.toWeb`; we assert
// that the resulting multipart Blob carries the right size and that the
// upload completes without OOM at peak heap. A loose heap-delta bound
// proves we didn't buffer the whole file (which would push RSS+heap by
// ~8 MB).
const SIZE = 8 * 1024 * 1024;
const tmp = join(tmpdir(), `clawdforge-ts-stream-${Date.now()}.bin`);
writeFileSync(tmp, Buffer.alloc(0));
truncateSync(tmp, SIZE);
try {
const { fetch, calls } = makeMockFetch(async (req) => {
// Drain the multipart body so undici streams it through. Without this
// the ReadableStream sits in the FormData unconsumed.
assert.ok(req.bodyForm);
const part = req.bodyForm!.get("file");
assert.ok(part && typeof part === "object" && "size" in part);
assert.equal((part as { size: number }).size, SIZE);
return jsonResponse(200, {
file_token: "ff_streamed",
ttl_secs: 3600,
size: SIZE,
});
});
const forge = new Forge({ ...BASE, fetch });
if (typeof globalThis.gc === "function") {
globalThis.gc();
}
const before = process.memoryUsage().heapUsed;
const ft = await forge.uploadFile(tmp, { contentType: "application/octet-stream" });
const after = process.memoryUsage().heapUsed;
assert.equal(ft.fileToken, "ff_streamed");
assert.equal(ft.size, SIZE);
assert.equal(calls.length, 1);
// Heap should grow by far less than the file size if we're streaming.
// Allow generous slack — V8 is noisy. The pre-fix code would have
// pushed +SIZE; this should comfortably stay under 2 MB of growth.
const delta = after - before;
assert.ok(
delta < SIZE / 2,
`heap delta ${delta} should be far below file size ${SIZE} (streaming)`
);
} finally {
try {
unlinkSync(tmp);
} catch {
/* ignore */
}
}
});
// --------------------------------------------------- audit fix tests (M4, M5)
test("constructor: baseUrl with newline rejected as not-a-URL", () => {
assert.throws(
() =>
new Forge({
baseUrl: "http://forge.test:8800\nX-Injected: yes",
token: "cf_x",
}),
(e: unknown) => {
assert.ok(e instanceof ForgeError);
assert.match((e as Error).message, /baseUrl is not a valid URL/);
return true;
}
);
});
test("constructor: baseUrl with embedded space rejected", () => {
assert.throws(
() =>
new Forge({
baseUrl: "http://forge .test:8800",
token: "cf_x",
}),
(e: unknown) => {
assert.ok(e instanceof ForgeError);
assert.match((e as Error).message, /baseUrl is not a valid URL/);
return true;
}
);
});
test("constructor: defaultTimeoutMs negative rejected", () => {
assert.throws(
() => new Forge({ ...BASE, defaultTimeoutMs: -1 }),
(e: unknown) => {
assert.ok(e instanceof ForgeError);
assert.match((e as Error).message, /defaultTimeoutMs/);
return true;
}
);
});
test("constructor: defaultTimeoutMs NaN rejected", () => {
assert.throws(
() => new Forge({ ...BASE, defaultTimeoutMs: Number.NaN }),
ForgeError
);
});
test("constructor: defaultTimeoutMs 0 explicitly disables", async () => {
// With timeout=0 the SDK should not arm an internal abort timer. We
// verify by waiting longer than any reasonable default would tick.
const { fetch } = makeMockFetch(() =>
jsonResponse(200, { ok: true, claude_present: true, claude_version: null })
);
const forge = new Forge({ ...BASE, fetch, defaultTimeoutMs: 0 });
const h = await forge.healthz();
assert.equal(h.ok, true);
});
test("constructor: uploadMaxBytes negative rejected", () => {
assert.throws(
() => new Forge({ ...BASE, uploadMaxBytes: -1 }),
ForgeError
);
});
// --------------------------------------------------------- audit fix test (L1)
test("run: empty 200 body throws ForgeAPIError instead of silent {}", async () => {
const { fetch } = makeMockFetch(
() =>
new Response("", {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => {
assert.ok(e instanceof ForgeAPIError);
assert.match((e as Error).message, /empty body/);
return true;
});
});
// --------------------------------------------------------- audit fix test (L7)
test("run: 200 with ok:false throws ForgeAPIError (defensive against buggy server)", async () => {
const { fetch } = makeMockFetch(() =>
jsonResponse(200, {
ok: false,
result: null,
duration_ms: 0,
stop_reason: null,
error: "lying server",
stderr: "",
})
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => {
assert.ok(e instanceof ForgeAPIError);
assert.equal((e as ForgeAPIError).status, 200);
assert.match((e as Error).message, /ok!=true/);
return true;
});
});
// ------------------------------------------------------- audit fix tests (L5)
test("L5: non-502 5xx (e.g. 500) → ForgeAPIError, not Auth/Transport", async () => {
const { fetch } = makeMockFetch(
() =>
new Response(JSON.stringify({ detail: "internal server boom" }), {
status: 500,
headers: { "Content-Type": "application/json" },
})
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => {
assert.ok(e instanceof ForgeAPIError);
assert.ok(!(e instanceof ForgeAuthError));
assert.equal((e as ForgeAPIError).status, 500);
return true;
});
});
test("L5: uploadFile from Blob includes file part and ttl_secs", async () => {
const { fetch, calls } = makeMockFetch(() =>
jsonResponse(200, { file_token: "ff_blob", ttl_secs: 3600, size: 4 })
);
const forge = new Forge({ ...BASE, fetch });
const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: "image/png" });
const ft = await forge.uploadFile(blob, { filename: "blob.png" });
assert.equal(ft.fileToken, "ff_blob");
const c = calls[0]!;
assert.ok(c.bodyForm);
assert.equal(c.bodyForm!.get("ttl_secs"), "3600");
const part = c.bodyForm!.get("file");
assert.ok(part && typeof part === "object" && "size" in part);
if (part && "name" in part) {
assert.equal((part as { name: string }).name, "blob.png");
}
});
test("L5: uploadFile rejects invalid source type with ForgeError", async () => {
const { fetch, calls } = makeMockFetch(() => jsonResponse(200, {}));
const forge = new Forge({ ...BASE, fetch });
// @ts-expect-error — intentionally testing the runtime guard
await assert.rejects(forge.uploadFile(12345 as unknown), ForgeError);
// @ts-expect-error
await assert.rejects(forge.uploadFile(null as unknown), ForgeError);
// @ts-expect-error
await assert.rejects(forge.uploadFile({} as unknown), ForgeError);
assert.equal(calls.length, 0);
});
test("L5: revokeToken with empty name rejected client-side", async () => {
const { fetch, calls } = makeMockFetch(() => jsonResponse(200, { ok: true }));
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.revokeToken(""), ForgeError);
assert.equal(calls.length, 0, "must not hit the network");
});
test("L5: error body parse fallback — non-JSON 500 body kept as raw string", async () => {
const { fetch } = makeMockFetch(
() =>
new Response("<!doctype html>nginx error page", {
status: 500,
headers: { "Content-Type": "text/html" },
})
);
const forge = new Forge({ ...BASE, fetch });
await assert.rejects(forge.healthz(), (e: unknown) => {
assert.ok(e instanceof ForgeAPIError);
assert.equal((e as ForgeAPIError).status, 500);
// body is the raw text, not the parsed object
assert.equal(typeof (e as ForgeAPIError).body, "string");
assert.match((e as ForgeAPIError).body as string, /nginx error page/);
return true;
});
});
test("L5: caller-aborted before fetch returns is reported as aborted=true", async () => {
// Race: caller's signal aborts WHILE the SDK's internal timer is also
// armed. The transport error must report `aborted=true` and identify the
// caller (not the timeout) as the reason.
const { fetch } = makeMockFetch(async () => {
await new Promise<void>(() => {});
return jsonResponse(200, {});
});
const forge = new Forge({ ...BASE, fetch, defaultTimeoutMs: 5_000 });
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);
// Should be reported as the caller's abort, not the timeout.
assert.match((e as Error).message, /aborted by caller/);
return true;
});
});
// --------------------------------------------------------- audit fix test (L2)
test("L2: timeout abort reason is DOMException('timeout','TimeoutError')", async () => {
// Exercise the internal combineSignals: the fetch impl receives an
// AbortSignal whose `reason`, when the timeout fires, should be a
// DOMException with name "TimeoutError" — for symmetry with
// AbortSignal.timeout().
let observedReason: unknown = null;
const fetchFn: typeof fetch = async (_input, init) => {
return await new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() => {
observedReason = init.signal!.reason;
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
},
{ once: true }
);
});
};
const forge = new Forge({ ...BASE, fetch: fetchFn, defaultTimeoutMs: 25 });
const keepalive = setInterval(() => {}, 5);
try {
await assert.rejects(forge.healthz(), ForgeTransportError);
} finally {
clearInterval(keepalive);
}
assert.ok(observedReason instanceof DOMException, "reason should be a DOMException");
assert.equal((observedReason as DOMException).name, "TimeoutError");
});