Skip to content

http/server ​

ts
import {
createServer
} from 'mikro/http/server'

Serve HTTP from the device: config portals (in WiFi AP mode), small JSON APIs, dashboards, or webhook receivers. The server is low-level: a request handler returns a response, and routing is left to you (branch on req.method and req.url). Bring up the network first with wifi (station or access point) before listening.

Plaintext, no built-in auth

The server speaks plain HTTP with no TLS and no authentication. Treat req.url, req.headers, and the request body as untrusted: validate and bound anything you read from them, and add your own auth if the device is reachable beyond a trusted network. Response header names and values containing CR, LF, or NUL are dropped to prevent header injection, but you remain responsible for the body you return.

Functions ​

createServer(handler, options?) ​

ts
function createServer(handler: Handler, options?: ServerOptions): Server

createServer is lazy: it does no I/O and never fails. Nothing binds until you call listen. The handler runs once per request on the event loop and returns a ServerResponse (or a promise of one).

ts
const 
server
=
createServer
((
req
) => {
const
path
=
req
.
url
.
split
('?')[0]
if (
req
.
method
=== 'GET' &&
path
=== '/') return {
status
: 200,
body
: 'Hello'}
return {
status
: 404,
body
: 'Not found'}
}) const
result
=
server
.
listen
({
port
: 80})
if (!
result
.
ok
) {
console
.
error
('listen failed:',
result
.
error
)
}

Requests are handled one at a time (the underlying server blocks until your handler responds), which keeps memory bounded on-device.

server.listen(options) ​

ts
listen(options: {port: number}): Result<void, ServerError>

Binds the port and starts serving. Synchronous, and returns a Result so a bind failure (for example when the port is taken) is handled explicitly. Only one server can listen per device; a second listen returns AlreadyListening.

server.close() ​

ts
close(): void

Stops serving and releases the port. Safe to call when not listening. You can listen again afterward.

Routing ​

There is no built-in router; routing is left to your app. Strip the query string and branch on req.method and the path:

ts
const 
server
=
createServer
((
req
): ServerResponse => {
const
path
=
req
.
url
.
split
('?')[0]
if (
req
.
method
=== 'GET' &&
path
=== '/status') return {
status
: 200,
body
: 'ok'}
if (
req
.
method
=== 'GET' &&
path
?.
startsWith
('/echo/')) {
return {
status
: 200,
body
:
decodeURIComponent
(
path
.
slice
('/echo/'.
length
))}
} return {
status
: 404,
body
: 'Not found'}
})

req.text(), req.json(), and req.bytes() each return a Result and drain the body once; req.body is the underlying single-shot async iterable. The request body is buffered up to maxBodySize before the handler runs; an oversized body surfaces as a BodyTooLarge error when you read it.

Header lookup is by name only

req.headers exposes get(name) / getAll(name) but no way to list all headers. ESP-IDF's HTTP server can fetch a header by name but cannot enumerate them. Look up the headers you need (content-type, authorization, custom x-* headers, and so on) by name. Lookups are case-insensitive.

Streaming responses ​

Return an async iterable (typically an async generator) as the body to stream a response chunk-by-chunk instead of buffering it. Chunks are sent with backpressure (the generator is only pulled as fast as the client drains), and pulling stops automatically if the client disconnects. Each chunk costs one event-loop round-trip (copy, send, ack), so prefer a handful of larger chunks over many tiny ones.

ts
async function* 
ticks
() {
for (let
i
= 0; ;
i
++) {
yield `data: tick ${
i
}\n\n`
await
sleep
(1000)
} } const
server
=
createServer
(() => ({
status
: 200,
headers
: {'content-type': 'text/event-stream'},
body
:
ticks
(),
}))

A chunk may be a string (UTF-8 encoded) or a Uint8Array.

Handling errors ​

A handler that panics (throws or rejects) is isolated to its one request: the server keeps serving and the client gets a 500. Pass an onPanic hook to log the failure or return a custom response:

ts
const 
server
=
createServer
(
() => { throw new
Error
('boom')
}, {
onPanic
: (
error
,
req
) => {
console
.
error
('handler panicked for %s',
req
.
url
)
return {
status
: 500,
body
: 'Something went wrong'}
}, }, )

If onPanic returns nothing (or itself throws), a plain 500 is sent. onPanic is a safety net for unexpected throws, not where you do normal error handling: handle expected failures inside the handler (check the Result from req.json()/req.text()) and return an explicit {status, body} for each case, so the handler never panics in the first place.

Serving static files ​

There's no built-in static file server; compose one from fs and the request path:

ts
const 
types
:
Record
<string, string> = {
html
: 'text/html',
css
: 'text/css',
js
: 'text/javascript'}
const
server
=
createServer
((
req
) => {
const
path
=
req
.
url
.
split
('?')[0] ?? '/'
const
rel
=
path
=== '/' ? 'index.html' :
path
.
replace
(/^\/+/, '')
const
file
=
readFile
(`/app/www/${
rel
}`)
if (!
file
.
ok
) return {
status
: 404,
body
: 'Not found'}
const
ext
=
rel
.
split
('.').
pop
() ?? ''
return {
status
: 200,
headers
: {'content-type':
types
[
ext
] ?? 'application/octet-stream'},
body
:
file
.
value
,
} })

Validate or sandbox the path before opening files if untrusted clients can reach the server (reject .. segments).

Types ​

ServerOptions ​

ts
interface ServerOptions {
  maxBodySize?: number // cap on the buffered request body, bytes (default 16 KiB)
  onPanic?: PanicHandler
}

Handler ​

ts
type Handler = (req: ServerRequest) => ServerResponse | Promise<ServerResponse>

ServerRequest ​

ts
interface ServerRequest {
  readonly method: string
  readonly url: string // path plus query string, e.g. "/hello?x=1"
  readonly headers: {
    get(name: string): string | undefined
    getAll(name: string): string[]
  }
  readonly body: AsyncIterable<Result<Uint8Array, RequestError>>
  text(): Promise<Result<string, RequestError>>
  json(): Promise<Result<unknown, RequestError>>
  bytes(): Promise<Result<Uint8Array, RequestError>>
}

ServerResponse ​

ts
interface ServerResponse {
  status: number // always explicit; there is no implicit 200
  headers?: [string, string][] | Record<string, string>
  body?: string | Uint8Array | AsyncIterable<Uint8Array | string>
}

String bodies are sent as UTF-8 bytes, but the response declares no charset unless you set one. For text/html (or any text type) with non-ASCII content, include it explicitly: 'content-type': 'text/html; charset=utf-8'. Otherwise browsers can decode the bytes as Latin-1 and mangle them. JSON and text/event-stream are UTF-8 by spec and need no charset.

PanicHandler ​

ts
type PanicHandler = (
  error: unknown,
  req: ServerRequest,
) => ServerResponse | undefined | void | Promise<ServerResponse | undefined | void>

Errors ​

ServerError ​

Returned by listen.

VariantFieldsDescription
AlreadyListening—A server is already listening; close() it first
OutOfMemorymessage: stringThe server could not be allocated
StartFailedmessage: stringBinding failed (the port is in use, for example)

Request body reads (text/json/bytes) return a RequestError, shared with the HTTP client.

Plain HTTP only

The server speaks plain HTTP, which is the norm for LAN and AP-local use (config portals, dashboards, webhooks on a trusted network). There is no TLS server.

Mikro.js is built with AI assistance, code and docs alike. Read the AI disclosure.