Node HTTP Frameworks
Three Node examples that all govern the same contract: GET /hello is allowed, POST /echo is denied without a capability and allowed with one. The differences are how each framework registers middleware and where the receipt id lands.
What it shows
@chio-protocol/express: Express middleware that decorates the request withreq.chioResult.@chio-protocol/fastify: a Fastify plugin that decoratesrequest.chioResultwith the same payload.@chio-protocol/elysia: an Elysia plugin that emits the receipt as a response header rather than mutating context.- All three call out to a local
chio api protectsidecar athttp://127.0.0.1:9090by default.
Sidecar topology
/chio/evaluate. The sidecar is what holds the kernel, the policy, and the receipt store. The Node process itself stays a thin HTTP wrapper. See HTTP Framework Middleware and Protect an API for the deeper pattern.Use this when
Prerequisites
- Node 20+ for Express and Fastify; Bun for Elysia.
- The
chioCLI onPATH. The smoke flow stands upchio trust serveandchio api protectfor you. - For Elysia: a built copy of the SDK at
sdks/typescript/packages/elysia/dist. Therun.shscript builds it on first run.
Run them
Each example has the same two entry points. Start the app only:
cd examples/hello-express # or hello-fastify, or hello-elysia
./run.shRun the full end-to-end smoke (sidecar, trust service, deny, allow):
./smoke.shDefault ports:
| Example | Env var | Default |
|---|---|---|
hello-express | HELLO_EXPRESS_PORT | 8011 |
hello-fastify | HELLO_FASTIFY_PORT | 8012 |
hello-elysia | HELLO_ELYSIA_PORT | 8014 |
Express
Plain Express middleware. Register chio() before the body parser; pull req.chioResult.receipt.id in your handler.
Before
import express from "express";
const app = express();
app.use(express.json());
app.get("/hello", (req, res) => {
res.json({ message: "hello from express" });
});
app.post("/echo", (req, res) => {
res.json(req.body);
});
app.listen(8011);After
import express from "express";
import { chio, chioErrorHandler } from "@chio-protocol/express";
const app = express();
app.use(
chio({
sidecarUrl: process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
skip: ["/healthz"],
}),
);
app.use(express.json());
app.get("/healthz", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/hello", (req, res) => {
res.json({
message: "hello from express",
receipt_id: req.chioResult?.receipt.id ?? null,
});
});
app.post("/echo", (req, res) => {
res.json({
...(typeof req.body === "object" && req.body !== null ? req.body : { payload: req.body }),
receipt_id: req.chioResult?.receipt.id ?? null,
has_raw_body: Buffer.isBuffer(req.rawBody),
});
});
app.use(chioErrorHandler);
const port = Number(process.env["HELLO_EXPRESS_PORT"] ?? "8011");
app.listen(port, "127.0.0.1");Notes: chio() buffers the raw body so the sidecar can hash it; the buffer is exposed as req.rawBody for downstream handlers that still want raw bytes. chioErrorHandler turns sidecar denials into proper 4xx responses.
Fastify
Fastify uses a plugin. Register it with await fastify.register(chio, ...) before defining routes; the plugin decorates request.chioResult on every non-skipped request.
Before
import Fastify from "fastify";
const fastify = Fastify({ logger: false });
fastify.get("/hello", async () => ({ message: "hello from fastify" }));
fastify.post("/echo", async (request) => request.body);
await fastify.listen({ host: "127.0.0.1", port: 8012 });After
import Fastify from "fastify";
import { chio } from "@chio-protocol/fastify";
const fastify = Fastify({ logger: false });
await fastify.register(chio, {
sidecarUrl: process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
skip: ["/healthz"],
});
fastify.get("/healthz", async () => ({ status: "ok" }));
fastify.get("/hello", async (request) => ({
message: "hello from fastify",
receipt_id: request.chioResult?.receipt.id ?? null,
}));
fastify.post("/echo", async (request) => {
const payload = request.body ?? {};
return {
...(typeof payload === "object" && payload !== null ? payload : { payload }),
receipt_id: request.chioResult?.receipt.id ?? null,
};
});
const port = Number(process.env["HELLO_FASTIFY_PORT"] ?? "8012");
await fastify.listen({ host: "127.0.0.1", port });Fastify body parsing happens automatically. The plugin reads the body from Fastify's pipeline rather than buffering separately, so nothing leaks into request.rawBody the way it does in Express.
Elysia (Bun)
Elysia is a Bun-first router. The @chio-protocol/elysia plugin is header-first: rather than mutating context, it sets an X-Chio-Receipt-style header on the response.
After
import http from "node:http";
import { Elysia } from "elysia";
import { chio } from "@chio-protocol/elysia";
const port = Number(process.env["HELLO_ELYSIA_PORT"] ?? "8014");
const app = new Elysia()
.use(
chio({
sidecarUrl: process.env["CHIO_SIDECAR_URL"] ?? "http://127.0.0.1:9090",
skip: ["/healthz"],
}),
)
.get("/healthz", () => ({ status: "ok" }))
.get("/hello", () => ({ message: "hello from elysia" }))
.post("/echo", ({ body }) => {
const payload = typeof body === "object" && body !== null ? body : {};
return payload;
});
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1:" + port}`);
const bodyChunks = [];
for await (const chunk of req) {
bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const request = new Request(url, {
method: req.method ?? "GET",
headers: req.headers,
body: bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : undefined,
});
const response = await app.handle(request);
res.statusCode = response.status;
response.headers.forEach((value, key) => res.setHeader(key, value));
res.end(Buffer.from(await response.arrayBuffer()));
});
server.listen(port, "127.0.0.1");The example uses Node's built-in http server to host the Elysia handler, which keeps the example runnable under both Node and Bun. The Chio plugin is identical either way.
Success criteria
All three smokes drive the same shape: a safe GET /hello, a denied POST /echo, then an allowed POST /echo with a capability token. Each Python assertion block below is the exact pass/fail contract.
hello-express
# GET /hello
assert body["message"] == "hello from express"
assert body["receipt_id"]
# POST /echo without capability
assert body["error"] == "chio_access_denied"
assert body["receipt_id"]
# POST /echo with X-Chio-Capability header
assert body["message"] == "hello"
assert body["count"] == 2
assert body["receipt_id"]
assert body["has_raw_body"] is Truehello-fastify
# GET /hello
assert body["message"] == "hello from fastify"
assert body["receipt_id"]
# POST /echo without capability
assert body["error"] == "chio_access_denied"
assert body["receipt_id"]
# POST /echo with ?chio_capability=<urlencoded token>
assert body["message"] == "hello"
assert body["count"] == 2
assert body["receipt_id"]Note that hello-fastify passes the capability via a query string parameter rather than a header, exercising the fallback wiring path.
hello-elysia
# GET /hello
assert body["message"] == "hello from elysia"
# POST /echo without capability
assert body["error"] == "chio_access_denied"
assert body["receipt_id"]
# POST /echo with X-Chio-Capability header
assert body["message"] == "hello"
assert body["count"] == 2Elysia surfaces the receipt as a response header rather than a body field, so the success-message printout reads x-chio-receipt-id from .artifacts/.../allow.headers.
Inspect after
Each smoke writes a timestamped artifact directory at examples/hello-<framework>/.artifacts/<ts>/. The trio below works for all three Node examples.
ART=$(ls -1d examples/hello-express/.artifacts/*/ | tail -n1)
# 1. The receipt id from the allowed echo response body
cat "$ART/allow.json" | jq .receipt_id
# expected: "rcpt_01J..." (non-empty string)
# 2. The same receipt id on the response header
grep -i "^x-chio-receipt-id:" "$ART/allow.headers"
# expected: x-chio-receipt-id: rcpt_01J...
# 3. The persisted receipt store from the sidecar
sqlite3 "$ART/state/sidecar-receipts.sqlite3" \
"select id, decision, route_pattern from receipts order by created_at desc limit 5;"
# expected: 3 rows, decisions = "Allow"/"Deny", route_pattern values include /hello and /echo
# 4. NDJSON receipt list, same content as the SQLite store
head -n 3 "$ART/receipts.ndjson" | jq -c '{id, decision, route_pattern}'
# expected: three compact JSON lines, one per call
# 5. App-side log line for the allow request
grep -E "POST /echo .* 200" "$ART/logs/app.log"
# expected: at least one line for the allowed POST /echoFor Fastify, swap examples/hello-express for examples/hello-fastify. For Elysia, the body of allow.json does not carry receipt_id; read it from allow.headers instead.
Critical-path request and response
Express, with a capability token. The same shape works for Fastify (header form) and Elysia (header response).
# → request (truncated capability token)
POST /echo HTTP/1.1
Host: 127.0.0.1:8011
Content-Type: application/json
X-Chio-Capability: chio.cap.v1.eyJzdWIiOiJhZ2VudC0...
Content-Length: 30
{"message":"hello","count":2}# ← response
HTTP/1.1 200 OK
Content-Type: application/json
x-chio-receipt-id: rcpt_01JABC...
Content-Length: 92
{"message":"hello","count":2,"receipt_id":"rcpt_01JABC...","has_raw_body":true}How the three differ
| Aspect | Express | Fastify | Elysia |
|---|---|---|---|
| Registration | app.use(chio(...)) | await fastify.register(chio, ...) | .use(chio(...)) |
| Receipt access | req.chioResult.receipt.id | request.chioResult.receipt.id | Response header |
| Body buffering | Plugin buffers; req.rawBody exposed | Reads from Fastify pipeline | Reads from Request object |
| Error handling | chioErrorHandler registered last | Built-in plugin error path | Built-in plugin error path |
| Async pattern | Sync handlers fine | async handlers required | async handlers fine |
Next
- HTTP Framework Middleware: the in-process pattern these plugins implement.
- Protect an API: the zero-code reverse-proxy alternative.
- Python HTTP Frameworks: FastAPI and Django with the same contract.
- Examples Overview