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

experimental Node.js web framework built from scratch with minimal runtime dependencies.

fully typed TypeScript · REST · WebSocket · SQL · JWT · HTTP

runtime dependencies: jose, esbuild, cookie, marked

packages

miqro
├── @miqro/core       router, middleware, session, CORS, logger
├── @miqro/jsx        vDOM, hooks, SSR runtime
├── @miqro/jsx-dom    Web Component define, browser runtime
├── @miqro/jsx-node   node SSR runtime
├── @miqro/query      query builder, ORM, migrations
├── @miqro/parser     schema validation
├── @miqro/request    http client
├── @miqro/runner     cluster manager
├── @miqro/test       test runner
└── @miqro/test-http  http test helper

@miqro/test and @miqro/request are meant to be phased out in favor of node:test and built-in fetch. that has not happened yet — miqro --test still runs on @miqro/test and test.request is @miqro/request.

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

composition

services run sequentially. each service runs on every request until a route sends a response.

{
  "services": ["a/", "b/", "c/"]
}
request →
  a/ runs (catch.ts, cors.ts, auth.ts, middleware.ts,
           http/ routes, static/ files, middleware.post)
  if no route in a/ matched, b/ runs
  if no route in b/ matched, c/ runs

earlier services establish state. later services consume it.

a/auth.ts    → sets req.session
b/db.ts      → req.server.db.get("b") available
c/http/      → has req.session and req.server.db, handles routes

req.session.account is the tenant identifier. use it to isolate data:

// any handler in c/
const posts = await req.server.db.get("b")
  .select().from("posts")
  .eq("account", req.session.account)
  .yield();

seed shared state in server.ts using isPrimaryWorker():

// a/server.ts
export default {
  load: async (server) => {
    if (server.isPrimaryWorker()) {
      const config = await server.db.get("b").select().from("config").yield();
      server.cache.set("config", config);
    }
  }
}

swap a/ for a different implementation — b/ and c/ don't change. they read from req.session, not from a/ directly.

same service folders, different miqro.json:

{ "services": ["a1/", "b/", "c/"] }   // production auth
{ "services": ["a2/", "b/", "c/"] }   // test auth
{ "services": ["b/", "c/"] }          // no auth

service folders can be npm packages:

{
  "services": [
    "node_modules/@myorg/auth/service/",
    "node_modules/@myorg/db/service/",
    "services/app/"
  ]
}

installation

npm

npm install miqro

getting started

mkdir -p example/http

example/http/index.html.tsx

import JSX from "@miqro/jsx";

export default (req, res) => {
  return <html>
    <body>
      <h1>Hello World!</h1>
    </body>
  </html>
}
miqro --watch --service example/

open http://localhost:8080/index.html.

generate static files

miqro --inflate --inflate-dir build/ --service example/

output in build/example/static/. serve with any http server.

service folder

app/
  http/          endpoints
  static/        files served as-is
  migration/     db migrations
  test/          tests
  db.ts          database config
  ws.ts          websocket config
  auth.ts        session/auth config
  server.ts      lifecycle hooks
  log.ts         log transport config
  cors.ts        CORS config
  doc.ts         API doc publishing
  middleware.ts  pre/post route middleware
  catch.ts       error handlers
  miqro.json     multi-service composition

all files and folders are optional.

miqro --service app/

http folder

files are served by their path in the directory.

http/
  index.html.tsx      → GET /index.html
  posts/
    index.html.tsx    → GET /posts/index.html
    list.api.ts       → GET /posts/list
    feed.json.ts      → GET /posts/feed.json
  js/
    app.min.tsx       → GET /js/app.min.js   (bundled + minified)
    vendor.bundle.ts  → GET /js/vendor.bundle.js (bundled, not minified)
  css/
    all.css.bundle    → GET /css/all.css     (concatenated)
  docs/
    guide.html.md     → GET /docs/guide.html

