v1.3.0 · Multi-listener & Service Worker client

Lynkio · Real-Time Event Engine

Pure Python, zero dependencies — HTTP routing, RFC6455 WebSockets, UDP datagrams and AUTO mode on one port. Now with multi-listener support and a browser-side JS client that ships a Service-Worker HTTP server plus an IndexedDB dataset layer.

What is Lynkio?

Lynkio is a lightweight, high-performance framework for building real-time applications using only the Python standard library. It combines a native HTTP router, a full WebSocket server (RFC6455), a UDP datagram server, and an innovative AUTO mode that serves TCP (HTTP/WebSocket) and UDP concurrently on the same port.

Multi-Listener NEW

Run multiple ports in one process. Each listener has its own routes, middleware, protocol, and connection pool.

Pure asyncio

Zero external dependencies. Only the Python standard library — trivial to deploy anywhere.

AUTO Protocol

TCP and UDP bound to the same port simultaneously. HTTP, WebSocket and datagrams coexist.

JS Client & SW Server NEW

Browser client includes a Service-Worker HTTP server, IndexedDB dataset, and full WebSocket stack.

Standards & Safety

RFC6455 fragmentation, rate limiting, origin validation, keep-alive, CORS, graceful shutdown.

soketDB Logging

Optional async file-based logging for HTTP, WebSocket and runtime events with auto-sync.

Core — the Lynk() constructor

The Lynk class is the main entry point. It holds all routing decorators, listener configuration, and shared state.

ParameterTypeDefaultDescription
hoststr"0.0.0.0"Default bind address for all listeners.
portint/NoneNoneLegacy single-port shorthand. Creates a listener named "default".
portslist[int]/NoneNoneLegacy multi-port shorthand. Creates listeners port_<n>.
listenersdict|list[Listener]NonePreferred. Dict of {name: port} or list of Listener objects.
protocolstr"TCP"Default protocol for listeners that don't override: "TCP", "UDP" or "AUTO".
max_payload_sizeint256 KiBMax WebSocket frame / UDP datagram size.
max_message_sizeint1 MiBMax reassembled fragmented WebSocket message.
max_body_sizeint1 MiBMax HTTP request body.
room_batch_sizeint100Recipients per broadcast batch in emit_to_room.
max_connectionsint/NoneNoneGlobal limit (per-listener limits live on Listener).
allowed_originslist[str][]WebSocket origin whitelist. Empty disables validation.
rate_limitint/NoneNoneMessages per second per WS client or UDP token.
enable_keep_aliveboolFalseHTTP keep-alive connections.
debugboolFalseEnable debug logging.
enable_databaseboolFalseEnable soketDB integration.
database_configdictNonesoketDB options (primary_storage, backups, cloud).
serve_clientboolFalseServe built-in JS client & service worker.
client_pathstr"/lynkio/client.js"URL path for the JS client.
service_worker_pathstr"/lynkio/service-ws.js"URL path for the service worker script. NEW
from lynkio import Lynk

# Single legacy port
app = Lynk(host="0.0.0.0", port=8080, protocol="AUTO", rate_limit=30, debug=True)

# Multi-listener (preferred)
from lynkio import Lynk, Listener

app = Lynk(
    listeners=[
        Listener("public",   port=8080, protocol="TCP", max_connections=1000),
        Listener("admin",    port=8081, protocol="TCP", max_connections=10),
        Listener("internal", port=9091, protocol="UDP", rate_limit=100),
    ],
    debug=True,
    serve_client=True,
)

app.run()
Response headers. Every HTTP response is stamped with X-Framework: Lynkio, X-Framework-Version: 1.3.0, and — when serving on a named listener — X-Lynk-Listener: <name>.
Listeners — multi-port servers NEW

A Listener describes one network endpoint. Pass a list to Lynk() and every route, middleware and event handler can be scoped to it via the listener="name" keyword.

FieldTypeDefaultDescription
namestr—Unique listener identifier (used as listener= value).
hoststr"0.0.0.0"Bind address for this listener.
portint8765Listen port.
protocolstr"TCP""TCP", "UDP" or "AUTO".
max_connectionsint/NoneNonePer-listener WebSocket client cap.
rate_limitint/NoneNonePer-listener messages/sec limit.
from lynkio import Lynk, Listener

