miqro · @miqro/core · @miqro/parser · @miqro/query · @miqro/jsx · @miqro/jsx-dom · @miqro/jsx-node · @miqro/request · @miqro/runner · @miqro/test · @miqro/test-http
promise based http router with TypeScript inference.
import { Router } from "@miqro/core";
const router = new Router();
router.use(handler); // all methods, all paths
router.use(handler, "/path"); // all methods, /path
router.use(handler, "/path", "GET"); // GET /path
router.get("/path", handler); // GET /path
router.catch(errorHandler); // error handler
await router.run(req, res); // returns true if no response sent
a GET route also answers HEAD.
handler return values:
return false; // stop chain
return true; // continue chain
return undefined; // continue chain
return { ... }; // pushed to req.results, continue chain
if no handler sends a response, App answers 404.
http.createServer with router built in.
import { App } from "@miqro/core";
const app = new App({
loggerFactory: (uuid, req) => logger,
onUpgrade: (req, socket, head) => { } // WebSocket upgrade
});
app.use(router);
await app.listen("3000");
await app.close();
routes declared as files in a directory.
import { resolve } from "path";
import { App, APIRouter } from "@miqro/core";
const app = new App();
app.use(await APIRouter({ dirname: resolve(import.meta.dirname, "api") }));
await app.listen("3000");
the filename is the method (get.js, post.js, use.js) and the directory is the path.
both are overridden by method and path on the route itself.
complete endpoint declaration. one per file, export default.
import { APIRoute, Request, JSONParser } from "@miqro/core";
export default {
name: "create post",
description: "creates a post",
path: "/posts",
method: "POST",
policy: {
groups: ["editor"],
groupPolicy: "at_least_one"
},
middleware: [JSONParser()],
request: {
body: {
title: "string",
content: "string",
tags: "string[]?"
},
query: {
draft: "boolean?"
}
},
response: {
status: [200, 400],
body: {
id: "number"
}
},
handler: async (req, res) => {
//req.body.title // is validated as string
return res.json({ id: 1 });
}
} as APIRoute<Request, any, any, any>;
a bare as APIRoute only types a route without a request schema. use defineRoute
below to get req typed from the schema instead of widening it to any.
response is checked against the value the handler returns, and a mismatch fails the
request as a 503. a handler that writes the response itself (res.json(...)) sends it
as is, without that check.
complete endpoint declaration.
import { defineRoute, JSONParser } from "@miqro/core";
export default defineRoute({
name: "create post",
description: "creates a post",
path: "/posts",
method: "POST",
policy: {
groups: ["editor"],
groupPolicy: "at_least_one"
},
middleware: [JSONParser()],
request: {
body: {
title: "string",
content: "string",
tags: "string[]?"
},
query: {
draft: "boolean?"
}
},
response: {
status: [200, 400],
body: {
id: "number"
}
},
handler: async (req, res) => {
//req.body!.title // is typed string. body is optional, ParseRequest fills it
//req.body!.tags // is typed string[] | undefined
//req.query.draft // is typed boolean | undefined
return res.json({ id: 1 });
}
});
method: "use" matches all methods — use for middleware:
export default {
path: "/admin",
method: "use",
handler: adminRouter
} as APIRoute;
parses application/json body into req.body.
import { JSONParser } from "@miqro/core";
router.use(JSONParser());
parses application/x-www-form-urlencoded into req.body.
import { URLEncodedParser } from "@miqro/core";
router.use(URLEncodedParser());
reads raw body into req.buffer.
import { ReadBuffer } from "@miqro/core";
router.use(ReadBuffer());
reads a text/plain body as string into req.body.
import { TextParser } from "@miqro/core";
router.use(TextParser());
the body parsers only run when the request Content-Type matches theirs and req.body
is still undefined. pass type to change the content type each one accepts.
import { CORS } from "@miqro/core";
router.use(CORS({
origins: ["https://myapp.com"],
methods: "GET,POST,PUT,DELETE"
}));
without options reads CORS_ORIGINS, CORS_METHODS, CORS_PREFLIGHT_CONTINUE env vars. default: all origins.
invalid origins throw BadRequestError.
verifies token and sets req.session.
import { SessionHandler } from "@miqro/core";
router.use(SessionHandler({
authService: {
verify: async ({ token, req, res }) => {
// return a session, or null to answer 401
return null;
}
}
}));
token location via env vars: TOKEN_LOCATION (header|query|cookie|free, default free),
TOKEN_HEADER, TOKEN_QUERY, TOKEN_COOKIE.
free passes token: null to verify without looking for one. the other locations
answer 401 when no token is present.
or verify against a remote endpoint with VerifyEndpointService as the authService:
import { SessionHandler, VerifyEndpointService } from "@miqro/core";
router.use(SessionHandler({
authService: new VerifyEndpointService()
}));
without options it reads TOKEN_VERIFY_ENDPOINT, TOKEN_VERIFY_ENDPOINT_METHOD and
TOKEN_VERIFY_LOCATION (header|query|cookie) env vars. all three are required,
it throws on a missing one.
under TOKEN_LOCATION=free there is no token to check, so it answers null — a 401 —
without calling the endpoint.
restricts access by req.session.groups.
import { GroupPolicyHandler } from "@miqro/core";
router.use(GroupPolicyHandler({
groups: ["admin", "editor"],
groupPolicy: "at_least_one" // or "all"
}));
throws ForbiddenError if policy not met, UnAuthorizedError if there is no req.session.
validates req.body, req.query, req.params, req.headers against schema.
import { ParseRequest } from "@miqro/core";
router.use(ParseRequest({
body: { title: "string", count: "number?" },
query: { page: "number?" },
params: { id: "integer" }
}));
string inputs are coerced to the declared type, so ?page=2 becomes the number 2.
values that already have a type, like a JSON body, are validated as they are: a body
{ "title": 123 } is rejected, not converted.
a property not in the schema is rejected too. pass bodyMode/queryMode/paramsMode
as "add_extra" or "remove_extra" to keep or drop it instead.
fails with ParseOptionsError from @miqro/parser, which the default error handler
answers as 400.
import {
BadRequestError, // 400
UnAuthorizedError, // 401
ForbiddenError, // 403
NotFoundError, // 404
MethodNotImplementedError // 501
} from "@miqro/core";
// throw from any handler
throw new BadRequestError("invalid input");
throw new UnAuthorizedError();
throw new ForbiddenError();
caught by router.catch() handlers or the default error handler.
the status is not carried on the error, the default error handler maps it from e.name.
anything it does not recognize is answered as 503.
import { getLogger, Logger, ConsoleTransport, FileTransport } from "@miqro/core";
const logger = getLogger("MY_SERVICE", {
transports: [ConsoleTransport(), FileTransport("server.log")]
});
logger.error("message %s", value);
logger.warn("...");
logger.info("...");
logger.debug("...");
logger.trace("...");
getLogger reads the level from env: LOG_LEVEL=debug, LOG_LEVEL_MY_SERVICE=trace.
it caches by identifier, so the same name returns the same logger.
new Logger("MY_SERVICE", "info", { transports }) builds one with a fixed level and
ignores those env vars.
import { Request } from "@miqro/core";
// path, query, cookies, uuid, startMS and results are set before any handler runs
req.path // normalized pathname
req.query // { [key]: string | string[] } — Object.create(null)
req.params // { [key]: string } — filled by the router from :params
req.cookies // { [name]: string } — Object.create(null)
req.uuid // randomUUID()
req.startMS // Date.now() at request start
req.logger // per-request logger
req.results // []
req.body // set by body parser middleware
req.buffer // set by ReadBuffer
req.session // set by SessionHandler
serve static files.
import { Static } from "@miqro/core";
router.use(Static({
directory: "./public",
list: true
}));
index is the file served for a directory, index.html by default. list generates a
directory listing when that file is not there. index404 serves a file for anything not
found, with index404Status as its status.
proxy requests to another server.
import { Proxy } from "@miqro/core";
router.use(Proxy({
url: "http://backend:3001"
}));
Cookie and Authorization are not forwarded upstream unless forwardCredentials: true.