the sub-extension decides the handler, and it stays in the served path: app.min.tsx is served at /js/app.min.js, not /js/app.js.

*.ignore.ts, *.test.ts and *.d.ts are skipped. anything else is served as-is by its path.

.html.tsx

server-rendered JSX. runs at request time or at build time with --inflate.

import JSX from "@miqro/jsx";

export default (req, res) => {
  return <html>
    <body>
      <h1>Hello</h1>
    </body>
  </html>
}

with database access:

import JSX from "@miqro/jsx";

export default async (req, res) => {
  const posts = await req.server.db.get("mydb")
    .select().from("posts").yield();

  return <html>
    <body>
      {posts.map(p => <h2>{p.title}</h2>)}
    </body>
  </html>
}

customize path/method with apiOptions:

import JSX from "@miqro/jsx";
import { APIOptions } from "miqro";

export const apiOptions: APIOptions = {
  path: ["/", "/index.html"],
  method: ["GET"]
};

export default (req, res) => { ... }

.min.tsx

bundled and minified client-side JavaScript. use to define Web Components.

import JSX, { useState } from "@miqro/jsx";
import { define } from "@miqro/jsx-dom";

function Counter() {
  const [n, setN] = useState(0);
  return <button id="count" onClick={() => setN(n + 1)}>count: {n}</button>;
}

define("my-counter", Counter, {
  observedAttributes: ["initial"],
  shadowInit: false
});

useState returns [value, set, get]. there is no functional updater — set(v) assigns v, so set(prev => ...) would store the function itself. to read current state from a callback that outlives the render, use the third element:

const [messages, setMessages, getMessages] = useState([]);
setMessages([...getMessages(), msg]);

attributes arrive as strings, and the element is passed as the $element$ prop.

included in HTML:

export default (req, res) => (
  <html>
    <body>
      <my-counter initial="0" />
      <script src="/js/app.min.js" />
    </body>
  </html>
);

.api.ts

REST endpoint. file path is the route path.

export default (req, res) => {
  return res.json({ ok: true });
}

full declaration with validation and typing:

import { defineRoute } from "miqro";
import { JSONParser } from "@miqro/core";

export default defineRoute({
  name: "create post",
  method: "POST",
  middleware: [JSONParser()],
  request: {
    body: {
      title: "string",
      content: "string"
    }
  },
  response: {
    status: [200],
    body: {
      id: "number"
    }
  },
  handler: async (req, res) => {
    const post = await req.server.db.get("mydb")
      .insert("posts")
      .values({ title: req.body.title, content: req.body.content })
      .returning("id")
      .yield();
    return res.json({ id: post[0].id });
  }
});

full declaration with validation but no req.body typing:

import { APIRoute, JSONParser } from "@miqro/core";

export default {
  name: "create post",
  method: "POST",
  middleware: [JSONParser()],
  request: {
    body: {
      title: "string",
      content: "string"
    }
  },
  response: {
    status: [200],
    body: {
      id: "number"
    }
  },
  handler: async (req, res) => {
    const post = await req.server.db.get("mydb")
      .insert("posts")
      .values({ title: req.body.title, content: req.body.content })
      .returning("id")
      .yield();
    return res.json({ id: post[0].id });
  }
} as APIRoute;

method: "use" registers the handler for all methods (middleware pattern):

import { Router } from "@miqro/core";

const router = new Router();
router.use(myMiddleware);

export default {
  path: "/admin",
  method: "use",
  handler: router
}

.json.ts

JSON endpoint. default-export a value, or a function returning one.

import { APIOptions } from "miqro";

export const apiOptions: APIOptions = { method: ["GET"] };

export default async (req, res) => {
  const posts = await req.server.db.get("mydb")
    .select().from("posts").yield();
  return { posts };
}

http/posts/feed.json.ts → GET /posts/feed.json. with --inflate the value is computed once at build time and written as a static .json file.

.html.md

markdown file converted to HTML. served as text/html.