listeners = [
    Listener("public",   port=8080, protocol="TCP", max_connections=1000),
    Listener("admin",    port=8081, protocol="TCP", max_connections=10),
    Listener("internal", port=9091, protocol="UDP", rate_limit=100),
]

app = Lynk(listeners=listeners, debug=True, serve_client=True)

# Scope routes / middleware to one listener:
@app.get("/", listener="public")
async def index(req):
    return {"listener": req.listener, "port": req.port}

@app.get("/admin/stats", listener="admin")
async def stats(req):
    return app.get_metrics()

# Global route: no listener= means it exists on every listener
@app.get("/health")
async def health(req):
    return {"status": "ok"}
Cross-listener broadcasting

WebSocket clients connected to different listeners all live in the same _clients_all registry. You can target a listener, a port, or the whole cluster.

await app.emit("chat", {"msg": "hi"})                 # All clients everywhere
await app.emit_to_listener("public", "news", {...})   # Only "public" listener
await app.emit_to_port(8080, "news", {...})           # Only port 8080
await app.emit_to_room("lobby", "chat", {...})        # Room broadcast

app.connection_count()                 # total
app.connection_count(listener="public")# per-listener
app.get_metrics()                      # per-listener dict
HTTP Routing & Responses
Route decorators

Path parameters support both /user/<user_id> and /post/{post_id} syntax. All decorators accept an optional listener="name".

@app.get("/users/<user_id>/posts/<post_id>")
async def get_post(req, user_id, post_id):
    return {"user": user_id, "post": post_id}

@app.post("/submit", listener="admin")
async def submit(req):
    return json_response({"ok": True}, 201)

@app.route("/mixed", methods=["GET", "POST"])
async def mixed(req):
    return {"method": req.method}
The request object
PropertyDescription
req.methodHTTP verb ("UDP" for UDP datagrams).
req.pathFull path including query string.
req.headersDict of lower-cased header names.
req.bodyRaw request body (bytes).
req.client_ipPeer IP address.
req.listenerName of listener that received the request.
req.portActual TCP/UDP port.
req.request_idUUID generated per request (also in ContextVar).
req.start_timeMonotonic timestamp for response-time logging.
req.query_paramsParsed query string dict.
req.cookiesParsed cookies dict.
await req.json()Parse body as JSON (cached).
await req.form()Parse body as URL-encoded form.
Response helpers
  • json_response(data, status=200) — force a JSON response.
  • redirect(location, status=302) — HTTP redirect.
  • abort(status_code, message) — raise an HTTPError.
  • send_file(...) — stream a file with Content-Disposition, Last-Modified, Cache-Control.
  • render_template(name, context, dir) — basic {{ dotted.path }} template engine.
  • FileResponse & StreamingResponse — for custom streamed bodies.

Returning a plain dict or str from a handler also works — Lynkio auto-detects content-type.

from lynkio import send_file, render_template, json_response

@app.get("/download")
async def download(req):
    return send_file("report.pdf", as_attachment=True, cache_control="max-age=3600")

@app.get("/page")
async def page(req):
    return render_template("index.html", {"title": "Lynkio"})
Static files & route groups
app.static("/assets", "public", listener="public")

api = app.group("/api/v1", listener="admin")
@api.get("/health")
async def health(req):
    return {"status": "ok"}
HTTP middleware (listener-scoped)

@app.http_middleware(listener="name") binds middleware to a listener. Global middleware uses no argument (or listener=None). Middleware can return None to continue or a response object to short-circuit.

ADMIN_KEY = "admin123"

@app.http_middleware(listener=None)      # runs on every listener
async def log_request(req):
    print(f"[{req.listener}] {req.method} {req.path}")
    return None

@app.http_middleware(listener="admin")
async def admin_auth(req):
    if req.headers.get("x-api-key") != ADMIN_KEY:
        return json_response({"error": "unauthorized"}, 401)
    return None
CORS
app.enable_cors(
    allowed_origins=["https://example.com"],
    allow_credentials=True,
)
WebSocket server (RFC6455)

Upgrade happens automatically when the client sends Upgrade: websocket. Lynkio supports fragmentation, ping/pong heartbeat, binary frames, and named binary events.

