@nxara/olio-http
Serve an olio app over HTTP, from any framework. It runs graphql-http, the reference GraphQL-over-HTTP implementation, and adds context, error masking and per-operation cleanup. Subscriptions aren’t included: you serve them with the libraries you choose (see Subscriptions).
Install
Section titled “Install”bun add @nxara/olio-http graphql@16graphql 16 is a peer dependency; graphql-http is its only third-party dependency.
Adapters
Section titled “Adapters”| Import | For |
|---|---|
@nxara/olio-http/fetch |
Anything that speaks the Fetch API: Hono, Elysia, Bun.serve, Deno, Cloudflare Workers, Next.js route handlers, SvelteKit, Remix |
@nxara/olio-http/node |
Node’s IncomingMessage / ServerResponse: node:http, Express, Connect, Koa (ctx.req, ctx.res), Fastify (request.raw, reply.raw) |
Your context hook builds ctx from the framework’s request: a Fetch Request, or a Node IncomingMessage. Once your app registers a ctx type (interface Olio { ctx: … }), the hook is required. Without it, graphql-http passes no context, and every resolver reading ctx would fail at runtime. So fetchHandler(app) is then a compile error (Expected 2 arguments), as is an options object without context.
import { fetchHandler } from "@nxara/olio-http/fetch";
const graphql = fetchHandler(app, { context: (request) => createContext(request.headers.get("authorization")), onOperationEnd: (ctx) => ctx.di.dispose()});
hono.on(["GET", "POST"], "/graphql", (c) => graphql(c.req.raw)); // HonoBun.serve({ fetch: graphql }); // Bunimport { nodeHandler } from "@nxara/olio-http/node";
const graphql = nodeHandler(app, { context: (req) => createContext(req.headers.authorization)});createServer(graphql); // node:httpexpressApp.use("/graphql", graphql); // Express, ConnectkoaApp.use((ctx) => graphql(ctx.req, ctx.res, ctx.request.body)); // Koa (set ctx.respond = false)fastifyApp.all("/graphql", async (request, reply) => { await graphql(request.raw, reply.raw, request.body); // Fastify return reply.hijack();});The Node handler reads the request body itself, unless a body parser got there first. Then it uses the parsed body: from req.body (Express’s express.json(), Connect’s body parsers) or from the third argument, for frameworks that keep it elsewhere (Fastify’s request.body, Koa’s ctx.request.body). If the body was read and isn’t passed, the request fails at once with a 500, and onUnexpectedError gets an error saying so.
Subscriptions
Section titled “Subscriptions”Like Apollo Server, @nxara/olio-http leaves subscriptions to you. Install and run the reference libraries yourself, at the versions you want, next to the HTTP handler:
- graphql-ws: subscriptions over WebSocket (
useServeron awsserver,makeHandleron Bun). - graphql-sse: subscriptions as server-sent events, on the same URL (route
Accept: text/event-streamto its handler).
Pass them app.schema. errorMasker hides their unexpected errors the way the HTTP handler does, and validateOperation applies the same validation rules. Here are both, with a context (and DI scope) per subscription that is disposed when the subscription ends:
import type { SubscriptionContext } from "@nxara/olio";import { errorMasker, validateOperation } from "@nxara/olio-http";import { createHandler } from "graphql-sse/lib/use/fetch";import type { ServerOptions } from "graphql-ws";import { useServer } from "graphql-ws/use/ws";import { WebSocketServer } from "ws";
const mask = errorMasker<SubscriptionContext>();
// graphql-sse: route requests with `Accept: text/event-stream` here, GET and// POST (what @nxara/olio-client sends; single-connection mode also needs PUT and DELETE).export const sseHandler = createHandler({ schema: app.schema, context: (req) => createContext(req.raw.headers.get("authorization")) as never, onSubscribe: (_req, params) => { const errors = validateOperation(app.schema, params, validationRules); return errors.length ? { data: null, errors: [...errors] } : undefined; // needs `data` }, onNext: (ctx, _req, result) => mask.result(result, ctx as unknown as SubscriptionContext), onComplete: (ctx) => (ctx as unknown as SubscriptionContext).di.dispose()});
// graphql-ws, on a `ws` server: `context` runs once per subscription, so the// scope is per operation; keep it by connection and id to end it.const live = new WeakMap<object, Map<string, SubscriptionContext>>();// Exported for testClient (see the note below).export const wsOptions: ServerOptions = { schema: app.schema, context: (ctx, id) => { // A browser can't set WebSocket headers: @nxara/olio-client sends them here. const headers = ctx.connectionParams?.headers as | Record<string, string> | undefined; const value = createContext(headers?.authorization); live.set(ctx, (live.get(ctx) ?? new Map()).set(id, value)); return value as never; }, onSubscribe: (_ctx, _id, payload) => { const errors = validateOperation(app.schema, payload, validationRules); return errors.length ? errors : undefined; }, onNext: (ctx, id, _payload, _args, result) => mask.result(result, live.get(ctx)?.get(id)), onError: (ctx, id, _payload, errors) => errors.map((e) => mask.error(e, live.get(ctx)?.get(id)).toJSON()), // Also runs when the socket closes mid-subscription (see the note below). onComplete: async (ctx, id) => { const value = live.get(ctx)?.get(id); live.get(ctx)?.delete(id); await value?.di.dispose(); }};useServer(wsOptions, new WebSocketServer({ server, path: "/graphql" }));When a socket closes, graphql-ws ends each live subscription and waits for it before onComplete. A resolver that’s an async * waiting inside for await can only end at its next event, so nothing is cleaned up until one arrives. Return the event source’s own iterator instead (see Subscriptions in olio’s docs).
serveris thenode:httpserver the HTTP handler runs on, so both share a port. With@hono/node-server,serve()is typed to return HTTP/2 servers too: passserver as Server(fromnode:http) toWebSocketServer.- Tests:
testClientfrom@nxara/olio-client/testruns these same options and handler in memory, which is whywsOptionsandsseHandlerare exported. - A running version:
apps/example-server/src/subscriptions.tsis all of this, running.
Refusing a bad token
Section titled “Refusing a bad token”Don’t throw from context. A throw there refuses the whole request before any field runs, so a client still holding an expired token can’t even call login to get a new one. Instead, build a context without a user and refuse in middleware. Public fields skip that middleware with @bypass (see Skipping middleware in olio’s docs):
fetchHandler(app, { // An invalid or expired token gives `userId: undefined`, not an error. context: (request) => ({ userId: verify(request.headers.get("authorization")) })});
const requireAuth = (): MiddlewareFn => ({ ctx, bypass }, next) => { if (!ctx.userId && !bypass.has("auth")) throw unauthenticated(); return next(); };It’s also the only way to give clients the same UNAUTHENTICATED result, at once, on every transport. Over subscriptions, a context that throws is a server error to both libraries: graphql-ws closes the socket with 4500 (the error’s code is lost), and graphql-sse’s handler throws (your framework answers 500). Refusing the socket in graphql-ws’s onConnect closes it with 4403, which graphql-ws’s client retries by default (see @nxara/olio-client).
Validation rules
Section titled “Validation rules”validationRules run with graphql’s own rules, before any resolver: a cost or depth limit, for example. They’re added to graphql’s rules, never used in their place. Pass a list, or a function of the operation ({ variables, operationName }) for rules that need its variables, like graphql-query-complexity pricing a page by its size:
import { createComplexityRule, fieldExtensionsEstimator, simpleEstimator} from "graphql-query-complexity";
fetchHandler(app, { context, validationRules: ({ variables }) => [ createComplexityRule({ maximumComplexity: 1000, variables, estimators: [ fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 }) ] }) ]});fieldExtensionsEstimator reads each field’s complexity extension, which olio sets from the resolver’s { resolve, extensions } (see Field extensions in olio’s docs). An operation over the limit gets the rule’s error, and nothing runs.
Subscription servers take the same rules through validateOperation(schema, operation, rules) in their onSubscribe; it returns the errors (none when the operation is valid):
import { validateOperation } from "@nxara/olio-http";
// graphql-wsonSubscribe: (_ctx, _id, payload) => { const errors = validateOperation(app.schema, payload, validationRules); return errors.length ? errors : undefined;};// graphql-sse: a result needs `data` to be read as oneonSubscribe: (_req, params) => { const errors = validateOperation(app.schema, params, validationRules); return errors.length ? { data: null, errors: [...errors] } : undefined;};Errors
Section titled “Errors”Only GraphQLErrors reach clients. Anything else that’s thrown, such as a failing database or a bug, is replaced by "Unexpected error." and passed to onUnexpectedError (default: console.error), so it isn’t lost.
That includes a context hook that throws. A GraphQLError from it (say, BAD_REQUEST for a malformed header) reaches the client as a normal GraphQL error with status 200; anything else is a 500 with “Unexpected error.”. Either way it refuses the whole request, so a bad token belongs in middleware instead (see Refusing a bad token).
throw new GraphQLError("Forbidden", { extensions: { code: "FORBIDDEN" } }); // the client sees thisthrow new Error("connection refused"); // the client sees "Unexpected error."onUnexpectedError(error, { ctx, request }) gets what the error happened in, so logs can tie it to a request. ctx is the operation’s context (undefined when the context hook itself failed), and request is the adapter’s (a Fetch Request, or Node’s IncomingMessage).
fetchHandler(app, { context, onUnexpectedError: (error, { ctx, request }) => log.error({ error, requestId: ctx?.requestId, url: request.url })});errorMasker(options?), from @nxara/olio-http, is the same masking for your own subscription servers. It takes the handlers’ maskErrors and onUnexpectedError (both optional) and returns the functions below. Each takes the operation’s context as its last argument, which onUnexpectedError receives as ctx (request is undefined there). Type it with errorMasker<SubscriptionContext>():
mask.result(result, ctx?) |
The result with its errors masked, any shape: graphql’s ExecutionResult, or what graphql-sse’s and graphql-ws’s onNext hand you |
mask.error(error, ctx?) |
One error, or any caught value: a GraphQLError passes (unless it wraps an unexpected error), anything else becomes “Unexpected error.” and is reported |
mask.report(error, ctx?) |
Sends an error to onUnexpectedError |
import { errorMasker } from "@nxara/olio-http";
const mask = errorMasker<SubscriptionContext>();createHandler({ schema: app.schema, onNext: (ctx, _req, result) => mask.result(result, ctx)}); // graphql-sseOptions
Section titled “Options”| Option | Default | |
|---|---|---|
context |
none | Builds ctx from the request. Required once the app registers a ctx type |
validationRules |
none | Rules run with graphql’s own before execution: a list, or ({ variables, operationName }) => rules. See Validation rules |
onOperationEnd |
none | Called once per operation, after the response is built. Release per-operation resources here, e.g. (ctx) => ctx.di.dispose(). A failure in it goes to onUnexpectedError |
maskErrors |
true |
Hide errors that aren’t GraphQLErrors. Turn off only while developing |
onUnexpectedError |
console.error of the error |
(error, { ctx, request }) => void: receives each hidden error, with the context and request it happened in (ctx is undefined when the context hook failed) |
| graphql-http’s own | its defaults | execute, parse, validate, getOperationAST, rootValue, formatError (runs after masking) and parseRequestParams (e.g. persisted queries: map an id to its stored query) go to graphql-http as they are. Not onSubscribe: operation args returned from it would skip context and every validation rule |