@cana-ai/sdk
v1.0.1
Published
OpenAI-compatible client + agent for Cana Apps — Bearer auth (App API key or short-lived browser JWT with auto-refresh), server-hosted MCP federation, streaming, typed structured output. Node 18+, Bun, Deno, Workers, browsers.
Downloads
1,265
Readme
@cana-ai/sdk
OpenAI-compatible client + prebuilt agent for Cana Apps — Bearer auth (App API key or short-lived browser JWT with auto-refresh), server-hosted MCP federation, streaming, typed structured output, and a hybrid local-tool loop. Web Crypto only — runs on Node 18+, Bun, Deno, Cloudflare Workers, and modern browsers out of the box.
npm install @cana-ai/sdk
# or pnpm add / yarn add / bun add<!-- or drop the IIFE bundle into a page -->
<script src="https://cana.build/sdk/openai.js"></script>Table of contents
- Quick start (prebuilt agent)
- Authentication
- Model strings
- Prebuilt agent:
CanaAgent - Low-level client:
CanaClient - Structured output (JSON Schema)
- Structured output (Zod helper)
- System prompts & multi-turn
- Usage / cost tracking
- Custom
fetch(testing, proxies, telemetry) - Hosted page / embed:
CanaIssuer - Browser —
<script>tag - Plain OpenAI SDK against the proxy
- Low-level:
signRequest - Low-level:
parseChunkStream - Errors
- TypeScript exports
Quick start (prebuilt agent)
Plug in a prompt + tools. The SDK handles auth, MCP discovery, tool dispatch, the chat loop, streaming, and error capture.
import { CanaAgent, defineTool } from "@cana-ai/sdk/openai";
import { z } from "zod";
const get_weather = defineTool({
description: "Current weather for a city",
parameters: z.object({
city: z.string(),
units: z.enum(["c", "f"]).optional(),
}),
handler: async ({ city, units }) => {
// ^^^^^^^^^^^^
// typed string + "c"|"f"|undefined via z.infer
return await fetchWeather(city, units);
},
});
const agent = new CanaAgent({
appId: "app_…",
apiKey: process.env.CANA_APP_KEY!, // cak_live_…
// or for the browser: getToken: () => fetchFreshJwtFromYourBackend()
model: "OpenAI/gpt-4o-mini",
systemPrompt: "You are helpful.",
tools: { get_weather }, // local — handler runs in your process
mcp: ["github"], // server-hosted — auto-dispatched
});
const r = await agent.run({ prompt: "What's the weather in Tokyo?" });
console.log(r.text); // "It's 22°C and sunny in Tokyo."
console.log(r.toolCalls); // [{ name: "get_weather", args: { city: "Tokyo" }, result: { … }, source: "local" }]
console.log(r.usage); // { prompt_tokens, completion_tokens, total_tokens }That's it — no chat loop to write, no messages array to manage, no
tool_calls to dispatch yourself.
For full control (driving rounds yourself, custom tool dispatch logic, raw
streaming chunks), drop down to the CanaClient
documented below.
Authentication
⚠ Breaking change in v1.0.0.
CanaClientandCanaAgentno longer use HMAC signing, and thesigningKeyconstructor option has been removed. Callers onsigningKeymust switch toapiKey(orgetToken). TheCanaIssuerflow is unchanged.
CanaAgent and CanaClient authenticate with a Bearer credential. Pass
exactly one of three options:
apiKey: "cak_live_…"— a static App API key for server-to-server use. Sent asAuthorization: Bearer cak_live_…. Create and revoke it from the App's OpenAI API dashboard tab; it's hash-stored and shown only once at creation, so copy it then. Never ship it in browser code.getToken: () => Promise<string> | string— for direct-browser use. Returns a short-lived embedder JWT. The client caches it, refreshes ~5s before the JWT'sexp, and on a401re-callsgetTokenonce and retries. This is the safe browser path — no long-lived secret ever reaches page source.bearerToken: "<jwt>"— a static JWT you manage yourself (a degenerategetToken: not refreshed; a401propagates).
// server-to-server
new CanaAgent({ appId, apiKey: process.env.CANA_APP_KEY! });
// browser — short-lived JWT, auto-refreshed on exp/401
new CanaAgent({ appId, getToken: () => fetch("/my-backend/cana-jwt").then(r => r.text()) });
// self-managed static JWT
new CanaAgent({ appId, bearerToken: myJwt });The JWTs returned by getToken / passed as bearerToken are minted by your
server with CanaIssuer. Use apiKey for
server-to-server, getToken for the browser, and bearerToken only when you
already have a JWT and want to manage its lifecycle yourself.
Model strings
Models are written as <providerConfigName>/<modelId>. providerConfigName
is the exact name of an LlmProviderConfig the App owner has access to
(case-insensitive; whitespace-stripped at create time). The proxy:
- finds an active config of that name (ORG > PLATFORM scope; newer wins on ties),
- confirms it hosts the requested
modelId, - forwards your request with the config's
baseUrl+ decrypted apiKey.
// Examples — substitute YOUR provider config names
"Kimi/moonshot-v1-32k"
"OpenAI/gpt-4o-mini"
"Anthropic/claude-haiku-4-5-20251001"
"Vertex/gemini-2.5-pro"
// No slash → fallback: any accessible config hosting that model id wins.
"gpt-4o-mini"Two distinct errors disambiguate misses: unknown_provider (no config of
that name) vs model_not_found (config exists but doesn't host the model).
Prebuilt agent: CanaAgent
agent.run — one-shot
const r = await agent.run({
prompt: "...",
// or full history:
// messages: [...],
// overrides per-call:
// mcp: ["slack"], maxRounds: 5, excludeTools: ["risky_tool"],
});
r.text; // string | null — final assistant text (null if ended on a tool round)
r.toolCalls; // ToolCallRecord[] — every tool call dispatched, in order:
// { name, args, result, isError, source: "local" | "mcp" }
r.messages; // ChatMessage[] — full conversation (system + user + assistant + tool)
r.rounds; // number — model rounds executed
r.usage; // { prompt_tokens, completion_tokens, total_tokens } — aggregatedmaxRounds (default 10) caps the loop so a misbehaving model can't burn
tokens forever. When hit, the run returns with whatever's accumulated; no
exception.
defineTool — Zod + JSON Schema
Two overloads. Pick whichever fits your project.
Zod path (recommended) — handler args are typed via z.infer:
import { z } from "zod";
const send_email = defineTool({
description: "Send a transactional email.",
parameters: z.object({
to: z.string().email(),
subject: z.string().min(1).max(100),
body: z.string(),
cc: z.array(z.string().email()).optional(),
}),
handler: async ({ to, subject, body, cc }) => {
// ^^^^^^^^^^^^^^^^^^^^^^
// all typed; Zod .parse() runs first
return await emailService.send({ to, subject, body, cc });
},
});The schema's .parse() runs on the model's args before your handler
sees them — invalid objects throw early. The schema is also converted to
JSON Schema (via the optional peer dep zod-to-json-schema, lazy-imported,
WeakMap-cached) and exposed to the model.
JSON Schema path — for callers without Zod:
const send_email = defineTool({
description: "Send a transactional email.",
parameters: {
type: "object",
required: ["to", "subject", "body"],
properties: {
to: { type: "string", format: "email" },
subject: { type: "string", maxLength: 100 },
body: { type: "string" },
cc: { type: "array", items: { type: "string", format: "email" } },
},
},
handler: async (args) => {
const { to, subject, body, cc } = args as { to: string; subject: string; body: string; cc?: string[] };
return await emailService.send({ to, subject, body, cc });
},
});Hybrid local + MCP tools
Local tools run in your process; MCP tools run on the server. The agent
auto-routes either way based on the tool name (MCP tools are prefixed
mcp__<connector>__<tool>).
import { CanaAgent, defineTool } from "@cana-ai/sdk/openai";
import { z } from "zod";
const fill_form = defineTool({
description: "Fill any subset of onboarding-form fields.",
parameters: z.object({
companyName: z.string().optional(),
teamSize: z.number().int().min(1).optional(),
priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
}),
handler: async (patch) => {
formStore.update(patch); // mutate your UI state
return { ok: true, form: formStore.snapshot() };
},
});
const agent = new CanaAgent({
appId, apiKey,
model: "Anthropic/claude-haiku-4-5-20251001",
systemPrompt: "You're an onboarding assistant.",
tools: { fill_form }, // local
mcp: ["slack"], // server-hosted
});
const r = await agent.run({
prompt: "Onboard Acme (12 ppl, high priority), then post to #onboarding.",
});
// r.toolCalls includes BOTH:
// { name: "fill_form", source: "local", result: { ok: true, form: {…} } }
// { name: "mcp__slack__send_message", source: "mcp", result: { … } }agent.stream — streamed events
Same arguments as run, returns an AsyncIterable<AgentEvent>.
for await (const ev of agent.stream({ prompt: "Tokyo weather?" })) {
switch (ev.type) {
case "round_start": console.log(`[round ${ev.round}]`); break;
case "text_delta": process.stdout.write(ev.delta); break; // token-by-token
case "tool_call": console.log(`→ ${ev.source}:${ev.name}(${JSON.stringify(ev.args)})`); break;
case "tool_result": console.log(`✓ ${ev.name} ${ev.isError ? "FAILED" : "ok"}`); break;
case "done": console.log("\nfinal:", ev.text, "usage:", ev.usage); break;
case "error": console.error("error:", ev.code, ev.message); break;
}
}Event shapes:
type AgentEvent =
| { type: "round_start"; round: number }
| { type: "text_delta"; delta: string; round: number }
| { type: "tool_call"; name: string; args: unknown; source: "local" | "mcp"; round: number }
| { type: "tool_result"; name: string; result: unknown; isError: boolean; source: "local" | "mcp"; round: number }
| { type: "done"; text: string | null; toolCalls: ToolCallRecord[]; messages: ChatMessage[]; usage: … }
| { type: "error"; code: string; message: string; status?: number };text_delta events stream the model's text token-by-token (using the SDK's
chunk aggregator). Tool-call deltas split across multiple OpenAI chunks are
reassembled before they're dispatched, so you get one tool_call event per
actual call, not one per partial chunk.
AgentSession — multi-turn memory
const s = agent.session();
const r1 = await s.send("My favorite city is Reykjavik.");
const r2 = await s.send("What's my favorite city?");
console.log(r2.text); // "Reykjavik."
s.messages; // ChatMessage[] — read-only
s.clear(); // reset to empty
// streaming variant:
for await (const ev of s.stream("Tell me a story.")) { /* ... */ }Each send carries the full prior history forward; the session stores
whatever the agent returns (system + user + assistant + tool messages
together).
Handler errors are captured, not thrown
If your tool handler throws, the agent records the error as the tool result and continues the loop. The model sees a JSON error blob and can either retry, switch tools, or apologise to the user.
const broken = defineTool({
description: "Always fails — for testing.",
parameters: z.object({ x: z.string() }),
handler: async () => { throw new Error("kaboom"); },
});
const r = await agent.run({ prompt: "Call broken with x='hi'." });
r.toolCalls[0];
// {
// name: "broken",
// args: { x: "hi" },
// result: { error: "kaboom" },
// isError: true,
// source: "local",
// }Low-level client: CanaClient
When you want to drive the loop yourself, or only need the wire-level helpers (auth, streaming, MCP RPC).
import { CanaClient } from "@cana-ai/sdk/openai";
const client = new CanaClient({
appId: "app_…",
apiKey: process.env.CANA_APP_KEY!, // cak_live_… (or getToken / bearerToken)
apiBase: "https://cana.build", // optional, this is the default
});Chat completions
const c = await client.chat.completions.create({
model: "OpenAI/gpt-4o-mini",
messages: [{ role: "user", content: "Summarize TCP slow-start in 3 bullets." }],
temperature: 0.2,
max_tokens: 300,
});
console.log(c.choices[0].message.content);
console.log("usage:", c.usage);Request fields supported: model, messages, tools, tool_choice,
response_format, stream, max_tokens, temperature, top_p, stop.
Streaming
const stream = await client.chat.completions.create({
model: "OpenAI/gpt-4o-mini",
messages: [{ role: "user", content: "Write a haiku about TCP." }],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) process.stdout.write(delta.content);
if (chunk.choices[0]?.finish_reason) console.log("\n[done]", chunk.usage);
}TypeScript narrows the return shape on stream:
// stream: true → AsyncIterable<ChatCompletionChunk>
// stream: false → ChatCompletionFunction tools — manual dispatch loop
OpenAI tool format works as-is. The low-level client doesn't dispatch
client tools for you — you do it. (Or use CanaAgent above and skip this.)
const tools = [{
type: "function",
function: {
name: "lookup_weather",
description: "Current temperature in °C for a city.",
parameters: { type: "object", required: ["city"], properties: { city: { type: "string" } } },
},
}];
let messages = [{ role: "user", content: "What's it like in Tokyo?" }];
for (let i = 0; i < 5; i++) {
const c = await client.chat.completions.create({ model: "OpenAI/gpt-4o-mini", messages, tools });
const msg = c.choices[0].message;
messages.push(msg);
if (!msg.tool_calls?.length) break;
for (const call of msg.tool_calls) {
const args = JSON.parse(call.function.arguments);
const result = await lookupWeather(args.city);
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
}
}MCP federation — runWithMcp auto-dispatch
const r = await client.runWithMcp({
model: "Anthropic/claude-haiku-4-5-20251001",
messages: [{ role: "user", content: "Find last week's PRs in cana-web." }],
mcp: ["github", "search"],
maxRounds: 5,
});
console.log(r.message.content);
console.log(r.rounds);runWithMcp discovers + dispatches mcp__* calls server-side, and EXITS
the loop when the model calls a non-MCP tool (so the caller can dispatch
local tools, append the result, and call again). For an integrated
local+MCP loop, prefer CanaAgent.
MCP federation — manual
const { tools, dropped } = await client.mcp.tools(["github"]);
// tools: OpenAI-format ChatTool[] you pass to chat.completions.create
// dropped: connector names the App owner doesn't have enabled
const c = await client.chat.completions.create({ model, messages, tools });
for (const call of c.choices[0].message.tool_calls ?? []) {
if (!call.function.name.startsWith("mcp__")) continue;
const out = await client.mcp.execute({
name: call.function.name,
arguments: JSON.parse(call.function.arguments),
});
// out.result / out.isError / out.durationMs
}Structured output (JSON Schema)
const c = await client.chat.completions.create({
model: "OpenAI/gpt-4o-mini",
messages: [{ role: "user", content: "Give me one US state with capital + population." }],
response_format: {
type: "json_schema",
json_schema: {
name: "USState",
strict: true,
schema: {
type: "object",
required: ["name", "capital", "population"],
properties: {
name: { type: "string" },
capital: { type: "string" },
population: { type: "integer", minimum: 0 },
},
},
},
},
});
const parsed = JSON.parse(c.choices[0].message.content!);Structured output (Zod helper)
import { z } from "zod";
const Person = z.object({
name: z.string(),
age: z.number().int(),
occupation: z.string(),
});
const { result, raw } = await client.chat.completions.generate({
model: "OpenAI/gpt-4o-mini",
messages: [{ role: "user", content: "Anna is a 34-year-old marine biologist." }],
schema: Person,
});
result.name; // "Anna"
result.age; // 34
raw.usage; // full ChatCompletion if you need itOptional peer dep: zod-to-json-schema (auto-installed in Node; bundle it
yourself in browser builds).
System prompts & multi-turn
For CanaAgent use systemPrompt + the built-in session(). For
CanaClient, just include {role: "system", …} messages and keep
appending:
const messages = [
{ role: "system", content: "You are a terse senior backend engineer." },
{ role: "user", content: "When is a UNIQUE index worse than a CHECK constraint?" },
];
const r1 = await client.chat.completions.create({ model, messages });
messages.push(r1.choices[0].message);
messages.push({ role: "user", content: "Give a concrete example." });
const r2 = await client.chat.completions.create({ model, messages });Usage / cost tracking
Every non-streaming response includes usage. Streaming responses include
it on the final chunk (OpenAI / Moonshot / Kimi / DeepSeek do).
CanaAgent.run aggregates across all rounds for you.
const c = await client.chat.completions.create({ model, messages });
const { prompt_tokens, completion_tokens, total_tokens } = c.usage!;Custom fetch (testing, proxies, telemetry)
Override the network layer for unit tests, edge-network proxies, request
mirroring, or tracing. Works on both CanaAgent and CanaClient.
const agent = new CanaAgent({
appId, apiKey, model: "OpenAI/gpt-4o-mini",
fetch: async (url, init) => {
console.log("→", init?.method, url);
const t0 = Date.now();
const res = await globalThis.fetch(url, init);
console.log("←", res.status, `${Date.now() - t0}ms`);
return res;
},
});Hosted page / embed: CanaIssuer
If your product hosts a Cana embed widget or sends users to the hosted
page (cana.build/a/<slug>), you typically want visitors to land
already authenticated as themselves, not as anonymous traffic.
CanaIssuer is the server-side helper for that flow:
import { CanaIssuer } from "@cana-ai/sdk/openai";
const issuer = new CanaIssuer({
appId: "app_…",
signingKey: process.env.CANA_APP_ISSUER_KEY!, // csk_live_… — issuer-purpose key
kid: process.env.CANA_APP_ISSUER_KID, // optional; defaults to appId
apiBase: "https://cana.build",
});
// Inside your issuer-URL endpoint (Next.js route handler, Express, …):
const { code } = await issuer.issueExchangeCode({
user: { sub: user.id, email: user.email, name: user.name },
// ttlSec: 30, // JWT lifetime (clamped to 1–300; default 30)
// codeTtlSec: 60, // exchange-code TTL on Cana's side (default 30)
});
return Response.redirect(issuer.buildReturnUrl(returnTo, code));
// ^ equivalent to `${returnTo}?exchange=<code>`Key separation. Use a dedicated "issuer"-purpose AppSigningKey
(csk_live_…), separate from the Bearer App API key (cak_live_…) the SDK
uses for CanaClient / CanaAgent auth. Cana's verifier filters by purpose,
so rotating one credential doesn't break the other. Mint the issuer key from
your App dashboard or via the cana-web admin path.
Just the JWT
const jwt = await issuer.signJwt({
user: { sub: "user_123", email: "[email protected]", name: "Alice" },
ttlSec: 60,
});The JWT is HS256 with header {alg, typ, kid} and claims
{sub, email?, name?, metadata?, iss, aud, iat, exp, jti} — matches
what Cana's verifyAppToken accepts.
End-to-end visitor flow
1. User opens https://cana.build/a/<slug>
2. Cana 302s → https://your.app/issuer?return_to=<encoded>
3. Your endpoint:
- checks your session cookie
- (optional) issuer.issueExchangeCode({ user })
- 302 to issuer.buildReturnUrl(returnTo, code)
4. Cana page redeems the code via /exchange/redeem,
drops a session cookie, page is authenticated.Direct API key vs issuer flow at a glance
| | Direct API key (CanaClient / CanaAgent) | Issuer flow (CanaIssuer) |
| --- | --- | --- |
| Triggered by | Your server code | A user visiting a Cana URL |
| Identity | Anonymous (App-level) | End user (sub claim) |
| Key | App API key (cak_live_…) | "issuer"-purpose AppSigningKey (csk_live_…) |
| Auth | Bearer App API key (cak_live_…) | HS256 JWT + one HMAC per exchange |
| Browser sees key | No | No |
| Sets Cana session cookie | No | Yes |
| Who renders the UI | You do | Cana does |
Browser — <script> tag
A pre-built IIFE bundle is served from your Cana instance and falls through
to jsDelivr for CDN edge caching. Exposes CanaOpenAI.CanaAgent,
CanaOpenAI.defineTool, CanaOpenAI.CanaClient, etc.
<script src="https://cana.build/sdk/openai.js"></script>
<script>
const agent = new CanaOpenAI.CanaAgent({
appId: "app_…",
// Fetch a short-lived JWT from YOUR backend (minted there with CanaIssuer).
// The SDK caches it, auto-refreshes before exp, and re-fetches once on a 401.
getToken: () => fetch("/my-backend/cana-jwt").then(r => r.text()),
model: "OpenAI/gpt-4o-mini",
});
agent.run({ prompt: "Hello" })
.then(r => console.log(r.text));
</script>✅ With
getToken, no long-lived secret ever reaches page source — your backend mints a short-lived JWT per session and the SDK refreshes it automatically. This is the safe browser pattern. Do not paste a staticapiKey(cak_live_…) into client-side code: anyone who views source can read it.
Plain OpenAI SDK against the proxy
The proxy authenticates with a standard Authorization: Bearer header, so any
OpenAI client (openai-node, openai-python, the AI SDK from Vercel, etc.)
works out of the box — no fetch wrapper, no signing. URL shape:
https://cana.build/api/v1/apps/<appId>/openai/chat/completionsJust pass your App API key as the client's apiKey; the OpenAI SDK sends it
as Authorization: Bearer … for you — example using openai-node 4.x:
import OpenAI from "openai";
const APP_ID = "app_…";
const openai = new OpenAI({
baseURL: `https://cana.build/api/v1/apps/${APP_ID}/openai`,
apiKey: process.env.CANA_APP_KEY!, // cak_live_… → sent as Authorization: Bearer
});Low-level: signRequest
A generic HMAC-SHA256 helper, still exported. As of v1.0.0 it is not
used for proxy auth — the proxy authenticates with Authorization: Bearer
(see Authentication), so you don't need this for normal
SDK or plain-OpenAI usage. It remains available if you have your own
HMAC-signing needs (e.g. webhooks). It is the same primitive CanaIssuer
uses internally for the /exchange/issue exchange.
import { signRequest } from "@cana-ai/sdk/openai";
const { signatureHeader, timestamp, signatureHex } = await signRequest({
signingKey: "csk_live_…",
rawBody: JSON.stringify({ … }),
// timestampMs: 1700000000000, // override for deterministic tests
});Low-level: parseChunkStream
Parses an OpenAI-style SSE body into AsyncIterable<ChatCompletionChunk>.
Handles partial-buffer reassembly, comment heartbeats, and the [DONE]
sentinel automatically.
import { parseChunkStream } from "@cana-ai/sdk/openai";
const res = await fetch(url, { method: "POST", headers, body });
for await (const chunk of parseChunkStream(res.body!)) {
process.stdout.write(chunk.choices[0]?.delta.content ?? "");
}Errors
All non-2xx responses throw CanaApiError with .status and a typed
.code. Common codes:
| code | HTTP | meaning |
| --- | --- | --- |
| signature_invalid | 401 | wrong key or malformed header |
| signature_expired | 401 | timestamp outside 5-min skew |
| signature_replayed | 401 | nonce already seen |
| idempotency_conflict | 409 | same key + different body |
| app_paused | 403 | App.status != LIVE |
| rate_limited | 429 | check Retry-After |
| billing_exceeded | 402 | App monthly cap hit |
| unknown_provider | 400 | no LlmProviderConfig with that name |
| model_not_found | 400 | config exists but doesn't host the model |
| request_invalid | 400 | Zod-rejected request body |
| response_format_unsupported_for_provider | 400 | Anthropic + strict JSON schema |
| mcp_connector_unknown | 400 | connector not enabled on this App |
| mcp_dispatch_failed | 502 | MCP server returned an error |
| upstream_error | 502 | LLM provider failed |
| internal | 500 | unhandled |
import { CanaApiError } from "@cana-ai/sdk/openai";
try {
await agent.run({ prompt: "..." });
} catch (e) {
if (!(e instanceof CanaApiError)) throw e;
if (e.code === "rate_limited") { /* back off, check Retry-After */ }
if (e.code === "billing_exceeded") { /* upgrade plan */ }
}In agent.stream, errors surface as a terminal {type:"error", code, message, status} event instead.
TypeScript exports
import {
// High-level agent
CanaAgent, AgentSession, defineTool,
type CanaAgentOptions, type AgentTool, type AgentToolContext,
type AgentRunArgs, type AgentRunResult, type AgentEvent, type ToolCallRecord,
// Hosted-page / embed issuer
CanaIssuer,
type CanaIssuerOptions, type CanaIssuerUser,
type SignJwtArgs, type IssueExchangeCodeArgs, type ExchangeCodeResult,
// Low-level client
CanaClient, type CanaClientOptions,
signRequest, parseChunkStream,
// Errors + wire types
CanaApiError,
type ChatMessage, type ChatTool, type ChatToolCall, type ChatToolChoice,
type ChatCompletion, type ChatCompletionChunk, type ChatCompletionRequest,
type ResponseFormat, type ResponseFormatJsonSchema,
type CanaErrorBody,
} from "@cana-ai/sdk/openai";Web Crypto only
Zero node:crypto import at compile time — bundles cleanly for Node 18+,
Bun, Deno, Cloudflare Workers, Vercel Edge, and modern browsers.
License
MIT