The Connection object
MemberDescription
client.idUnique UUID for this session.
client.sessionDict for arbitrary app state.
client.roomsSet of room names the client belongs to.
client.listenerListener name the socket is attached to.
client.portListener port.
await client.send(payload, text=True)Send string or bytes.
await client.ping()Send a ping frame.
await client.close(code, reason)Send a close frame and shut down.
await client.send_binary_event(name, data)Send a named binary event to this client.
Event handlers
@app.on("chat")                             # JSON events: {"event": "...", "data": ...}
async def chat(client, data):
    await app.emit_to_room(data["room"], "chat", data["text"])

@app.on_binary                            # Any binary frame
async def any_binary(client, payload: bytes):
    print(len(payload))

@app.on_binary_event("image")             # Named binary event
async def image_stream(client, payload: bytes):
    print(f"chunk {len(payload)} bytes")
WebSocket middleware (listener-scoped)

Middleware receives (client, event, data), may return modified data, or raise StopProcessing to drop the event.

from lynkio import StopProcessing

@app.middleware                     # global
async def log_mw(client, event, data):
    print(client.listener, event)
    return data

@app.middleware(listener="admin")   # listener-scoped NEW
async def admin_only(client, event, data):
    if not client.session.get("auth"):
        raise StopProcessing
    return data
Internal lifecycle hooks
@app.on_internal("connect")
async def on_connect(client, _):
    print("connected", client.id)

# Available: "connect", "disconnect", "subscribe", "unsubscribe", "message"
Rooms & pub/sub

Rooms are global across all listeners, so a client on public can broadcast to a client on admin.

@app.on("join")
async def join(client, data):
    room = data["room"]
    app.join_room(client.id, room)
    await app.emit_to_room(room, "system", f"{client.id} joined", exclude=client.id)

@app.on("leave")
async def leave(client, data):
    app.leave_room(client.id, data["room"])

app.get_room_clients("lobby")          # -> Set[str]
app.is_client_in_room(client_id, "lobby")  # -> bool
Batched broadcast. emit_to_room sends in chunks of room_batch_size (default 100) to keep the event loop responsive on very large rooms.
UDP datagram server & AUTO mode

@app.udp(path) registers a JSON-datagram handler. Each datagram is a JSON object containing "path", optional "data" and optional "client_id" (used for rate limiting).

@app.udp("/sensor", listener="internal")
async def sensor(req):
    payload = await req.json()
    return {"status": "ok", "echo": payload}

# Client side:
# echo '{"path":"/sensor","data":{"temp":22.5},"client_id":"sensor1"}' | nc -u localhost 9091

UDP shares the HTTP middleware chain (including CORS and listener-scoped middleware), rate limiting, and max payload enforcement.

AUTO mode. Set protocol="AUTO" on a listener to bind both TCP (HTTP/WebSocket) and UDP on the same port. Traffic is dispatched by packet type.
Background tasks & scheduler
@app.task
async def monitor():
    while app._running:
        await asyncio.sleep(30)
        print(f"Active connections: {app.connection_count()}")

@app.schedule(3600)
async def hourly_cleanup():
    await app.query_database("logs", "DELETE FROM old_entries")

Both are cancelled automatically during graceful shutdown.

soketDB integration & logging
app = Lynk(enable_database=True)
db = app.create_database("myapp", create_log_table=True, auto_sync_log=True)

When create_log_table=True, three tables are created: http_logs, wss_logs, runtime_logs. With auto_sync_log=True every request, message, connect/disconnect and lifecycle event is written asynchronously.

# Manual log entry
await app.add_log("runtime", level="INFO", message="Custom event", source="auth")

# Distributed query (runs on any registered soketDB in a thread executor)
rows = await app.query_database(
    "myapp",
    "SELECT * FROM http_logs WHERE status_code = $1",
    (200,),
)
JavaScript client & service worker server

The bundled JS client is exported as globalThis.LynkClient. It ships three building blocks: a WebSocket client, an in-browser HTTP server (routed through a Service Worker), and an IndexedDB dataset wrapper.

Install & import

Enable serve_client=True in the Python app, then load the script in the browser:

<script src="/lynkio/client.js"></script>

<script>
  const lynk = new LynkClient(
    "ws://localhost:8080",          // WebSocket host
    { public: 8080, admin: 8081 },  // Optional: listener → port map
    { maxReconnectAttempts: 10 }
  );
