"Demino" (Deno minimal) - minimalistic web server framework built on top of the Deno's built-in HTTP server, providing routing, middlewares support, error handling and a little more.
The design goal of this project is to provide a thin and sweet
extensible layer on top of the Deno.serve
handler. Nothing more, nothing less.
In other words, this is a building blocks framework, not a full featured web server.
Despite being marked as 1.0.x
, it is still in its early stages, where the
API may occasionally change.
deno add jsr:@marianmeres/demino
import { demino } from "@marianmeres/demino";
// create the Demino app instance
const app = demino();
// register method and route handlers...
app.get("/", () => "Hello, World!");
// serve (Demino app is a `Deno.serve` handler)
Deno.serve(app);
Every Demino app is created with a route prefix called the mountPath
. The default
mountPath
is an empty string. Every route
is then
joined as mountPath + route
, which - when joined - must begin with a /
.
Every incoming request in Demino app is handled based on its pathname
which is matched
against the registered routes.
The actual route matching is handled by the router. By default, Demino uses simple-router.
// create a Demino with a `/api` mount path
const api = demino("/api");
// will handle `HTTP GET /api/users/123`
api.get("/users/[userId]", (req, info, ctx) => Users.find(ctx.params.userId));
Demino also comes with URL Pattern based router. Read more about it below.
The stuff happens in route handlers. Or in middlewares. Or in both. In fact, they are technically the same thing - the route handler is just the final middleware in the internal collection.
Having said that, they are still expected to behave a little differently. Middlewares mainly do something (eg validate), while route handlers mainly return something (eg html string or json objects).
As soon as any middleware decides to return a thing, the middlewares
execution chain is terminated and a Response
is sent immediately.
Unlike in Deno.serve
handlers, the Demino route handlers are not required
to return a Response
instance, it will be created automatically
based on what they return:
- if the value is
undefined
, empty204 No Content
response will be created, - if the value is a plain object (or
null
, ortoJSON
aware) it will beJSON.stringify
-ed and served asapplication/json
content type, - everything else is cast to string and served as
text/html
.
The automatic content type headers above are only set if none exist.
You can safely bypass this opinionated behavior by returning the Response
instance
yourself.
// conveniently return plain object and have it be converted
// to a Response instance automatically
app.get("/json", () => ({ this: 'will', be: 'JSON', string: 'ified'}));
// or return any other type (the `toString` method, if available, will be invoked by js)
class MyRenderer {
constructor(private data) {...}
toString() { return `...`; }
}
app.get('/templated', (_r, _i, c) => new MyRenderer(c.locals))
// or return the Response instance directly
app.get('/manual', () => new Response('This will be sent as is.'))
The middleware and/or route handler has the following signature (note that the arguments
are a subset of the normal Deno.ServeHandler
, meaning that any valid Deno.ServeHandler
is a valid Demino app handler):
function handler(req: Request, info: Deno.ServeHandlerInfo, context: DeminoContext): any;
Middlewares can be registered as:
app.use(middleware)
- globally per app (will be invoked for every method on every route),app.use("/route", middleware)
- globally per route (will be invoked for every method on a given route),app.get("/route", middleware, handler)
- locally for given method and route.
The global ones must be registered before the local ones to take effect.
// GOOD - the globals are registered before the final handler
app.use(someGlobal).use("/secret", authCheck).get("/secret", readSecret, handler);
// BAD! neither `someGlobal` nor `authCheck` will be used for the `GET /secret` route
app.get("/secret", readSecret, handler).use("/secret", authCheck).use(someGlobal);
Each middleware receives a DeminoContext
sealed object as its last parameter
which visibility and lifetime is limited to the scope and lifetime of the request handler.
It has these props:
params
- the readonly router parsed params,locals
- plain object, where each middleware can write and read arbitrary data.
Additionally, it also exposes:
status
- HTTP status number to be optionally used in the final response,headers
- any headers to be optionally used in the final response,error
- to be used in a custom error handler.
const app = demino('/articles');
// example middleware loading article (from DB, let's say)...
app.use(async (_req: Request, _info: Deno.ServeHandlerInfo, ctx: DeminoContext) => {
// eg any route which will have an `/[articleId]/` segment, we automatically read
// article data (which also means, it will auto validate the parameter)
if (ctx.params.articleId) {
ctx.locals.article = await Article.find(ctx.params.articleId);
if (!ctx.locals.article) {
throw new ArticleNotFound(`Article ${ctx.params.articleId} not found`);
}
}
})
// ...and route handler acting as a pure renderer. This handler will not
// be reached if the article is not found
app.get("/[articleId]", (_req, _info, ctx) => render(ctx.locals.article));
Errors are caught and passed to the error handler. The built-in error handler can be
replaced via the app.error
method (eg app.error(myErrorHandler)
):
// example: customized json response error handler
app.error((_req, _info, ctx) => {
ctx.status = ctx.error?.status || 500;
return { ok: false, message: ctx.error.message };
});
All features described below are extensions to the base framework. Some batteries are included after all.
The default router, by design, sees /foo
and /foo/
as the same routes,
which may not be always desired (eg for SEO). This is where the trailing slash
middleware helps.
// will ensure every request will be redirected (if needed) to the trailing slash route
app.use(trailingSlash(true))
// and the opposite:
// app.use(trailingSlash(false))
Work in progress...
In addition to the default simple-router,
Demino comes with URL Pattern
router implementation that can be activated via the routerFactory
factory setting.
const app = demino("", [], { routerFactory: () => new DeminoUrlPatternRouter() });
app.get("/", () => "home");
app.get("/user/:foo/section/:bar", (_r, _i, ctx) => ctx.params);
deminoFileBased
function allows you to register routes and route handlers from the file system.
It will search the provided directory for index.(j|t)s
and _middleware.(j|t)s
modules.
If found, it will import and collect the exported symbols (will look for HTTP method named
exports, or default exports of array of middlewares) and apply it all to the provided app instance.
The presence of the index.ts
with at least one known exported symbol marks the directory
as a valid route. Any directory with path segment starting with _
or .
will be skipped. The
optional _middleware.ts
are collected along the path from the beginning, so
multiple ones may be effective for the final route handler.
So, instead of writing manually:
app.use(globalMw);
app.get('/users', usersMw, () => ...);
app.get('/users/[userId]', userMw, () => ...);
you can achieve the same effect like this (assuming the following directory structure):
+-- routes
| +-- users
| | +-- [userId] (with brackets - a named segment)
| | | +-- _middleware.ts (default exports [userMw])
| | | +-- index.ts (with exported GET function)
| | +-- _middleware.ts (default exports [usersMw])
| | +-- index.ts (with exported GET function)
| +-- _middleware.ts (default exports [globalMw])
import { demino, deminoFileBased } from "@marianmeres/demino";
const app = demino();
await deminoFileBased(app, './routes')
Note that this feature is designed to work with the default router only.
Multiple apps on a different mount paths can be composed into a single app. For example:
import { demino, deminoCompose } from "@marianmeres/demino";
// skipping routes setup here...
const home = demino("", loadMetaOgData);
const api = demino("/api", [addJsonHeader, validateBearerToken]);
// compose all together, and serve as a one handler
Deno.serve(deminoCompose([home, api]));