http/
  docs/
    guide.html.md    → GET /docs/guide.html

guide.html.md:

# Guide

some **markdown** content.

the .html stays in the route — guide.html.md is served at /docs/guide.html.

converted using marked. also works with --inflate — outputs static HTML.

available as await req.server.inflateMDtoHTML(str) from any handler (it is async). raw HTML in the markdown is passed through; pass { sanitize: true } for markdown you did not write.

.bundle

*.bundle.ts is bundled by esbuild but not minified, and served at <name>.bundle.js.

*.css.bundle is a list of css file paths, one per line, resolved relative to the bundle file and concatenated. lines starting with # are ignored. all.css.bundle is served at /all.css.

static folder

files served as-is, by path.

static/
  logo.png     → GET /logo.png
  style.css    → GET /style.css

db.ts

import { DBConfig } from "miqro";

export default {
  dialect: "node:sqlite",       // node:sqlite | pg | pg-pool
  name: "mydb",                 // req.server.db.get("mydb")
  storage: "./data.sqlite3",    // sqlite only
  // url: "postgres://...",     // postgres — the field is `url`, not `connectionString`
  // disabled: false,
  // pool: { min: 1, max: 10 }  // pg-pool
} as DBConfig;

db names are global across services — two services cannot both define mydb.

migrations do not run on startup. run them with --migrate-up / --migrate-down, or from a handler with req.server.db.migrate({ direction: "up" }).

migration folder

files named NNN-name.ts run in order. the sort key is the number before the first dash — a file with no dash, or a non-numeric prefix, is silently skipped. only .ts, .tsx, .js and .mjs are picked up.

// migration/001-create-posts.ts
import { Migration } from "@miqro/query";

export default {
  up: async (db, logger) => {
    await db.createTable("posts", {
      id: { type: "integer", primaryKey: true, autoIncrement: true },
      title: { type: "string" },
      content: { type: "string" }
    }).yield(logger);
  },
  down: async (db, logger) => {
    await db.dropTable("posts").yield(logger);
  }
} as Migration;

a migration is { up, down }. both are required — a module without down is rejected when it is loaded, not when you run --migrate-down. extra fields are tolerated but ignored: the migration name is the file name, and the db is the one declared by the service's db.ts, so neither is set in the module.

applied migrations are recorded in a migrations table, so up is a no-op the second time.

run them:

miqro --migrate-up --service app/
miqro --migrate-down --service app/

cors.ts

import { CORSConfig } from "miqro";

export default {
  origins: ["https://myapp.com", "https://staging.myapp.com"],
  methods: "GET,POST,PUT,DELETE",
  // preflightContinue: false,
  // validate: (origin, origins) => boolean   // replaces the default origin check
} as CORSConfig;

the exported type is CORSConfig, not CORSOptions.

without cors.ts no CORS middleware runs at all — no Access-Control-Allow-Origin header is sent. with it only listed origins are accepted; requests from other origins get 400 Bad Request. "*" in origins allows any.

cors.ts with no origins allows any origin (*). the CORS_ORIGINS, CORS_METHODS and CORS_PREFLIGHT_CONTINUE env vars are not read for cors.ts — they only apply to req.server.middleware.cors() called with no arguments.

ORM

quick reference. full docs in @miqro/query.

import { defineModel } from "@miqro/query";

const Post = defineModel(db, "posts", {
  id:        { type: "integer", primaryKey: true, autoIncrement: true },
  title:     { type: "string" },
  published: { type: "boolean" },
  createdAt: { type: "datetime" }
});

// create
await Post.create({ title: "hello", published: false });

// findAll
const posts = await Post.findAll(
  Post.where().eq("published", true).order("createdAt", "DESC"),
  { limit: 10 }
);

// updateAll
await Post.updateAll({ published: true }, Post.where().eq("id", 1));

// deleteAll
await Post.deleteAll(Post.where().eq("id", 1));

