Add the fetch battery: call a third-party API without its key in the bundle - #28
Conversation
…ubstitution
Lets a deployed app call a third-party API using a key its owner stored with
Bool, without the key entering the app bundle. Write {{SECRET_NAME}} wherever the
key belongs — the URL, a header value, the body — and the gateway's fetch plane
substitutes the real value server-side.
const res = await bool.fetch(
"https://api.example.com/v1/things?key={{EXAMPLE_API_KEY}}",
);
if (!res.ok) return;
const data = await res.json();
Shaped as fetch on purpose. It takes the same arguments as the global fetch and
resolves to a real Response carrying the third party's status, headers and body,
so res.ok / res.status / res.json() mean what they always mean and the only new
thing to learn is the placeholder. An object-shaped API returning {status, body}
was the alternative; it reads as more explicit but every caller then has to
relearn a shape they already know, and every generated call site pays for that.
Two details worth knowing:
- A response from the API is never an error, including a 4xx. BoolFetchError is
thrown only when the request was never made (secret_not_set, unknown_secret,
host_not_allowed, rate_limited, out_of_app_credits), mirroring how fetch throws
on a network failure but not on a 404. It carries `secrets`, the key names
involved, so an app can tell its user which key is still missing rather than
failing opaquely.
- 204/205/304 come back with a null body. The Response constructor rejects a body
on those statuses, so passing one through would turn a successful DELETE into a
thrown error.
The call routes through the resolved gateway base like every other plane, not a
relative URL: the editor preview runs on a different origin from the gateway, so
a relative call would only work once an app was published. `aiHeaders` becomes
`batteryHeaders` — same envelope (preview viewer token, end-user session,
local-development key), now shared by both batteries rather than named after one.
Minor release, purely additive: apps on ^0.3.x are unaffected until they
reinstall.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Heads up — this PR and #29 both claim VersioningWe landed on this PR moving to
The merge conflictThree files conflict textually, so whichever merges second needs a rebase:
#29 is the smaller diff, so it's cheaper for it to go first and for this branch to rebase onto it. Happy either way — say which and I'll do the rebase. One design question, unrelated to the above
Worth deciding deliberately, though: are these two error surfaces meant to share a code vocabulary, or just happen to overlap on the two codes that come from shared infrastructure? If it's the former there's a case for a common type; if the latter, the duplication is fine and a shared type would imply a guarantee neither plane makes. Nothing to change here either way — just easier to answer now than after both are published. |
|
Correction to my comment above: ignore the versioning ask — this PR keeps The rest of that comment still stands: the One consequence worth recording, since it's the kind of thing that goes stale silently: the platform-repo gate that decides whether a project's prompt teaches the AI error codes is keyed to SDK minor 5, not 4 — because |
client.fetchlets a deployed app call a third-party API using a key its owner stored with Bool, without the key entering the app bundle. Write{{SECRET_NAME}}wherever the key belongs — the URL, a header value, the body — and the gateway's fetch plane substitutes the real value server-side.Why it's shaped like
fetchIt takes the same arguments as the global
fetchand resolves to a realResponsecarrying the third party's status, headers and body, sores.ok,res.statusandres.json()mean what they always mean. The only new thing to learn is the placeholder.The alternative was an object-shaped call returning
{ status, headers, body }. That reads as more explicit about the proxy being there, but it makes every caller relearn a shape they already know — and the cost is paid at every call site, forever. Matchingfetchkeeps the API surface to one idea.Two details worth reviewing
A response from the API is never an error, including a 4xx.
BoolFetchErroris thrown only when the request was never made —secret_not_set,unknown_secret,host_not_allowed,rate_limited,out_of_app_credits— mirroring howfetchthrows on a network failure but not on a 404. The error carriessecrets, the key names involved, so an app can tell its user which key is still missing rather than failing opaquely.204/205/304come back with a null body. TheResponseconstructor rejects a body on a null-body status, so passing one through would turn a successfulDELETEinto a thrown error.Notes for the reviewer
aiHeadersis renamedbatteryHeaders. Same envelope (preview viewer token, end-user session, local-development key), now shared by both batteries instead of being named after one. Internal tocreateBoolClient; no export changes.BoolFetch,BoolFetchInit,BoolFetchError.bodyis typed asstring, as withfetch—JSON.stringifyyour object.FormData, streaming bodies andAbortSignalare deliberately not forwarded, since the call is described to the gateway as data rather than opened from the browser.Versioning
0.4.0— purely additive, so apps on^0.3.xare unaffected until they reinstall. Requires a gateway with the fetch plane enabled for the workspace; until then the plane is not routable and calls fail closed.Testing
bun run typecheckclean,bun test179 pass / 0 fail,bun run buildemits the battery intodist/withfetch: BoolFetchon the publishedBoolClienttype. Eleven new tests cover the wire shape,Headers/URLinputs, third-party status passthrough, JSON vs text bodies, the 204 case, both throw paths, the identity headers, and that the request never goes out relative.🤖 Generated with Claude Code