miqro · @miqro/core · @miqro/parser · @miqro/query · @miqro/jsx · @miqro/jsx-dom · @miqro/jsx-node · @miqro/request · @miqro/runner · @miqro/test · @miqro/test-http
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
.html.tsx handlers run against a live DB at build timenode:sqlite, postgres with migrationsmiqro
├── @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
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/"
]
}
npm install miqro
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.
miqro --inflate --inflate-dir build/ --service example/
output in build/example/static/. serve with any http server.
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/
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.
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) => { ... }
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>
);
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 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.
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.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.
files served as-is, by path.
static/
logo.png → GET /logo.png
style.css → GET /style.css
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" }).
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/
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.
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]);
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 });
}
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 });
}
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: ...
}
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.
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;
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;
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.
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.
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.
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.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 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/
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 });
}
}
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
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.
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/
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.
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:
postject invalidates the ad-hoc signature node
ships with, and the sign-*.sh scripts do nothing on Linux, so a macOS binary cross built
from Linux is SIGKILLed on Apple Silicon. Re-codesign it on a Mac before distributing.compile.js spawns
sh ./compile.sh.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.
npm install
npm run build