// count
const n = await Post.count(Post.where().eq("published", true));

// sync (create table if not exists)
await Post.sync();

access the db directly:

const db = req.server.db.get("mydb");
const rows = await db.select().from("posts").eq("published", true).yield();
const raw = await db.query("SELECT * FROM posts WHERE id = ?", [1]);

ws.ts

import { WSConfig } from "miqro";

export default {
  path: "/updates",              // req.server.ws.get("/updates")
  disabled: false,
  maxConnections: 1000,
  validate: async (req) => req.headers.origin === "https://myapp.com",
  onConnection: async (client) => { ... },   // { uuid, req, socket, head }
  onMessage: async (client, data) => { ... },
  onDisconnect: async (client) => { ... }
} as WSConfig;

a websocket upgrade is exempt from the same origin policy and cookies are attached to it, so without an origin check any site can open an authenticated socket against your server. use validate — it is the only origin check available from ws.ts.

loader gap: WSConfig extends WebSocketServerOptions, so TypeScript accepts allowedOrigins, maxFrameSize and onError — but the ws.ts loader validates against a narrower schema and rejects them with ParseOptionsError: ws.ts.<key> option not valid. that error is only logged and the websocket is then never registered, so the server starts looking healthy with no socket at all. the keys ws.ts accepts are exactly: path, disabled, maxConnections, validate, onConnection, onMessage, onDisconnect.

broadcast from a handler:

export default async (req, res) => {
  const ws = req.server.ws.get("/updates");
  await ws.broadcast(JSON.stringify({ type: "update", data: "..." }));
  return res.json({ ok: true });
}

WebSocket client (.min.tsx)

import JSX, { useState, useEffect } from "@miqro/jsx";
import { define } from "@miqro/jsx-dom";

function LiveFeed(props) {
  // third element is the getter — useState has no functional updater
  const [messages, setMessages, getMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket(`ws://${location.host}/updates`);
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      setMessages([...getMessages(), msg]);
    };
    return () => ws.close();
  }, []);

  return <ul>{messages.map((m, i) => <li>{m.text}</li>)}</ul>;
}

define("live-feed", LiveFeed, { shadowInit: false });

broadcast from a handler:

// http/publish.api.ts
export default async (req, res) => {
  const ws = req.server.ws.get("/updates");
  await ws.broadcast(JSON.stringify({ text: req.body.text }));
  return res.json({ ok: true });
}

auth.ts

import { AuthConfig, jwt } from "miqro";
import { createSecretKey } from "node:crypto";

const secret = createSecretKey(Buffer.from(process.env.JWT_SECRET, "hex"));

export default {
  authService: {
    verify: async ({ token, req, res }) => {
      try {
        // jose returns { payload, protectedHeader } — not the payload
        const { payload } = await jwt.verify(token, secret);
        return {
          account: payload.account as string,
          username: payload.username as string,
          groups: payload.groups as string[],
          token
        };
      } catch {
        return null;
      }
    }
  },
  options: {
    tokenLocation: "header",          // header | query | cookie | free
    tokenLocationName: "authorization"
  }
} as AuthConfig;

verify goes under authService — a top-level verify fails validation with auth.ts.authService not defined, and that error is only logged: the service keeps serving every route unauthenticated. check your startup logs.

options matters more than it looks. without it tokenLocation comes from the TOKEN_LOCATION env var, which defaults to free — and free never reads a token, so verify always gets token: null. per-location name fallbacks are TOKEN_HEADER (default Authorization), TOKEN_QUERY (default token) and TOKEN_COOKIE (default Authorization).

verify returns a session or null. null, or a missing token when tokenLocation is not free, answers 401.

auth.ts registers the handler for the whole service, so every route in it requires a token. put public routes in an earlier service without auth.ts.

session available on req.session:

req.session.account   // tenant identifier
req.session.username
req.session.groups
req.session.token

restrict by group using policy on any endpoint:

