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.
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.
Lynk() constructorThe Lynk class is the main entry point. It holds all routing decorators, listener configuration, and shared state.
| Parameter | Type | Default | Description |
|---|---|---|---|
host | str | "0.0.0.0" | Default bind address for all listeners. |
port | int/None | None | Legacy single-port shorthand. Creates a listener named "default". |
ports | list[int]/None | None | Legacy multi-port shorthand. Creates listeners port_<n>. |
listeners | dict|list[Listener] | None | Preferred. Dict of {name: port} or list of Listener objects. |
protocol | str | "TCP" | Default protocol for listeners that don't override: "TCP", "UDP" or "AUTO". |
max_payload_size | int | 256 KiB | Max WebSocket frame / UDP datagram size. |
max_message_size | int | 1 MiB | Max reassembled fragmented WebSocket message. |
max_body_size | int | 1 MiB | Max HTTP request body. |
room_batch_size | int | 100 | Recipients per broadcast batch in emit_to_room. |
max_connections | int/None | None | Global limit (per-listener limits live on Listener). |
allowed_origins | list[str] | [] | WebSocket origin whitelist. Empty disables validation. |
rate_limit | int/None | None | Messages per second per WS client or UDP token. |
enable_keep_alive | bool | False | HTTP keep-alive connections. |
debug | bool | False | Enable debug logging. |
enable_database | bool | False | Enable soketDB integration. |
database_config | dict | None | soketDB options (primary_storage, backups, cloud). |
serve_client | bool | False | Serve built-in JS client & service worker. |
client_path | str | "/lynkio/client.js" | URL path for the JS client. |
service_worker_path | str | "/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()X-Framework: Lynkio, X-Framework-Version: 1.3.0, and — when serving on a named listener — X-Lynk-Listener: <name>.
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.
| Field | Type | Default | Description |
|---|---|---|---|
name | str | — | Unique listener identifier (used as listener= value). |
host | str | "0.0.0.0" | Bind address for this listener. |
port | int | 8765 | Listen port. |
protocol | str | "TCP" | "TCP", "UDP" or "AUTO". |
max_connections | int/None | None | Per-listener WebSocket client cap. |
rate_limit | int/None | None | Per-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"}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 dictPath 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}| Property | Description |
|---|---|
req.method | HTTP verb ("UDP" for UDP datagrams). |
req.path | Full path including query string. |
req.headers | Dict of lower-cased header names. |
req.body | Raw request body (bytes). |
req.client_ip | Peer IP address. |
req.listener | Name of listener that received the request. |
req.port | Actual TCP/UDP port. |
req.request_id | UUID generated per request (also in ContextVar). |
req.start_time | Monotonic timestamp for response-time logging. |
req.query_params | Parsed query string dict. |
req.cookies | Parsed cookies dict. |
await req.json() | Parse body as JSON (cached). |
await req.form() | Parse body as URL-encoded form. |
json_response(data, status=200)— force a JSON response.redirect(location, status=302)— HTTP redirect.abort(status_code, message)— raise anHTTPError.send_file(...)— stream a file withContent-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"})app.static("/assets", "public", listener="public")
api = app.group("/api/v1", listener="admin")
@api.get("/health")
async def health(req):
return {"status": "ok"}@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 Noneapp.enable_cors(
allowed_origins=["https://example.com"],
allow_credentials=True,
)Upgrade happens automatically when the client sends Upgrade: websocket. Lynkio supports fragmentation, ping/pong heartbeat, binary frames, and named binary events.
| Member | Description |
|---|---|
client.id | Unique UUID for this session. |
client.session | Dict for arbitrary app state. |
client.rooms | Set of room names the client belongs to. |
client.listener | Listener name the socket is attached to. |
client.port | Listener 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. |
@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")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@app.on_internal("connect")
async def on_connect(client, _):
print("connected", client.id)
# Available: "connect", "disconnect", "subscribe", "unsubscribe", "message"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") # -> boolemit_to_room sends in chunks of room_batch_size (default 100) to keep the event loop responsive on very large rooms.
@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 9091UDP shares the HTTP middleware chain (including CORS and listener-scoped middleware), rate limiting, and max payload enforcement.
protocol="AUTO" on a listener to bind both TCP (HTTP/WebSocket) and UDP on the same port. Traffic is dispatched by packet type.
@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.
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,),
)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.
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>| Method | Returns | Description |
|---|---|---|
new LynkClient(host, listeners?, options?) | LynkClient | Create a client. host must be a WS/WSS URL. |
await client.connect() | Promise | Open the WebSocket. Resolves on onopen. |
client.on(event, cb) | void | Register a JSON event handler. |
client.emit(event, data) | void | Send {"event", "data"} as JSON. |
client.onBinary(cb) | void | Register a raw binary frame handler (Uint8Array). |
client.onBinaryEvent(name, cb) | void | Register a named binary event handler. |
client.sendBinary(data) | void | Send a raw binary frame. |
client.sendBinaryEvent(name, data) | void | Send a [uint16 len][name][payload] frame. |
client.createTask(fn, interval) | id | setInterval (or setTimeout if 0). |
client.scheduleTask(interval, fn) | id | Alias for createTask with interval. |
client.clearTask(id) | void | Cancel a scheduled task. |
client.joinRoom(room) | void | Emits join with { room }. |
client.leaveRoom(room) | void | Emits leave with { room }. |
client.setSession(key, value) | void | Emits set_session. |
client.sendUdp(path, data) | void | Emits __udp passthrough. |
client.server() | server | Return the Service-Worker-backed HTTP server (singleton). |
client.createDataset(name) | user_dataset | Create an IndexedDB dataset wrapper. |
client.close() | void | Stop 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();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.
| Method | Description |
|---|---|
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();lynk.createDataset(name) returns a small promise-based wrapper over IndexedDB with one key-value store per table.
| Method | Description |
|---|---|
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();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.
def metrics_plugin(app):
@app.get("/metrics")
async def metrics(req):
return app.get_metrics()
app.use(metrics_plugin)resp = await app.fetch("https://api.example.com/data")
data = await resp.json()
print(resp.status_code, resp.headers, resp.content)@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}")| Method / Decorator | Description |
|---|---|
@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_binary | Generic 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.task | Background 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. |
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)#!/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()