Standard Servers

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.

logger.tsts
// 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.

Proposal 1

StandardContext

A standard way to read the incoming Request and prepare the outgoing Response. Frameworks can also expose runtime-specific data and waitUntil.

standard-contextts
/**
 * 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:

not-found.tsts
const std = c["~standard"];
std.res = { status: 404, body: "Not Found", headers: std.res.headers };
Proposal 2

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.

standard-middlewarets
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

Library author

Write middleware once

middleware.tsts
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();
};
Framework author

Expose ~standard on the context

H3's context already has the right shape, so it maps almost one to one:

h3ts
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:

honots
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:

elysiats
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); },
  },
}));
App developer

Use it in any framework

app.tsts
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.