Bun-compatible API (lib/serve.js, lib/lws/response.js, lib/lws/request.js, lib/websocket.js)
serve() (lib/serve.js) is a deliberately Bun.serve()-shaped
HTTP(+WS+raw) server, built on createContext()/lib/lws/protocols.js's
http() adapter. The goal: the same application code that calls
Bun.serve({...}) should run against qjs-lws with a single import-line
switch (import { serve } from 'bun' -> `import { serve } from
'qjs-lws/lib/serve.js'`), as far as lws's underlying primitives allow.
This file lists exactly what matches Bun's real API/semantics, what's close-but-not-identical, and what's a known, documented constraint of lws's model rather than an oversight. When something can't be made to behave exactly like Bun, that's called out explicitly here rather than silently shipping a different API under the same name.
serve(options[, fetch])
import { serve, Response } from './lib/serve.js';
const server = serve({ port: 8080 }, req => new Response('hello'));Matches Bun:
port,hostname/host,tls({cert, key}- constructs an SSL-capable vhost; see "TLS" below, a real vhost-construction bug exists right now).fetch(request, server)- called once per HTTP request, `(request: Request) => Response | Promise<Response>. The second argument is theServer` thisserve()call returns (seeserver.upgrade()/server.publish()below).routes-{ '/path/:id': handler | Response | {GET, POST, ...} }, tried beforefetch,req.paramspopulated from:namesegments, method dispatch with an automatic 405 (allowheader) andHEAD->GETfallback, matching Bun.- Returned
Serverhas.stop(),.port,.hostname. - A response with no
content-lengthheader streams asTransfer-Encoding: chunkedas the body is produced, not buffered first to compute a length - same as Bun. Setcontent-lengthyourself if you already know it and want it declared instead (streamed as-is, unchanged).
Not from Bun, additive (documented in serve()'s own doc comment,
lib/serve.js):
- No callback at all ->
serve()returns an async-iterable instead (for await(const x of serve({port}))) - a lower-level escape hatch, not part of Bun's API. options.raw- non-HTTP-looking raw TCP connections on the same port.options.mounts/options.protocols- drop to lws's own mount/protocol config directly when the high-level API isn't enough.options.{headers,html,access,auth}- rarer server-side lws callbacks (ADD_HEADERS/PROCESS_HTML/CHECK_ACCESS_RIGHTS/VERIFY_BASIC_AUTHORIZATION) with no Bun equivalent.
Request/Response/Headers (lib/lws/request.js, lib/lws/response.js, lib/lws/headers.js)
Standard WHATWG fetch() shapes (same classes fetch(), lib/fetch.js,
uses on the client side) - new Request(url, {method, headers, body}),
new Response(body, {status, headers}), .text()/.json()/
.arrayBuffer()/.formData(), Response.json()/.redirect(). Response
also carries a couple of Express-style conveniences Bun doesn't have
(.status(code)/.cookie()/.clearCookie(), chainable) - additive, not a
conflict, since status used as a property (not called) is a WHATWG
Response method here, not a number; use .statusCode for the number if
you're not calling .status(...) as a setter.
server.upgrade(request, options) - dynamic per-request WS upgrade
serve({
websocket: {
open(ws) { ws.subscribe('room:' + ws.data.room); },
message(ws, data) { ws.publish('room:' + ws.data.room, data); },
close(ws) {},
},
fetch(req, server) {
const url = new URL(req.url);
if(url.pathname === '/ws') return server.upgrade(req, { data: { room: 'lobby' } }) ? undefined : new Response('upgrade failed', { status: 500 });
if(url.pathname === '/serial') return server.upgrade(req, { data: { room: 'serial' } }) ? undefined : new Response('upgrade failed', { status: 500 });
return new Response('plain http');
},
});Matches Bun: fetch decides, per request (URL/headers/auth/whatever),
whether to promote this connection to a WebSocket, instead of a single
fixed mountpoint chosen up front. server.upgrade() returns true/false
matching Bun exactly; options.data becomes ws.data, readable in
open/message/close, same as Bun. There's still only one shared
open/message/close handler set (configured once, at the top of
serve()'s own options) - exactly like Bun, which also has no
per-endpoint handlers, only per-connection ws.data to differentiate
behavior (as in the example above).
Requires websocket to be configured in Bun's evented shape
({open, message, close}) - .upgrade() is a no-op returning false
with a plain Class/mountpoint-only websocket option, or with no
fetch handler (iterator-mode serve()) at all.
Must be called synchronously - before fetch's first await if it's
async - matching Bun's own contract. This one isn't optional cosmetics
here: it's how .upgrade() is actually implemented (see "How it works"
below), and it's a hard requirement of the underlying native callback.
How it works (and its real constraints)
lws never fires LWS_CALLBACK_HTTP (fetch normally) for a genuine
upgrade request at all - confirmed directly against lws's own
lib/roles/http/server/server.c: an Upgrade header routes straight to
LWS_CALLBACK_HTTP_CONFIRM_UPGRADE, then lws's own native WS handshake,
entirely bypassing the normal HTTP-request dispatch. server.upgrade()
is built by treating that callback as the "call fetch, see if it
upgraded" dispatch point instead - synthesizing a Request from the
still-unestablished connection's already-parsed headers/URL/method, and
checking - strictly synchronously - whether fetch's call made
server.upgrade() accept.
This has two real, load-bearing consequences, not just implementation trivia:
A
fetchthat rejects an upgrade (didn't call.upgrade()) has to answer back through the same synchronous native callback. Only a plain, already-in-memoryResponsereturned synchronously (not aPromise, not a streamed body) can be sent as that reply - lws's contract for a confirm-upgrade rejection requires the response already fully written by the time the callback returns, and there is no way to defer that from JS. Anasync fetch(or a streamed rejection body) can't satisfy this, so that case hangs up the connection outright instead of risking a malformed response. In practice this covers the overwhelmingly common case (`return new Response('Unauthorized', {status: 401})`) fine; it's a real gap for anything fancier.Which protocol an accepted upgrade actually binds to is resolved by lws purely by matching the client's
Sec-WebSocket-Protocolheader against registered protocol names (or the vhost's first/default protocol if that header is absent) - confirmed empirically to be completely independent of mount/URL.server.upgrade()accepting the request doesn't override this. The ordinarynew WebSocket(url)case (noSec-WebSocket-Protocolheader sent) is unaffected; a client that explicitly passes customprotocolstonew WebSocket(url, protocols)naming something this vhost never registered will fail the native handshake regardless of whatfetchdecided.
Status: implemented and confirmed working in every focused test
(a single WS endpoint, ws.data round-tripping correctly, a plain HTTP
request handled alongside it) - but a fuller multi-endpoint integration
test hit an unresolved hang, not yet root-caused. See `BUGS:
serve-upgrade-hangs-in-fuller-scenarioin this project'sBUGS` file
before relying on more than one .upgrade()-accepting endpoint per
server.
WS pub/sub - ws.subscribe()/.unsubscribe()/.publish(), server.publish()
serve({
websocket: {
open(ws) { ws.subscribe('chat'); },
message(ws, data) { ws.publish('chat', data); }, // -> everyone in 'chat' except ws itself
close(ws) {}, // subscriptions are dropped automatically
},
fetch(req, server) {
server.publish('chat', 'a message from outside any connection'); // -> everyone in 'chat', nobody excluded
return new Response('ok');
},
});Matches Bun's signatures and exclusion semantics exactly:
ws.subscribe(topic)/ws.unsubscribe(topic)/ws.isSubscribed(topic)ws.publish(topic, message)- broadcasts totopic's subscribers, excluding the calling socket (matches Bun)server.publish(topic, message)- same, but excludes nobody
A server-wide topic -> Set<WebSocket> registry (TopicRegistry,
lib/websocket.js) backs this - the app doesn't track its own list of
live sockets, and a closed socket's subscriptions are dropped
automatically.
Return value note: all three return the total bytes handed to
wsi.write() across every recipient - "bytes attempted", not a
confirmed-delivered count, since lws doesn't expose a per-write
backpressure/delivery result to JS the way Bun's (uWebSockets-backed)
exact accounting does. Close enough for "did this reach anyone" (0
means no subscribers), not for precise flow-control decisions.
Only available on the evented WebSocket class (lib/websocket.js) -
i.e. websocket: {open, message, close} (Bun's own shape) or a
server.upgrade()-accepted connection (which uses the same class
underneath). A plain Class/WebSocketStream-shaped websocket option
doesn't get these methods, matching how Bun's own pub/sub is specifically
a ServerWebSocket feature.
Idle timeout, backpressure, TLS peer info
server.timeout(seconds)- server-wide idle timeout, matching Bun's semantics: applied (and re-armed on every request/WS message) via the nativewsi.setTimeout()binding (lws_set_timeout(),doc/native/LWSSocket.md), which force-closes an idle connection -0disables it.websocket.idleTimeout- same mechanism, per-WS-handler; takes precedence overserver.timeout()when both are set on the same connection.websocket.drain(ws)- now actually fires:ws.send()arms a one-shot native "write queue fully flushed" callback whenever it leaves data buffered, which dispatches the'drain'event.WebSocket#writableNeedDrain/TCPSocket#writableNeedDrain- Node'ssocket.writableNeedDrainequivalent (wsi.sendPipeChoked) - true if asend()/write()right now would buffer instead of going out immediately.WebSocket#bufferedAmount- WHATWG/Bun/Deno standard property, bytes still queued to be sent (wsi.bufferedAmount).- TLS peer certificate info:
TCPSocket#peerCertificate/#tlsSessionReusedandWebSocket#peerCertificate/#tlsSessionReused(Node'stlsSocket.getPeerCertificate()/.isSessionReused()equivalents, seedoc/native/LWSSocket.md).ServerRequestdoesn't get its own copies - userequest.wsi.peerCertificatevia the existing.wsiescape hatch.
TLS
serve({ tls: {cert, key} }) constructs an SSL-capable vhost, matching
Bun's tls option shape (lib/lws/tls.js's tlsContextOptions()).
Currently segfaults during vhost construction - see `BUGS:
serve-tls-option-segfaultsin this project'sBUGS` file. Not
root-caused; found while verifying the streaming-response fix, out of
scope to fix as part of that work.
Known gaps not covered by this file
See TODO.md item 2.3/2.4 in this project (serve()'s section) for
smaller, longer-standing gaps against Bun: no bound-port reporting when
port: 0 is requested, no Bun.serve({static: {...}})-equivalent static
file convenience (drop to options.mounts by hand for now), and
HttpClientProtocol.connect() (the client side, fetch()) always
buffers the whole request body up front rather than streaming an
unknown-length one.