@radically-straightforward/server v1.0.12
Radically Straightforward · Server
🦾 HTTP server in Node.js
Introduction
@radically-straightforward/server is a layer on top of Node.js’s HTTP server. The server() function is similar to http.createServer(), and we follow Node.js’s way of doing things as much as possible. You should familiarize yourself with how to create a server with Node.js to appreciate what @radically-straightforward/server provides—the rest of this documentation assumes that you have read Node.js’s documentation.
Here’s an overview of @radically-straightforward/server provides on top of Node.js’s http module:
Router: Simple to understand and powerful.
Request Parsing: Including file uploads.
Response Helpers: Set cookies with secure options by default, send redirect responses, and so forth.
Live Connection: Update a page with new contents without reloading (for better user experience) using server-side rendering (for better developer experience), detect that the user has internet connection, and much more.
Health Check: A simple but useful feature that’s built-in.
Image/Video/Audio Proxy: Avoid issues with mixed content and Content Security Policy.
CSRF Protection: It’s built-in.
Convenient Defaults: Logging of requests and responses, graceful termination, and so forth.
Installation
$ npm install @radically-straightforward/serverExample
import server from "@radically-straightforward/server";
import * as serverTypes from "@radically-straightforward/server";
import html from "@radically-straightforward/html";
// CSRF Protection is turned off to simplify this example. You should use `@radically-straightforward/javascript` with Live Navigation instead.
const application = server({ csrfProtectionExceptionPathname: new RegExp("") });
const messages = new Array<string>();
application.push({
method: "GET",
pathname: "/",
handler: (request, response) => {
response.end(html`
<!doctype html>
<html>
<head></head>
<body>
<h1>@radically-straightforward/server</h1>
<ul>
$${messages.map((message) => html`<li>${message}</li>`)}
</ul>
<form method="POST">
<input type="text" name="message" placeholder="Message…" required />
<button type="submit">Send</button>
</form>
</body>
</html>
`);
},
});
application.push({
method: "POST",
pathname: "/",
handler: (
request: serverTypes.Request<{}, {}, {}, { message: string }, {}>,
response,
) => {
if (
typeof request.body.message !== "string" ||
request.body.message.trim() === ""
)
throw "validation";
messages.push(request.body.message);
response.redirect();
},
});Visit http://localhost:18000.
Features
Router
Node.js’s http.createServer() expects one requestListener—a function which is capable of handling every kind of request that your server may ever receive. But typically it makes more sense to organize an application into multiple functions, which may even live in different files. For example, one function for the home page, another for the settings page, and so forth. And these functions should run only if the HTTP request satisfies some conditions, for example, the function for the settings page should run only if the HTTP method is GET and the pathname is /settings.
That’s what the @radically-straightforward/server router does: It allows you to define multiple functions that are called depending on the characteristics of the request.
See the Route type for more details.
Compared to Other Libraries
@radically-straightforward/server’s router is simpler: It’s an Array ofRoutes that are tested against the request one by one in order and that may or may not apply. A@radically-straightforward/serverapplication is more straightforward to understand than, for example, an application that uses Express’s nestedRouters and things likenext("route").At the same time,
@radically-straightforward/server’s router has features that other libraries lack, for example:
- When a route has finished running,
@radically-straightforward/serverchecks whether a response has been sent and stops subsequent routes from running. This prevents you from writing content to a response that has alreadyend()ed.- When every route has been considered,
@radically-straightforward/serverchecks whether the response hasn’t been sent and responds with an error. This prevents you from leaving a request without a response.Together, this means that
@radically-straightforward/serverdoes the right thing without you having to remember to callnext().Note: If you need to run code after the response has been sent (that is, code that would be below a call to
next()in an Express middleware), you should use Node.js’sresponse.once("close")event.Also,
@radically-straightforward/server’s routes support asynchronous functions, which is unsupported in Express version 4 (it’s supported in the 5 beta version).
Request Parsing
The Node.js http module only parses the request up to the point of distinguishing the headers from the body and separating the headers from one another. This is by design, to keep things flexible.
In @radically-straightforward/server we take request parsing some steps further, satisfying the needs of most web applications. We parse the request URL, cookies, body (including regular forms and file uploads), and so forth.
We also include an assortment of request helpers including a unique request identifier, a logger, and so forth.
See the Request type for more details.
Compared to Other Libraries
@radically-straightforward/serveris more batteries-included in this area, and it doesn’t require any configuration (consider, for example, Express’sapp.use(express.urlencoded({ extended: true }))).
Response Helpers
Send cookies and redirects with secure options by default.
See the Response type for more details.
Compared to Other Libraries
@radically-straightforward/serveroffers fewer settings and less sugar, for example, instead of Express’sresponse.json(___), you should use Node.js’sresponse.setHeader("Content-Type", "application/json; charset=utf-8").end(JSON.stringify(___)).
Live Connection
Live Connections are a simple but powerful solution to many typical problems in web applications, for example:
Update a page with new contents without reloading (for better user experience) using server-side rendering (for better developer experience).
Detect that the user has internet connection (or, more specifically, that the browser may connect to the server).
Register that a user is online.
Detect that a new version of the application has been deployed and a reload may be necessary.
In development, perform a reload when a file has been modified (something often called Live Reload in other tools).
And more…
Note: Use Live Connections with
@radically-straightforward/javascript, which implements the browser side of these features and subsumes many of the details below.
A Live Connection is a variation on a GET request in which the server doesn’t response.end(), but leaves the connection open and the browser waiting for more content. When there’s a change that requires an update on the page, the server runs the request and response through the routes again and sends the updated page to the browser through that connection.
From the perspective of the application developer this is advantageous because there’s a single source of truth for how to present a page to the user: the server-side rendered page. It’s as if the browser knew that a new version of a page is available and requested it. Also, in combination with @radically-straightforward/javascript only the part of the page that changed is touched (without the need for virtual DOMs, complex browser state management, and so forth).
To establish a Live Connection perform a GET request with the Live-Connection header set to the request.id of the request for the original page (or, failing that, to a random string which will become the request.id moving forward), for example:
await fetch(location.href, {
headers: { "Live-Connection": requestIdWhichWasObtainedInSomeWay },
});This changes the behavior of @radically-straightforward/server:
The
Content-Typeof the response is set toapplication/json-lines; charset=utf-8(JSON lines).You may not set headers or cookies (which includes not being able to manipulate user sessions).
response.end(___)doesn’t end the response, butresponse.write(___)s it in a new line of JSON, so that the browser stays connected and waiting for more content.Periodically a heartbeat (a newline without any JSON) is sent to keep the connection alive even when there are pieces of infrastructure that would otherwise close inactive connections, for example, a proxy on the user’s network.
Periodically an update is sent with a new version of the page (encoded as a line of JSON). On the server this is implemented by running the
requestandresponsethrough the routes again. On the browser there should be code to read the streaming response and render the new version of the page by applying the changes without reloading.You may trigger an immediate update by performing a request coming from the same machine in which the server is running with a method of
POSTat pathname/__live-connectionsincluding a form field calledpathnamewhich is a regular expression forpathnames that should receive an immediate update.A
request.liveConnectionproperty is set.
Note: If you’re running the server in multiple processes, then Live Connections requires the load balancer to have sticky sessions, because the management of Live Connections is stateful. That’s the default in
@radically-straightforward/caddy.
Example
import server from "@radically-straightforward/server";
import * as serverTypes from "@radically-straightforward/server";
const application = server();
application.push({
handler: (request, response) => {
if (request.liveConnection?.establish) {
// Here there could be, for example, a [`backgroundJob()`](https://github.com/radically-straightforward/radically-straightforward/tree/main/utilities#backgroundjob) which updates a timestamp of when a user has last been seen online.
if (request.liveConnection?.skipUpdateOnEstablish) response.end();
}
},
});
application.push({
method: "GET",
pathname: new RegExp("^/conversations/(?<conversationId>[0-9]+)$"),
handler: (request, response) => {
response.end(
`<!DOCTYPE html>
<html>
<head>
<script>
(async () => {
const responseBodyReader = (await fetch(location.href, { headers: { "Live-Connection": ${JSON.stringify(request.id)} } })).body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const value = (
await responseBodyReader.read().catch(() => ({ value: undefined }))
).value;
if (value === undefined) break;
console.log(value);
}
})();
</script>
</head>
<body>Live Connection: ${new Date().toISOString()}. Open the Developer Tools Console and see the updates arriving.</body>
</html>
`,
);
},
});Visit http://localhost:18000/conversations/10.
Send an immediate update with one of the following snippets:
await fetch("http://localhost:18000/__live-connections", {
method: "POST",
headers: { "CSRF-Protection": "true" },
body: new URLSearchParams({ pathname: "^/conversations/10$" }),
});$ curl --request POST --header "CSRF-Protection: true" --data "pathname=^/conversations/10$" "http://localhost:18000/__live-connections"Compared to Other Libraries
Some tools like Hotwire has similar concepts, but Live Connection as implemented in
@radically-straightforward/serveris a novel idea.A Live Connection is reminiscent of Server-Sent Events (SSE). Unfortunately SSEs are limited in features, for example, they don’t allow for sending custom headers (we need a
Live-Connectionheader to communicate back to the server therequest.idof the request for the original page, which avoids an immediate update upon establishing every connection). What’s more, SSEs don’t appear to receive much attention from browser implementors and are unlikely to receive new features.
Health Check
An endpoint at /_health to test whether the application is online. It may be used by @radically-straightforward/monitor, by Caddy’s active health checks, and so forth.
Compared to Other Libraries
Typically you either have to add a third-party library specifically to handle health checks, or you have to implement them yourself. In fairness, a health check is straightforward to implement, but it’s nice to have the server library take care of that for you, and it’s nice to have a predictable endpoint for the health check.
Image/Video/Audio Proxy
An endpoint at /_proxy?destination=<URL> (for example, /_proxy?destination=https%3A%2F%2Finteractive-examples.mdn.mozilla.net%2Fmedia%2Fcc0-images%2Fgrapefruit-slice-332-332.jpg) which proxies images, videos, and audios from other origins.
This is useful for content generated by users that includes images/videos/audios from third-party websites. It avoids issues with mixed content and Content Security Policy.
Compared to Other Libraries
Typically you either have to add a third-party library specifically to handle image/video/audio proxying, or you have to implement it yourself.
Note that the implementation in
@radically-straightforward/serveris very simple: it doesn’t resize images, reencode videos, and so forth; it doesn’t cache images/videos/audios to potentially speed things up and to prevent content from disappearing as third-party websites change; and so forth.
CSRF Protection
@radically-straightforward/server implements the simplest yet effective protection against CSRF: Requiring a custom request header for non-GET requests.
In your application:
Don’t let routes with method
GEThave side-effects.Ensure that all requests with methods other than
GET(for example,POST,PATCH,PUT,DELETE, and so forth) include a header ofCSRF-Protection: true. If you’re using regular HTML forms, we recommend using@radically-straightforward/javascript’s Live Navigation which already does this.If there are routes that really should not have CSRF protection, use
server()’scsrfProtectionExceptionPathnameoption.
Convenient Defaults
Logging: In the style of
@radically-straightforward/utilities’slog().Graceful Termination: Using
@radically-straightforward/node’s graceful termination.Automatic Management of Uploaded Files: When parsing the request, the uploaded files are put in a temporary directory, and if the application doesn’t move them to a permanent location, they’re automatically deleted after the response is sent.
Designed to Be Used with a Reverse Proxy (Caddy): A reverse proxy is essential in deploying a Node.js application. It provides HTTPS, HTTP/2 (and newer versions), load balancing between multiple server processes, static file serving, and so forth. Node.js could provide these features, but it’d be slower and clunkier at them.
@radically-straightforward/serveris designed to be used with@radically-straightforward/caddy, which entails the following:The server binds to
localhost(because Caddy runs on the same machine) and doesn’t respond to requests coming from other machines.The server trusts the
X-Forwarded-For,X-Forwarded-Proto, andX-Forwarded-Hostrequest headers, which normally could be spoofed but can be trusted because they’re set by Caddy.The server doesn’t support serving static files—it doesn’t have the equivalent of
express.static().
Request Size Limits and Timeouts:
Issue HTTP response status Handled by Headers too big 431 Node.js ( maxHeaderSize)Body too big 413 busboy ( headerPairs,fields,fieldNameSize,fieldSize,files, andfileSize)Headers timeout 408 Node.js ( headersTimeout)Body timeout 408 Node.js ( requestTimeout)
Usage
import server from "@radically-straightforward/server";
import * as serverTypes from "@radically-straightforward/server";Server
export type Server = ReturnType<typeof server>;A Server is an auxiliary type for convenience.
Route
export type Route = {
method?: string | RegExp;
pathname?: string | RegExp;
error?: boolean;
handler: (
request: Request<{}, {}, {}, {}, {}>,
response: Response,
) => void | Promise<void>;
};A Route is a combination of some conditions that the request must satisfy for the handler to be called, and the handler that produces a response. An application is an Array of Routes.
method: The HTTP request method, for example"GET"or/^PATCH|PUT$/.pathname: Thepathnamepart of the HTTP request. Named capturing groups are available in thehandlerunderrequest.pathname, for example, givenpathname: new RegExp("^/conversations/(?<conversationPublicId>[0-9]+)$"), theconversationPublicIdis available atrequest.pathname.conversationPublicId.error: Indicates that thishandlershould only be called if a previoushandlerthrew an exception.handler: The function that produces the response. It’s similar to a function that you’d provide tohttp.createServer()as arequestListener, with two differences: 1. Thehandleris called only if the request satisfies the conditions above; and 2. Therequestandresponseparameters are extended with extra functionality (seeRequestandResponse). Thehandlermay be synchronous or asynchronous.
Request
export type Request<Pathname, Search, Cookies, Body, State> =
http.IncomingMessage & {
id: string;
start: bigint;
log: (...messageParts: string[]) => void;
ip: string;
URL: URL;
pathname: Partial<Pathname>;
search: Partial<Search>;
cookies: Partial<Cookies>;
body: Partial<Body>;
state: Partial<State>;
getFlash: () => string | undefined;
error?: unknown;
liveConnection?: RequestLiveConnection;
};An extension of Node.js’s http.IncomingMessage with the following extra functionality:
id: A unique request identifier.start: A timestamp of when the request arrived.log: A logging function which includes information about the request and formats the message with@radically-straightforward/utilities’slog().ip: The IP address of the request originator as reported by Caddy (the reverse proxy) (uses theX-Forwarded-ForHTTP request header).URL: Therequest.urlparsed into aURLobject, including the appropriate protocol (uses theX-Forwarded-ProtoHTTP request header) and host (uses theX-Forwarded-Hostor theHostHTTP request header) as reported by Caddy.pathname: The variable parts of thepathnamepart of theURL, as defined in the named capturing groups of the regular expression from theroute’spathname. Note that this depends on user input, so it’s important to validate explicitly (the genericPathnamein TypeScript isPartial<>to encourage you to perform these validations).search: Thesearchpart of theURLparsed into an object. If a field name ends in[], for example,colors[], then multiple occurrences of the same field are captured into an array—this is useful for<input type="checkbox" />s with the samename. Note that this depends on user input, so it’s important to validate explicitly (the genericSearchin TypeScript isPartial<>to encourage you to perform these validations).cookies: The cookies sent via theCookieheader parsed into an object. Note that this depends on user input, so it’s important to validate explicitly (the genericCookiesin TypeScript isPartial<>to encourage you to perform these validations).body: The request body parsed into an object. Uses busboy. It supportsContent-Typesapplication/x-www-form-urlencoded(the default type of form submission in browsers) andmultipart/form-data(used for uploading files). Form fields become strings, and files becomeRequestBodyFileobjects. The files are saved to disk in a temporary directory and deleted after the response is sent—if you wish to keep the files you must move them to a permanent location. If a field name ends in[], for example,colors[], then multiple occurrences of the same field are captured into an array—this is useful for<input type="checkbox" />s with the samename, and for uploading multiple files. Note that this depends on user input, so it’s important to validate explicitly (the genericBodyin TypeScript isPartial<>to encourage you to perform these validations).state: An object to communicate state across multiplehandlers that handle the same request, for example, a handler may authenticate a user and set arequest.state.userproperty for subsequenthandlers to use. Note that the genericStatein TypeScript isPartial<>because the state may not be set depending on whichhandlers ran previously—you may either use runtime checks that the expectedstateis set, or use, for example,request.state.user!if you’re sure that the state is set by other means.getFlash(): Get a flash message that was set by a previousresponsethatsetFlash()and thenredirect()ed. This is useful, for example, for a message such as “User settings updated successfully.”error:In error handlers, this is the error that was thrown.Note: There’s an special kind of error that may be thrown, which is the string
"validation". This sets the HTTP response status to 422 instead of 500.liveConnection:If this is a Live Connection, then this property is set to aRequestLiveConnectioncontaining more information about the state of the Live Connection.
RequestBodyFile
export type RequestBodyFile = busboy.FileInfo & {
path: string;
};A type that may appear under elements of request.body which includes information about the file that was uploaded and the path in a temporary directory where you may find the file. The files are deleted after the response is sent—if you wish to keep them you must move them to a permanent location.
RequestLiveConnection
export type RequestLiveConnection = {
establish?: boolean;
skipUpdateOnEstablish?: boolean;
};Information about a Live Connection that is available under request.liveConnection.
establish: Whether the connection is just being established. In other words, whether it’s the first time that thehandlers are being called for this request. You may use this, for example, to start abackgroundJob()which updates a timestamp of when a user has last been seen online.skipUpdateOnEstablish: Whether it’s necessary to send an update with a new version of the page upon establishing the Live Connection. An update may be skipped if the page hasn’t been marked as modified since the last update was sent. You must only check this variable ifestablishistrue.
Response
export type Response = http.ServerResponse & {
setCookie: (key: string, value: string, maxAge?: number) => Response;
deleteCookie: (key: string) => Response;
setFlash: (message: string) => Response;
redirect: (
destination?: string,
type?: "see-other" | "temporary" | "permanent",
) => Response;
};An extension of Node.js’s http.ServerResponse with the following extra functionality:
Note: The extra functionality is only available in requests that are not Live Connections, because Live Connections must not set headers.
setCookie: Sets aSet-Cookieheader with secure settings. Also updates therequest.cookiesobject so that the new cookies are visible from within the request itself.Note: The noteworthy cookie settings are the following:
- The cookie name is prefixed with
__Host-. This assumes that the application is available under a single domain, and that the application is the only thing running on that domain (it can’t, for example, be mounted under a/my-application/pathname and share a domain with other applications). - The
SameSitecookie option is set toNone, which is necessary for things like SAML to work (for example, when the Identity Provider sends aPOSTrequest back to the application’s Assertion Consumer Service (ACS), the application needs the cookies to determine if there’s a previously established session).
- The cookie name is prefixed with
deleteCookie: Sets an expiredSet-Cookieheader without a value and with the same secure settings used bysetCookie. Also updates therequest.cookiesobject so that the new cookies are visible from within the request itself.setFlash(): Set a flash message that will be available to the nextrequestviagetFlash()(the nextrequesttypically is the result of aredirect()ion). This is useful, for example, for a message such as “User settings updated successfully.”redirect: Sends theLocationheader and an HTTP status of 303 ("see-other") (default), 307 ("temporary"), or 308 ("permanent"). Note that there are no options for the legacy statuses of 301 and 302, because they may lead some clients to change the HTTP method of the redirected request by mistake. Thedestinationparameter is relative torequest.URL, for example, if nodestinationis provided, then the default is to redirect to the samerequest.URL.
server()
export default function server({
port = 18000,
csrfProtectionExceptionPathname = "",
}: {
port?: number;
csrfProtectionExceptionPathname?: string | RegExp;
} = {}): Route[];An extension of Node.js’s http.createServer() which provides all the extra functionality of @radically-straightforward/server. Refer to the README for more information.
port: A port number for the server. By default it’s18000, which is well out of the range of most applications to avoid collisions.csrfProtectionExceptionPathname: Exceptions for the CSRF prevention mechanism. This may be, for example,new RegExp("^/saml/(?:assertion-consumer-service|single-logout-service)$")for applications that work as SAML Service Providers which include routes for Assertion Consumer Service (ACS) and Single Logout (SLO), because the Identity Provider makes the browser send these requests as cross-originPOSTs (but SAML includes other mechanisms to prevent CSRF in these situations).
Related Work
Server Libraries
- https://expressjs.com/
- https://fastify.dev/
- https://koajs.com/
- https://hono.dev/
- https://routup.net/
- https://itty.dev/itty-router
- https://github.com/lukeed/worktop
- And so forth…
For a feature-by-feature comparison, refer to the sections named Compared to Other Libraries.
In a nutshell, @radically-straightforward/server does less and more than other libraries. It does less in the sense of not including a templating language (use @radically-straightforward/html instead), having a more simpler router, and so forth. It does more in the sense of parsing requests including file uploads, Live Connections, CSRF protection, and so forth.
Also, @radically-straightforward/server follows a more didactic approach. It avoids Embedded Domain-Specific Languages (eDSL) (for example, Express’s .get("/")), in favor of a more explicit and flexible approach.
Live Connection
Live Connections were inspired by the projects above, but it’s conceptually simpler—it boils down to keeping a connection alive and re-running request and response through the application when an update is necessary.
We expect that Live Connections are interoperable in the sense that other libraries and frameworks, even those implemented in other programming languages, may implement a similar idea.
Server-Sent Events
The way Live Connections work is very similar (and inspired) by the stock browser functionality called Server-Sent Events. What sets them apart are:
We have more control over the HTTP request for a Live Connection. This is useful, for example, to include the
Live-ConnectionHTTP header which identifies the original request and allows the server to avoid a redundant update as soon as the connection is established.In Live Connection updates we use JSON lines instead of the more obscure
text/event-streamformat.We may see more HTTP features being available in Live Connections, whereas Server-Sent Events probably won’t see many improvements moving forward because it seems to be a low priority for browser developers. See https://github.com/whatwg/html/issues/2177.
We have observed some edge cases in which the Server-Sent Events connection was closed and not retried, while Live Connections retries are more reliable.
Image/Video/Audio Proxy
- https://github.com/atmos/camo
- https://github.com/imgproxy/imgproxy
- https://github.com/willnorris/imageproxy
- https://github.com/http-party/node-http-proxy
- https://github.com/chimurai/http-proxy-middleware
- https://github.com/cookpad/ecamo
- https://github.com/weserv/images
- https://github.com/jpmckinney/image-proxy
- https://github.com/sdepold/node-imageable
- https://github.com/marcjacobs1021/node-image-proxy
The projects above, particularly camo, were the inspiration for this feature. The differences are:
It’s a feature of the server library, instead of being a separate service to manage.
It’s simpler: It doesn’t implement a image resizer, video re-encoder, cache, and so forth.
It doesn’t support HMAC to guarantee that the requests came from the same origin and prevent abuse. Instead, it relies on a Cross-Origin Resource Policy which prevents proxied content from being embedded in third-party websites.