Node, Express, Fastify and Koa
Run withBetterSupabase on node:http, Express, Fastify or Koa, with route guards and Problem Details errors.
better-supabase/node bridges Node's IncomingMessage and ServerResponse
to the Web Request and Response the entries run on. It types Express,
Fastify and Koa structurally, so it adds no dependency.
node:http
toNodeHandler(entries, handler) returns a request listener. The entries run
around the handler, so refreshed session cookies, withDbStats totals and
Server-Timing reach the client. The handler's value becomes the response (a
plain value as JSON):
import { createServer } from "node:http";
import { toNodeHandler } from "better-supabase/node";
import {
createServer as createBetterServer,
withBetterSupabase,
} from "better-supabase/server";
import { betterSupabase } from "./lib/supabase";
const bs = createBetterServer(betterSupabase);
createServer(
toNodeHandler(
[withBetterSupabase(bs, { allow: ["user"] })],
(request, { db }) =>
db.notes.findMany({ select: ["id", "title"] }).orThrow(),
),
).listen(3000);Express
toExpress(entries) is middleware that runs before the routes. A refusal from
the entries (a 401, a CORS preflight) is the answer; otherwise the entries'
cookies and headers go on the response and every contribution lands on
res.locals. guard(options) refuses callers per route, and
problemErrorHandler() answers errors thrown by .orThrow() with Problem
Details:
import express from "express";
import { guard, problemErrorHandler, toExpress } from "better-supabase/node";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "./lib/server";
const app = express();
app.use(
toExpress([
withBetterSupabase(bs, { refresh: true, allow: ["user", "anon"] }),
]),
);
app.get("/notes", async (req, res) => {
res.json(await res.locals.db.notes.findMany().orThrow());
});
app.get("/admin/members", guard({ roles: ["admin"] }), async (req, res) => {
res.json(await res.locals.db.members.findMany().orThrow());
});
app.use(problemErrorHandler());Guards take the same options as every adapter: allow, aal, scopes,
roles and roleClaim, requireTenant, permission, authorize, and the
signIn and mfa redirects for browser routes.
A permission needs an authorizer, passed on
the guard. It is checked after requireTenant and roles and before
authorize, with { type: "route" } as the resource. A guard with a
permission and no authorizer refuses every caller:
app.get(
"/reports",
guard({ permission: "reports:read", authorizer }),
async (req, res) => {
res.json(await res.locals.db.reports.findMany().orThrow());
},
);fastifyGuard and koaGuard take the same two options.
Express types res.locals as a record of any. Extend its Locals
interface with BetterSupabaseContributions once, and res.locals.db is
typed by your schema:
import type { BetterSupabaseContributions } from "better-supabase/server";
import type { Functions, Models } from "./lib/supabase/index.ts";
declare global {
namespace Express {
interface Locals extends BetterSupabaseContributions<
Models,
Functions,
unknown
> {}
}
}Express 5 sends a rejected promise from an async route to the error
handler, so problemErrorHandler() answers .orThrow() failures without a
wrapper. The Express example
puts these together, with a smoke test against the local stack.
Fastify
toFastify(entries) is an onRequest hook that puts the contributions on
request.locals, and fastifyGuard(options) is a preHandler:
import Fastify from "fastify";
import { fastifyGuard, toFastify } from "better-supabase/node";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "./lib/server";
const app = Fastify();
app.decorateRequest("locals", null);
app.addHook(
"onRequest",
toFastify([withBetterSupabase(bs, { allow: ["user"] })]),
);
app.get("/notes", (request) => request.locals.db.notes.findMany().orThrow());
app.get(
"/admin/members",
{ preHandler: fastifyGuard({ roles: ["admin"] }) },
(request) => request.locals.db.members.findMany().orThrow(),
);Koa
toKoa(entries) puts the contributions on ctx.state, awaits the rest of the
chain and answers thrown DbExceptions with Problem Details. koaGuard(options)
refuses callers on the routes it guards:
import Koa from "koa";
import { koaGuard, toKoa } from "better-supabase/node";
import { withBetterSupabase } from "better-supabase/server";
import { bs } from "./lib/server";
const app = new Koa();
app.use(toKoa([withBetterSupabase(bs, { allow: ["user"] })]));
app.use(koaGuard({ roles: ["admin"] }));
app.use(async (ctx) => {
ctx.body = await ctx.state.db.members.findMany().orThrow();
});What runs where
Express, Fastify and Koa own the response, so their middleware runs the
entries before the route and copies the entries' headers onto the framework's
response. Entries that read the final response (withDbStats,
withServerTiming) see an empty one there; serve those routes with
toNodeHandler to measure them.
The bridge reads the URL from Express's originalUrl, so a router mounted at a
path still sees the full path. Pass trustProxy: true behind a proxy you
control to read the protocol and host from x-forwarded-proto and
x-forwarded-host. The middleware leaves the request body unread, so body
parsers run as usual. toWebRequest and sendWebResponse are exported for
other Node frameworks.
Last updated on