8.2 The JavaScript service contract
A JavaScript custom service is a single-file ES module whose default export is the handler. This section is the contract: what your function receives, what it must return, and the context object it works with.
The handler
export default async (msg, ctx) => {
const id = msg.url.split("/").pop();
const order = ctx.request("orders", { url: `/${id}` });
ctx.log("info", `loaded ${order.body.status}`);
return {
status: 200,
headers: { "x-source": "my-service" },
body: { orderStatus: order.body.status },
};
};
The default export is async (msg, ctx) => response — or an object with a
handle method (export default { handle }).
The incoming message — msg
{ method, url, headers, body, bodySize, mediaType }
urlis the path and query string.headersis a plain object.bodyarrives parsed for JSON; other media types arrive as strings.- With one of the streaming modes below,
bodyisnullandbodySizereports the declared length when it is known.
The response
Return an envelope, or a plain value:
| You return | Becomes |
|---|---|
{ status?, headers?, body?, mediaType? } |
that response |
| a string | 200, text/plain (unless you set mediaType) |
| an object (non-envelope) | 200 JSON body |
null / undefined |
204 |
The context — ctx
ctx.config— the mount'sconfigobject (your service's settings).ctx.log(level, text)— structured logging; lands in the tenant's logs stamped with the request's trace (Part 10).ctx.state.get(key)/ctx.state.put(key, value)— durable string state that survives invocations, keyed per service version. This is where cross-request state goes, because globals do not survive (fresh isolate per invocation).ctx.request(capability, { method?, url, headers?, body?, mediaType? })— the only way out of the sandbox.ctx.readBody()/ctx.body()/ctx.beginStream()— the opt-in body streaming surface described below.
ctx.request — the single exit
Every interaction with the outside world goes through ctx.request, naming a
granted capability (8.5):
const r = ctx.request("orders", { method: "GET", url: "/42" });
// r = { status, headers, body (parsed if JSON), mediaType }
- It's synchronous from the guest's perspective (no
awaitneeded, though awaiting works — the isolate parks on the host future). - An ungranted capability throws an
Errorwithe.code === "capability_denied"; uncaught, it fails the invocation with that structured error.
Do work in the handler, not at module top level
Module top-level code runs at deploy-time validation. Code that does I/O at the
top level fails deployment. Keep imports and pure setup at the top; do all
work — especially anything touching ctx — inside the handler.
Streaming large or incremental bodies
The normal contract materializes a request before the handler runs. Three mount-config flags opt a JS service into different behavior:
| Flag | What changes |
|---|---|
bodyPassthrough: true |
msg.body is null; if the handler does not return a body, the original request body passes through unchanged. Good for header/status rewriting over large bodies |
requestStreaming: true |
Pull chunks with ctx.readBody() (Uint8Array or null at EOF), or for await (const chunk of ctx.body()) |
responseStreaming: true |
Call ctx.beginStream({status, headers?, mediaType?}), then write(bytesOrString) chunks; writes apply backpressure and the stream ends when the handler returns |
export default async (_msg, ctx) => {
const out = ctx.beginStream({ status: 200, mediaType: "text/event-stream" });
for (let i = 0; i < 10; i++) out.write(`data: ${i}\n\n`);
};
Streaming is JavaScript-only; the Wasm boundary still materializes. The byte
cap and wall-clock limit cover the whole stream, including backpressure waits.
A streamed response is ephemeral, non-cacheable, and has no ETag. The global
ReadableStream remains a compatibility stub — use these ctx methods.
The next section details exactly which JavaScript/Web APIs are available inside that handler.
← Previous: 8.1 When to reach for custom code · Manual home · Next: 8.3 The supported API surface →