{ } qjs-lws
QuickJS × libwebsockets

Network servers that fit where Node.js doesn't.

qjs-lws binds libwebsockets into QuickJS: HTTP/1.1 and HTTP/2, WebSocket, raw TCP and UDP, TLS — driven by QuickJS's own event loop, with a JavaScript layer that looks like the web platform you already know.

Example for API:

import { serve, Response } from 'serve';

serve({
  port: 8080,

  websocket: {
    open(ws)          { ws.subscribe('chat'); },
    message(ws, data) { ws.publish('chat', data); },
  },

  fetch(req, server) {
    const { pathname } = new URL(req.url);

    if(pathname === '/ws' && server.upgrade(req))
      return;

    return new Response('hello from qjs-lws');
  },
});
import { createServer, LWSMPRO_CALLBACK, LWSMPRO_NO_MOUNT, LWS_WRITE_HTTP_FINAL } from 'lws';

createServer({
  port: 8080,
  vhostName: 'localhost',

  mounts: [
    { mountpoint: '/ws', protocol: 'ws',   originProtocol: LWSMPRO_NO_MOUNT },
    { mountpoint: '/',   protocol: 'http', originProtocol: LWSMPRO_CALLBACK },
  ],

  protocols: [
    {
      name: 'ws',
      onReceive(wsi, data) { wsi.write(data); },
    },
    {
      name: 'http',
      onHttp(wsi, uri) {
        wsi.respond(200, { 'content-type': 'text/plain' });
        wsi.write('hello from qjs-lws\n', LWS_WRITE_HTTP_FINAL);
      },
    },
  ],
});

Why it exists

A production-grade C networking stack and a very small interpreter, joined so that neither one has to pretend to be the other.

◇

No service loop

The binding hooks libwebsockets' pollfd callbacks straight into QuickJS's own os.setReadHandler and os.setWriteHandler. No lws_service() spun from JS, no polling tax — your script's normal event loop drives the stack. An optional epoll(7) backend collapses it to a single fd.

◈

Every role, one context

HTTP client and server, WebSocket client and server, raw TCP and UDP, static-file mounts, redirects, and TLS with peer-certificate introspection — all from one LWSContext, on one port if you want it that way.

◉

APIs you already know

fetch(), WebSocket, URL/URLSearchParams, Headers, Request, Response, FormData, WHATWG Streams, AbortController, TCPSocket. Written from the specs, not approximated.

▤

Bun-shaped serve()

serve({ fetch, websocket }) with server.upgrade(), WebSocket pub/sub topics, chunked streaming and backpressure — close enough to Bun that most handlers port across unchanged.

⇉

Routing and middleware

An Express-compatible app/Router layer when you want one: path params, method dispatch, sub-routers, sessions, multipart bodies, MIME types. Optional — the primitives underneath stay reachable.

◐

Drop to C-level control

The wrappers are a convenience, never a wall. Every callback reason libwebsockets emits is reachable as an onEventName handler, with the raw wsi in hand.

Built for the small end

Where a Node or Bun runtime is the largest thing on the device, and the job is one well-defined piece of networking.

Embedded & appliance firmware

A scriptable control plane on hardware with a flash budget: config UI, telemetry push over WebSocket, firmware upload through the native multipart parser.

Small utility servers

Health endpoints, webhook receivers, metrics scrapers, LAN dashboards — the services that never justified a node_modules tree.

Gateways & proxies

Terminate TLS, translate protocols, bridge WebSocket to raw TCP. Mixed HTTP and raw listeners on one vhost make protocol-sniffing front doors straightforward.

Two altitudes, same stack

Native — you hold the wsi

Protocol handlers map one-to-one onto struct lws_protocols.

import { createServer, LWSMPRO_NO_MOUNT } from 'lws';

createServer({
  port: 8080,
  vhostName: 'localhost',
  mounts: [{
    mountpoint: '/chat',
    protocol: 'chat',
    originProtocol: LWSMPRO_NO_MOUNT,
  }],
  protocols: [{
    name: 'chat',
    onEstablished(wsi)   { console.log('open', wsi.peer?.host); },
    onReceive(wsi, data) { wsi.write(data); },
    onClosed(wsi)        { console.log('close'); },
  }],
});

Web-shaped — you hold a Request

The same connection, wrapped in the APIs a browser would hand you.

import { fetch } from 'fetch';

const res = await fetch('https://example.com/api', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ hello: 'world' }),
});

console.log(res.status, res.headers.get('content-type'));
console.log(await res.json());

Get it running

CMake builds the module and, if you let it, the vendored libwebsockets too.

git clone --recursive https://github.com/rsenn/qjs-lws.git
cd qjs-lws
mkdir build && cd build
cmake -DDEBUG_OUTPUT=OFF -DDO_TESTS=ON ..
make -j

# lws.so lands in build/; point QuickJS at it
qjs -I ./build ../tests/unittests/test-lwscontext.js

Getting started → Full build options

Where it stands

Honest status: qjs-lws is in daily use and the native API is stable, but it hasn't cut a versioned release yet — track the changelog and pin a commit. The web-platform layer is a deliberate subset: no CORS, cache, or service-worker semantics, because this runs server-side. Known gaps are written down rather than papered over — see the API compatibility report and the BUGS file.

Worth a look next