export default {
  policy: {
    groups: ["admin"],
    groupPolicy: "at_least_one"
  },
  handler: ...
}

server.ts

lifecycle hooks.

import { ServerConfig } from "miqro";

export default {
  preload: async (server) => {
    // during inflate() — db connections are already up,
    // routes have not been built yet
  },
  load: async (server) => {
    // during start() — after routes and websockets, before listening
    // seed cache here
    const rows = await server.db.get("mydb").select().from("config").yield();
    server.cache.set("config", rows);
  },
  start: async (server) => {
    // after listening
  },
  unload: async (server) => {
    // on reload/restart, before the old routes are dropped
  },
  stop: async (server) => {
    // on shutdown
  }
} as ServerConfig;

all hooks run on all workers. use server.isPrimaryWorker() to gate one-time operations.

middleware.ts

pre and post route middleware for the service.

import { MiddlewareConfig } from "miqro";

export default {
  middleware: [
    // runs before all routes
    async (req, res) => {
      req.startTime = Date.now();
    }
  ],
  post: [
    // runs after all routes
    async (req, res) => {
      req.logger.debug("took %dms", Date.now() - req.startTime);
    }
  ]
} as MiddlewareConfig;

catch.ts

error handlers.

import { ErrorConfig } from "miqro";

export default {
  catch: [
    async (err, req, res) => {
      req.logger.error(err);
      return res.json({ error: err.message }, {}, 500);
    }
  ]
} as ErrorConfig;

log.ts

custom log transport. called for every log message.

import { LogConfig } from "miqro";

export default {
  level: "error",              // only receive messages at this level
  replaceConsoleTransport: false,  // keep default console output
  replaceFileTransport: false,     // keep default file output (only if LOG_FILE is set)
  write: async ({ out, level, identifier }) => {
    // out       — formatted log string
    // level     — "error" | "warn" | "info" | "debug" | "trace"
    // identifier — route/worker identifier
    await fetch("https://logs.example.com/ingest", {
      method: "POST",
      body: JSON.stringify({ out, level, identifier })
    });
  }
} as LogConfig;

level is an exact match, not a minimum — a transport with level: "error" receives error messages only.

log levels: error → warn → info → debug → trace → none. default info.

default output is console only. there is no ./server.log default — file output is off unless you set LOG_FILE, --log-file, or logFile in miqro.json.

per-identifier level override via env. the identifier is the route path uppercased with /, . and - replaced by _, then the method — prefixed with WORKER_<n>_ in cluster mode, and with the server name when one is set. the lookup is an exact match, not a prefix, so there is no identifier that means "all of worker 0":

LOG_LEVEL=info
LOG_LEVEL_POSTS_GET=debug                 // GET /posts
LOG_LEVEL_INDEX_HTML_GET=trace            // GET /index.html
LOG_LEVEL_WORKER_1_POSTS_GET=trace        // GET /posts on worker 1
LOG_LEVEL_MYAPP_POSTS_GET=debug           // with name: "myapp"

cluster mode: each worker writes to the same log file independently. at high throughput use pipes instead of FileTransport:

miqro --service app/ 2>&1 | tee app.log

or send to an external aggregator via log.ts write.

doc.ts

publish API documentation as a static file or live endpoint.

import { DocConfig } from "miqro";

export default {
  publish: {
    "/api/docs":   { type: "MD" },    // markdown
    "/api/schema": { type: "JSON" },  // json
    "/api/docs.html": { type: "HTML" } // html
  }
} as DocConfig;

with --inflate the docs are written to build/ as static files.

at runtime the docs are served as live endpoints — useful for development.

generate docs via CLI:

miqro --generate-doc --generate-doc-out API.md --service app/
miqro --generate-doc --generate-doc-type JSON --generate-doc-out api.json --service app/

--generate-doc-type accepts MD and JSON only. HTML is available through doc.ts publish, not from the CLI.

doc output is derived from APIRoute declarations — name, description, request, response, policy fields.

