Recreate a Node.js (actually Deno) web framework. The API mirrors (a subset of) expressjs.
- Personal engineering skill issues:
- Sloppy abstractions.
- Wrong abstractions.
- Unnecessary abstraction or high-cognitive-load abstraction.
- Abstractions that don't compose.
- Unnecessarily complex code.
- Sloppy abstractions.
- The
expressjs
API to me is very elegant:-
Simple/Straightforward.
-
Streamline:
res.status(200).send("This is the response");
-
Composable: You can create complex handlers and register it with
express()
. The handler can be a simple function or a complexRouter
, which is itself a set of functions orRouter
s.const cRouter = express.Router(); cRouter.use('/child', ...); const pRouter = express.Router(); pRouter.use('/parent', cRouter); const app = express(); app.use('/app', pRouter);
After reading the classic SICP book (my notes), this property is called
closure
-expressjs
allows the combination of handlers into a complex handler and the resulting handler can itself be combined further with other handlers.
-
- I want to play with Node's HTTP API a bit.
I don't intend to make this express
-compatible. I can go extra length to do this, which will unlock the following things:
- Readily available test cases from
express
itself. - The diverse plugin ecosystem of
express
.
This project is nothing but a toy for self-orientation.
As far as I know, the HTTP API in Node handles HTTP at mostly the connection level:
- It can accept and serve concurrent connections well. The serving logic is left to the programmer.
- It allows us to receive requests and send appropriate responses.
- It provides us a low-level mechanism to extract request headers, cookies and body.
This is still lackluster (understandable) to be an HTTP-compliant HTTP server & a web framework.
-
In terms of an HTTP-compliant HTTP server, we need:
- Content negotiation.
- Conditional-GET handling.
- Range requests.
- etc.
These are mostly left to the programmer in the Node's HTTP API & even in
express
to allow for more flexibility. However,express
does provide ways to handle these easier. -
In terms of a Web framework:
- The HTTP API is primitive:
- HTTP request body is streamed incrementally and we need to put together the chunks to get the full HTTP body.
- HTTP cookies need to be manually parsed.
- etc.
- Some common functionality is missing:
- Specifying routes & handlers.
- Specifying middleware.
- HTTP request body parsing based on its
Content-Type
header. - Quickly build responses: send cookies, serve static files, render HTML.
- The HTTP API is primitive:
Keep it simple. Just write the code first & abstract when needed or may abstract in advance but only minimally.
I intend to cover the breadth but not much depth:
- Easily extract headers, cookies, querystring, body from a request.
- Easily set headers, cookie, body on a response.
- Register a handler for a route.
Router
s are not supported. - Serve static files.
- Support templating engines.
-
Path manipulation is tricky. Common operations I perform on a path:
- Equality check:
- Handle paths that can come in with or without the
/
. - Handle
..
and.
- Handle paths that can come in with or without the
- Ancestor/Descendant directory check:
- Either the ancestor/descendant path can come in with or without
/
.
- Either the ancestor/descendant path can come in with or without
-> May worth creating a
Path
abstraction to:- Normalize paths: Remove
..
,.
and standardize whether to include trailing/
. - Perform equality check.
- Perform ancestor/descendant check.
- Equality check:
-
Serving static files takes some considerations and I may miss something:
- Security issues:
- Scope serving requests to some folder only: Be careful of relative paths, etc. This can lead to arbitrary files in the filesystem being sent.
- Consider dotfiles -
static-serve
ignores these by default. - Avoid following symlinks (or just following but avoid allowing users to upload symlink). I just follow symlinks in this project.
- Range requests.
- Security issues:
-
Always read raw data into
Buffer
instead ofstring
.
I don't want to create noise on jsr for this hobby project so if you wanna test this, please replace 'expresso'
with the path you clone it into in your project.
import expresso from 'expresso';
import expresso from 'expresso';
const app = expresso();
Like expressjs
's app.use
.
path
: The path for which the middleware function is invoked; can be any of:- A string representing a path.
- A path pattern string using an extension of JS's regex notation (this differs from
express
). - An array of combinations of any of the above.
callback
: A function of type(req: Request, res: Response, next: (void) => unknown) => unknown
.
The path pattern strings can also contain the :param
pattern, which is equivalent to: (?<param>[^/]+)
.
With path
omitted, '.*'
is assumed and callback
is invoked for all path
.
Like expressjs
's app.METHOD
.
METHOD
is one of the HTTP methods or all
.
Start the HTTP and listening on port port
.
Close the HTTP server.
The request's body. By default, this is undefined
- you need to use the body-parser
plugin.
import { bodyParser } from 'expresso'
app.use([path], bodyParser.raw); // `req.body` will return the raw content of the request's body.
app.use([path], bodyParser.json); // `req.body` will return the content as json of the request's body.
app.use([path], bodyParser.urlencoded); // `req.body` will return the content of the request's body as urlencoded string.
The request's cookies, which is an key-value object mapping cookie's name to the cookie's value.
The value of the request's Host
header. If the Host
header is missing, this field is set to ""
.
The method of the request.
The full URL of the request.
The requested path of the request, omitting the query string.
"/path?qs=q" // -> "/path"
"/path" // -> "/path"
The path parameters of the matched route.
"/path/:id" // -> { id: "..." }
The query string of the request, parsed using the qs
package.
"/path?qs=q" // { qs: "q" }
"/path?qs[0]=q1&qs[1]=q2" // { qs: [ "q1", "q2" ] }
The IP address of the client.
Set the header field
to a header value or an array of header values. If the header already exists, the header value is appended instead of being overridden.
Get the value of the header field
.
Set the header field
to value
.
Set a cookie name
to value
along with some options. The options
object has the following optional fields:
domain?: string
: TheDomain
property of the cookie.encode?: (_: string) => string
: Encode the cookie's value.expires?: Date | 0
: TheExpires
property of the cookie.httpOnly?: boolean
: TheHttpOnly
property of the cookie.maxAge?: number
: TheMax-Age
property of the cookie.path?: string
: ThePath
property of the cookie.secure?: boolean
: TheSecure
property of the cookie.sameSite?: "None" | "Lax" | "Strict"
: TheSame-Site
property of the cookie.
Set the response's status code.
Set the response's status code and the body also to code
.
- If
data
is aBuffer
:- Set the response's body to
data
. - Set the
Content-Type
header toapplication/octet-stream
. - Set the
Content-Length
header todata
's length.
- Set the response's body to
- If
data
is aString
:- Set the response's body to
data
. - Set the
Content-Type
header totext/html
. - Set the
Content-Length
header todata
's length.
- Set the response's body to
- If
data
is aJsonConvertible
value:- Set the response's body to
data
. - Set the
Content-Type
header toapplication/json
. - Set the
Content-Length
header todata
's length.
- Set the response's body to
Like Response.send
but do not set Content-Type
.
Set the response's body to data
and set the Content-Type
header to application/json
.
Set the Location
header to path
.
If path
is "back"
, set Location
to the corresponding Request
's Referer
header.
Set the status code to status
(by default 302) and set the Location
header to path
.
Return the headers sent on this response.
Append value
to the Vary
header.
Set the Content-Type
header to value
.