</script>
LynkClient API
MethodReturnsDescription
new LynkClient(host, listeners?, options?)LynkClientCreate a client. host must be a WS/WSS URL.
await client.connect()PromiseOpen the WebSocket. Resolves on onopen.
client.on(event, cb)voidRegister a JSON event handler.
client.emit(event, data)voidSend {"event", "data"} as JSON.
client.onBinary(cb)voidRegister a raw binary frame handler (Uint8Array).
client.onBinaryEvent(name, cb)voidRegister a named binary event handler.
client.sendBinary(data)voidSend a raw binary frame.
client.sendBinaryEvent(name, data)voidSend a [uint16 len][name][payload] frame.
client.createTask(fn, interval)idsetInterval (or setTimeout if 0).
client.scheduleTask(interval, fn)idAlias for createTask with interval.
client.clearTask(id)voidCancel a scheduled task.
client.joinRoom(room)voidEmits join with { room }.
client.leaveRoom(room)voidEmits leave with { room }.
client.setSession(key, value)voidEmits set_session.
client.sendUdp(path, data)voidEmits __udp passthrough.
client.server()serverReturn the Service-Worker-backed HTTP server (singleton).
client.createDataset(name)user_datasetCreate an IndexedDB dataset wrapper.
client.close()voidStop reconnect loop, cancel tasks, close socket.
const lynk = new LynkClient("ws://localhost:8080", { public: 8080 });

lynk.on("message", (data) => console.log("message", data));
lynk.onBinary((chunk) => console.log("binary", chunk.length));
lynk.onBinaryEvent("audio", (payload) => { /* Uint8Array */ });

await lynk.connect();
lynk.joinRoom("lobby");
lynk.emit("chat", { room: "lobby", text: "hi" });
lynk.sendBinaryEvent("image", new Uint8Array([1, 2, 3]));

const tid = lynk.createTask(() => lynk.emit("ping", {}), 5000);
lynk.clearTask(tid);

lynk.close();
Service-worker HTTP server NEW

Call lynk.server() to obtain an Express-style router that runs entirely in a Service Worker. Routes are registered from the page and executed in the SW; requests are intercepted at the network layer.

MethodDescription
srv.use(fn)Global middleware (req, res, next).
srv.intercept(fn)Response interceptor, runs just before the response is returned.
srv.get(path, listener, handler)Register GET. listener may be null.
srv.post(path, listener, handler)Register POST.
srv.put(path, listener, handler)Register PUT.
srv.delete(path, listener, handler)Register DELETE.
srv.route(path, listener, {method}, handler)Register with explicit method.
srv.cache(files, options)Pre-cache a list of URLs in the service worker.
await srv.ready()Wait for all pending route registrations to reach the worker.