miqro.json

compose multiple services.

{
  "name": "myapp",
  "port": "3000",
  "services": [
    "a/",
    "b/",
    "c/"
  ],
  "inflateDir": "build/",
  "logFile": false,
  "browser": true
}

every field is optional, but services has to come from either miqro.json or --service.

name              server name — also prefixes every log identifier
port              default: 8080. takes precedence over the PORT env var
services          ordered list of service folders
inflateDir        default inflate output directory
logFile           false | true | "./path/server.log"
browser           open browser on start (true | false | "browser-name")
etag              default: true
noBuild           skip esbuild, use pre-compiled files
noMinify          skip minification
inflateOnlyAssets inflate assets only
inflateFlat       inflate into inflateDir directly
inflateParallel   max parallel esbuild instances (default: 1)
https             serve with https
serverOptions     { key, cert } — PEM contents, see https below
httpsRedirect     http redirect port (a number)

unknown keys are a hard error: the parser rejects the file and the process exits.

generate default miqro.json:

miqro --install-miqrojson

run with miqro.json:

miqro

or override:

miqro --service a/ --service b/

swap implementations by changing the services array. same service folders work across different compositions.

req.server

available on every handler.

req.server.db.get("name")          // Database | null
req.server.db.getMigrations()      // NamedMigration[]
req.server.db.migrate({ direction: "up" })
req.server.ws.get("/path")         // WebSocketServer | undefined
req.server.ws.disconnectAll("/path")
req.server.cache                   // ClusterCache — synced across all cluster workers via IPC
req.server.localCache              // LocalCache — in-memory, per worker only
req.server.cache.set("key", value)
req.server.cache.get("key")
req.server.cache.has("key")
req.server.cache.unset("key")
req.server.cache.set_add("key", value)   // set operations
req.server.cache.set_has("key", value)
req.server.cache.set_delete("key", value)
req.server.cache.set_clear("key")
req.server.cache.array_push("key", value)
req.server.cache.array_clear("key")

req.server.middleware.json()       // body parser → req.body
req.server.middleware.url()        // url-encoded body parser → req.body
req.server.middleware.text()       // text body parser → req.body
req.server.middleware.buffer()     // raw buffer → req.buffer
req.server.middleware.cors(opts)   // CORS middleware
req.server.middleware.session(opts)// auth middleware

req.server.jwt.sign(payload, secret, opts)
req.server.jwt.verify(token, secret, opts)
req.server.jwt.decode(token)

req.server.isPrimaryWorker()       // true on worker 0
req.server.getWorkerNumber()       // 0..n
req.server.getWorkerCount()        // total workers

req.server.reload()                // hot reload
req.server.restart()               // full restart
req.server.stop()                  // shutdown

req.server.encodeHTML(str)
req.server.getHTML(element, runtime?)    // render JSX to an HTML string
await req.server.inflateMDtoHTML(str)    // async
req.server.openBrowser("/path")
req.server.newParser()
req.server.newClusterCache(name)
req.server.newLocalCache(name)
req.server.getLogger(identifier)

req

req.path          // normalized pathname
req.hash          // url hash fragment
req.searchParams  // URLSearchParams
req.query         // parsed query string { [key]: string | string[] }
req.params        // path parameters { [key]: string }
req.cookies       // parsed cookies { [name]: string }
req.body          // parsed body (requires body parser middleware)
req.buffer        // raw body buffer (requires ReadBuffer middleware)
req.session       // set by auth.ts
req.session.account   // tenant identifier
req.session.username
req.session.groups    // string[]
req.session.token
req.uuid          // unique request id
req.startMS       // request start timestamp ms
req.logger        // per-request logger — includes path/method/uuid/remoteAddress
req.results       // pipeline accumulator

test folder

test files named *.test.ts or *.test.tsx, anywhere in the service folder.

describe, it, before and after are globals injected by the runner. do not import them from node:test — those register with a different runner and miqro --test would collect nothing.

