{"id":50100478,"url":"https://github.com/lanteanio/svelte-adapter-uws","last_synced_at":"2026-05-23T07:10:45.704Z","repository":{"id":343343716,"uuid":"1177363577","full_name":"lanteanio/svelte-adapter-uws","owner":"lanteanio","description":"SvelteKit adapter for uWebSockets.js with built-in WebSocket support","archived":false,"fork":false,"pushed_at":"2026-05-16T20:12:19.000Z","size":3074,"stargazers_count":27,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-16T20:39:20.223Z","etag":null,"topics":["adapter","svelte","sveltekit","uwebsockets","websocket"],"latest_commit_sha":null,"homepage":"https://svelte-realtime.dev/docs/ecosystem/adapter","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lanteanio.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-10T00:39:49.000Z","updated_at":"2026-05-16T20:14:08.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/lanteanio/svelte-adapter-uws","commit_stats":null,"previous_names":["lanteanio/svelte-adapter-uws"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/lanteanio/svelte-adapter-uws","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanteanio%2Fsvelte-adapter-uws","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanteanio%2Fsvelte-adapter-uws/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanteanio%2Fsvelte-adapter-uws/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanteanio%2Fsvelte-adapter-uws/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lanteanio","download_url":"https://codeload.github.com/lanteanio/svelte-adapter-uws/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lanteanio%2Fsvelte-adapter-uws/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33386147,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T04:15:53.637Z","status":"ssl_error","status_checked_at":"2026-05-23T04:15:53.242Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["adapter","svelte","sveltekit","uwebsockets","websocket"],"created_at":"2026-05-23T07:10:44.593Z","updated_at":"2026-05-23T07:10:45.692Z","avatar_url":"https://github.com/lanteanio.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# svelte-adapter-uws\n\nA SvelteKit adapter powered by [uWebSockets.js](https://github.com/uNetworking/uWebSockets.js) - the fastest HTTP/WebSocket server available for Node.js, written in C++ and exposed through V8.\n\nI've been loving Svelte and SvelteKit for a long time. I always wanted to expand on the standard adapters, sifting through the internet from time to time, never finding what I was searching for - a proper high-performance adapter with first-class WebSocket support, native TLS, pub/sub built in, and a client library that just works. So I'm doing it myself.\n\n## What you get\n\n- **HTTP \u0026 HTTPS** - native TLS via uWebSockets.js `SSLApp`, no reverse proxy needed\n- **WebSocket \u0026 WSS** - built-in pub/sub with a reactive Svelte client store\n- **In-memory static file cache** - assets loaded once at startup, served from RAM with precompressed brotli/gzip variants\n- **Dynamic response compression** - SSR HTML and API JSON compressed on the fly with brotli or gzip\n- **Backpressure handling** - streaming responses that won't blow up memory\n- **Graceful shutdown** - waits for in-flight requests before exiting\n- **Health check endpoint** - `/healthz` out of the box\n- **Zero-config WebSocket** - just set `websocket: true` and go\n\n**Upgrading from 0.4.x?** See the [migration guide](./MIGRATION.md) for every breaking change between 0.4.x and 0.5.x.\n\n---\n\n## Table of contents\n\n**Getting started**\n- [Installation](#installation)\n- [Quick start: HTTP](#quick-start-http)\n- [Quick start: HTTPS](#quick-start-https)\n- [Quick start: WebSocket](#quick-start-websocket)\n- [Quick start: WSS (secure WebSocket)](#quick-start-wss-secure-websocket)\n- [Development, Preview \u0026 Production](#development-preview--production)\n\n**Configuration**\n- [Adapter options](#adapter-options)\n- [Environment variables](#environment-variables)\n- [TypeScript setup](#typescript-setup)\n- [Svelte 4 support](#svelte-4-support)\n\n**WebSocket deep dive**\n- [WebSocket handler (`hooks.ws`)](#websocket-handler-hooksws)\n- [Authentication](#authentication)\n- [Refreshing session cookies on WebSocket connect](#refreshing-session-cookies-on-websocket-connect)\n- [Platform API (`event.platform`)](#platform-api-eventplatform)\n- [Client store API](#client-store-api)\n- [Seeding initial state](#seeding-initial-state)\n\n**Plugins**\n- [Middleware](#middleware)\n- [Replay (SSR gap)](#replay-ssr-gap)\n- [Dedup (idempotency window)](#dedup-idempotency-window)\n- [Presence](#presence)\n- [Typed channels](#typed-channels)\n- [Throttle/debounce](#throttledebounce)\n- [Rate limiting](#rate-limiting)\n- [Cursor (ephemeral state)](#cursor-ephemeral-state)\n- [Queue (ordered delivery)](#queue-ordered-delivery)\n- [Lock (per-key serialization)](#lock-per-key-serialization)\n- [Session (in-process store with sliding TTL)](#session-in-process-store-with-sliding-ttl)\n- [Broadcast groups](#broadcast-groups)\n\n**Deployment \u0026 scaling**\n- [Deploying with Docker](#deploying-with-docker)\n- [Clustering](#clustering)\n- [OS tuning for production](#os-tuning-for-production)\n- [Performance](#performance)\n\n**Examples**\n- [Full example: real-time todo list](#full-example-real-time-todo-list)\n\n**Help**\n- [Troubleshooting](#troubleshooting)\n- [Related projects](#related-projects)\n- [License](#license)\n\n---\n\n**Getting started**\n\n## Version compatibility\n\nThe three ecosystem packages move together. Bump them as a group:\n\n| `svelte-adapter-uws` | `svelte-realtime` | `svelte-adapter-uws-extensions` | Notes |\n|---|---|---|---|\n| `^0.4.x` | `^0.4.x` | `^0.4.x` | Legacy stable |\n| `^0.5.0` | `^0.5.0` | `^0.5.0` | Current. Node 22+ required. See `MIGRATION.md` if upgrading from 0.4. |\n\nMixed-version installs are rejected at install time with a peer-dep warning.\n\n## Installation\n\n### Starting from scratch\n\nIf you don't have a SvelteKit project yet:\n\n```bash\nnpx sv create my-app\ncd my-app\nnpm install\n```\n\n### Adding the adapter\n\n```bash\nnpm install svelte-adapter-uws\nnpm install uNetworking/uWebSockets.js#v20.60.0\n```\n\n\u003e **Note:** uWebSockets.js is a native C++ addon installed directly from GitHub, not from npm. It may not compile on all platforms. Check the [uWebSockets.js README](https://github.com/uNetworking/uWebSockets.js) if you have issues.\n\u003e\n\u003e **Docker:** Use `node:22-trixie-slim` or another glibc \u003e= 2.38 image. Bookworm-based images and Alpine won't work. See [Deploying with Docker](#deploying-with-docker).\n\nIf you plan to use WebSockets during development, also install `ws`:\n\n```bash\nnpm install -D ws\n```\n\n---\n\n## Quick start: HTTP\n\nThe simplest setup - just swap the adapter and you're done.\n\n**svelte.config.js**\n```js\nimport adapter from 'svelte-adapter-uws';\n\nexport default {\n  kit: {\n    adapter: adapter()\n  }\n};\n```\n\n**Build and run:**\n```bash\nnpm run build\nnode build\n```\n\nYour app is now running on `http://localhost:3000`.\n\nTo change the host or port:\n```bash\nHOST=0.0.0.0 PORT=8080 node build\n```\n\n---\n\n## Quick start: HTTPS\n\nNo reverse proxy needed. uWebSockets.js handles TLS natively with its `SSLApp`.\n\n**svelte.config.js** - same as HTTP, no changes needed:\n```js\nimport adapter from 'svelte-adapter-uws';\n\nexport default {\n  kit: {\n    adapter: adapter()\n  }\n};\n```\n\n**Build and run with TLS:**\n```bash\nnpm run build\nSSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build\n```\n\nYour app is now running on `https://localhost:3000`.\n\n\u003e Both `SSL_CERT` and `SSL_KEY` must be set. Setting only one will throw an error.\n\n### Behind a reverse proxy (nginx, Caddy, etc.)\n\nIf your proxy terminates TLS and forwards to HTTP:\n\n```bash\nORIGIN=https://example.com node build\n```\n\nOr if you want flexible header-based detection:\n```bash\nPROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build\n```\n\n\u003e **Important:** `PROTOCOL_HEADER`, `HOST_HEADER`, `PORT_HEADER`, and `ADDRESS_HEADER` are trusted verbatim. Only set these when running behind a reverse proxy that overwrites the corresponding headers on every request. If the server is directly internet-facing, clients can spoof these values. When in doubt, use a fixed `ORIGIN` instead.\n\n---\n\n## Quick start: WebSocket\n\nThree things to do:\n\n1. **Enable WebSocket in the adapter**\n2. **Add the Vite plugin** (for dev mode)\n3. **Use the client store** in your Svelte components\n\n### Step 1: Enable WebSocket\n\n**svelte.config.js**\n```js\nimport adapter from 'svelte-adapter-uws';\n\nexport default {\n  kit: {\n    adapter: adapter({\n      websocket: true\n    })\n  }\n};\n```\n\nThat's it. This gives you a pub/sub WebSocket server at `/ws` with no authentication. Any client can connect, subscribe to topics, and receive messages.\n\n### Step 2: Add the Vite plugin (required)\n\nThe Vite plugin is **required** when using WebSockets. It does two things:\n\n1. **Dev mode** - spins up a WebSocket server so `event.platform` works during `npm run dev`\n2. **Production builds** - runs your `hooks.ws` file through Vite's pipeline so `$lib`, `$env`, and `$app` imports resolve correctly\n\nWithout it, your `hooks.ws` file won't be able to import from `$lib` or use `$env` variables, and `event.platform` won't work in dev.\n\n**vite.config.js**\n```js\nimport { sveltekit } from '@sveltejs/kit/vite';\nimport uws from 'svelte-adapter-uws/vite';\n\nexport default {\n  plugins: [sveltekit(), uws()]\n};\n```\n\n### Step 3: Use the client store\n\n**src/routes/+page.svelte**\n```svelte\n\u003cscript\u003e\n  import { on, status } from 'svelte-adapter-uws/client';\n\n  // Subscribe to the 'notifications' topic\n  // Auto-connects, auto-subscribes, auto-reconnects\n  const notifications = on('notifications');\n\u003c/script\u003e\n\n{#if $status === 'open'}\n  \u003cspan\u003eConnected\u003c/span\u003e\n{/if}\n\n{#if $notifications}\n  \u003cp\u003eEvent: {$notifications.event}\u003c/p\u003e\n  \u003cp\u003eData: {JSON.stringify($notifications.data)}\u003c/p\u003e\n{/if}\n```\n\n### Step 4: Publish from the server\n\n**src/routes/api/notify/+server.js**\n```js\nexport async function POST({ request, platform }) {\n  const data = await request.json();\n\n  // This sends to ALL clients subscribed to 'notifications'\n  platform.publish('notifications', 'new-message', data);\n\n  return new Response('OK');\n}\n```\n\n**Build and run:**\n```bash\nnpm run build\nnode build\n```\n\n---\n\n## Quick start: WSS (secure WebSocket)\n\nWSS works automatically when you enable TLS. WebSocket connections upgrade over the same HTTPS port.\n\n**svelte.config.js**\n```js\nimport adapter from 'svelte-adapter-uws';\n\nexport default {\n  kit: {\n    adapter: adapter({\n      websocket: true\n    })\n  }\n};\n```\n\n```bash\nnpm run build\nSSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build\n```\n\nThe client store automatically uses `wss://` when the page is served over HTTPS - no configuration needed on the client side.\n\n---\n\n## Development, Preview \u0026 Production\n\n### `npm run dev` - works (with the Vite plugin)\n\nThe Vite plugin is required for WebSocket support in both dev and production (see [Step 2](#step-2-add-the-vite-plugin-required)). It spins up a `ws` WebSocket server alongside Vite's dev server, so your client store and `event.platform` work identically to production.\n\nChanges to your `hooks.ws` file are picked up automatically - the plugin reloads the handler on save and closes existing connections so they reconnect with the new code. No dev server restart needed.\n\n**Note:** The dev plugin enforces `allowedOrigins` on WebSocket upgrades the same way the production handler does. For local dev scenarios that need to accept arbitrary origins (e.g. WSS from a staging client during integration), pass `devSkipOriginCheck: true` to the plugin: `uws({ devSkipOriginCheck: true })`.\n\n**vite.config.js**\n```js\nimport { sveltekit } from '@sveltejs/kit/vite';\nimport uws from 'svelte-adapter-uws/vite';\n\nexport default {\n  plugins: [sveltekit(), uws()]\n};\n```\n\n### `npm run preview` - WebSockets don't work\n\nSvelteKit's preview server is Vite's built-in HTTP server. It doesn't know about uWebSockets.js or WebSocket upgrades. Your HTTP routes and SSR will work, but **WebSocket connections will fail**.\n\nUse `node build` instead of preview for testing WebSocket features.\n\n### `node build` - production, everything works\n\nThis is the real deal. uWebSockets.js handles everything:\n\n```bash\nnpm run build\nnode build\n```\n\nOr with environment variables:\n```bash\nPORT=8080 HOST=0.0.0.0 node build\n```\n\nOr with TLS:\n```bash\nSSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build\n```\n\n---\n\n**Configuration**\n\n## Adapter options\n\n```js\nadapter({\n  // Output directory for the build\n  out: 'build', // default: 'build'\n\n  // Precompress static assets with brotli and gzip\n  precompress: true, // default: true\n\n  // Prefix for environment variables (e.g. 'MY_APP_' -\u003e MY_APP_PORT)\n  envPrefix: '', // default: ''\n\n  // Health check endpoint (set to false to disable)\n  healthCheckPath: '/healthz', // default: '/healthz'\n\n  // WebSocket configuration\n  websocket: true // or false, or an options object (see below)\n})\n```\n\n### WebSocket options\n\n```js\nadapter({\n  websocket: {\n    // Path for WebSocket connections\n    path: '/ws', // default: '/ws'\n\n    // Path to your custom handler module (auto-discovers src/hooks.ws.js if omitted)\n    handler: './src/lib/server/websocket.js', // default: auto-discover\n\n    // Max message size in bytes (connections sending larger messages are closed)\n    maxPayloadLength: 1024 * 1024, // default: 1 MB\n\n    // Seconds of inactivity before the connection is closed\n    idleTimeout: 120, // default: 120\n\n    // Max bytes of backpressure per connection before messages are dropped.\n    // uWS defaults to 64 KB; this adapter uses 1 MB to handle pub/sub spikes.\n    // Lower this if you expect many slow consumers.\n    maxBackpressure: 1024 * 1024, // default: 1 MB\n\n    // Enable per-message deflate compression\n    compression: false, // default: false\n\n    // Automatically send pings to keep the connection alive\n    sendPingsAutomatically: true, // default: true\n\n    // Seconds before an async upgrade handler is rejected with 504 (0 to disable)\n    upgradeTimeout: 10, // default: 10\n\n    // Sliding-window rate limit: max WebSocket upgrade requests per IP per window.\n    // Prevents connection flood attacks. Uses a sliding window so a client cannot\n    // double the effective rate by placing requests at a fixed-window boundary.\n    // Set to 0 to disable.\n    upgradeRateLimit: 10,       // default: 10\n    upgradeRateLimitWindow: 10, // window size in seconds, default: 10\n\n    // Allowed origins for WebSocket connections\n    // 'same-origin' - only accept where Origin matches Host and scheme (default)\n    // '*' - accept from any origin\n    // ['https://example.com'] - whitelist specific origins\n    // Requests without an Origin header (non-browser clients) are rejected\n    // unless an upgrade handler is configured to authenticate them.\n    allowedOrigins: 'same-origin' // default: 'same-origin'\n  }\n})\n```\n\n### Backpressure and connection limits\n\nThese options control how the server handles misbehaving or slow clients at the WebSocket level:\n\n**`maxPayloadLength`** (default: 1 MB) - the maximum size of a single incoming WebSocket message. If a client sends a message larger than this, uWS closes the connection immediately (not just the message - the entire connection is dropped). Set this based on the largest message your application expects to receive. uWS's own default is 16 KB, which the adapter previously matched; the 1 MB default ships now to handle typical app payloads in a single frame without forcing chunked-upload frameworks into ~12 KB chunks (which the previous 16 KB cap did). For a stricter cap, pin an explicit value (e.g. `16 * 1024` for the uWS-matching 16 KB).\n\n**`maxBackpressure`** (default: 1 MB) - the per-connection outbound send buffer, AND the threshold above which `publish` / `send` / `publishBatched` silently skip a subscriber. When a specific subscriber's buffer is over this size, uWS drops that frame *for that subscriber only* while continuing to deliver to every non-backpressured subscriber. This makes `publish` / `send` / `publishBatched` volatile-by-default for slow consumers (the right behavior for cursor positions, typing indicators, presence pings - see \"Volatile / fire-and-forget delivery\" below). The `drain` hook fires per-connection when the buffer empties again. Lower this if you want subscribers shed sooner; raise it if you prefer to keep the connection queued and absorb temporary slowness. uWS's own default is 64 KB; this adapter sets 1 MB to favor keeping the connection alive under pub/sub spikes.\n\n**`upgradeRateLimit`** (default: 10 per 10s window) - sliding-window rate limit on WebSocket upgrade requests per client IP. Clients exceeding the limit get a `429 Too Many Requests` response. The IP rate map is capped at 10,000 entries with LRU eviction by activity score, so sustained connection floods from many IPs don't cause unbounded memory growth.\n\n**`upgradeAdmission`** (default: disabled) - two-layer admission control on the upgrade path, both opt-in:\n\n- `maxConcurrent` caps how many upgrades may be in flight at once. Crossed requests get a fast `503 Service Unavailable` before any per-request work, so a connection storm can be shed without spending CPU on TLS, header parsing, or cookie decoding. Set this just above your steady-state in-flight count to act as a circuit breaker.\n- `perTickBudget` caps how many actual `res.upgrade()` calls run per Node.js event-loop tick. Once the budget is spent, subsequent calls are deferred via `setImmediate` so the loop is not starved by 10K synchronous handshakes from one I/O batch. Pre-upgrade work (rate limit, origin check, hook dispatch) still runs in the original tick; only the hand-off to the C++ upgrade path is paced. Start with `64` and adjust based on your peak burst envelope.\n\n```js\nadapter({\n  websocket: {\n    upgradeAdmission: { maxConcurrent: 1000, perTickBudget: 64 }\n  }\n});\n```\n\nThe two layers are independent: each works without the other. Both default to `0` (disabled) so the upgrade path stays unchanged unless you opt in.\n\n#### Layered admission: upgrade-path + message-path\n\n`upgradeAdmission` operates at the WebSocket handshake. It sheds connection attempts before TLS work and before any per-request CPU is spent. That is the right primitive when the threat is \"too many clients are trying to connect\" - a connection flood, a thundering herd after a deploy, a runaway client retry loop.\n\nIt is NOT the right primitive when the threat is \"established connections are sending too many RPCs\" - a chatty client, an abusive presence ping loop, a misbehaving game tick. Those calls have already passed the handshake; the connection is open; you want to shed at the message dispatch layer instead.\n\nFor that second layer, [`svelte-adapter-uws-extensions`](https://github.com/lanteanio/svelte-adapter-uws-extensions) ships `createAdmissionControl`, an opt-in message-path admission wrapper that runs against already-accepted connections. The two stack naturally:\n\n```js\n// Production wiring sketch\nimport { createAdmissionControl } from 'svelte-adapter-uws-extensions';\n\nconst messageAdmission = createAdmissionControl({ /* RPC concurrency, per-key buckets, ... */ });\n\n// In hooks.ws.js\nexport function message(ws, ctx) {\n  messageAdmission.run(ws, ctx, async () =\u003e {\n    // ... your message handler ...\n  });\n}\n\n// In svelte.config.js\nadapter({\n  websocket: {\n    upgradeAdmission: { maxConcurrent: 1000, perTickBudget: 64 }  // handshake layer\n  }\n});\n```\n\nThe two layers do not share state, configuration, or call sites. They cannot drift apart because the WebSocket lifecycle enforces the ordering: a connection that fails `upgradeAdmission` never reaches the message handler at all, so `createAdmissionControl` only ever sees connections that were already admitted at the handshake. The layering is a structural property, not a runtime one.\n\n### Security configuration\n\nDefense-in-depth opt-ins layered on top of `allowedOrigins`. All default to safe values; flip them only after the documented audit step.\n\n- **`websocket.authPathRequireOrigin`** (default `true`) - the `/__ws/auth` POST endpoint requires `x-requested-with: XMLHttpRequest`, `Sec-Fetch-Site: same-origin`, or an `Origin` matching `allowedOrigins`. The adapter client always stamps `x-requested-with` so the browser path is unaffected. Set `false` to accept native (non-browser) clients without those headers.\n- **`websocket.compressCredentialedResponses`** (default `false`) - requests carrying `Cookie` or `Authorization` skip dynamic brotli/gzip compression to defend against the [BREACH](https://en.wikipedia.org/wiki/BREACH) attack (compressed length leaks attacker-influenced reflected input alongside a secret). Set `true` only after auditing the page surface for BREACH defenses (random per-response masking, prefix randomization, no secrets reflected with attacker input). Build-time precompressed static files are unaffected.\n- **`websocket.unsafeSameOriginWithoutHostPin`** (default `false`) - when `allowedOrigins: 'same-origin'` is paired with no fronting trust (no `ORIGIN` env, no `HOST_HEADER` env, no native TLS, no `upgrade()` hook), the runtime throws at startup because the same-origin check then compares two attacker-controlled headers (Origin vs Host). Set `true` to restore the previous warn-only behavior. Pin the deployment shape first (`ORIGIN`, `HOST_HEADER`, native TLS, or an `upgrade()` hook).\n\n`websocket.allowSystemTopicSubscribe` (default `false`) and `websocket.allowNonAsciiTopics` (default `false`) are documented in [Topic validation](#topic-validation). The Vite plugin mirrors all of these flags; `devSkipOriginCheck` (default `false`) on the plugin disables the dev-mode `allowedOrigins` enforcement for local-only scenarios.\n\n### Capacity model\n\nEvery internal `Map` / `Set` that grows with client behaviour or topic cardinality has an explicit upper bound and a defined behaviour at saturation. The defaults are deliberately generous (1,000,000 across the board) - far above any healthy single-connection use, even at uWS's million-connection scale - so the cap catches obvious bugs and runaway clients without ever biting real apps. Aggregate memory at extreme scale is bounded separately by `upgradeAdmission.maxConcurrent`; per-connection caps are not the right place to defend against a 1M-connection DoS.\n\n| Site | Default cap | Behaviour at saturation | Override |\n|------|-------------|-------------------------|----------|\n| Subscriptions per connection | 1,000,000 | `subscribe-denied` with reason `'RATE_LIMITED'` | not exposed |\n| Pending `platform.request` calls per connection | 1,000,000 | promise rejects with \"pending requests exceeded\" | not exposed |\n| `sendCoalesced` keys per connection | 1,000,000 | drop oldest insertion-order entry on insert | not exposed |\n| Topic seq registry (`topicSeqs`) | 1,000,000 | one structured `console.warn` with topN publishers; publish continues | not exposed (resume protocol depends on persistence) |\n| Runaway-publisher warn dedup | 1,000,000 | FIFO-evict oldest entry on insert | not exposed |\n| `envelopePrefixCache` | 256 | FIFO half-evict | not exposed |\n| `decodeCache` | 256 | FIFO half-evict | not exposed |\n| SSR dedup in-flight | 500 | new request bypasses dedup | not exposed |\n| SSR dedup body buffer per request | 512 KB | response replays without dedup | not exposed |\n| Upgrade rate-limit IP map | 10,000 | LRU on 60s sweep | not exposed |\n| Aggregate live connections | unbounded by default | reject upgrade with 503 once `maxConcurrent` set | `upgradeAdmission.maxConcurrent` |\n| Outbound buffer per connection | 1 MB | uWS drops the frame for that subscriber only | `wsOptions.maxBackpressure` |\n\n**Plugin caps** all default to 1,000,000 with the same idiot-proof bias:\n\n| Plugin | Cap | Behaviour at saturation | Override |\n|--------|-----|-------------------------|----------|\n| `replay` | `maxTopics: 100`, ring `size: 1000` | LRU evict / ring overwrite | per-topic options |\n| `presence` | `maxConnections: 1_000_000`, `maxTopics: 1_000_000` | drop oldest insertion-order entry | constructor options |\n| `cursor` | `maxConnections: 1_000_000`, `maxTopics: 1_000_000` | drop oldest insertion-order entry; pending throttle timers cleared | constructor options |\n| `throttle` / `debounce` | `maxTopics: 1_000_000` | flush pending then drop oldest topic | second arg to `throttle(interval, options)` / `debounce(...)` |\n| `lock` | `maxKeys: 1_000_000` | new-key `withLock` rejects with \"active key count exceeded\" | constructor options |\n| `ratelimit` | `maxBuckets: 1_000_000` | drop oldest insertion-order bucket on insert | constructor options |\n| `queue` | `maxSize: 1_000_000` per key | `push` rejects, `onDrop` callback fires | constructor options (pass `Infinity` to opt out) |\n| `dedup` | `maxEntries: 10_000` | soft + hard cap, oldest insertion-order evicted | constructor options |\n| `session` | `maxEntries: 10_000` | soft + hard cap, oldest insertion-order evicted | constructor options |\n| `groups` | `maxMembers` (per group, required) | `join` returns `false`, `onFull` callback fires | required option |\n\nTwo policy notes:\n\n- **Per-conn cap math at uWS scale.** `1,000,000 subscriptions × 1,000,000 connections` is more than any realistic process can handle. The per-conn caps catch single-connection bugs (a `for (i=0; i\u003cN; i++) ws.subscribe('topic-' + i)` loop, a misbehaving extension); they do not pretend to OOM-protect a 1M-connection server. Set `upgradeAdmission.maxConcurrent` for that.\n- **`topicSeqs` is warn-only.** The seq registry cannot evict entries - the resume protocol depends on each topic's monotonic counter persisting for the process lifetime, and dropping a row would corrupt any reconnecting client trying to resume that topic. The cap fires a single structured `console.warn` with the topN recent publishers when the threshold is first crossed; ops sees the leak shape and can reduce topic cardinality (or opt out with `{ seq: false }` per publish) before OOM.\n\n### Static file behavior\n\nAll static assets (from the `client/` and `prerendered/` output directories) are loaded once at startup and served directly from RAM. Each response automatically includes:\n\n- `Content-Type`: detected from the file extension\n- `Vary: Accept-Encoding`: required for correct CDN/proxy caching when serving precompressed variants\n- `Accept-Ranges: bytes`: enables partial content requests (e.g. for download resume)\n- `X-Content-Type-Options: nosniff`: prevents MIME-type sniffing in browsers\n- `ETag`: derived from the file's modification time and size; enables `304 Not Modified` responses\n- `Cache-Control: public, max-age=31536000, immutable`: for versioned assets under `/_app/immutable/`\n- `Cache-Control: no-cache`: for all other assets (forces ETag revalidation)\n\n**Range requests (HTTP 206):** The server handles `Range: bytes=start-end` requests for static files. Single byte ranges are supported (`bytes=0-499`, `bytes=-500`, `bytes=500-`). Multi-range requests (comma-separated) are served as full `200` responses. An unsatisfiable range returns `416 Range Not Satisfiable`. When a `Range` header is present, the response is always served uncompressed so byte offsets are correct. The `If-Range` header is respected: if it doesn't match the file's ETag, the full file is returned.\n\nFiles with extensions that browsers cannot render inline (`.zip`, `.tar`, `.tgz`, `.exe`, `.dmg`, `.pkg`, `.deb`, `.apk`, `.iso`, `.img`, `.bin`, etc.) automatically receive `Content-Disposition: attachment` so browsers prompt a download dialog instead of attempting to display them.\n\nIf `precompress: true` is set in the adapter options, brotli (`.br`) and gzip (`.gz`) precompressed variants are loaded at startup and served when the client's `Accept-Encoding` header includes `br` or `gzip`. Precompressed variants are only used when they are smaller than the original file.\n\n---\n\n## Environment variables\n\nAll variables are set at **runtime** (when you run `node build`), not at build time.\n\nIf you set `envPrefix: 'MY_APP_'` in the adapter config, all variables are prefixed (e.g. `MY_APP_PORT` instead of `PORT`).\n\n| Variable | Default | Description |\n|---|---|---|\n| `HOST` | `0.0.0.0` | Bind address |\n| `PORT` | `3000` | Listen port |\n| `ORIGIN` | *(derived)* | Fixed origin (e.g. `https://example.com`) |\n| `SSL_CERT` | - | Path to TLS certificate file |\n| `SSL_KEY` | - | Path to TLS private key file |\n| `PROTOCOL_HEADER` | - | Header for protocol detection (e.g. `x-forwarded-proto`) |\n| `HOST_HEADER` | - | Header for host detection (e.g. `x-forwarded-host`) |\n| `PORT_HEADER` | - | Header for port override (e.g. `x-forwarded-port`) |\n| `ADDRESS_HEADER` | - | Header for client IP (e.g. `x-forwarded-for`) |\n| `XFF_DEPTH` | `1` | Position from right in `X-Forwarded-For` |\n| `BODY_SIZE_LIMIT` | `512K` | Max request body size (supports `K`, `M`, `G` suffixes) |\n| `SHUTDOWN_TIMEOUT` | `30` | Seconds to wait during graceful shutdown |\n| `CLUSTER_WORKERS` | - | Number of worker threads (or `auto` for CPU count) |\n| `CLUSTER_MODE` | *(auto)* | `reuseport` (Linux default) or `acceptor` (other platforms) |\n| `WS_DEBUG` | - | Set to `1` to enable structured WebSocket debug logging (open, close, subscribe, publish) |\n\n### Graceful shutdown\n\nOn `SIGTERM` or `SIGINT`, the server:\n1. Stops accepting new connections\n2. Waits for in-flight SSR requests to complete (up to `SHUTDOWN_TIMEOUT` seconds)\n3. Emits a `sveltekit:shutdown` event on `process` (for cleanup hooks like closing database connections)\n4. Exits\n\n```js\n// Listen for shutdown in your server code (e.g. hooks.server.js)\nprocess.on('sveltekit:shutdown', async (reason) =\u003e {\n  console.log(`Shutting down: ${reason}`);\n  await db.close();\n});\n```\n\n### Examples\n\n```bash\n# Simple HTTP\nnode build\n\n# Custom port\nPORT=8080 node build\n\n# Behind nginx\nORIGIN=https://example.com node build\n\n# Behind a proxy with forwarded headers\nPROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host ADDRESS_HEADER=x-forwarded-for node build\n\n# Native TLS\nSSL_CERT=./cert.pem SSL_KEY=./key.pem node build\n\n# Everything at once\nSSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 HOST=0.0.0.0 BODY_SIZE_LIMIT=10M SHUTDOWN_TIMEOUT=60 node build\n```\n\n---\n\n## TypeScript setup\n\nAdd the platform type to your `src/app.d.ts`:\n\n```ts\nimport type { Platform as AdapterPlatform } from 'svelte-adapter-uws';\n\ndeclare global {\n  namespace App {\n    interface Platform extends AdapterPlatform {}\n  }\n}\n\nexport {};\n```\n\nNow `event.platform.publish()`, `event.platform.topic()`, etc. are fully typed.\n\n---\n\n## Svelte 4 support\n\nThis adapter supports both Svelte 4 and Svelte 5. All examples in this README use Svelte 5 syntax (`$props()`, runes). If you're on Svelte 4, here's how to translate:\n\n**Svelte 5 (used in examples)**\n```svelte\n\u003cscript\u003e\n  import { crud } from 'svelte-adapter-uws/client';\n\n  let { data } = $props();\n  const todos = crud('todos', data.todos);\n\u003c/script\u003e\n```\n\n**Svelte 4 equivalent**\n```svelte\n\u003cscript\u003e\n  import { crud } from 'svelte-adapter-uws/client';\n\n  export let data;\n  const todos = crud('todos', data.todos);\n\u003c/script\u003e\n```\n\nThe only difference is how you receive props. The client store API (`on`, `crud`, `lookup`, `latest`, `count`, `once`, `status`, `connect`) works identically in both versions - it uses `svelte/store` which hasn't changed.\n\n---\n\n**WebSocket deep dive**\n\n## WebSocket handler (`hooks.ws`)\n\n### No handler needed (simplest)\n\nWith `websocket: true`, a built-in handler accepts all connections and handles subscribe/unsubscribe messages from the client store. No file needed.\n\n\u003e **Note:** `websocket: true` only sets up the server side. To actually receive messages in the browser, you need to import the client store (`on`, `crud`, etc.) in your Svelte components. Without the client store, the WebSocket endpoint exists but nothing connects to it.\n\n### Auto-discovered handler\n\nCreate `src/hooks.ws.js` (or `.ts`, `.mjs`) and it will be automatically discovered - no config needed:\n\n**src/hooks.ws.js**\n```js\n// Called during the HTTP -\u003e WebSocket upgrade handshake.\n// Return an object to accept (becomes ws.getUserData()).\n// Return false to reject with 401.\n// Omit this export to accept all connections.\nexport async function upgrade({ headers, cookies, url, remoteAddress }) {\n  const sessionId = cookies.session_id;\n  if (!sessionId) return false;\n\n  const user = await validateSession(sessionId);\n  if (!user) return false;\n\n  // Whatever you return here is available as ws.getUserData()\n  return { userId: user.id, name: user.name };\n}\n\n// Called when a connection is established\nexport function open(ws, { platform }) {\n  const { userId } = ws.getUserData();\n  console.log(`User ${userId} connected`);\n\n  // Subscribe this connection to a user-specific topic\n  ws.subscribe(`user:${userId}`);\n}\n\n// Called when a message is received.\n// Note: subscribe/unsubscribe messages from the client store are\n// handled automatically BEFORE this function is called.\n//\n// `msg` is the JSON-parsed envelope when the adapter parsed the frame\n// for control-message routing but no control type matched (i.e. it\n// looks like `{\"type\":\"\u003ccustom\u003e\",...}` from a plugin). The adapter\n// already did `TextDecoder + JSON.parse` once during routing, so this\n// avoids a second parse on the dispatch path. `msg` is `undefined`\n// for binary frames, prefix-miss frames, parse failures, or frames\n// that parse to a non-object.\nexport function message(ws, { data, isBinary, msg }) {\n  if (msg) {\n    // Already-parsed JSON object envelope - dispatch by msg.type\n    console.log('Got envelope:', msg);\n    return;\n  }\n  // Binary or non-envelope text frame - decode manually\n  console.log('Got raw frame, byteLength:', data.byteLength);\n}\n\n// Called when a client tries to subscribe to a topic (optional)\n// Return false to deny the subscription\nexport function subscribe(ws, topic, { platform }) {\n  const { role } = ws.getUserData();\n  // Only admins can subscribe to admin topics\n  if (topic.startsWith('admin') \u0026\u0026 role !== 'admin') return false;\n}\n\n// Called when a client unsubscribes from a topic (optional)\n// Use this to clean up per-topic state (presence, groups, etc.)\nexport function unsubscribe(ws, topic, { platform }) {\n  console.log(`Unsubscribed from ${topic}`);\n}\n\n// Called when the connection closes. The context carries per-connection\n// stats (id / duration / messagesIn / messagesOut / bytesIn / bytesOut)\n// alongside `code` / `message` / `subscriptions`. Counters are only\n// populated when this hook is exported - the adapter skips the\n// per-connection bookkeeping otherwise to keep the hot path zero-cost.\nexport function close(ws, { code, id, duration, messagesIn, messagesOut, bytesIn, bytesOut, subscriptions }) {\n  const { userId } = ws.getUserData();\n  console.log(\n    `User ${userId} (session ${id}) disconnected after ${duration}ms ` +\n    `(${messagesIn} in / ${messagesOut} out, ${bytesIn} / ${bytesOut} bytes, ` +\n    `topics: ${[...subscriptions].join(', ')})`\n  );\n}\n\n// Called when backpressure has drained (optional, for flow control)\nexport function drain(ws, { platform }) {\n  // You can resume sending large messages here\n}\n\n// Called when a reconnecting client presents the previous session id\n// plus the per-topic seq numbers it last saw. Use this to fill the\n// disconnect gap, typically by replaying buffered events. Optional -\n// without this hook, reconnects still work; the client just falls\n// through to live mode without a gap fill.\nexport function resume(ws, { sessionId, lastSeenSeqs, platform }) {\n  for (const [topic, sinceSeq] of Object.entries(lastSeenSeqs)) {\n    replay.replay(ws, topic, sinceSeq, platform);\n  }\n}\n```\n\n### Session resume\n\nOn every WS open, the server stamps a session id and announces it to the client (`{\"type\":\"welcome\",\"sessionId\":\"...\"}`). The client stores the id in `sessionStorage` (keyed per ws path) and tracks the highest `seq` it has seen for each topic.\n\nWhen the connection drops and the client reconnects, it presents the previous session id plus the per-topic last-seen seqs in a `{\"type\":\"resume\", sessionId, lastSeenSeqs}` frame, sent before `subscribe-batch`. If you export a `resume` hook, you receive `(ws, { sessionId, lastSeenSeqs, platform })` and can replay any events the client missed during the disconnect window. The server acks with `{\"type\":\"resumed\"}` once your hook returns; the client then resubscribes and live messages resume.\n\nWithout a `resume` hook the protocol is still safe: the server acks the resume frame, the client falls through to live mode, and your app behaves the same as a cold connect.\n\nThe session id is per-process and per-connection. It does not persist across server restarts; a client presenting a session id the server has never seen receives the same `resumed` ack and falls through.\n\n### Subscribe acknowledgements\n\nWhen the client subscribes, it includes a numeric `ref` so the server can ack with the result:\n\n- `{\"type\":\"subscribed\", topic, ref}` - subscription accepted.\n- `{\"type\":\"subscribe-denied\", topic, ref, reason}` - subscription rejected. `reason` is one of the canonical codes `'UNAUTHENTICATED'`, `'FORBIDDEN'`, `'INVALID_TOPIC'`, `'RATE_LIMITED'`, or any custom string the server's `subscribe` hook returned.\n\nThe denial is surfaced on the client through the `denials` store. Show it as a banner, route to a login page, anything you like:\n\n```svelte\n\u003cscript\u003e\n  import { denials } from 'svelte-adapter-uws/client';\n\u003c/script\u003e\n\n{#if $denials}\n  \u003cp class=\"error\"\u003eCannot subscribe to {$denials.topic}: {$denials.reason}\u003c/p\u003e\n{/if}\n```\n\nThe server's `subscribe` hook controls denial reasons:\n\n```js\nexport function subscribe(ws, topic, { platform }) {\n  const { userId, role } = ws.getUserData();\n  if (!userId) return 'UNAUTHENTICATED';                  // -\u003e subscribe-denied\n  if (topic.startsWith('admin') \u0026\u0026 role !== 'admin') {\n    return 'FORBIDDEN';\n  }\n  // omit / return undefined / return true -\u003e subscribed\n}\n```\n\nOld clients that send `subscribe` without a `ref` get no ack frame (silent allow / silent deny, as before). Old servers that ignore `ref` don't break new clients - they just don't emit acks; the client sees no entry in `denials` and treats the subscription as active.\n\n`subscribe-batch` works the same way: one ack frame per topic in the batch, all sharing the batch's single `ref`.\n\nFor batch subscribes (typically the resubscribe-on-reconnect path) you can opt into a single-call `subscribeBatch` hook instead of paying N `subscribe` calls. The framework calls it once with all pre-validated topics and applies the returned per-topic decisions:\n\n```js\nexport async function subscribeBatch(ws, topics, { platform }) {\n  const { userId } = ws.getUserData();\n  // One DB query for all topics instead of N\n  const allowed = await db.allowedTopics(userId, topics);\n  const allowedSet = new Set(allowed);\n  const denials = {};\n  for (const topic of topics) {\n    if (!allowedSet.has(topic)) denials[topic] = 'FORBIDDEN';\n  }\n  return denials; // omit a topic -\u003e allow; false -\u003e FORBIDDEN; string -\u003e that reason\n}\n```\n\nIf you only export `subscribe`, the framework still loops it per topic for batch-subscribes (no behaviour change). Export `subscribeBatch` only when you need the single-query optimization. The hook is sync in this version; for async lookups, pre-cache user grants on `userData` during `upgrade`.\n\n### Message protocol\n\nThe adapter uses a JSON envelope format for all pub/sub messages: `{ topic, event, data, seq? }`. Control messages from the client store (`subscribe`, `unsubscribe`, `subscribe-batch`, `resume`) use `{ type, topic, ref? }`, `{ type, topics, ref? }`, or `{ type, sessionId, lastSeenSeqs }`. The server emits `{\"type\":\"welcome\",\"sessionId\":\"...\"}` on open, `{\"type\":\"resumed\"}` after a resume frame, and `{\"type\":\"subscribed\",...}` / `{\"type\":\"subscribe-denied\",...}` per topic when the client supplied a `ref`.\n\nTo avoid JSON-parsing every incoming message, the handler uses a byte-prefix discriminator: control messages start with `{\"type\"` (byte 3 is `y`), while user envelopes start with `{\"topic\"` (byte 3 is `o`). A single byte comparison skips `JSON.parse` entirely for user messages. Messages over 8 KB are also skipped (generous ceiling for `subscribe-batch` with many topics, well above any realistic control message).\n\n### Topic validation\n\nTopics submitted by clients are validated before being accepted:\n\n- Must be between 1 and 256 characters\n- Default accept set is printable ASCII (0x20-0x7E) excluding `\"` and `\\`. Control bytes, line separators (U+2028/U+2029), bidirectional overrides (U+202E), the byte-order mark, and other non-ASCII runes are rejected at the wire boundary so log dashboards and admin UIs see a clean, greppable topic name. Apps that legitimately accept non-ASCII topic names from clients can opt in via `websocket.allowNonAsciiTopics: true` (always-illegal `\"` and `\\` remain rejected).\n- `subscribe-batch` accepts at most 256 topics per message (the client only sends what it was subscribed to before a reconnect)\n\nTopics prefixed with `__` are reserved for framework-internal channels (presence uses `__presence:*`, replay uses `__replay:*`, plus `__signal:*`, `__group:*`, `__rpc`, etc.). Wire-level subscribes to `__`-prefixed topics are rejected with `INVALID_TOPIC`, so a client cannot intercept signals routed to other users or plugin broadcasts. Server-side `platform.subscribe(ws, '__signal:userId')` (the legitimate pattern that `enableSignals` uses) still works because the block is on the wire layer only. Advanced apps that intentionally route public topics through the `__` prefix can opt out via `websocket.allowSystemTopicSubscribe: true`.\n\n### Explicit handler path\n\nIf your handler is somewhere other than `src/hooks.ws.js`:\n\n```js\nadapter({\n  websocket: {\n    handler: './src/lib/server/websocket.js'\n  }\n})\n```\n\n### What the handler gets\n\nThe `upgrade` function receives an `UpgradeContext`:\n\n```js\n{\n  headers: { 'cookie': '...', 'host': 'localhost:3000', ... },  // all lowercase\n  cookies: { session_id: 'abc123', theme: 'dark' },             // parsed from Cookie header\n  url: '/ws?token=abc',                                           // request path + query string\n  remoteAddress: '127.0.0.1'                                     // client IP\n}\n```\n\nThe `subscribe` function receives `(ws, topic)` and can return `false` to deny a client's subscription request. Omit it to allow all subscriptions.\n\nThe `ws` object in `open`, `message`, `close`, and `drain` is a [uWebSockets.js WebSocket](https://github.com/uNetworking/uWebSockets.js). Key methods:\n\n- `ws.getUserData()` - returns whatever `upgrade` returned\n- `ws.subscribe(topic)` - subscribe to a topic for `app.publish()`\n- `ws.unsubscribe(topic)` - unsubscribe from a topic\n- `ws.send(data)` - send a message to this connection\n- `ws.close()` - close the connection\n\n---\n\n## Authentication\n\nWebSocket authentication uses the exact same cookies as your SvelteKit app. When the browser opens a WebSocket connection, it sends all cookies for the domain - including session cookies set by SvelteKit's `cookies.set()`. No tokens, no query parameters, no extra client-side code.\n\nHere's the full flow from login to authenticated WebSocket:\n\n### Step 1: Login sets a cookie (standard SvelteKit)\n\n**src/routes/login/+page.server.js**\n```js\nimport { authenticate, createSession } from '$lib/server/auth.js';\n\nexport const actions = {\n  default: async ({ request, cookies }) =\u003e {\n    const form = await request.formData();\n    const email = form.get('email');\n    const password = form.get('password');\n\n    const user = await authenticate(email, password);\n    if (!user) return { error: 'Invalid credentials' };\n\n    const sessionId = await createSession(user.id);\n\n    // This cookie is automatically sent on WebSocket upgrade requests\n    cookies.set('session', sessionId, {\n      path: '/',\n      httpOnly: true,\n      sameSite: 'strict',\n      secure: true,\n      maxAge: 60 * 60 * 24 * 7 // 1 week\n    });\n\n    return { success: true };\n  }\n};\n```\n\n### Step 2: WebSocket handler reads the same cookie\n\n**src/hooks.ws.js**\n```js\nimport { getSession } from '$lib/server/auth.js';\n\nexport async function upgrade({ cookies }) {\n  // Same cookie that SvelteKit set during login\n  const sessionId = cookies.session;\n  if (!sessionId) return false; // -\u003e 401, connection rejected\n\n  const user = await getSession(sessionId);\n  if (!user) return false; // -\u003e 401, expired or invalid session\n\n  // Attach user data to the socket - available via ws.getUserData()\n  // To refresh the session cookie on connect, use the `authenticate` hook\n  // (see \"Refreshing session cookies on WebSocket connect\" below).\n  // `upgradeResponse()` with custom non-cookie headers is also supported:\n  // return upgradeResponse({ userId: user.id }, { 'x-session-version': '2' });\n  return { userId: user.id, name: user.name, role: user.role };\n}\n\nexport function open(ws, { platform }) {\n  const { userId, role } = ws.getUserData();\n  console.log(`${userId} connected (${role})`);\n\n  // Subscribe to user-specific and role-based topics\n  ws.subscribe(`user:${userId}`);\n  if (role === 'admin') ws.subscribe('admin');\n}\n\nexport function close(ws, { platform }) {\n  const { userId } = ws.getUserData();\n  console.log(`${userId} disconnected`);\n}\n```\n\n### Step 3: Client - nothing special needed\n\n**src/routes/dashboard/+page.svelte**\n```svelte\n\u003cscript\u003e\n  import { on, status } from 'svelte-adapter-uws/client';\n\n  // The browser sends cookies automatically on the upgrade request.\n  // If the session is invalid, the connection is rejected and\n  // auto-reconnect will retry (useful if the user logs in later).\n  const notifications = on('notifications');\n  const userMessages = on('user-messages');\n\u003c/script\u003e\n\n{#if $status === 'open'}\n  \u003cspan\u003eAuthenticated \u0026 connected\u003c/span\u003e\n{:else if $status === 'connecting'}\n  \u003cspan\u003eConnecting...\u003c/span\u003e\n{:else}\n  \u003cspan\u003eDisconnected (not logged in?)\u003c/span\u003e\n{/if}\n```\n\n### Step 4: Send messages to specific users from anywhere\n\n**src/routes/api/notify/+server.js**\n```js\nimport { json } from '@sveltejs/kit';\n\nexport async function POST({ request, platform }) {\n  const { userId, message } = await request.json();\n\n  // Only that user receives this (they subscribed in open())\n  platform.publish(`user:${userId}`, 'notification', { message });\n\n  return json({ sent: true });\n}\n```\n\n### Why this works\n\nThe WebSocket upgrade is an HTTP request. The browser treats it like any other request to your domain - it includes all cookies, follows the same-origin policy, and respects `httpOnly`/`secure`/`sameSite` flags. There's no difference between how cookies reach a `+page.server.js` load function and how they reach the `upgrade` handler.\n\n| What | Where | Same cookies? |\n|---|---|---|\n| Page load | `+page.server.js` `load()` | Yes |\n| Form action | `+page.server.js` `actions` | Yes |\n| API route | `+server.js` | Yes |\n| Server hook | `hooks.server.js` `handle()` | Yes |\n| **WebSocket upgrade** | **`hooks.ws.js` `upgrade()`** | **Yes** |\n\n### Refreshing session cookies on WebSocket connect\n\nFor short-lived sessions you often want to rotate the session cookie every time a client connects. The obvious approach - attaching `Set-Cookie` to the 101 Switching Protocols response via `upgradeResponse()` - is RFC-compliant but **is silently rejected by Cloudflare Tunnel, Cloudflare's proxy, and some other strict edge proxies**. The symptom is that the WebSocket `open` handler fires server-side, then the connection closes with code 1006 (`Received TCP FIN before WebSocket close frame`) before any frames are exchanged. The adapter emits a build-time warning when it detects this pattern.\n\nThe adapter ships a first-class solution: the optional `authenticate` hook runs as a normal HTTP POST **before** the WebSocket upgrade. `Set-Cookie` rides on a standard 2xx response, which every proxy handles correctly; the browser then attaches the refreshed cookie to the upgrade request that follows.\n\n**Step 1: add an `authenticate` export to `hooks.ws.js`**\n\n```js\n// src/hooks.ws.js\nimport { getSession, renewSession } from '$lib/server/auth.js';\n\n// Runs as POST /__ws/auth, before the WebSocket upgrade.\n// cookies.set() becomes Set-Cookie on a standard 204 response.\nexport async function authenticate({ cookies }) {\n  const session = await getSession(cookies.get('session'));\n  if (!session) return false; // -\u003e 401, client does not open the WebSocket\n\n  const renewed = await renewSession(session);\n  cookies.set('session', renewed.token, {\n    path: '/',\n    httpOnly: true,\n    secure: true,\n    sameSite: 'lax',\n    maxAge: 60 * 60 * 24 * 7\n  });\n}\n\n// Your existing upgrade() hook stays unchanged - it reads the now-fresh cookie.\nexport async function upgrade({ cookies }) {\n  const session = await getSession(cookies.session);\n  if (!session) return false;\n  return { userId: session.userId, role: session.role };\n}\n```\n\nThe `authenticate` event exposes the SvelteKit event shape you already know: `{ request, headers, cookies, url, remoteAddress, getClientAddress, platform }`. Return values:\n\n- `undefined` / nothing - success, responds `204 No Content` with any `Set-Cookie` headers from `cookies.set()` (recommended).\n- `false` - responds `401 Unauthorized`. The client does not open the WebSocket.\n- A full `Response` - used as-is; any `cookies.set()` calls are merged in.\n\n**Step 2: opt in from the client**\n\n```js\nimport { connect } from 'svelte-adapter-uws/client';\n\n// Hit /__ws/auth before every WebSocket connect (including reconnects)\nconnect({ auth: true });\n\n// Or point at a custom path (e.g. behind a Cloudflare Access rule)\nconnect({ auth: '/api/ws-auth' });\n```\n\nWith `auth: true` the client stores runs `fetch('/__ws/auth', { method: 'POST', credentials: 'include' })` before every `new WebSocket(...)` call, including after automatic reconnects. Concurrent connect attempts share a single in-flight preflight. A `4xx` response is treated as terminal (the user is not authenticated); `5xx` and network errors fall back to the normal reconnect backoff.\n\n**Configuration**\n\n- The default auth path is `/__ws/auth`. Override with `adapter({ websocket: { authPath: '/api/ws-auth' } })`.\n- The hook is only mounted when `authenticate` is exported from `hooks.ws` - no runtime cost when unused.\n- Dev mode (Vite plugin) mirrors the production route on the same path.\n- The endpoint requires `x-requested-with: XMLHttpRequest`, `Sec-Fetch-Site: same-origin`, or an `Origin` matching `allowedOrigins` (CSRF defense). The adapter client always stamps `x-requested-with`. Native (non-browser) clients that need to reach this endpoint without those headers can opt out via `websocket.authPathRequireOrigin: false`. See [Security configuration](#security-configuration).\n\n**Why not put `Set-Cookie` on the 101?**\n\nCloudflare's HTTP/2 WebSocket bridging rewrites 101 responses, and `Set-Cookie` on the 101 trips the edge into tearing the connection down. This is undocumented Cloudflare behavior, but reproducible on every tunnel and proxy connector. The `authenticate` hook sidesteps it entirely by using a standard HTTP response.\n\n---\n\n## Platform API (`event.platform`)\n\nAvailable in server hooks, load functions, form actions, API routes, and WebSocket hooks (`hooks.ws`).\n\n### `platform.publish(topic, event, data, options?)`\n\nSend a message to all WebSocket clients subscribed to a topic.\n\nTopic and event names are validated before being written into the JSON envelope - quotes, backslashes, and control characters will throw. This prevents JSON injection when names are built from dynamic values like user IDs (`platform.publish(\\`user:\\${id}\\`, ...)`). The validation is a single-pass char scan and adds no measurable overhead.\n\nIn cluster mode, the message is automatically relayed to all other workers. Pass `{ relay: false }` to skip the relay when the message originates from an external pub/sub source (Redis, Postgres LISTEN/NOTIFY, etc.) that already delivers to every process:\n\n```js\n// Redis subscriber running on every worker - relay would cause duplicates\nsub.on('message', (channel, payload) =\u003e {\n  platform.publish(channel, 'update', JSON.parse(payload), { relay: false });\n});\n```\n\nEvery published frame is also stamped with a monotonic per-topic `seq` field in the envelope (first publish to a topic is `seq: 1`, then 2, 3, ...). Reconnecting clients can use this to detect dropped frames and resume from where they left off. Pass `{ seq: false }` to skip stamping for ephemeral or high-cardinality topics where the counter map would grow unbounded:\n\n```js\n// Skip seq for per-user cursor topics: counter map would grow with users\nplatform.publish(`cursor:${userId}`, 'move', pos, { seq: false });\n```\n\n```js\n// src/routes/todos/+page.server.js\nexport const actions = {\n  create: async ({ request, platform }) =\u003e {\n    const formData = await request.formData();\n    const todo = await db.createTodo(formData.get('text'));\n\n    // Every client subscribed to 'todos' receives this\n    platform.publish('todos', 'created', todo);\n\n    return { success: true };\n  }\n};\n```\n\n### `platform.send(ws, topic, event, data)`\n\nSend a message to a single WebSocket connection. Wraps in the same `{ topic, event, data }` envelope as `publish()`.\n\nThis is useful when you store WebSocket references (e.g. in a `Map`) and need to message specific connections from SvelteKit handlers:\n\n```js\n// src/hooks.ws.js - store connections by user ID\nconst userSockets = new Map();\n\nexport function open(ws, { platform }) {\n  const { userId } = ws.getUserData();\n  userSockets.set(userId, ws);\n}\n\nexport function close(ws, { platform }) {\n  const { userId } = ws.getUserData();\n  userSockets.delete(userId);\n}\n\n// Export the map so SvelteKit handlers can access it\nexport { userSockets };\n```\n\n```js\n// src/routes/api/dm/+server.js - send to a specific user\nimport { userSockets } from '../../hooks.ws.js';\n\nexport async function POST({ request, platform }) {\n  const { targetUserId, message } = await request.json();\n  const ws = userSockets.get(targetUserId);\n  if (ws) {\n    platform.send(ws, 'dm', 'new-message', { message });\n  }\n  return new Response('OK');\n}\n```\n\nYou can also reply directly from inside `hooks.ws.js` using `platform.send()` or `ws.send()` with the envelope format:\n\n```js\n// src/hooks.ws.js\nexport function message(ws, { data, platform }) {\n  const msg = JSON.parse(Buffer.from(data).toString());\n  // Using platform.send (recommended):\n  platform.send(ws, 'echo', 'reply', { got: msg });\n  // Or using ws.send with manual envelope:\n  ws.send(JSON.stringify({ topic: 'echo', event: 'reply', data: { got: msg } }));\n}\n```\n\n### `platform.sendCoalesced(ws, { key, topic, event, data })`\n\nSend a message to a single connection with **coalesce-by-key** semantics. Each `(connection, key)` pair holds at most one pending message; if a newer call for the same `key` arrives before the previous frame drains to the wire, the older value is replaced in place.\n\nUse this for latest-value streams where intermediate values are noise - price ticks, cursor positions, presence state, typing indicators, scroll position. Under load, this is the difference between the client lagging by a thousand stale frames and the client always seeing the most recent value.\n\nIf you want a backpressured subscriber to keep eventually receiving the latest value (the queue-and-drain shape), `sendCoalesced` is the right primitive. If you want backpressured subscribers skipped entirely so the wire stays current for everyone else, use `platform.publish` / `platform.send` instead - those drop on backpressure (see the \"Volatile / fire-and-forget delivery\" section below). `sendCoalesced` is explicitly drop-the-middle, keep-the-latest; `publish` / `send` are explicitly drop-the-laggard, keep-everyone-else-current.\n\n```js\n// src/hooks.ws.js - cursor positions during a collaborative edit\nexport function message(ws, { data, platform }) {\n  const msg = JSON.parse(Buffer.from(data).toString());\n  if (msg.event === 'cursor') {\n    const { docId, userId } = ws.getUserData();\n    // Coalesce per (connection, user) - one pending cursor frame per peer.\n    // High-frequency mousemove updates collapse cleanly under backpressure.\n    for (const peer of getPeersOf(docId)) {\n      platform.sendCoalesced(peer, {\n        key: 'cursor:' + userId,\n        topic: 'doc:' + docId,\n        event: 'cursor',\n        data: { userId, x: msg.data.x, y: msg.data.y }\n      });\n    }\n  }\n}\n```\n\nThree properties worth knowing:\n\n- **Latest value wins.** `set` on an existing key replaces the value but keeps the original slot, so coalescing one key never reorders the rest of the queue.\n- **Lazy serialization.** `data` is held as-is in the per-connection buffer and only `JSON.stringify`'d at flush time. A stream that overwrites the same key 1000 times before a single drain pays one serialization, not 1000.\n- **Auto-resume on drain.** When `maxBackpressure` is hit, pumping stops and resumes on the next uWS drain event automatically. No manual flow control.\n\n### `platform.sendTo(filter, topic, event, data)`\n\nSend a message to all connections whose `userData` matches a filter function. Returns the number of connections the message was sent to.\n\nThis is simpler than manually maintaining a `Map` of connections - no `hooks.ws.js` needed:\n\n```js\n// src/routes/api/dm/+server.js - send to a specific user\nexport async function POST({ request, platform }) {\n  const { targetUserId, message } = await request.json();\n  const count = platform.sendTo(\n    (userData) =\u003e userData.userId === targetUserId,\n    'dm', 'new-message', { message }\n  );\n  return new Response(count \u003e 0 ? 'Sent' : 'User offline');\n}\n```\n\n```js\n// Send to all admins\nplatform.sendTo(\n  (userData) =\u003e userData.role === 'admin',\n  'alerts', 'warning', { message: 'Server load high' }\n);\n```\n\n\u003e **Performance:** `sendTo` iterates every open connection and runs your filter function against each one. It's fine for low-frequency operations like sending a DM or notifying admins, but don't use it in a hot loop. If you're broadcasting to a known group of users, subscribe them to a shared topic and use `platform.publish()` instead - topic-based pub/sub is handled natively by uWS in C++ and doesn't touch the JS event loop.\n\n### `platform.connections`\n\nNumber of active WebSocket connections:\n\n```js\n// src/routes/api/stats/+server.js\nimport { json } from '@sveltejs/kit';\n\nexport async function GET({ platform }) {\n  return json({ online: platform.connections });\n}\n```\n\n### `platform.subscribers(topic)`\n\nNumber of clients subscribed to a specific topic:\n\n```js\nexport async function GET({ platform, params }) {\n  return json({\n    viewers: platform.subscribers(`page:${params.id}`)\n  });\n}\n```\n\n### `platform.assertions`\n\nPer-category counter of framework invariant violations. The adapter ships internal hard-asserts at ~30 invariant sites (envelope build, WebSocket lifecycle, subscription bookkeeping, cross-worker IPC payloads, server-initiated request entry shape, sendCoalesced state). When one fires, the counter for that category increments and a structured `[adapter-uws/assert]` line is logged.\n\nMost apps will never see a non-empty entry here. A non-zero counter indicates a regression in the framework or a third-party plugin and should be reported as a GitHub issue with the category string and accompanying log context.\n\n```js\nexport async function GET({ platform }) {\n  // Surface the counters in your /healthz or ops dashboard\n  const assertions = {};\n  for (const [category, count] of platform.assertions) {\n    assertions[category] = count;\n  }\n  return json({ healthy: Object.keys(assertions).length === 0, assertions });\n}\n```\n\nThe returned `Map` is the live module-level instance - read-only, do not mutate. In test mode (`process.env.VITEST` set, or `NODE_ENV === 'test'`) the assert helper additionally throws so test runners surface the failure; in production it logs and counts but does not throw, so a violation inside a uWS callback frame cannot crash the worker.\n\n### `platform.closedWsAborts`\n\nPer-worker count of best-effort uWS operations that aborted because the underlying WebSocket had already closed. Bumped every time `platform.subscribe`, `platform.unsubscribe`, `platform.send`, `platform.sendCoalesced`, `platform.sendTo`, or `platform.request` is called on a `ws` whose native handle has been freed - typically because the caller `await`-ed something (auth, loader, subscribe hook) and the client closed during the wait.\n\nThese methods are *closed-WS safe* by contract: they swallow uWS's `Invalid access of closed uWS.WebSocket` exception, return a success-shaped no-op sentinel (`null` for subscribe, `false` for unsubscribe, `2` for send, etc.), and bump this counter. Callers can fire-and-forget without a per-site try/catch.\n\n```js\nexport async function GET({ platform }) {\n  return json({ closedWsAborts: platform.closedWsAborts });\n}\n```\n\nA non-zero value is normal under client churn (tab close, network blips, mass reconnect waves). A rapidly-growing value under steady load indicates either pathological client behaviour or that the server's async setup path is too long for its connect rate. In clustered mode, sum across workers for cluster-wide visibility.\n\nMonotonic, per-worker, reset only on process restart.\n\n### `platform.pressure` and `platform.onPressure(cb)`\n\nWorker-local backpressure signal. The adapter samples once per second (configurable) and reports the most urgent active stress as a single `reason` enum, so user code can degrade with intent instead of generic panic.\n\n```js\nplatform.pressure\n// {\n//   active: false,\n//   subscriberRatio: 12.4,    // total subscriptions / connections, on this worker\n//   publishRate: 240,         // platform.publish() calls/sec, last sample\n//   memoryMB: 128,            // process.memoryUsage().rss in MB\n//   reason: 'NONE'            // 'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY'\n// }\n```\n\nReading `platform.pressure` is a property access - safe in hot paths, no I/O. Use it for synchronous shed decisions in request handlers:\n\n```js\n// src/routes/api/heavy-write/+server.js\nexport async function POST({ platform, request }) {\n  if (platform.pressure.reason === 'MEMORY') {\n    return new Response('Try again shortly', { status: 503 });\n  }\n  // ... normal write path\n}\n```\n\n`platform.onPressure(cb)` fires only on **transitions** (when `reason` changes between samples), not on every tick. Returns an unsubscribe function:\n\n```js\n// src/hooks.ws.js - notify the connected client when pressure state changes\nexport function open(ws, { platform }) {\n  const off = platform.onPressure(({ reason, active }) =\u003e {\n    platform.send(ws, '__pressure', reason, { active });\n  });\n  ws.getUserData().__offPressure = off;\n}\n\nexport function close(ws) {\n  ws.getUserData().__offPressure?.();\n}\n```\n\n**Reason precedence is fixed:** `MEMORY \u003e PUBLISH_RATE \u003e SUBSCRIBERS`. A worker under multiple stresses reports the most urgent one. Memory wins because the worker is approaching OOM and nothing else matters; publish rate is next because CPU saturation cascades fastest; subscriber ratio is last because heavy fan-out degrades gracefully.\n\n**Thresholds are configurable per-deployment.** Defaults are conservative - a healthy small app should never trip them in steady state. Override via `WebSocketOptions.pressure`:\n\n```js\n// svelte.config.js\nimport adapter from 'svelte-adapter-uws';\n\nexport default {\n  kit: {\n    adapter: adapter({\n      websocket: {\n        pressure: {\n          memoryHeapUsedRatio: 0.9,         // default 0.85\n          publishRatePerSec: 50000,          // default 10000 (aggregate)\n          subscriberRatio: false,            // disable this signal\n          sampleIntervalMs: 500,             // default 1000; clamped to \u003e=100\n          topicPublishRatePerSec: 10000,     // default 5000 (per topic)\n          topicPublishBytesPerSec: 5_000_000 // default 10485760 (10 MB/s per topic)\n        }\n      }\n    })\n  }\n};\n```\n\nSet any individual threshold to `false` to disable that signal. `sampleIntervalMs` is clamped to a minimum of 100 ms.\n\n\u003e **Clustering:** `platform.pressure` is per-worker. Each worker samples its own counters and reports its own snapshot. There is no aggregate \"cluster pressure\" - a hot worker should shed its own load without waiting for the rest of the cluster.\n\n#### Per-topic publish-rate detection\n\nBeyond the aggregate `publishRatePerSec` signal, the sampler also tracks **per-topic** publish rates and surfaces the top 5 each tick:\n\n```js\nplatform.pressure.topPublishers\n// [\n//   { topic: 'cursor:room-42', messagesPerSec: 8500, bytesPerSec: 1234567 },\n//   { topic: 'audit:org-1',    messagesPerSec: 1200, bytesPerSec:  234567 },\n//   ...\n// ]\n```\n\nWhen a topic crosses `topicPublishRatePerSec` or `topicPublishBytesPerSec` in a sample window, the adapter flags it as a runaway publisher. By default this prints a throttled `console.warn` (one per topic per minute). For programmatic handling, register `platform.onPublishRate(cb)` - doing so suppresses the default warning so you own the surface:\n\n```js\nplatform.onPublishRate((events) =\u003e {\n  for (const e of events) {\n    metrics.record('runaway_publisher', {\n      topic: e.topic,\n      msgRate: e.messagesPerSec,\n      byteRate: e.bytesPerSec\n    });\n  }\n});\n```\n\nThe bookkeeping is cheap: the per-topic counter mutates two integer fields in place per `platform.publish()` call (one entry allocated the first time a topic is published to, then zero allocations forever after). Set `topicPublishRatePerSec: false` and `topicPublishBytesPerSec: false` to disable per-topic tracking entirely if you do not want it.\n\n### `platform.topic(name)` - scoped helper\n\nReduces repetition when publishing multiple events to the same topic:\n\n```js\n// src/routes/todos/+page.server.js\nexport const actions = {\n  create: async ({ request, platform }) =\u003e {\n    const todos = platform.topic('todos');\n    const todo = await db.create(await request.formData());\n    todos.created(todo);  // shorthand for platform.publish('todos', 'created', todo)\n  },\n\n  update: async ({ request, platform }) =\u003e {\n    const todos = platform.topic('todos');\n    const todo = await db.update(await request.formData());\n    todos.updated(todo);\n  },\n\n  delete: async ({ request, platform }) =\u003e {\n    const todos = platform.topic('todos');\n    const id = (await request.formData()).get('id');\n    await db.delete(id);\n    todos.deleted({ id });\n  }\n};\n```\n\nThe topic helper also has counter methods:\n\n```js\nconst online = platform.topic('online-users');\nonline.set(42);         // -\u003e { event: 'set', data: 42 }\nonline.increment();     // -\u003e { event: 'increment', data: 1 }\nonline.increment(5);    // -\u003e { event: 'increment', data: 5 }\nonline.decrement();     // -\u003e { event: 'decrement', data: 1 }\n```\n\n### `platform.batch(messages)`\n\nPublish multiple messages in a single call. Useful when an action updates several topics at once:\n\n```js\nplatform.batch([\n  { topic: 'todos', event: 'created', data: todo },\n  { topic: `user:${userId}`, event: 'activity', data: { action: 'create' } },\n  { topic: 'stats', event: 'increment', data: { key: 'todos_created' } }\n]);\n```\n\nEach entry is published with `platform.publish()`. Cross-worker relay is batched automatically, so this is more efficient than three separate `publish()` calls from a relay overhead perspective.\n\n### `platform.request(ws, event, data, options?)`\n\nSend a request to one connection and await its reply. Use this for server-driven confirmations, capability challenges, or any flow where the server needs an answer from a specific client.\n\n```js\n// In a hook on the server\nconst reply = await platform.request(ws, 'confirm-action', { op: 'delete' }, {\n  timeoutMs: 5000\n});\nif (reply.confirmed) {\n  await actuallyDelete();\n}\n```\n\nThe framework picks a fresh `ref`, sends `{type:'request', ref, event, data}`, and the returned Promise resolves with whatever the client's `onRequest` handler returned. Rejects with `Error('request timed out')` after `timeoutMs` (default `5000`) and with `Error('connection closed')` if the WebSocket closes before a reply arrives.\n\nThe client side opts in by registering a single handler:\n\n```js\nimport { onRequest } from 'svelte-adapter-uws/client';\n\nonRequest(async (event, data) =\u003e {\n  if (event === 'confirm-action') {\n    return { confirmed: confirm(`Are you sure? (${data.op})`) };\n  }\n  throw new Error('unknown event: ' + event);\n});\n```\n\nThrow or reject from the handler to send an error reply; the server's awaiting Promise rejects with the same message. With no handler installed, request frames are dropped silently and the server times out.\n\n### `platform.publishBatched(messages)`\n\nPublish a list of `{topic, event, data}` events as a single `{type:'batch', events:[...]}` WebSocket frame per affected subscriber, instead of one frame per event. Each subscriber receives only the events whose topics are in their subscription set, in submitted order. Subscribers with no overlap with the batch's topics receive nothing.\n\n```js\n// Form action publishing several related events:\nexport const actions = {\n  closeBook: async ({ platform }) =\u003e {\n    const { items, audit } = await db.closeBook();\n    platform.publishBatched([\n      ...items.map(i =\u003e ({ topic: 'org:42:items', event: 'updated', data: i })),\n      { topic: 'org:42:audit', event: 'closed', data: audit }\n    ]);\n  }\n};\n```\n\nThe frame the client receives:\n\n```json\n{ \"type\": \"batch\", \"events\": [\n  { \"topic\": \"org:42:items\", \"event\": \"updated\", \"data\": ..., \"seq\": 17 },\n  { \"topic\": \"org:42:items\", \"event\": \"updated\", \"data\": ..., \"seq\": 18 },\n  { \"topic\": \"org:42:audit\", \"event\": \"closed\", \"data\": ..., \"seq\": 4 }\n] }\n```\n\nThe bundled `svelte-adapter-uws/client` decodes the batch frame and dispatches each contained event through the same per-topic store ladder a single-event frame would take - indistinguishable from N individual frames except for the latency drop and the lower onmessage bill.\n\n**When the win shows up.** Wire-level batching has two characteristic shapes that pay off:\n\n- **Bulk fan-out, single topic.** A bulk import publishing 50 events to a topic with 500 subscribers used to send 25,000 frames (50 events x 500 subs); now it sends 500 frames (1 batch x 500 subs). The publishBatched path benches at ~3.2M events/sec on this profile vs ~840K events/sec for an equivalent `publish()` loop (~3.8x speedup, measured locally).\n- **Room-state reset, overlapping topics.** A handler that publishes 5 events across 3 rooms where every viewer is in all 3 rooms benches at ~1.46M events/sec vs ~1.15M events/sec for the loop (~27% speedup).\n\n**When the win does not apply.** Mixed subscriber views (some subs see only a subset of the batch's topics) and small disjoint batches (e.g. 3 events to 3 topics with disjoint subscriber sets) cannot share one frame - the C++ TopicTree fanout used by `publish()` is faster than building per-subscriber payloads in JS for those shapes. `publishBatched` detects these cases and falls back to a per-event `publish()` loop, so calling it is at least as fast as the publish loop the user would have written by hand. Verified on a third bench profile (3 events x 50 subs disjoint topics): delta `0.3%` (within noise).\n\n**Capability handshake.** The client opts in by sending `{type:'hello', caps:['batch']}` after the WebSocket opens. The bundled client does this automatically. When the server detects that any interested subscriber has not advertised the `'batch'` capability, the call falls back to the publish loop so old clients receive plain envelopes they can decode. Mixing old and new clients in the same call is safe; old clients simply do not benefit from the batched optimization.\n\n**Cross-worker relay.** A single `publish-batched` IPC frame carries the full event list to other workers in cluster mode. Each receiving worker re-runs the fast-path detection against ITS local subscriber set and dispatches via either a single batch envelope (fast path) or per-event publishes (slow path). Wire batching is preserved cluster-wide instead of degrading to per-event relays at the worker boundary. Pass `{relay: false}` per-event to skip the relay (use when the messages came from an external pub/sub source already fanning out to every worker).\n\n**Coalesce by key.** Each event takes an optional `coalesceKey?: string` field. Events that share a key collapse so only the latest value survives, with the surviving entry kept at the latest occurrence's position. Events without a key pass through unchanged. Use this for high-frequency batches where intermediate values are noise - a single call carrying 100 cursor positions for the same user delivers only the latest.\n\n```js\nplatform.publishBatched(positions.map(p =\u003e ({\n  topic: 'cursors',\n  event: 'move',\n  data: p,\n  coalesceKey: 'cursor:' + p.userId   // latest position per user wins\n})));\n```\n\n**Frame-size budget.** A batched frame larger than 256 KB triggers a throttled `console.warn`; uWS per-message-deflate may kick in at large sizes and surprise CPU budgets. Chunk into multiple `publishBatched` calls when the warning fires.\n\n**Order and seq.** Within one batched frame, events appear in call order (after any `coalesceKey` collapsing). Each event is independently stamped with a per-topic monotonic `seq`, identical to `publish()`; pass `{seq: false}` per-event to skip stamping for that one event. The streaming `sendCoalesced` API (per-key replacement on a single connection) is independent of `publishBatched`'s batch-level `coalesceKey`; mixing the two on the same subscriber is supported but produces separate frames.\n\n**Two distinct contracts - pick one.** The existing `platform.batch(messages)` is NOT wire-level batching - it is a `for` loop calling `publish()` once per message, so N submitted messages still produce N WebSocket frames per subscribed connection. The cross-worker relay coalesces per microtask, but the client still pays N onmessage dispatches. Use `batch()` when you want per-message return values; use `publishBatched()` when you want one-frame-per-subscriber wire batching.\n\n### `platform.requestId`\n\nA correlation id you can thread through structured logs to follow a single request across server hooks, load functions, and downstream services.\n\nFor HTTP requests, a fresh UUID is generated per request. For WebSocket connections the id is stamped once at upgrade and reused for every hook on that connection (`open`, `subscribe`, `message`, `drain`, `close`). In both cases an inbound `X-Request-ID` header overrides the generated value when present, so callers, gateways, and tracing collectors can supply their own id and have it follow the request through your code.\n\n```js\n// src/routes/api/orders/+server.js\nexport async function POST({ platform, request }) {\n  const log = logger.child({ requestId: platform.requestId });\n  log.info('order request received');\n\n  const order = await db.create(await request.json());\n  platform.publish('orders', 'created', order);\n\n  log.info({ orderId: order.id }, 'order published');\n  return json({ ok: true, requestId: platform.requestId });\n}\n```\n\n```js\n// hooks.ws.js - the same id flows through every WS hook on a connection\nexport function open(ws, { platform }) {\n  logger.info({ requestId: platform.requestId }, 'ws open');\n}\n\nexport function close(ws, { platform, code }) {\n  logger.info({ requestId: platform.requestId, code }, 'ws close');\n}\n```\n\nThe header value is sanitized before being used: only printable ASCII (no whitespace, no control chars) up to 128 chars is honoured. Anything else is ignored and a fresh UUID is generated instead, so the id is always safe to interpolate into log lines.\n\nThe adapter never writes `X-Request-ID` on the response automatically - emitting it back is an app-layer choice (usually for outbound observability). Set it explicitly if you want callers to see it:\n\n```js\nreturn new Response(body, {\n  headers: { 'x-request-id': platform.requestId }\n});\n```\n\n\u003e **Dev-mode note:** in `vite dev`, the dev server generates a fresh UUID per request but does not honour `X-Request-ID` for HTTP traffic (SvelteKit's `emulate.platform()` runs without access to request headers). Production reads the header. Dev-mode WebSocket connections honour the header normally.\n\n### Volatile / fire-and-forget delivery\n\n`platform.publish`, `platform.send`, and `platform.publishBatched` are **all volatile under backpressure**. When a specific subscriber's outbound buffer is over `maxBackpressure` (default 1 MB, configurable in `websocket.maxBackpressure`), uWS skips that subscriber for that frame while continuing to deliver to every non-backpressured subscriber. The skip is silent, per-subscriber, and does not queue for retry. There is no separate `volatile: true` flag because the volatile semantic is the default.\n\nThis is the right behavior for transient state where stale values are worse than dropped ones - cursor positions, typing indicators, presence pings, telemetry pulses, draft auto-saves. Slow / disconnected / backgrounded subscribers fall behind silently while everyone else stays current.\n\n```js\n// Cursor broadcast: every reader gets the latest position they can keep up\n// with; lagging readers silently lose intermediate values, no queue grows.\nplatform.publish(`doc:${docId}:cursors`, 'move', { userId, x, y }, { seq: false });\n```\n\nPair with `{ seq: false }` to opt out of seq stamping for these high-cardinality, replay-uninteresting topics. The seq counter map is per-topic and grows with cardinality, so opting out keeps memory bounded for unbounded-cardinality topic spaces like `cursor:${userId}` or `presence:${sessionId}`.\n\nFor the **drop-the-middle, keep-the-latest** shape on a single connection (the value still arrives, just collapsed across intermediate frames), use `platform.sendCoalesced(ws, ...)` instead. That path queues per `(connection, key)` and drains on the next uWS `drain` event rather than dropping. Quick comparison:\n\n| Primitive | Behavior under backpressure |\n|---|---|\n| `platform.publish` / `send` / `publishBatched` | Skip the backpressured subscriber, deliver to others. No retry. |\n| `platform.sendCoalesced(ws, { key, ... })` | Queue per `(ws, key)`, latest value wins, drain on next `onWritable`. |\n\nTo tune how aggressively backpressured subscribers get skipped, lower `maxBackpressure` in `websocket` options (the smaller the buffer, the sooner uWS starts skipping). The 1 MB default favors keeping the connection alive over shedding load; drop to 64 KB or 256 KB if your workload prefers shedding faster.\n\nFor visibility into whether subscribers are actually being skipped at scale, watch `platform.pressure.publishRate` and `platform.pressure.topPublishers` - a topic publishing far above its consumer rate is the canonical signature of a backpressure-shedding workload.\n\n---\n\n## Client store API\n\nImport from `svelte-adapter-uws/client`. Everything auto-connects - you don't need to call `connect()` first.\n\n### `on(topic)` - subscribe to a topic\n\nThe main function most users need. Returns a Svelte readable store that updates whenever a message is published to the topic.\n\n\u003e **Important:** The store starts as `null` (no message received yet). Always use `{#if $store}` before accessing properties, or you'll get \"Cannot read properties of null\".\n\n```svelte\n\u003cscript\u003e\n  import { on } from 'svelte-adapter-uws/client';\n\n  // Full event envelope: { topic, event, data }\n  const todos = on('todos');\n\u003c/script\u003e\n\n\u003c!-- ALWAYS guard with {#if} - $todos is null until the first message arrives --\u003e\n{#if $todos}\n  \u003cp\u003e{$todos.event}: {JSON.stringify($todos.data)}\u003c/p\u003e\n{/if}\n\n\u003c!-- WRONG - will crash with \"Cannot read properties of null\" --\u003e\n\u003c!-- \u003cp\u003e{$todos.event}\u003c/p\u003e --\u003e\n```\n\n### `on(topic, event)` - subscribe to a specific event\n\nFilters to a single event name and wraps the payload in `{ data }`:\n\n```svelte\n\u003cscript\u003e\n  import { on } from 'svelte-adapter-uws/client';\n\n  // Only 'created' events, wrapped in { data }\n  const newTodo = on('todos', 'created');\n\u003c/script\u003e\n\n{#if $newTodo}\n  \u003cp\u003eNew todo: {$newTodo.data.text}\u003c/p\u003e\n{/if}\n```\n\n### `.scan(initial, reducer)` - accumulate state\n\nLike `Array.reduce` but reactive. Each new event feeds through the reducer:\n\n```svelte\n\u003cscript\u003e\n  import { on } from 'svelte-adapter-uws/client';\n\n  const todos = on('todos').scan([], (list, { event, data }) =\u003e {\n    if (event === 'created') return [...list, data];\n    if (event === 'updated') return list.map(t =\u003e t.id === data.id ? data : t);\n    if (event === 'deleted') return list.filter(t =\u003e t.id !== data.id);\n    return list;\n  });\n\u003c/script\u003e\n\n{#each $todos as todo (todo.id)}\n  \u003cp\u003e{todo.text}\u003c/p\u003e\n{/each}\n```\n\n### `onDerived(topicFn, store)` - reactive topic subscription\n\nSubscribes to a topic derived from a reactive value. When the source store changes, the old topic is released and the new one is subscribed automatically.\n\n```svelte\n\u003cscript\u003e\n  import { page } from '$app/stores';\n  import { onDerived } from 'svelte-adapter-uws/client';\n  import { derived } from 'svelte/store';\n\n  // Subscribe to a different topic based on the current route\n  const roomId = derived(page, ($page) =\u003e $page.params.id);\n  const messages = onDerived((id) =\u003e `room:${id}`, roomId);\n\u003c/script\u003e\n\n{#if $messages}\n  \u003cp\u003e{$messages.event}: {JSON.stringify($messages.data)}\u003c/p\u003e\n{/if}\n```\n\nWithout `onDerived`, you'd need to manually watch the source store and call `connect().subscribe()` / `connect().unsubscribe()` yourself when it changes. `onDerived` handles the full lifecycle: subscribes when the first Svelte subscriber arrives, switches topics when the source changes, and unsubscribes from the server when the last Svelte subscriber leaves.\n\n### `crud(topic, initial?, options?)` - live CRUD list\n\nSubscribes to a topic and handles `created`, `updated`, and `deleted` events automatically:\n\n```svelte\n\u003cscript\u003e\n  import { crud } from 'svelte-adapter-uws/client';\n\n  let { data } = $props(); // from +page.server.js load()\n\n  // $todos auto-updates when server publishes created/updated/deleted\n  const todos = crud('todos', data.todos);\n\u003c/script\u003e\n\n{#each $todos as todo (todo.id)}\n  \u003cp\u003e{todo.text}\u003c/p\u003e\n{/each}\n```\n\nOptions:\n- `key` - property to match items by (default: `'id'`)\n- `prepend` - add new items to the beginning instead of end (default: `false`)\n- `maxAge` - auto-remove entries that haven't been created/updated within this many milliseconds (see [maxAge](#maxage---client-side-entry-expiry) below)\n\n```js\n// Notifications, newest first\nconst notifications = crud('notifications', [], { prepend: true });\n\n// Items keyed by 'slug' instead of 'id'\nconst posts = crud('posts', data.posts, { key: 'slug' });\n```\n\nPair with `platform.topic()` on the server:\n\n```js\n// Server: +page.server.js\nexport const actions = {\n  create: async ({ request, platform }) =\u003e {\n    const todo = await db.create(await request.formData());\n    platform.topic('todos').created(todo);      // client sees 'created'\n  },\n  update: async ({ request, platform }) =\u003e {\n    const todo = await db.update(await request.formData());\n    platform.topic('todos').updated(todo);      // client sees 'updated'\n  },\n  delete: async ({ request, platform }) =\u003e {\n    await db.delete((await request.formData()).get('id'));\n    platform.topic('todos').deleted({ id });    // client sees 'deleted'\n  }\n};\n```\n\n### `lookup(topic, initial?, options?)` - live keyed object\n\nLike `crud()` but returns a `Record\u003cstring, T\u003e` instead of an array. Better for dashboards and fast lookups:\n\n```svelte\n\u003cscript\u003e\n  import { lookup } from 'svelte-adapter-uws/client';\n\n  let { data } = $props();\n  const users = lookup('users', data.users);\n\u003c/script\u003e\n\n{#if $users[selectedId]}\n  \u003cUserCard user={$users[selectedId]} /\u003e\n{/if}\n```\n\nOptions:\n- `key` - property to match items by (default: `'id'`)\n- `maxAge` - auto-remove entries that haven't been created/updated within this many milliseconds (see [maxAge](#maxage---client-side-entry-expiry) below)\n\n### `maxAge` - client-side entry expiry\n\nBoth `crud()` and `lookup()` accept a `maxAge` option (in milliseconds). When set, entries that haven't received a `created` or `updated` event within that window are automatically removed from the store. Explicit `deleted` events still remove entries immediately.\n\nThis is useful for state backed by an external store with TTL (e.g. Redis). If the server fails to broadcast a removal event (mass disconnects, crashes, Redis TTL expiry without keyspace notifications), clients clean up on their own:\n\n```js\n// Presence entries expire after 90s without a refresh\nconst users = lookup('__presence:board', data.users, { key: 'key', maxAge: 90_000 });\n\n// Sensor readings expire after 30s without an update\nconst sensors = lookup('sensors', [], { key: 'id', maxAge: 30_000 });\n\n// Same option works on crud()\nconst items = crud('items', data.items, { maxAge: 60_000 });\n```\n\nThe sweep runs at `maxAge / 2` intervals (minimum 1 second). The timer is cleaned up automatically when the last subscriber unsubscribes.\n\n### `latest(topic, max?, initial?)` - ring buffer\n\nKeeps the last N events. Perfect for chat, activity feeds, notifications:\n\n```svelte\n\u003cscript\u003e\n  import { latest } from 'svelte-adapter-uws/client';\n\n  // Keep the last 100 chat messages\n  const messages = latest('chat', 100);\n\u003c/script\u003e\n\n{#each $messages as msg}\n  \u003cp\u003e\u003cb\u003e{msg.event}:\u003c/b\u003e {msg.data.text}\u003c/p\u003e\n{/each}\n```\n\n### `count(topic, initial?)` - live counter\n\nHandles `set`, `increment`, and `decrement` events:\n\n```svelte\n\u003cscript\u003e\n  import { count } from 'svelte-adapter-uws/client';\n\n  const online = count('online-users');\n\u003c/script\u003e\n\n\u003cp\u003e{$online} users online\u003c/p\u003e\n```\n\nServer (from any hook or handler that has `platform`):\n```js\n// In hooks.ws.js - track connected users:\nexport function open(ws, { platform }) {\n  platform.topic('online-users').increment();\n}\nexport function close(ws, { platform }) {\n  platform.topic('online-users').decrement();\n}\n\n// Or from a SvelteKit handler:\nplatform.topic('online-users').set(42);\n```\n\n\u003e **Heads up:** The increment/decrement pattern above has a subtle race condition - a newly connected client won't see the current count because its `subscribe` message hasn't been processed yet when `open` fires. See [Seeding initial state](#seeding-initial-state) for the fix.\n\n### `once(topic, event?, options?)` - wait for one event\n\nReturns a promise that resolves with the first matching event and then unsubscribes:\n\n```js\nimport { once } from 'svelte-adapter-uws/client';\n\n// Wait for any event on the 'jobs' topic\nconst event = await once('jobs');\n\n// Wait for a specific event\nconst result = await once('jobs', 'completed');\n\n// With a timeout (rejects if no event within 5 seconds)\nconst result = await once('jobs', 'completed', { timeout: 5000 });\n\n// Timeout without event filter\nconst event = await once('jobs', { timeout: 5000 });\n```\n\n### `status` - connection status\n\nReadable store with the current connection state. Five states drive distinct UI affordances:\n\n- `'connecting'` - establishing a connection (initial attempt or retry)\n- `'open'` - connected, live data is flowing\n- `'suspended'` - WS is technically open but the tab is in the background; server may close idle backgrounded sockets, so live data is best-effort\n- `'disconnected'` - lost connection, will retry automatically\n- `'failed'` - terminal: auth denied, max retries exhausted, or `close()` called\n\n```svelte\n\u003cscript\u003e\n  import { status } from 'svelte-adapter-uws/client';\n\u003c/script\u003e\n\n{#if $status === 'open'}\n  \u003cspan class=\"badge green\"\u003eLive\u003c/span\u003e\n{:else if $status === 'suspended'}\n  \u003cspan class=\"badge muted\"\u003ePaused (tab in background)\u003c/span\u003e\n{:else if $status === 'connecting'}\n  \u003cspan class=\"badge yellow\"\u003eConnecting...\u003c/span\u003e\n{:else if $status === 'disconnected'}\n  \u003cspan class=\"badge orange\"\u003eReconnecting...\u003c/span\u003e\n{:else}\n  \u003cspan class=\"badge red\"\u003eConnection failed\u003c/span\u003e\n{/if}\n```\n\nThe `'suspended'` overlay flips back to `'open'` automatically when the tab returns to the foreground (assuming the WebSocket survived the hide period; if it did not, the state machine drives `'connecting'` -\u003e `'open'` via the normal reconnect path).\n\n### `failure` - cause of the most recent disconnect\n\nSibling Readable to `status`. Use `status` to drive UI state; use `failure` to drive what message you show. Stays at `null` while connected, set when the connection drops, cleared on the next successful `'open'`.\n\nThe value is a discriminated union by `kind`:\n\n```ts\ntype Failure =\n  | { kind: 'ws-close'; class: 'TERMINAL' | 'EXHAUSTED' | 'THROTTLE' | 'RETRY'; code: number; reason: string }\n  | { kind: 'auth-preflight'; class: 'AUTH'; status: number; reason: string };\n```\n\nFive `class` values let consumers render targeted UI without inspecting raw close codes:\n\n- `'TERMINAL'` - server permanently rejected the client (close codes 1008 / 4401 / 4403). The retry loop is stopped; the user must re-authenticate or refresh.\n- `'EXHAUSTED'` - reconnect attempts exceeded `maxReconnectAttempts`. The network never recovered; surface a manual-retry button.\n- `'THROTTLE'` - server signalled rate-limiting (close code 4429). Reconnect is still scheduled, jumped ahead in the backoff curve.\n- `'RETRY'` - normal transient drop (1006 abnormal closure, network blip, server restart). Reconnect is in progress; usually paired with the `'disconnected'` status.\n- `'AUTH'` - the auth preflight (`{ auth: true }`) failed before the WebSocket was opened. 4xx is terminal; 5xx and network errors retry. The HTTP status code is in `status`, not `code`.\n\n`failure === null` while `status === 'failed'` is the deliberately-ended state - the user called `close()`, not a transport-level failure.\n\n```svelte\n\u003cscript\u003e\n  import { status, failure } from 'svelte-adapter-uws/client';\n\u003c/script\u003e\n\n{#if $failure?.class === 'TERMINAL'}\n  \u003cp class=\"error\"\u003eSession expired. \u003ca href=\"/login\"\u003eSign in again\u003c/a\u003e\u003c/p\u003e\n{:else if $failure?.class === 'EXHAUSTED'}\n  \u003cp class=\"error\"\u003eConnection lost. \u003cbutton onclick={() =\u003e location.reload()}\u003eReload\u003c/button\u003e\u003c/p\u003e\n{:else if $failure?.class === 'THROTTLE'}\n  \u003cp class=\"warn\"\u003eServer is busy. Retrying shortly...\u003c/p\u003e\n{:else if $failure?.class === 'AUTH'}\n  \u003cp class=\"error\"\u003eCould not authenticate (HTTP {$failure.status}). \u003ca href=\"/login\"\u003eSign in\u003c/a\u003e\u003c/p\u003e\n{:else if $status === 'disconnected'}\n  \u003cspan\u003eReconnecting...\u003c/span\u003e\n{/if}\n```\n\n### `ready()` - wait for connection\n\nReturns a promise that resolves when the WebSocket connection is open:\n\n```js\nimport { ready } from 'svelte-adapter-uws/client';\n\nawait ready();\n// connection is now open, safe to send messages\n```\n\nIn SSR (no browser WebSocket and no explicit `url`), `ready()` resolves immediately and is a no-op. In native app environments where `window` doesn't exist but you passed a `url` to `connect()`, `ready()` correctly waits for the connection to open.\n\n`ready()` rejects if the connection is permanently closed before it opens. This happens when the server sends a terminal close code (1008/4401/4403), retries are exhausted, or `close()` is called explicitly. If you call `ready()` in a context where permanent closure is possible, add a `.catch()` handler or use `try/await/catch`.\n\n### `connect(options?)` - power-user API\n\nMost users don't need this - `on()` and `status` auto-connect. Use `connect()` when you need `close()`, `send()`, or custom options.\n\n**If you pass custom options** (like a non-default `path`), call `connect()` before any `on()`, `status`, `ready()`, or `once()` calls. Those functions auto-connect with defaults, and the connection is locked once created. A console warning will fire if your options are ignored due to ordering:\n\n```js\nimport { connect } from 'svelte-adapter-uws/client';\n\nconst ws = connect({\n  url: 'wss://my-app.com/ws', // full URL for cross-origin / native app usage (overrides path)\n  path: '/ws',               // default: '/ws'\n  reconnectInterval: 3000,   // default: 3000 ms\n  maxReconnectInterval: 30000, // default: 30000 ms\n  maxReconnectAttempts: Infinity, // default: Infinity\n  debug: true                // default: false - turn this on to see everything!\n});\n\n// With debug: true, you'll see every WebSocket event in the browser console:\n//   [ws] connected\n//   [ws] subscribe -\u003e todos\n//   [ws] \u003c- todos created { id: 1, text: \"Buy milk\" }\n//   [ws] send -\u003e { type: \"ping\" }\n//   [ws] disconnected\n//   [ws] queued -\u003e { type: \"important\" }\n//   [ws] resubscribe-batch -\u003e ['todos', 'chat']\n//   [ws] flush -\u003e { type: \"important\" }\n\n// Manual topic management\nws.subscribe('chat');\nws.unsubscribe('chat');\n\n// Send custom messages to the server\nws.send({ type: 'ping' });\n\n// Send with queue (messages queue up while disconnected, flush on reconnect)\nws.sendQueued({ type: 'important', data: '...' });\n\n// Permanent disconnect (won't auto-reconnect)\nws.close();\n```\n\n### Automatic connection behaviors\n\nThe client handles several edge cases automatically, with no configuration required:\n\n**Exponential backoff with proportional jitter**: each reconnect attempt waits longer than the previous one. The jitter is +-25% of the base delay (not a fixed +-500ms), so at high attempt counts thousands of clients are spread over a wide window rather than clustering.\n\n**Page visibility reconnect**: when a browser tab resumes from background or a phone is unlocked, the client reconnects immediately instead of waiting for the backoff timer. Browsers often close WebSocket connections silently when a tab is hidden.\n\n**Batch resubscription**: on reconnect, all topics are resubscribed in batched `subscribe-batch` messages. Each batch stays under the server's 8 KB control-message ceiling and 256-topic-per-batch cap. For typical apps (under 200 topics with short names) this is a single frame; larger sets are automatically chunked.\n\n**Microtask-batched initial subscribes**: multiple `subscribe(topic)` calls landing in the same microtask coalesce into one `subscribe-batch` wire frame. A page that mounts many topic stores in a tight loop (a multi-stream dashboard, a `svelte-realtime` page initializing 5 stream RPCs) triggers the server's `subscribeBatch` hook ONCE instead of the per-topic `subscribe` hook N times. Single-topic case stays as a plain `subscribe` frame for the minimal-change wire shape. Same chunking limits as the reconnect path. Topics are still added to the local subscription set synchronously, so a disconnect between the call and the microtask flush loses nothing - the reopen path picks them up. **Test-code note**: code asserting on the exact wire shape of two same-microtask subscribes seeing two `subscribe` frames now sees one `subscribe-batch` frame; use `.find(m =\u003e m.type === 'subscribe-batch' \u0026\u0026 m.topics.includes(...))` instead.\n\n**Zombie detection**: the client checks every 30 seconds whether the server has been completely silent for more than 150 seconds (2.5x the server's idle timeout). If so, it forces a close and reconnects. This catches connections that appear open but were silently dropped by the server, which is common on mobile after wake from sleep.\n\n### Cross-origin and native app usage\n\nBy default, the client derives the WebSocket URL from `window.location`. If your client runs on a different origin - a mobile app (Svelte Native, React Native), a standalone Node.js script, or any context where the backend lives elsewhere - pass a `url` to connect to it directly:\n\n```js\nimport { connect, on } from 'svelte-adapter-uws/client';\n\nconnect({ url: 'wss://my-app.com/ws' });\n\nconst todos = on('todos');\n```\n\nWhen `url` is set, `path` is ignored and the `window` check is bypassed, so the client works in environments without a browser DOM. All other features (reconnect, backoff, batch resubscription, topic stores) work the same way.\n\n\u003e **Note:** Your server's `allowedOrigins` config must include the origin your client connects from (or `'*'` during development). See the [origin validation](#origin-validation) section.\n\n---\n\n## Seeding initial state\n\nWhen a client connects, there's a window between the WebSocket opening and the client's topic subscriptions being processed. Any `platform.publish()` calls that happen during `open` will be missed by the connecting client, because it hasn't subscribed to those topics yet.\n\nThis matters most with `count()`. If your `open` hook does `platform.topic('online').set(total)`, the connecting client won't see it - the `set` event is broadcast before the client's `subscribe` message arrives.\n\nThe fix is to use the `subscribe` hook instead of (or alongside) `open` to send the current value directly to the subscribing client:\n\n```js\n// src/hooks.ws.js\nlet online = 0;\n\nexport function open(ws, { platform }) {\n  online++;\n  platform.topic('online').set(online); // broadcasts to already-subscribed clients\n}\n\nexport function subscribe(ws, topic, { platform }) {\n  // When a client subscribes to 'online', send it the current count\n  if (topic === 'online') {\n    platform.send(ws, 'online', 'set', online);\n  }\n}\n\nexport function close(ws, { platform }) {\n  online--;\n  platform.topic('online').set(online);\n}\n```\n\n```svelte\n\u003c!-- src/routes/+page.svelte --\u003e\n\u003cscript\u003e\n  import { count } from 'svelte-adapter-uws/client';\n\n  const online = count('online');\n\u003c/script\u003e\n\n\u003cp\u003e{$online} online\u003c/p\u003e\n```\n\nThe `subscribe` hook fires at the right moment - after the client is actually subscribed to the topic. `platform.send()` sends only to that one client, so it gets the current value without waiting for the next broadcast.\n\nThis same pattern works for any topic where new subscribers need to see the current state. For a CRUD list, you could send the full dataset in `subscribe`:\n\n```js\n// src/hooks.ws.js\nexport async function subscribe(ws, topic, { platform }) {\n  if (topic === 'todos') {\n    const todos = await db.getTodos();\n    for (const todo of todos) {\n      platform.send(ws, 'todos', 'created', todo);\n    }\n  }\n}\n```\n\n```svelte\n\u003cscript\u003e\n  import { crud } from 'svelte-adapter-uws/client';\n\n  // No need for load() data - the subscribe hook seeds the list\n  const todos = crud('todos');\n\u003c/script\u003e\n\n{#each $todos as todo (todo.id)}\n  \u003cp\u003e{todo.text}\u003c/p\u003e\n{/each}\n```\n\n---\n\n## Plugins\n\nOpt-in modules that build on top of the adapter's public API. They don't change any core behavior - if you don't import them, they don't exist. Each plugin ships in its own subdirectory under `plugins/` with separate server and client entry points.\n\n### Authorization model\n\nEvery plugin in this directory is an **authorization-free primitive**. None of them know who the caller is, what roles the caller has, or whether the requested action is allowed - they execute whatever the caller passes. Calling `withLock(key, fn)`, `replay.replay(ws, topic, since)`, or `idempotency.handle(key, fn)` is no more an authorization check than calling `Map.set(key, value)` is one.\n\nYour message handler is the gate. Identity is established at connect time by the [`upgrade()` hook](#authentication) and stashed on the socket via `ws.getUserData()`. Your `message()` handler reads that identity, decides whether the action is allowed, and **only then** invokes the plugin:\n\n```js\n// hooks.ws.js\nimport { withLock } from 'svelte-adapter-uws/plugins/lock';\n\nexport async function message(ws, { data }) {\n  const { topic, action, payload } = JSON.parse(Buffer.from(data).toString());\n  const { userId, role } = ws.getUserData() ?? {};\n\n  // 1. Authentication: did upgrade() reject? If not, ws.getUserData() is non-empty.\n  if (!userId) return;\n\n  // 2. Authorization: this handler decides. The plugin does not.\n  if (action === 'reset-counter' \u0026\u0026 role !== 'admin') return;\n\n  // 3. Only now is the plugin invoked. withLock has no idea who the caller is.\n  await withLock(`counter:${topic}`, async () =\u003e {\n    // ... critical section\n  });\n}\n```\n\nHigher-level frameworks built on this adapter (e.g. [`svelte-realtime`](https://github.com/lanteanio/svelte-realtime)) wrap this pattern: `ctx.user` is the same identity object the `upgrade()` hook returned, and the framework's `_guard` / `live.public()` / `// realtime-allow-public` machinery is the authorization layer at the RPC seam. The plugins below still do not gate anything themselves; the framework's auth lives outside them.\n\nThe same pattern applies to every plugin in this section: read identity, decide, then invoke. A plugin that \"looks like an auth gate\" by virtue of taking a userId-shaped key (e.g. `presence.subscribe(`user:${userId}`)`) is just substituting whatever string the caller hands it - if your handler interpolates `payload.targetUserId` from the wire without checking that the caller owns it, the plugin will happily address a user the caller has no business touching.\n\n### Middleware\n\nComposable message processing pipeline. Chain functions that run on inbound messages before your handler logic. Each middleware receives a context and a `next` function - call `next()` to continue, skip it to stop the chain.\n\n#### Setup\n\n```js\n// src/lib/server/pipeline.js\nimport { createMiddleware } from 'svelte-adapter-uws/plugins/middleware';\n\nexport const pipeline = createMiddleware(\n  // logging\n  async (ctx, next) =\u003e {\n    console.log(`[${ctx.topic}] ${ctx.event}`);\n    await next();\n  },\n  // auth check\n  async (ctx, next) =\u003e {\n    const userId = ctx.ws.getUserData()?.userId;\n    if (!userId) return; // stop chain - unauthenticated\n    ctx.locals.userId = userId;\n    await next();\n  },\n  // data enrichment\n  async (ctx, next) =\u003e {\n    ctx.data = { ...ctx.data, processedAt: Date.now() };\n    await next();\n  }\n);\n```\n\n#### Usage\n\n```js\n// src/hooks.ws.js\nimport { pipeline } from '$lib/server/pipeline';\n\nexport async function message(ws, { data, platform }) {\n  const msg = JSON.parse(Buffer.from(data).toString());\n  const ctx = await pipeline.run(ws, msg, platform);\n  if (!ctx) return; // chain was stopped (e.g. auth failed)\n\n  // ctx.locals.userId is available here\n  // ctx.data has the enriched data\n}\n```\n\n#### API\n\n| Method | Description |\n|---|---|\n| `pipeline.run(ws, message, platform)` | Execute the chain. Returns","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flanteanio%2Fsvelte-adapter-uws","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flanteanio%2Fsvelte-adapter-uws","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flanteanio%2Fsvelte-adapter-uws/lists"}