Supported path patterns: /users/<id> (named params), /files/* (wildcard), and /users/<id>/* (param + wildcard). Handlers receive a req object with { url, method, pathname, hostname, port, headers, query, cookies, body, bodyType, params, wildcard, route, timestamp, requestId } and a res object with .json(), .text(), .status(), .setHeader(). Returning an object auto-JSONs; returning a Response passes through.

const lynk = new LynkClient("ws://localhost:8080", { public: 8080 });
const srv  = lynk.server();

srv.use(async (req, res, next) => {
  console.log("[SW]", req.method, req.pathname);
  await next();
});

srv.intercept(async (req, res) => {
  res.setHeader("X-Powered-By", "Lynkio-SW");
});

srv.get("/api/hello", null, async (req, res) => {
  return { message: "hello from the service worker" };
});

srv.get("/users/<id>", null, async (req, res) => {
  return { id: req.params.id, query: req.query };
});

srv.post("/echo", null, async (req, res) => {
  return { body: req.body, type: req.bodyType };
});

await srv.ready();
IndexedDB dataset NEW

lynk.createDataset(name) returns a small promise-based wrapper over IndexedDB with one key-value store per table.

MethodDescription
await db.create_table(name)Create an object store if it doesn't exist.
await db.insert(table, value, key)put(value, key) — insert or overwrite.
await db.select_from(table, key)Retrieve one record.
await db.delete_from(table, key)Delete one record.
await db.delete_all(table)Clear a table.
await db.delete_dataset()Drop the entire database.
const db = lynk.createDataset("mydb");

await db.create_table("users");
await db.insert("users", { name: "Alex", role: "admin" }, "user_1");

const user = await db.select_from("users", "user_1");
console.log(user);                        // { name: "Alex", role: "admin" }

await db.delete_from("users", "user_1");
await db.delete_all("users");
await db.delete_dataset();
Python client (lynkio_client)

Full async client with WebSocket, HTTP and UDP support. Import from lynkio.

from lynkio import LynkClient
import asyncio

async def main():
    client = LynkClient("localhost", 8765)
    await client.ws.connect()
    client.on("greet", print)
    await client.ws.emit("ping", "hello")
    await client.ws.send_binary_event("data", b'\x01\x02')
    # HTTP
    status, headers, body = await client.http.get("/api/status")
    # UDP
    resp = await client.udp.send(b'{"path":"/ping"}')
    await client.ws.close()

asyncio.run(main())

Separate classes WebSocketClient, HTTPClient, and UDPClient are also available.

Plugins, fetch & internal hooks
Plugin system
def metrics_plugin(app):
    @app.get("/metrics")
    async def metrics(req):
        return app.get_metrics()

app.use(metrics_plugin)
Internal HTTP client
resp = await app.fetch("https://api.example.com/data")
data = await resp.json()
print(resp.status_code, resp.headers, resp.content)
Both HTTP + WebSocket on one path
@app.both("/room")
async def room(*args):
    if len(args) == 1:          # HTTP request
        return {"rooms": len(app._rooms)}
    client, data = args         # WebSocket event
    await client.send(f"hi {client.id}")
Complete method reference
Method / DecoratorDescription
@app.get/.post/.put/.delete/.patch(path, listener=?)HTTP route. Path params via <id> or {id}.
@app.route(path, methods, listener=?)Explicit HTTP method list.
@app.udp(path, listener=?)UDP datagram route (JSON body).
@app.both(path, listener=?)Dual HTTP + WebSocket handler.
app.static(prefix, dir, listener=?)Serve static files (traversal-safe).
app.group(prefix, listener=?)Prefix + middleware group.
@app.on(event)WebSocket JSON event handler.
@app.on_binaryGeneric binary frame handler.
@app.on_binary_event(name)Named binary event handler.
@app.on_internal(event)Lifecycle hook (connect, disconnect, subscribe, unsubscribe, message).
@app.middleware(listener=?)WebSocket middleware; can raise StopProcessing.
@app.http_middleware(listener=?)HTTP middleware; return response to short-circuit.
app.enable_cors(origins, credentials)Enable global CORS + preflight.
app.join_room(cid, room)Add a client to a room.
app.leave_room(cid, room)Remove a client from a room.
app.get_room_clients(room)Set of client IDs in a room.
app.is_client_in_room(cid, room)Boolean check.
await app.emit(event, data, client_id?)Target one client or broadcast.
await app.emit_to_listener(name, event, data)Broadcast within a listener. NEW
await app.emit_to_port(port, event, data)Broadcast within a port. NEW
await app.emit_to_room(room, event, data, exclude?)Batched room broadcast.
await app.send_binary_event(cid, name, data)Send a named binary event to one client.
app.connection_count(listener=?)Total / per-listener WS count.
app.get_metrics()Per-listener connection metrics dict. NEW
@app.taskBackground coroutine started with the server.
@app.schedule(interval)Periodic scheduled coroutine (seconds).
app.create_database(name, create_log_table, auto_sync_log)Init soketDB + log tables.
await app.add_log(table, **kwargs)Manual log write (http|wss|runtime).
await app.query_database(db, sql, params?)Async query on any registered soketDB.
await app.fetch(url, ...)Async HTTP client inside the server.
app.use(plugin)Register a plugin (callable).
app.run() / await app.stop()Start / graceful shutdown.
Full real-time chat server
import asyncio
from lynkio import Lynk, render_template

app = Lynk(protocol="AUTO", serve_client=True, enable_database=True)
app.create_database("chat_logs", create_log_table=True, auto_sync_log=True)

@app.get("/")
async def index(req):
    return render_template("chat.html", {"title": "Lynkio Chat"})

@app.on("join")
async def join(client, data):
    name = data.get("name", "anon")
    client.session["name"] = name
    app.join_room(client.id, "main")
    await app.emit_to_room("main", "system", f"{name} joined")

@app.on("msg")
async def message(client, data):
    await app.emit_to_room("main", "chat", {
        "name": client.session.get("name"),
        "text": data["text"],
    })

@app.task
async def stats():
    while app._running:
        await asyncio.sleep(60)
        print(f"Active connections: {app.connection_count()}")

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)
Multi-listener reference server
#!/usr/bin/env python3
"""
Lynkio Multi-Listener Server
  • public    : HTTP + WebSocket  → 8080
  • admin     : HTTP (X-API-Key)  → 8081
  • internal  : UDP telemetry     → 9091
Serves the built-in JS client at /lynkio/client.js
"""

import asyncio, time, json
from lynkio import (
    Lynk, Listener, json_response, redirect, render_template
)

listeners = [
    Listener("public",   port=8080, protocol="TCP", max_connections=1000),
    Listener("admin",    port=8081, protocol="TCP", max_connections=10),
    Listener("internal", port=9091, protocol="UDP", rate_limit=100),
]

app = Lynk(listeners=listeners, debug=True, serve_client=True)

# ------------------------------------------------------------------
# Middleware
# ------------------------------------------------------------------
@app.http_middleware(listener=None)
async def log_request(req):
    print(f"[{req.listener}] {req.method} {req.path}")
    return None

ADMIN_KEY = "admin123"

@app.http_middleware(listener="admin")
async def admin_auth(req):
    if req.headers.get("x-api-key") != ADMIN_KEY:
        return json_response({"error": "unauthorized"}, 401)
    return None

# ------------------------------------------------------------------
# Public routes
# ------------------------------------------------------------------
@app.get("/", listener="public")
async def index(req):
    return render_template("home.html")

@app.get("/health", listener="public")
async def health(req):
    return {"status": "ok", "timestamp": time.time(),
            "listener": req.listener}

@app.post("/echo", listener="public")
async def echo(req):
    return json_response(await req.json())

# ------------------------------------------------------------------
# Admin routes
# ------------------------------------------------------------------
@app.get("/admin/stats", listener="admin")
async def admin_stats(req):
    return {
        "connections": app.connection_count(),
        "rooms": len(app._rooms),
        "metrics": app.get_metrics(),
    }

# ------------------------------------------------------------------
# WebSocket events (global across listeners)
# ------------------------------------------------------------------
@app.on("join")
async def ws_join(client, data):
    room = data.get("room", "lobby")
    app.join_room(client.id, room)
    await app.emit_to_room(room, "system", "Client joined",
                           exclude=client.id)
    await client.send(json.dumps({
        "event": "connected",
        "data": {"room": room, "listener": client.listener},
    }))

@app.on("chat")
async def ws_chat(client, data):
    room, text = data.get("room"), data.get("text")
    if room and text and app.is_client_in_room(client.id, room):
        await app.emit_to_room(room, "chat", {
            "from": client.id, "text": text, "timestamp": time.time(),
        }, exclude=client.id)

@app.on_binary_event("binary_ping")
async def ws_binary_ping(client, payload):
    print(f"binary ping from {client.id}: {len(payload)} bytes")
    await client.send_binary_event("binary_echo", payload[:100] + b"...")

# ------------------------------------------------------------------
# UDP
# ------------------------------------------------------------------
@app.udp("/telemetry", listener="internal")
async def udp_telemetry(req):
    payload = await req.json()
    print("telemetry:", payload)
    return {"status": "ok", "received": payload}

# ------------------------------------------------------------------
# Background task
# ------------------------------------------------------------------
@app.task
async def stats_printer():
    while app._running:
        await asyncio.sleep(30)
        print(f"Stats: {app.connection_count()} connections, "
              f"{len(app._rooms)} rooms")

if __name__ == "__main__":
    print("=" * 52)
    print("🚀 Lynkio multi-listener server")
    print("=" * 52)
    print("🌐 public   http/ws : http://localhost:8080")
    print("🔒 admin    http    : http://localhost:8081 (X-API-Key: admin123)")
    print("📡 internal udp     : localhost:9091 (path: /telemetry)")
    print("📦 JS client        : /lynkio/client.js")
    print("=" * 52)
    print("WebSocket clients on any listener share the same "
          "global rooms & broadcast registry.")
    app.run()