Handlers import from @coop/runtime (packages/coop-runtime).
import {
CoopRequest, respond, redirect, jsonResponse, withCacheHeaders,
db, kv, storage, queue, secrets, log, coopFetch,
} from "@coop/runtime";Not all of this surface is wired to a backend yet. Check here before designing around a module:
| module | status |
|---|---|
log |
implemented — structured lines to the worker's log stream |
secrets |
module implemented — reads COOP_SECRET_<NAME>, throws if unset. Nothing in the host exports those variables yet: [capabilities] secrets parses in coop.toml but is never read, and there is no secrets file format. |
queue |
implemented — host-owned durable queue via js_coop_queue_enqueue |
coopFetch |
module implemented — policy-wrapped outbound fetch. Nothing in the host exports COOP_FETCH_ALLOWLIST yet, so the allowlist is empty and every domain is permitted. |
db |
module implemented — connects via COOP_DB_URL. Nothing in the host exports that variable yet; a deployment must get it into the worker's environment itself. |
kv |
implemented — Redis via Perry's ioredis, per-deployment key prefix |
storage |
implemented — files under a per-deployment directory |
kv and storage are only available to a deployment with its own worker
process (isolation.class = "dedicated"). Both are scoped by environment
variables the worker exports, and in_process / sharded deployments share one
process — so a shared namespace there would be a data-isolation break, not a
degraded feature. In those modes the variables are absent and both modules throw
a message saying so, rather than silently sharing a keyspace or a directory.
const req = new CoopRequest(reqJson);
req.method // string
req.path // string
req.text() // string
req.json() // any
req.formData() // Record<string, string>
req.header(name) // string | undefined
req.queryParam(name) // string | undefined
req.ip() // stringrespond(status, headers, body) // string
jsonResponse(status, value) // string
redirect(location, status?) // string
withCacheHeaders(response, seconds) // stringA handler returns the string these produce:
export function handle(reqJson: string): string {
const req = new CoopRequest(reqJson);
return jsonResponse(200, { path: req.path });
}log.debug(msg, fields?);
log.info(msg, fields?);
log.warn(msg, fields?);
log.error(msg, fields?);fields is a Record<string, any> merged into the structured line.
const token = secrets.get("POSTMARK_TOKEN");get throws a descriptive error if the secret is not configured, rather than
returning undefined — a missing credential should fail loudly at first use.
The intended host side is that coop-worker decrypts the deployment's secrets
file at startup and exports each as COOP_SECRET_<NAME>. That part does not
exist yet. [capabilities] secrets parses in coop.toml and is never read,
there is no secrets file format, and no code anywhere sets a COOP_SECRET_*
variable — so today secrets.get throws for every name unless something outside
Coop put the variable in the worker's environment.
A query builder over the connection named by COOP_DB_URL:
const rows = db.table("subscribers")
.select("id", "email")
.where({ active: true })
.orderBy("created_at", "DESC")
.limit(10);Available builder methods: table, select, where, join, groupBy,
orderBy, limit, offset.
Nothing in the daemon or the worker exports COOP_DB_URL today — unlike
COOP_REDIS_URL and COOP_STORAGE_DIR, which coop-worker derives and
exports for a dedicated worker. Until it does, the variable has to reach the
worker's environment some other way, or db throws on first use.
await queue.send("email", { to: "user@example.com", subject: "Welcome" });
await queue.send("email", payload, { delay: 60_000 });
await queue.sendRaw("binary", Buffer.from([0, 255]));Enqueueing is host-owned: it calls into the worker rather than talking to a broker from application code, so the queue survives the application being recompiled or rolled back.
A wrapper around fetch that applies the deployment's outbound policy. Use it
instead of bare fetch so that egress stays attributable to a deployment.
The allowlist comes from COOP_FETCH_ALLOWLIST, which nothing exports yet, and
an empty allowlist permits every domain. [capabilities.fetch.allowlist] parses
in coop.toml but is not read. Treat this as retry and timeout handling today,
not as an egress control.
A Redis key-value store, backed by Perry's ioredis binding.
await kv.set("session:abc", JSON.stringify(data), { ex: 3600 });
const value = await kv.get("session:abc"); // string | null
const count = await kv.incr("rate_limit:ip:1.2.3.4");
const removed = await kv.del("session:abc"); // number of keys removedConfigure it with [redis] url in runtime.toml. The daemon passes that URL to
the worker through the environment, so a password never reaches the process
table.
kv prefixes every key with COOP_REDIS_PREFIX, which the worker derives from
the deployment name as coop:<name>:. Deployments cannot address each other's
keys because the prefix is injected by the host, not the application. The worker
refuses to start a deployment whose name contains :, because that would make
the prefix ambiguous — deployment a writing the literal key b:x would
otherwise land exactly where deployment a:b writes x.
Three behaviours follow from what Perry's ioredis binding can actually lower,
and are worth knowing before you design around kv:
{ ex }is not atomic. Perry'ssettakes exactly two arguments, soSET ... EXcannot be expressed andsetexhas no dispatch entry either. A TTL is issued asSETfollowed byEXPIRE. There is a window in which the key exists without its expiry; a crash inside it leaves a key that never expires.setchecks theEXPIREreply and throws if it did not apply.exmust be a whole number of seconds ≥ 1.EXPIREhas one-second resolution, so a fractional value is rejected rather than truncated.- The surface is
get/set/del/incrand nothing else. Perry resolves Redis methods through a fixed compile-time table; a method with no entry —ttl,mget,keys,scan,setex, anything list/set/hash — does not fail to compile, it evaluates toundefined. Adding one tokv.tswithout checking that table would produce a silent no-op.
An S3-shaped object store, files on disk in v0.
await storage.put("uploads/avatar.jpg", imageBytes, { contentType: "image/jpeg" });
const bytes = await storage.get("uploads/avatar.jpg"); // Buffer | null
await storage.del("uploads/avatar.jpg");
const keys = await storage.list({ prefix: "uploads/", limit: 100 });Configure the box-wide root with [paths] storage_dir in runtime.toml. The
worker creates and exports <storage_dir>/<deployment> as COOP_STORAGE_DIR;
the application never sees the root, only keys under its own directory.
Layout inside that directory:
objects/<key> the bytes, verbatim
meta/<key>.json {"contentType": "..."} when one was given
getreturns aBuffer, not a string. Perry'sreadFileSync(path, "utf8")is lossy above0x80, so a string return could not carry the binary payloads this interface exists for. Callbytes.toString("utf8")when the object is known to be text.putaccepts either aBufferor a string.- Keys are rejected, not sanitised. A key must be relative, non-empty, and
free of
./../empty segments, backslashes and NUL. Rewriting../../etc/passwdinto something storable would put the object somewhere the caller did not ask for, so it throws instead. putverifies its own write. Perry'sfs.writeFileSyncreturns without throwing when the write fails, so a full disk or a permissions error would otherwise look like a stored object.putstats the result and compares byte counts.contentTypeis recorded but not yet read. v0 has nohead()and no storage-backed serving path; storing it means neither has to guess later. Re-putting without one clears the previous value.delon an absent key succeeds, matching S3.