One middleware, every framework
Write server utilities and middleware once, then run them on any Fetch-based framework.
The problem
Modern JavaScript server frameworks all speak the Fetch API: a
Request comes in, a Response goes out. But
each one wraps it in its own context or
event, so a utility written for one framework doesn't
work in the others.
// H3
app.use((event) => console.log(event.req.method, event.url.pathname));
// Hono
app.use(async (c, next) => {
console.log(c.req.method, c.req.path);
await next();
});
// Elysia
app.onRequest(({ request }) => {
console.log(request.method, new URL(request.url).pathname);
});
Same logger, written three times. Now multiply that by every library and every framework.
The idea
Inspired by Standard Schema,
frameworks keep their own context and add one property to it:
~standard.
Libraries read the request and shape the response through it, without caring which framework they're running in. Frameworks can adopt it without breaking anything.
StandardContext
A standard way to read the incoming Request and prepare
the outgoing Response. Frameworks can also expose
runtime-specific data and waitUntil.
/**
* The Standard Context type.
*
* @template C Type of the framework defined `context` or `event`.
* @template R Type of the JavaScript runtime.
* @template W Return type of `waitUntil`.
*/
export type StandardContext<C = {}, R = unknown, W = unknown> = C & {
/** The Standard Context properties. */
readonly "~standard": StandardContext.Props<R, W>;
};
export declare namespace StandardContext {
/**
* The Standard Context properties interface.
*
* @template R Type of the JavaScript runtime.
* @template W Return type of `waitUntil`.
*/
export interface Props<R = unknown, W = unknown> {
/** The name of the server framework. */
readonly framework: string;
/** Incoming web `Request`. */
readonly req: Request;
/**
* Prepared response.
*
* Its fields are not guaranteed to be writable. Assign a new object to replace it.
*/
res: PreparedResponse;
/**
* JavaScript runtime specific data.
*
* @example
*
* ```ts
* type Runtime = { node?: { req: IncomingMessage; res: ServerResponse } };
* ```
*/
readonly runtime?: R;
/**
* Wait until a promise has resolved.
*
* @param promise Promise to resolve before exit.
*/
readonly waitUntil?: (promise: Promise<any>) => W;
}
/**
* The Prepared Response properties interface.
*
* Fields are readonly so that a standard `Response` (whose properties are readonly) is a valid value.
*
* Frameworks may optionally provide a mutable object, but portable code should not rely on it.
*/
export interface PreparedResponse {
/** `Response` body. */
readonly body?: BodyInit | null;
/** `Response` status code. */
readonly status?: number;
/** `Response` headers. */
readonly headers?: Headers;
}
}
res describes the final response. Its fields are
readonly, so a standard Response is a valid value. To
change the response, assign a new object to res. The
headers object can still be changed in place:
const std = c["~standard"];
std.res = { status: 404, body: "Not Found", headers: std.res.headers };
StandardMiddleware
Built on top of StandardContext, a middleware is an async
function of (context, next). Frameworks compose a stack
of them like koa-compose:
code before await next() runs on the way in, code after
it runs on the way out.
import type { StandardContext } from "standard-context";
/**
* The Standard Next type.
*
* Any async function that runs the next middleware in the stack.
*
* @template Args Type of the optional arguments.
* @template R Type of the resolved value.
* @param args Optional arguments passed into `next`.
* @returns Promise that resolves when the next middleware has executed.
*/
export type StandardNext<Args extends unknown[] = [], T = unknown> = (...args: Args) => Promise<T>;
/**
* The Standard Middleware type.
*
* @template C Type of the context.
* @template N Type of the `next` function.
* @template R Type of the resolved value.
* @param context Context of the current request.
* @param next `next` function that calls the next middleware.
* @returns Resolved value.
*/
export type StandardMiddleware<
C extends StandardContext = StandardContext,
N extends StandardNext = StandardNext,
R = void,
> = (context: C, next: N) => Promise<R>;
How it fits together
Write middleware once
import type { StandardMiddleware } from "standard-middleware";
export const logger: StandardMiddleware = async (c, next) => {
await next();
const { req, res } = c["~standard"];
console.log(res.status ?? 200, req.method, req.url);
};
export const addFrameworkHeader: StandardMiddleware = async (c, next) => {
c["~standard"].res.headers?.set("x-framework", c["~standard"].framework);
await next();
};
Expose ~standard on the context
H3's context already has the right shape, so it maps almost one to one:
event["~standard"] = {
framework: "h3",
req: event.req,
res: event.res,
waitUntil: event.waitUntil,
};
Other frameworks can bridge their own APIs with getters and setters.
In Hono, c.res is a standard Response that's
replaced once the handler returns, so res reads and
replaces the current one:
c["~standard"] = {
framework: "hono",
req: c.req.raw,
get res() { return c.res; },
set res(r) { c.res = new Response(r.body, r); },
// c.executionCtx throws outside Workers, so read it lazily
waitUntil: (promise) => c.executionCtx.waitUntil(promise),
};
Elysia can add it with derive, which runs per request
after validation. Its response headers are a plain object and the
body is the handler's return value, so headers and
body would need support inside Elysia itself:
import { StatusMap } from "elysia";
app.derive(({ request, set }) => ({
"~standard": {
framework: "elysia",
req: request,
// set.status can be a number or a name, like "Created"
get res() { return { status: typeof set.status === "string" ? StatusMap[set.status] : set.status }; },
set res(r) { set.status = r.status; },
// Bun's server is long-lived, so nothing needs to be held open
waitUntil: (promise) => { promise.catch(console.error); },
},
}));
Use it in any framework
import { logger, addFrameworkHeader } from "some-standard-library";
app.use(logger, addFrameworkHeader);
Get involved
This is a starting point for a conversation between framework and library authors. Read the proposals, try the examples for H3, Hono and Remix, and tell us what you think.