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 }
  • url is the path and query string.
  • headers is a plain object.
  • body arrives parsed for JSON; other media types arrive as strings.
  • With one of the streaming modes below, body is null and bodySize reports 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's config object (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 await needed, though awaiting works — the isolate parks on the host future).
  • An ungranted capability throws an Error with e.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 →