import { strictEqual } from "node:assert";

describe("posts", () => {
  it("GET /posts returns 200", async () => {
    const res = await test.request({
      url: "/posts",
      method: "GET",
      disableThrow: true
    });
    strictEqual(res.status, 200);
  });
});

test.request hits the running miqro server via Unix socket. no port needed.

test JSX components:

import JSX from "@miqro/jsx";
import { strictEqual } from "node:assert";

// the second argument is required — test.jsx.test(cb) alone throws
// TypeError: Cannot read properties of undefined (reading 'runtimeOptions')
it("renders", test.jsx.test(async (container, root, runtime) => {
  function Hello() {
    return <p id="hello">HelloWorld</p>;
  }
  container.render(<Hello />);
  strictEqual(runtime.getElementById("hello").textContent, "HelloWorld");
}, {}));

test.jsx.test renders in a node runtime — runtime.getElementById is how you read the result back.

known limitation: components that call hooks (useState, useEffect, …) fail here with Error: not in render. only hook-free components can be tested this way today; test stateful components through their rendered HTML instead.

run tests:

miqro --test --service app/

Miqro object

import { Miqro } from "miqro";

const app = new Miqro({
  services: ["app/"],
  port: "3000",
  name: "myapp",       // prefixes log identifiers
  hotreload: false
});

await app.inflate({ inflateDir: "build/" });  // generate static files
await app.inflate();                           // inflate to memory
await app.start();                             // start server
await app.stop();                              // stop server
await app.reload();                            // hot reload
await app.restart();                           // full restart
await app.deflate();                           // drop the inflated router
app.disconnect();                              // detach cluster IPC, close db + ws

there is no dispose(). stop() then disconnect() is the shutdown pair; deflate() is what lets you inflate() again.

MiqroOptions also carries options the CLI and miqro.json do not expose:

allowedRedirectHosts  hosts the http→https redirect may target
serverName            the host this app is served as (redirect target)
logger                replace the root logger
logProviderOptions    transports / formatter for every logger

trigger static generation from a running server:

export default {
  path: "/publish",
  method: "POST",
  handler: async (req, res) => {
    const generator = new Miqro({
      services: ["app/"],
      name: "generator",
      port: "0"          // never listened on, inflate only
    });
    await generator.inflate({ inflateDir: "build/" });
    await generator.deflate();
    return res.json({ ok: true });
  }
}

cluster

npx miqro-cluster --service app/
CLUSTER_COUNT=4 npx miqro-cluster --service app/
npx miqro-cluster

CLUSTER_COUNT defaults to os.cpus().length. --inflate, --test, --compile, --migrate-up and --migrate-down run single-process even under miqro-cluster.

to run arbitrary scripts in cluster mode:

npx miqro-runner server.js
CLUSTER_COUNT=4 npx miqro-runner server.js

--no-build

use tsc instead of esbuild. run tsc first, then miqro.

generate a tsconfig:

miqro --install-tsconfig

tsconfig.json:

{
  "compilerOptions": {
    "target": "es2022",
    "noEmit": true,
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "lib": ["es2021", "dom"],
    "jsx": "react",
    "jsxFactory": "JSX.createElement",
    "jsxFragmentFactory": "JSX.Fragment"
  }
}

noEmit: true — tsc type-checks only, esbuild handles transpilation at runtime. remove noEmit and set outDir to use tsc output with --no-build.

development with tsc + miqro:

tsc --watch &
miqro --no-build --watch --service app/

note: in --no-build mode module-level state is shared across routes within a process. the stateless coding guarantee that esbuild provides does not apply. avoid module-level mutable state.

static site generator

miqro --inflate --inflate-dir build/ --service app/

.html.tsx handlers run with a live db connection. output is static HTML written to build/.

miqro --inflate --inflate-dir build/ --service app/
python3 -m http.server 8080 -d build/app/static/

https

