Package Exports
- midwinter
- midwinter/routing
- midwinter/validation
Readme
Midwinter.js
Midwinter.js is a powerful, experimental middleware engine which allows middleware to define imperative code, metadata and type information, enabling a much smarter plugin system.
It is specifically built for WinterCG runtimes, hence the name.
npm i midwinterOverview
Most middleware is purely imperative. To understand what middleware does, we must either manually trace the code, or actually run the code itself.
Midwinter enables middleware to declare it's effects via metadata (for static information) and types (for how the request context changes over time).
This simple – but very powerful – paradigm means plugins (like routing, validation) can truly extend upon the simple core of Midwinter, as opposed to merely hacking atop it like traditional middleware.
Basic Usage
const mid = new Midwinter();
const handler = mid
.use((req, ctx) => {
return { userId: "123" }; // Update the request context
})
.end((req, ctx) => {
// .end middleware pipeline
return Response.json({ userId: ctx.userId });
});
const response = await handler(new Request(opts));
// ^?
// (req: Request) => Promise<Response>Table of Contents
-
2.1. Official Plugins
..2.1.1. Validation
..2.1.2. Routing
Introduction
How does Midwinter work?
Midwinter is a middleware engine which creates pipelines of middleware. This enables building fully-fledged backend apps in any modern runtime environment.
With Midwinter, everything is middleware and all middleware can optionally provide clues about how it modifies our application.
For changes that occur during the request lifecycle, middleware can inform us how it might change the request context. For changes to the application as a whole, metadata can be specified.
Any given middleware can provide any number of:
- Request/response handling
- Request context updates
- Metadata updates
Downstream middleware can utilise the updated request context. Plugins can utilise metadata to extend functionality. Both occur with full type-safety, enabling both programmatic and type-level introspection and processing.
In turn, framework-level functionality can be provided as a meagre plugin.
Key Concepts
Request Context
The request context represents how our app changes over the lifetime of a request. Using Midwinter, the changes to this context are automatically inferred, and can be explicitly defined if necessary.
e.g. etermining the current user and adding to the request context
Metadata
Middleware can also register information that doesn't depend on the request lifecycle, in the form of metadata. For example, a given middleware could provide metadata about OpenAPI validation schema, to trivially enable client-side types.
e.g specifying the path, method or validation schema for a request handler, for later use with a routing or validation plugin.
Middleware Guide
Midwinter intentionally aims for a minimal, unopinionated API surface. As such, middleware are regular basic functions built on WinterCG web standards.
const isAuthed = (req: Request) => {
if (getUser() == null) {
return Response.json({ code: "UNAUTHORISED" }, { status: 401 });
}
}; // optionally: `satisfies Middleware`This can then be used to create a basic middleware pipeline.
const handleRequest = new Midwinter()
.use(isAuthed) // Using our simple function
.end((req, ctx) => {
return Response.json({ ok: true });
});To avoid having to explicitly define the parameter types, we should use the .use and .define methods instead.
const mid = new Midwinter();
const isAuthed = mid.define((req, ctx, meta) => {
if (getUser() == null) {
return Response.json({ code: "UNAUTHORISED" }, { status: 401 });
}
});From here on we'll use these.
Return value behaviour
Expand
Middleware often needs to update the request context,return early responses and observe/modify outbound responses. Midwinter achieves this using a functional style, relying on the value returned by a middleware function.
| Object | Update request context (shallowly) |
| Function | Registers a "response listener" (outbound middleware) |
| Response | Return the response, passing through any response listeners defined upstream |
Note that response listeners are executed in reverse or "inside-out" order
On top of being convenient and simple, relying on return type maintains type-safety across our middleware pipeline by simply inferring what request context updates our middleware makes. In turn, we can avoid entire classes of errors and work, knowing what data does (not) exist at a given point.
These three options look something like:
mid.define(() => {
if (withUpdate) {
return { foo: "bar" };
}
if (withResponse) {
return Response.json({ early: true });
}
return (res: Response) => {
// Optionally return a modified response
return new Response(res.body, { status: 301 });
};
});Request Context
Expand
The second argument Midwinter passes to any middleware is the request context. By default, the middleware instance we're extending from will populate the ctx type, but we can also override this.
const ipLogger = mid.define((req, ctx: { ip: boolean }) => {
// Ctx type has been override here
console.log(ctx.ip);
});If we try to .use this middleware somewhere ip is not yet part of the context, we will get a typescript error.
The third parameter is the middleware meta, which cannot be mutated.
Updating the request context
Via return value
We can return a simple JavaScript object to indicate a context update.
const withReqTime = mid.define((req, ctx) => {
return { start: new Date() };
});
new Midwinter()
.use(withReqTime) //
.use((req, ctx) => {
// Access updated context
ctx.start instanceof Date; // true
});Via mutation
Much of the time, we only need to return one of the above three return value possibilities: object, function or response.
While returning both a response and response listener is redundant, we may still want to update the request context during these two cases. To do so, we can mutate the request context directly.
const withReqTime = mid.define<{ start: number }>((req, ctx) => {
const start = Date.now()
// Update context
ctx.start = start
// Returning a response listener
return () => {
const end = Date.now()
console.log("Took" start - end)
};
});In this example, the type has been explicitly provided, which maintains type-safety. However, this is entirely optional.
Listening to responses
Expand
Returning a function enables listening to and modifying the outbound response. The function takes a single argument: the response object.
mid.define(() => {
return (res: Response) => {
// TODO: Return a new response... or not
};
});We can modify the response object by returning a new one. Bear in mind both Request and Response, as per the WinterCG standard, are immutable, so you should make use of the .clone() method when applicable.
Chaining
Expand
Midwinter makes heavy use of chaining and TypeScript inference which helps to compose complex middleware pipelines using simple code, and to string our applications together at the type-level to surface issues before running anything, like trying to access missing data from the request context.
const adminRoute = mid
.use(withAuth) // 1
.use(isAdmin); // 2
const superAdminRoute = isAdminMid // Reuse this pipeline
.use(hasEmailDomain("@test.com")); // 3By chaining we can extend middleware pipelines easily and create complex pathways for a request to travel through.
Each time we chain middleware a new instance is returned, meaning each pipeline is independent and changing one will not change any others.
Ending a pipeline: returning a response
Expand
To officially end a middleware pipeline, and thus return a request handler we can run, we use the .end method. This is is similar to .use, but must return a Response object.
Instead of a new Midwinter instance being returned, .end results in a request handler function that accepts a Request and returns a Response promise.
const handle = mid
.use(withA)
.use(withB)
.use(withC)
.end((req, ctx, meta) => {
return Response.json({ ok: true });
});
// Invoke the handler like:
const response = await handle(request);Metadata
Metadata enables middleware to "decorate" our backend apps in powerful ways that traditional middleware can't.
To define middleware metadata, we can use the .define method, passing a second argument.
const withName = (name: string) => {
const meta = { name };
return mid.define(
() => {},
// Pass any meta here
meta // <--
);
};If we use this middeware, our resulting request handler will possess this metadata.
const handle = mid
.use(withName("getUser")) // <--
.end(() => {
// ...
});
handle.meta.name === "getUserThis approach to metadata enables plugins, or own code, to fully "see" our app, both programmatically and type-wise. This feature enables a very simple and powerful paradigm for plugins.
Metadata merging
Like request context, metadata is shallowly merged,
Plugins
In Midwinter, "plugins" (as opposed to regular middleware), broadly refers to a set of interacting middleware or functionality that operates on request handlers.
For example, a plugin might add some metadata to a middleware pipeline and then access that metadata elsewhere, down the line.
Below are some examples.
Official Plugins
Midwinter is both simple and powerful. Therefore, core backend functionality (like routing) is able to be provided as a plugin, without any concessions.
Midwinter provides several official plugins, but even these core parts can be swapped for any third-party or custom approaches one might prefer more.
More official plugins will be added in coming months. Feel free to open a PR for any requests.
Routing
Expand
Routing is central to any backend app. However, with the advent of full-stack frameworks and file-system routing, not all apps need an explicit router.
For those who do, this plugin enables a flexible routing solution.
In short, the routing plugin turns a list of request handlers into an actual "app".
Setup
import * as Routing from "midwinter/routing";
export const { router, routing } = Routing.init(opts);routing
The routing function is a middleware that adds routing-related metadata to a route.
const handle = mid
.use(
routing({
path: "/user",
method: "/get",
})
)
.end(() => {});We can optionally specify a prefix which will continue along any subsequent middleware pipelines.
const apiRoute = mid.use(
routing({
prefix: "/api/v1",
})
);
const getPost = apiRoute
.use(routing({
path: "/post/:id",
method: "get
}))
.end(() => {
// ...
})router
To actually instantiate our app router, we use the router function.
const app = router([getPost, ...others], opts);
// Call app router on new request:
const response = await app(request);Validation
Expand
Input and output validation is table stakes for any serious backend app. While we can always imperatively validate data, the validation plugin enables a declarative API that is more concise.
By virtue of being declarative, the validation plugin also facilitates opportunities for easily inferring types or generating specs like OpenAPI.
The validation plugin works with most popular schema validation libraries out of the box. The below examples are using zod.
Setup
import * as Validation from "midwinter/validation";
export const { valid, validLazy, output } = Validation.init(opts);valid
The valid function enables validating various input and outputs of your backend.
const Schema = z.object({
// ...
});
mid
.use(
valid({
// All possible fields:
Query: Schema,
Params: Schema,
Headers: Schema,
Body: Schema,
Output: Schema,
})
)
.end((req, ctx) => {
const { query, params, headers, body } = ctx;
});With valid, all components are pre-parsed and added to the context.
Note that
Outputdoes not parse the response.body. See below.
validLazy
In contrast to valid, validLazy does not pre-parse anything. Instead a parsing function is added to the context, offering greater flexibility over how parsing is handled and what to parse.
mid
.use(
validLazy({
// ...same options
})
)
.end(async (req, ctx) => {
const { parse } = ctx;
// Parse a single part
const query = await parse("query");
// or parse all parts
const { query, params, body, headers } = await parse();
});output
At a minimum, the output function allows us to return a value, which will then get packaged into a JSON response.
If we've specified an Output schema, our return value will be validated against this.
mid
.use(
valid({
Output: z.object({ foo: z.string() }), // <-- Optionally specify output schema here
})
)
.end(
output((req, ctx) => {
return { foo: "bar" }; // <-- Will be parsed and returned as a JSON Response
})
);API Reference
The API surface is intentionally very concise, comprised of only three methods.
.define(middleware)
Define a reusable middleware
Define a simple auth middleware
const mid = new Midwinter();
const withAuth = mid.define((req, ctx) => {
if (isAuthed) {
return { user: { id: "123" } }; // Return ctx updates
}
// Early return with a response
return Response.json({ error: true });
});Manually specify the context updates type
const withMutation = mid.define<{ wasMutated: boolean }>((req, ctx) => {
ctx.wasMutated = true;
});Chaining middleware
const withAuthAndMutation = withAuth.use(withMutation);.use(middleware)
Register a middleware to a middleware pipeline
Use predefined middleware
const getPostMid = mid.use(withAuth).end((req, ctx) => {
ctx.user; // defined
});Use inline middleware
const getPost = mid
.use((req, ctx) => {
return { user };
})
.end((req, ctx) => {
ctx.user; // defined
});Inline middleware with mutations
.use<{ wasMutated: boolean }>((req, ctx) => {
ctx.wasMutated = true;
});.end(middleware)
Use a middleware that must return a response
// Respond to the request and terminate the `.use` chain
const getPost = mid
// ...snip
.end((req, ctx) => {
return new Response();
});
// Use the request handler however you want
const response = await getPost(new Request(url));