miqro · @miqro/core · @miqro/parser · @miqro/query · @miqro/jsx · @miqro/jsx-dom · @miqro/jsx-node · @miqro/request · @miqro/runner · @miqro/test · @miqro/test-http

@miqro/core

promise based http router with TypeScript inference.

Router

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.

App

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();

APIRouter

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.

APIRoute

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.

APIRoute with TypeScript inference (experimental)

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;

middleware

JSONParser

parses application/json body into req.body.

import { JSONParser } from "@miqro/core";
router.use(JSONParser());

URLEncodedParser

parses application/x-www-form-urlencoded into req.body.

import { URLEncodedParser } from "@miqro/core";
router.use(URLEncodedParser());

ReadBuffer

reads raw body into req.buffer.

import { ReadBuffer } from "@miqro/core";
router.use(ReadBuffer());

TextParser

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.

CORS

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.

SessionHandler

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.

GroupPolicyHandler

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.

ParseRequest

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.

errors

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.

Logger

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.

Request

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

Static

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

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.