miqro --https --https-key server.key --https-cert server.cert --service app/

with http redirect:

miqro --https --https-key server.key --https-cert server.cert --https-redirect 8080 --service app/

starts an additional http server on port 8080 that redirects all requests to https.

the redirect never trusts the caller's Host header. without allowedRedirectHosts or serverName — neither of which is settable from the CLI or miqro.json — only loopback hosts (localhost, 127.0.0.1, ::1) are redirected; anything else gets 400 Invalid Host header. set them through new Miqro({ ... }) for a real hostname.

via miqro.json:

{
  "https": true,
  "serverOptions": { "key": "-----BEGIN PRIVATE KEY-----\n...", "cert": "-----BEGIN CERTIFICATE-----\n..." },
  "httpsRedirect": 8080
}

serverOptions is handed straight to https.createServer, so key and cert must be the PEM contents — miqro.json does not read files, and a path fails at startup with PEM routines::no start line. use --https-key / --https-cert to load from disk. httpsRedirect is a number, not a string. there is no env var for https.

cli

miqro --service app/
miqro --watch --service app/
miqro --test --service app/
miqro --migrate-up --service app/
miqro --migrate-down --service app/
miqro --inflate --inflate-dir build/ --service app/
miqro --generate-doc --generate-doc-out API.md --service app/
miqro --compile --service app/

the compiled binary reads PORT from the environment, falling back to the port in miqro.json:

PORT=8080 ./app

known limitations of --compile:

flags:

-v, --version           print the version
-h, --help              print usage
--watch                 auto reload on file changes
--hot-reload            enable hot-reload with --watch
--test                  run tests
--migrate-up            run migrations up
--migrate-down          run migrations down
--inflate               generate static files
--inflate-dir           output directory (default: inflated/)
--generate-doc          generate API documentation
--generate-doc-out      output file (default: API.md)
--generate-doc-type     MD | JSON (default: MD)
--generate-doc-all      include all routes
--compile               build NODE:SEA binary
--no-build              skip esbuild, use pre-compiled files
--no-minify             skip minification
--inflate-only-assets   inflate assets only
--inflate-flat          inflate into dir directly
--inflate-sea           inflate with SEA compilation scripts
--install-tsconfig      create tsconfig.json
--install-miqrojson     create miqro.json
--install               install from binary cache (SEA only)
--disable-miqrojson     ignore miqro.json
--log-file              override LOG_FILE
--browser               override BROWSER
--config                override miqro.json path
--port                  override PORT
--name                  override server name
--https                 serve with https
--https-key             path to server.key
--https-cert            path to server.cert
--https-redirect        http redirect port
--inflate-parallel      max parallel esbuild instances (default: 1)
--enable-etag           enable etag generation by default (this is the default)
--disable-etag          disable etag generation by default

environment variables:

PORT                    default: 8080. miqro.json "port" takes precedence over it
BROWSER                 default browser, none to disable
LOG_FILE                log file path. unset means no file output
LOG_LEVEL               error | warn | info | debug | trace | none (default: info)
LOG_LEVEL_<IDENTIFIER>  per-route log level
TOKEN_LOCATION          header | query | cookie | free (default: free)
TOKEN_HEADER            default: Authorization
TOKEN_QUERY             default: token
TOKEN_COOKIE            default: Authorization
CORS_ORIGINS            default: *   (only for middleware.cors() with no args)
CORS_METHODS            default: GET,HEAD,PUT,PATCH,POST,DELETE
CORS_PREFLIGHT_CONTINUE default: false
CLEAR_JSX_CACHE         clear esbuild cache (default: 1)
JSX_TMP                 esbuild tmp dir (default: /tmp/jsx_tmp)
CLUSTER_COUNT           cluster workers (default: os.cpus().length)

miqro --help still lists DB, DB_STORAGE, DB_DIALECT and DB_CONNECTION. nothing reads them — database configuration comes from db.ts only.

build

npm install
npm run build