svelte-adapter-uws 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1553 +1,1560 @@
1
- # svelte-adapter-uws
2
-
3
- A 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.
4
-
5
- I'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.
6
-
7
- ## What you get
8
-
9
- - **HTTP & HTTPS** - native TLS via uWebSockets.js `SSLApp`, no reverse proxy needed
10
- - **WebSocket & WSS** - built-in pub/sub with a reactive Svelte client store
11
- - **In-memory static file cache** - assets loaded once at startup, served from RAM with precompressed brotli/gzip variants
12
- - **Backpressure handling** - streaming responses that won't blow up memory
13
- - **Graceful shutdown** - waits for in-flight requests before exiting
14
- - **Health check endpoint** - `/healthz` out of the box
15
- - **Zero-config WebSocket** - just set `websocket: true` and go
16
-
17
- ---
18
-
19
- ## Table of contents
20
-
21
- - [Installation](#installation)
22
- - [Quick start: HTTP](#quick-start-http)
23
- - [Quick start: HTTPS](#quick-start-https)
24
- - [Quick start: WebSocket](#quick-start-websocket)
25
- - [Quick start: WSS (secure WebSocket)](#quick-start-wss-secure-websocket)
26
- - [Development, Preview & Production](#development-preview--production)
27
- - [Adapter options](#adapter-options)
28
- - [Environment variables](#environment-variables)
29
- - [WebSocket handler (`hooks.ws`)](#websocket-handler-hooksws)
30
- - [Authentication](#authentication)
31
- - [Platform API (`event.platform`)](#platform-api-eventplatform)
32
- - [Client store API](#client-store-api)
33
- - [TypeScript setup](#typescript-setup)
34
- - [Svelte 4 support](#svelte-4-support)
35
- - [Deploying with Docker](#deploying-with-docker)
36
- - [Clustering (Linux)](#clustering-linux)
37
- - [Performance](#performance)
38
- - [Troubleshooting](#troubleshooting)
39
- - [License](#license)
40
-
41
- ---
42
-
43
- ## Installation
44
-
45
- ### Starting from scratch
46
-
47
- If you don't have a SvelteKit project yet:
48
-
49
- ```bash
50
- npx sv create my-app
51
- cd my-app
52
- npm install
53
- ```
54
-
55
- ### Adding the adapter
56
-
57
- ```bash
58
- npm install svelte-adapter-uws
59
- npm install uNetworking/uWebSockets.js#v20.60.0
60
- ```
61
-
62
- > **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.
63
- >
64
- > **Docker:** Use `node:22-trixie-slim` or another glibc >= 2.38 image. Bookworm-based images and Alpine won't work. See [Deploying with Docker](#deploying-with-docker).
65
-
66
- If you plan to use WebSockets during development, also install `ws`:
67
-
68
- ```bash
69
- npm install -D ws
70
- ```
71
-
72
- ---
73
-
74
- ## Quick start: HTTP
75
-
76
- The simplest setup - just swap the adapter and you're done.
77
-
78
- **svelte.config.js**
79
- ```js
80
- import adapter from 'svelte-adapter-uws';
81
-
82
- export default {
83
- kit: {
84
- adapter: adapter()
85
- }
86
- };
87
- ```
88
-
89
- **Build and run:**
90
- ```bash
91
- npm run build
92
- node build
93
- ```
94
-
95
- Your app is now running on `http://localhost:3000`.
96
-
97
- To change the host or port:
98
- ```bash
99
- HOST=0.0.0.0 PORT=8080 node build
100
- ```
101
-
102
- ---
103
-
104
- ## Quick start: HTTPS
105
-
106
- No reverse proxy needed. uWebSockets.js handles TLS natively with its `SSLApp`.
107
-
108
- **svelte.config.js** - same as HTTP, no changes needed:
109
- ```js
110
- import adapter from 'svelte-adapter-uws';
111
-
112
- export default {
113
- kit: {
114
- adapter: adapter()
115
- }
116
- };
117
- ```
118
-
119
- **Build and run with TLS:**
120
- ```bash
121
- npm run build
122
- SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build
123
- ```
124
-
125
- Your app is now running on `https://localhost:3000`.
126
-
127
- > Both `SSL_CERT` and `SSL_KEY` must be set. Setting only one will throw an error.
128
-
129
- ### Behind a reverse proxy (nginx, Caddy, etc.)
130
-
131
- If your proxy terminates TLS and forwards to HTTP:
132
-
133
- ```bash
134
- ORIGIN=https://example.com node build
135
- ```
136
-
137
- Or if you want flexible header-based detection:
138
- ```bash
139
- PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build
140
- ```
141
-
142
- ---
143
-
144
- ## Quick start: WebSocket
145
-
146
- Three things to do:
147
-
148
- 1. **Enable WebSocket in the adapter**
149
- 2. **Add the Vite plugin** (for dev mode)
150
- 3. **Use the client store** in your Svelte components
151
-
152
- ### Step 1: Enable WebSocket
153
-
154
- **svelte.config.js**
155
- ```js
156
- import adapter from 'svelte-adapter-uws';
157
-
158
- export default {
159
- kit: {
160
- adapter: adapter({
161
- websocket: true
162
- })
163
- }
164
- };
165
- ```
166
-
167
- That'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.
168
-
169
- ### Step 2: Add the Vite plugin
170
-
171
- This makes WebSockets work during `npm run dev`. Without this, `event.platform` won't have WebSocket methods in dev mode.
172
-
173
- **vite.config.js**
174
- ```js
175
- import { sveltekit } from '@sveltejs/kit/vite';
176
- import uwsDev from 'svelte-adapter-uws/vite';
177
-
178
- export default {
179
- plugins: [sveltekit(), uwsDev()]
180
- };
181
- ```
182
-
183
- ### Step 3: Use the client store
184
-
185
- **src/routes/+page.svelte**
186
- ```svelte
187
- <script>
188
- import { on, status } from 'svelte-adapter-uws/client';
189
-
190
- // Subscribe to the 'notifications' topic
191
- // Auto-connects, auto-subscribes, auto-reconnects
192
- const notifications = on('notifications');
193
- </script>
194
-
195
- {#if $status === 'open'}
196
- <span>Connected</span>
197
- {/if}
198
-
199
- {#if $notifications}
200
- <p>Event: {$notifications.event}</p>
201
- <p>Data: {JSON.stringify($notifications.data)}</p>
202
- {/if}
203
- ```
204
-
205
- ### Step 4: Publish from the server
206
-
207
- **src/routes/api/notify/+server.js**
208
- ```js
209
- export async function POST({ request, platform }) {
210
- const data = await request.json();
211
-
212
- // This sends to ALL clients subscribed to 'notifications'
213
- platform.publish('notifications', 'new-message', data);
214
-
215
- return new Response('OK');
216
- }
217
- ```
218
-
219
- **Build and run:**
220
- ```bash
221
- npm run build
222
- node build
223
- ```
224
-
225
- ---
226
-
227
- ## Quick start: WSS (secure WebSocket)
228
-
229
- WSS works automatically when you enable TLS. WebSocket connections upgrade over the same HTTPS port.
230
-
231
- **svelte.config.js**
232
- ```js
233
- import adapter from 'svelte-adapter-uws';
234
-
235
- export default {
236
- kit: {
237
- adapter: adapter({
238
- websocket: true
239
- })
240
- }
241
- };
242
- ```
243
-
244
- ```bash
245
- npm run build
246
- SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build
247
- ```
248
-
249
- The client store automatically uses `wss://` when the page is served over HTTPS - no configuration needed on the client side.
250
-
251
- ---
252
-
253
- ## Development, Preview & Production
254
-
255
- ### `npm run dev` - works (with the Vite plugin)
256
-
257
- Development works as expected. The Vite plugin (`svelte-adapter-uws/vite`) spins up a `ws` WebSocket server alongside Vite's dev server, so your client store and `event.platform` work identically to production.
258
-
259
- **vite.config.js**
260
- ```js
261
- import { sveltekit } from '@sveltejs/kit/vite';
262
- import uwsDev from 'svelte-adapter-uws/vite';
263
-
264
- export default {
265
- plugins: [sveltekit(), uwsDev()]
266
- };
267
- ```
268
-
269
- Without the Vite plugin:
270
- - HTTP routes work fine
271
- - `event.platform` is `undefined` - any code calling `platform.publish()` will throw
272
- - The client store will try to connect to `/ws` and fail silently (auto-reconnect will keep trying)
273
-
274
- ### `npm run preview` - WebSockets don't work
275
-
276
- SvelteKit'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**.
277
-
278
- Use `node build` instead of preview for testing WebSocket features.
279
-
280
- ### `node build` - production, everything works
281
-
282
- This is the real deal. uWebSockets.js handles everything:
283
-
284
- ```bash
285
- npm run build
286
- node build
287
- ```
288
-
289
- Or with environment variables:
290
- ```bash
291
- PORT=8080 HOST=0.0.0.0 node build
292
- ```
293
-
294
- Or with TLS:
295
- ```bash
296
- SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build
297
- ```
298
-
299
- ---
300
-
301
- ## Adapter options
302
-
303
- ```js
304
- adapter({
305
- // Output directory for the build
306
- out: 'build', // default: 'build'
307
-
308
- // Precompress static assets with brotli and gzip
309
- precompress: true, // default: true
310
-
311
- // Prefix for environment variables (e.g. 'MY_APP_' → MY_APP_PORT)
312
- envPrefix: '', // default: ''
313
-
314
- // Health check endpoint (set to false to disable)
315
- healthCheckPath: '/healthz', // default: '/healthz'
316
-
317
- // WebSocket configuration
318
- websocket: true // or false, or an options object (see below)
319
- })
320
- ```
321
-
322
- ### WebSocket options
323
-
324
- ```js
325
- adapter({
326
- websocket: {
327
- // Path for WebSocket connections
328
- path: '/ws', // default: '/ws'
329
-
330
- // Path to your custom handler module (auto-discovers src/hooks.ws.js if omitted)
331
- handler: './src/lib/server/websocket.js', // default: auto-discover
332
-
333
- // Max message size in bytes (connections sending larger messages are closed)
334
- maxPayloadLength: 16 * 1024, // default: 16 KB
335
-
336
- // Seconds of inactivity before the connection is closed
337
- idleTimeout: 120, // default: 120
338
-
339
- // Max bytes of backpressure before messages are dropped
340
- maxBackpressure: 1024 * 1024, // default: 1 MB
341
-
342
- // Enable per-message deflate compression
343
- compression: false, // default: false
344
-
345
- // Automatically send pings to keep the connection alive
346
- sendPingsAutomatically: true, // default: true
347
-
348
- // Allowed origins for WebSocket connections
349
- // 'same-origin' - only accept where Origin matches Host (default)
350
- // '*' - accept from any origin
351
- // ['https://example.com'] - whitelist specific origins
352
- allowedOrigins: 'same-origin' // default: 'same-origin'
353
- }
354
- })
355
- ```
356
-
357
- ---
358
-
359
- ## Environment variables
360
-
361
- All variables are set at **runtime** (when you run `node build`), not at build time.
362
-
363
- If you set `envPrefix: 'MY_APP_'` in the adapter config, all variables are prefixed (e.g. `MY_APP_PORT` instead of `PORT`).
364
-
365
- | Variable | Default | Description |
366
- |---|---|---|
367
- | `HOST` | `0.0.0.0` | Bind address |
368
- | `PORT` | `3000` | Listen port |
369
- | `ORIGIN` | *(derived)* | Fixed origin (e.g. `https://example.com`) |
370
- | `SSL_CERT` | - | Path to TLS certificate file |
371
- | `SSL_KEY` | - | Path to TLS private key file |
372
- | `PROTOCOL_HEADER` | - | Header for protocol detection (e.g. `x-forwarded-proto`) |
373
- | `HOST_HEADER` | - | Header for host detection (e.g. `x-forwarded-host`) |
374
- | `PORT_HEADER` | - | Header for port override (e.g. `x-forwarded-port`) |
375
- | `ADDRESS_HEADER` | - | Header for client IP (e.g. `x-forwarded-for`) |
376
- | `XFF_DEPTH` | `1` | Position from right in `X-Forwarded-For` |
377
- | `BODY_SIZE_LIMIT` | `512K` | Max request body size (supports `K`, `M`, `G` suffixes) |
378
- | `SHUTDOWN_TIMEOUT` | `30` | Seconds to wait during graceful shutdown |
379
- | `CLUSTER_WORKERS` | - | Number of worker processes (or `auto` for CPU count). Linux only, HTTP only |
380
-
381
- ### Graceful shutdown
382
-
383
- On `SIGTERM` or `SIGINT`, the server:
384
- 1. Stops accepting new connections
385
- 2. Emits a `sveltekit:shutdown` event on `process` (for cleanup hooks like closing database connections)
386
- 3. Waits for in-flight SSR requests to complete (up to `SHUTDOWN_TIMEOUT` seconds)
387
- 4. Exits
388
-
389
- ```js
390
- // Listen for shutdown in your server code (e.g. hooks.server.js)
391
- process.on('sveltekit:shutdown', async (reason) => {
392
- console.log(`Shutting down: ${reason}`);
393
- await db.close();
394
- });
395
- ```
396
-
397
- ### Examples
398
-
399
- ```bash
400
- # Simple HTTP
401
- node build
402
-
403
- # Custom port
404
- PORT=8080 node build
405
-
406
- # Behind nginx
407
- ORIGIN=https://example.com node build
408
-
409
- # Behind a proxy with forwarded headers
410
- PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host ADDRESS_HEADER=x-forwarded-for node build
411
-
412
- # Native TLS
413
- SSL_CERT=./cert.pem SSL_KEY=./key.pem node build
414
-
415
- # Everything at once
416
- SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 HOST=0.0.0.0 BODY_SIZE_LIMIT=10M SHUTDOWN_TIMEOUT=60 node build
417
- ```
418
-
419
- ---
420
-
421
- ## WebSocket handler (`hooks.ws`)
422
-
423
- ### No handler needed (simplest)
424
-
425
- With `websocket: true`, a built-in handler accepts all connections and handles subscribe/unsubscribe messages from the client store. No file needed.
426
-
427
- > **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.
428
-
429
- ### Auto-discovered handler
430
-
431
- Create `src/hooks.ws.js` (or `.ts`, `.mjs`) and it will be automatically discovered - no config needed:
432
-
433
- **src/hooks.ws.js**
434
- ```js
435
- // Called during the HTTP → WebSocket upgrade handshake.
436
- // Return an object to accept (becomes ws.getUserData()).
437
- // Return false to reject with 401.
438
- // Omit this export to accept all connections.
439
- export async function upgrade({ headers, cookies, url, remoteAddress }) {
440
- const sessionId = cookies.session_id;
441
- if (!sessionId) return false;
442
-
443
- const user = await validateSession(sessionId);
444
- if (!user) return false;
445
-
446
- // Whatever you return here is available as ws.getUserData()
447
- return { userId: user.id, name: user.name };
448
- }
449
-
450
- // Called when a connection is established
451
- export function open(ws) {
452
- const { userId } = ws.getUserData();
453
- console.log(`User ${userId} connected`);
454
-
455
- // Subscribe this connection to a user-specific topic
456
- ws.subscribe(`user:${userId}`);
457
- }
458
-
459
- // Called when a message is received
460
- // Note: subscribe/unsubscribe messages from the client store are
461
- // handled automatically BEFORE this function is called
462
- export function message(ws, data, isBinary) {
463
- const msg = JSON.parse(Buffer.from(data).toString());
464
- console.log('Got message:', msg);
465
- }
466
-
467
- // Called when a client tries to subscribe to a topic (optional)
468
- // Return false to deny the subscription
469
- export function subscribe(ws, topic) {
470
- const { role } = ws.getUserData();
471
- // Only admins can subscribe to admin topics
472
- if (topic.startsWith('admin') && role !== 'admin') return false;
473
- }
474
-
475
- // Called when the connection closes
476
- export function close(ws, code, message) {
477
- const { userId } = ws.getUserData();
478
- console.log(`User ${userId} disconnected`);
479
- }
480
-
481
- // Called when backpressure has drained (optional, for flow control)
482
- export function drain(ws) {
483
- // You can resume sending large messages here
484
- }
485
- ```
486
-
487
- ### Explicit handler path
488
-
489
- If your handler is somewhere other than `src/hooks.ws.js`:
490
-
491
- ```js
492
- adapter({
493
- websocket: {
494
- handler: './src/lib/server/websocket.js'
495
- }
496
- })
497
- ```
498
-
499
- ### What the handler gets
500
-
501
- The `upgrade` function receives an `UpgradeContext`:
502
-
503
- ```js
504
- {
505
- headers: { 'cookie': '...', 'host': 'localhost:3000', ... }, // all lowercase
506
- cookies: { session_id: 'abc123', theme: 'dark' }, // parsed from Cookie header
507
- url: '/ws', // request path
508
- remoteAddress: '127.0.0.1' // client IP
509
- }
510
- ```
511
-
512
- The `subscribe` function receives `(ws, topic)` and can return `false` to deny a client's subscription request. Omit it to allow all subscriptions.
513
-
514
- The `ws` object in `open`, `message`, `close`, and `drain` is a [uWebSockets.js WebSocket](https://github.com/uNetworking/uWebSockets.js). Key methods:
515
-
516
- - `ws.getUserData()` - returns whatever `upgrade` returned
517
- - `ws.subscribe(topic)` - subscribe to a topic for `app.publish()`
518
- - `ws.unsubscribe(topic)` - unsubscribe from a topic
519
- - `ws.send(data)` - send a message to this connection
520
- - `ws.close()` - close the connection
521
-
522
- ---
523
-
524
- ## Authentication
525
-
526
- WebSocket 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.
527
-
528
- Here's the full flow from login to authenticated WebSocket:
529
-
530
- ### Step 1: Login sets a cookie (standard SvelteKit)
531
-
532
- **src/routes/login/+page.server.js**
533
- ```js
534
- import { authenticate, createSession } from '$lib/server/auth.js';
535
-
536
- export const actions = {
537
- default: async ({ request, cookies }) => {
538
- const form = await request.formData();
539
- const email = form.get('email');
540
- const password = form.get('password');
541
-
542
- const user = await authenticate(email, password);
543
- if (!user) return { error: 'Invalid credentials' };
544
-
545
- const sessionId = await createSession(user.id);
546
-
547
- // This cookie is automatically sent on WebSocket upgrade requests
548
- cookies.set('session', sessionId, {
549
- path: '/',
550
- httpOnly: true,
551
- sameSite: 'strict',
552
- secure: true,
553
- maxAge: 60 * 60 * 24 * 7 // 1 week
554
- });
555
-
556
- return { success: true };
557
- }
558
- };
559
- ```
560
-
561
- ### Step 2: WebSocket handler reads the same cookie
562
-
563
- **src/hooks.ws.js**
564
- ```js
565
- import { getSession } from '$lib/server/auth.js';
566
-
567
- export async function upgrade({ cookies }) {
568
- // Same cookie that SvelteKit set during login
569
- const sessionId = cookies.session;
570
- if (!sessionId) return false; // → 401, connection rejected
571
-
572
- const user = await getSession(sessionId);
573
- if (!user) return false; // → 401, expired or invalid session
574
-
575
- // Attach user data to the socket - available via ws.getUserData()
576
- return { userId: user.id, name: user.name, role: user.role };
577
- }
578
-
579
- export function open(ws) {
580
- const { userId, role } = ws.getUserData();
581
- console.log(`${userId} connected (${role})`);
582
-
583
- // Subscribe to user-specific and role-based topics
584
- ws.subscribe(`user:${userId}`);
585
- if (role === 'admin') ws.subscribe('admin');
586
- }
587
-
588
- export function close(ws) {
589
- const { userId } = ws.getUserData();
590
- console.log(`${userId} disconnected`);
591
- }
592
- ```
593
-
594
- ### Step 3: Client - nothing special needed
595
-
596
- **src/routes/dashboard/+page.svelte**
597
- ```svelte
598
- <script>
599
- import { on, status } from 'svelte-adapter-uws/client';
600
-
601
- // The browser sends cookies automatically on the upgrade request.
602
- // If the session is invalid, the connection is rejected and
603
- // auto-reconnect will retry (useful if the user logs in later).
604
- const notifications = on('notifications');
605
- const userMessages = on('user-messages');
606
- </script>
607
-
608
- {#if $status === 'open'}
609
- <span>Authenticated & connected</span>
610
- {:else if $status === 'connecting'}
611
- <span>Connecting...</span>
612
- {:else}
613
- <span>Disconnected (not logged in?)</span>
614
- {/if}
615
- ```
616
-
617
- ### Step 4: Send messages to specific users from anywhere
618
-
619
- **src/routes/api/notify/+server.js**
620
- ```js
621
- import { json } from '@sveltejs/kit';
622
-
623
- export async function POST({ request, platform }) {
624
- const { userId, message } = await request.json();
625
-
626
- // Only that user receives this (they subscribed in open())
627
- platform.publish(`user:${userId}`, 'notification', { message });
628
-
629
- return json({ sent: true });
630
- }
631
- ```
632
-
633
- ### Why this works
634
-
635
- The 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.
636
-
637
- | What | Where | Same cookies? |
638
- |---|---|---|
639
- | Page load | `+page.server.js` `load()` | Yes |
640
- | Form action | `+page.server.js` `actions` | Yes |
641
- | API route | `+server.js` | Yes |
642
- | Server hook | `hooks.server.js` `handle()` | Yes |
643
- | **WebSocket upgrade** | **`hooks.ws.js` `upgrade()`** | **Yes** |
644
-
645
- ---
646
-
647
- ## Platform API (`event.platform`)
648
-
649
- Available in server hooks, load functions, form actions, and API routes.
650
-
651
- ### `platform.publish(topic, event, data)`
652
-
653
- Send a message to all WebSocket clients subscribed to a topic:
654
-
655
- ```js
656
- // src/routes/todos/+page.server.js
657
- export const actions = {
658
- create: async ({ request, platform }) => {
659
- const formData = await request.formData();
660
- const todo = await db.createTodo(formData.get('text'));
661
-
662
- // Every client subscribed to 'todos' receives this
663
- platform.publish('todos', 'created', todo);
664
-
665
- return { success: true };
666
- }
667
- };
668
- ```
669
-
670
- ### `platform.send(ws, topic, event, data)`
671
-
672
- Send a message to a single WebSocket connection. Wraps in the same `{ topic, event, data }` envelope as `publish()`.
673
-
674
- This is useful when you store WebSocket references (e.g. in a `Map`) and need to message specific connections from SvelteKit handlers:
675
-
676
- ```js
677
- // src/hooks.ws.js - store connections by user ID
678
- const userSockets = new Map();
679
-
680
- export function open(ws) {
681
- const { userId } = ws.getUserData();
682
- userSockets.set(userId, ws);
683
- }
684
-
685
- export function close(ws) {
686
- const { userId } = ws.getUserData();
687
- userSockets.delete(userId);
688
- }
689
-
690
- // Export the map so SvelteKit handlers can access it
691
- export { userSockets };
692
- ```
693
-
694
- ```js
695
- // src/routes/api/dm/+server.js - send to a specific user
696
- import { userSockets } from '../../hooks.ws.js';
697
-
698
- export async function POST({ request, platform }) {
699
- const { targetUserId, message } = await request.json();
700
- const ws = userSockets.get(targetUserId);
701
- if (ws) {
702
- platform.send(ws, 'dm', 'new-message', { message });
703
- }
704
- return new Response('OK');
705
- }
706
- ```
707
-
708
- To reply directly from inside `hooks.ws.js` (where `platform` isn't available), use `ws.send()` with the envelope format:
709
-
710
- ```js
711
- // src/hooks.ws.js
712
- export function message(ws, rawData) {
713
- const msg = JSON.parse(Buffer.from(rawData).toString());
714
- // Reply to sender using the same envelope format the client store expects
715
- ws.send(JSON.stringify({ topic: 'echo', event: 'reply', data: { got: msg } }));
716
- }
717
- ```
718
-
719
- ### `platform.sendTo(filter, topic, event, data)`
720
-
721
- Send a message to all connections whose `userData` matches a filter function. Returns the number of connections the message was sent to.
722
-
723
- This is simpler than manually maintaining a `Map` of connections - no `hooks.ws.js` needed:
724
-
725
- ```js
726
- // src/routes/api/dm/+server.js - send to a specific user
727
- export async function POST({ request, platform }) {
728
- const { targetUserId, message } = await request.json();
729
- const count = platform.sendTo(
730
- (userData) => userData.userId === targetUserId,
731
- 'dm', 'new-message', { message }
732
- );
733
- return new Response(count > 0 ? 'Sent' : 'User offline');
734
- }
735
- ```
736
-
737
- ```js
738
- // Send to all admins
739
- platform.sendTo(
740
- (userData) => userData.role === 'admin',
741
- 'alerts', 'warning', { message: 'Server load high' }
742
- );
743
- ```
744
-
745
- ### `platform.connections`
746
-
747
- Number of active WebSocket connections:
748
-
749
- ```js
750
- // src/routes/api/stats/+server.js
751
- import { json } from '@sveltejs/kit';
752
-
753
- export async function GET({ platform }) {
754
- return json({ online: platform.connections });
755
- }
756
- ```
757
-
758
- ### `platform.subscribers(topic)`
759
-
760
- Number of clients subscribed to a specific topic:
761
-
762
- ```js
763
- export async function GET({ platform, params }) {
764
- return json({
765
- viewers: platform.subscribers(`page:${params.id}`)
766
- });
767
- }
768
- ```
769
-
770
- ### `platform.topic(name)` - scoped helper
771
-
772
- Reduces repetition when publishing multiple events to the same topic:
773
-
774
- ```js
775
- // src/routes/todos/+page.server.js
776
- export const actions = {
777
- create: async ({ request, platform }) => {
778
- const todos = platform.topic('todos');
779
- const todo = await db.create(await request.formData());
780
- todos.created(todo); // shorthand for platform.publish('todos', 'created', todo)
781
- },
782
-
783
- update: async ({ request, platform }) => {
784
- const todos = platform.topic('todos');
785
- const todo = await db.update(await request.formData());
786
- todos.updated(todo);
787
- },
788
-
789
- delete: async ({ request, platform }) => {
790
- const todos = platform.topic('todos');
791
- const id = (await request.formData()).get('id');
792
- await db.delete(id);
793
- todos.deleted({ id });
794
- }
795
- };
796
- ```
797
-
798
- The topic helper also has counter methods:
799
-
800
- ```js
801
- const online = platform.topic('online-users');
802
- online.set(42); // → { event: 'set', data: 42 }
803
- online.increment(); // → { event: 'increment', data: 1 }
804
- online.increment(5); // → { event: 'increment', data: 5 }
805
- online.decrement(); // { event: 'decrement', data: 1 }
806
- ```
807
-
808
- ---
809
-
810
- ## Client store API
811
-
812
- Import from `svelte-adapter-uws/client`. Everything auto-connects - you don't need to call `connect()` first.
813
-
814
- ### `on(topic)` - subscribe to a topic
815
-
816
- The main function most users need. Returns a Svelte readable store that updates whenever a message is published to the topic.
817
-
818
- > **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".
819
-
820
- ```svelte
821
- <script>
822
- import { on } from 'svelte-adapter-uws/client';
823
-
824
- // Full event envelope: { topic, event, data }
825
- const todos = on('todos');
826
- </script>
827
-
828
- <!-- ALWAYS guard with {#if} - $todos is null until the first message arrives -->
829
- {#if $todos}
830
- <p>{$todos.event}: {JSON.stringify($todos.data)}</p>
831
- {/if}
832
-
833
- <!-- WRONG - will crash with "Cannot read properties of null" -->
834
- <!-- <p>{$todos.event}</p> -->
835
- ```
836
-
837
- ### `on(topic, event)` - subscribe to a specific event
838
-
839
- Filters to a single event name and returns just the `data` payload (no envelope):
840
-
841
- ```svelte
842
- <script>
843
- import { on } from 'svelte-adapter-uws/client';
844
-
845
- // Only 'created' events, gives you just the data
846
- const newTodo = on('todos', 'created');
847
- </script>
848
-
849
- {#if $newTodo}
850
- <p>New todo: {$newTodo.text}</p>
851
- {/if}
852
- ```
853
-
854
- ### `.scan(initial, reducer)` - accumulate state
855
-
856
- Like `Array.reduce` but reactive. Each new event feeds through the reducer:
857
-
858
- ```svelte
859
- <script>
860
- import { on } from 'svelte-adapter-uws/client';
861
-
862
- const todos = on('todos').scan([], (list, { event, data }) => {
863
- if (event === 'created') return [...list, data];
864
- if (event === 'updated') return list.map(t => t.id === data.id ? data : t);
865
- if (event === 'deleted') return list.filter(t => t.id !== data.id);
866
- return list;
867
- });
868
- </script>
869
-
870
- {#each $todos as todo (todo.id)}
871
- <p>{todo.text}</p>
872
- {/each}
873
- ```
874
-
875
- ### `crud(topic, initial?, options?)` - live CRUD list
876
-
877
- One-liner for real-time collections. Handles `created`, `updated`, and `deleted` events automatically:
878
-
879
- ```svelte
880
- <script>
881
- import { crud } from 'svelte-adapter-uws/client';
882
-
883
- let { data } = $props(); // from +page.server.js load()
884
-
885
- // $todos auto-updates when server publishes created/updated/deleted
886
- const todos = crud('todos', data.todos);
887
- </script>
888
-
889
- {#each $todos as todo (todo.id)}
890
- <p>{todo.text}</p>
891
- {/each}
892
- ```
893
-
894
- Options:
895
- - `key` - property to match items by (default: `'id'`)
896
- - `prepend` - add new items to the beginning instead of end (default: `false`)
897
-
898
- ```js
899
- // Notifications, newest first
900
- const notifications = crud('notifications', [], { prepend: true });
901
-
902
- // Items keyed by 'slug' instead of 'id'
903
- const posts = crud('posts', data.posts, { key: 'slug' });
904
- ```
905
-
906
- Pair with `platform.topic()` on the server:
907
-
908
- ```js
909
- // Server: +page.server.js
910
- export const actions = {
911
- create: async ({ request, platform }) => {
912
- const todo = await db.create(await request.formData());
913
- platform.topic('todos').created(todo); // client sees 'created'
914
- },
915
- update: async ({ request, platform }) => {
916
- const todo = await db.update(await request.formData());
917
- platform.topic('todos').updated(todo); // client sees 'updated'
918
- },
919
- delete: async ({ request, platform }) => {
920
- await db.delete((await request.formData()).get('id'));
921
- platform.topic('todos').deleted({ id }); // client sees 'deleted'
922
- }
923
- };
924
- ```
925
-
926
- ### `lookup(topic, initial?, options?)` - live keyed object
927
-
928
- Like `crud()` but returns a `Record<string, T>` instead of an array. Better for dashboards and fast lookups:
929
-
930
- ```svelte
931
- <script>
932
- import { lookup } from 'svelte-adapter-uws/client';
933
-
934
- let { data } = $props();
935
- const users = lookup('users', data.users);
936
- </script>
937
-
938
- {#if $users[selectedId]}
939
- <UserCard user={$users[selectedId]} />
940
- {/if}
941
- ```
942
-
943
- ### `latest(topic, max?, initial?)` - ring buffer
944
-
945
- Keeps the last N events. Perfect for chat, activity feeds, notifications:
946
-
947
- ```svelte
948
- <script>
949
- import { latest } from 'svelte-adapter-uws/client';
950
-
951
- // Keep the last 100 chat messages
952
- const messages = latest('chat', 100);
953
- </script>
954
-
955
- {#each $messages as msg}
956
- <p><b>{msg.event}:</b> {msg.data.text}</p>
957
- {/each}
958
- ```
959
-
960
- ### `count(topic, initial?)` - live counter
961
-
962
- Handles `set`, `increment`, and `decrement` events:
963
-
964
- ```svelte
965
- <script>
966
- import { count } from 'svelte-adapter-uws/client';
967
-
968
- const online = count('online-users');
969
- </script>
970
-
971
- <p>{$online} users online</p>
972
- ```
973
-
974
- Server:
975
- ```js
976
- platform.topic('online-users').increment();
977
- platform.topic('online-users').decrement();
978
- platform.topic('online-users').set(42);
979
- ```
980
-
981
- ### `once(topic, event?, options?)` - wait for one event
982
-
983
- Returns a promise that resolves with the first matching event and then unsubscribes:
984
-
985
- ```js
986
- import { once } from 'svelte-adapter-uws/client';
987
-
988
- // Wait for any event on the 'jobs' topic
989
- const event = await once('jobs');
990
-
991
- // Wait for a specific event
992
- const result = await once('jobs', 'completed');
993
-
994
- // With a timeout (rejects if no event within 5 seconds)
995
- const result = await once('jobs', 'completed', { timeout: 5000 });
996
-
997
- // Timeout without event filter
998
- const event = await once('jobs', { timeout: 5000 });
999
- ```
1000
-
1001
- ### `status` - connection status
1002
-
1003
- Readable store with the current connection state:
1004
-
1005
- ```svelte
1006
- <script>
1007
- import { status } from 'svelte-adapter-uws/client';
1008
- </script>
1009
-
1010
- {#if $status === 'open'}
1011
- <span class="badge green">Live</span>
1012
- {:else if $status === 'connecting'}
1013
- <span class="badge yellow">Connecting...</span>
1014
- {:else}
1015
- <span class="badge red">Disconnected</span>
1016
- {/if}
1017
- ```
1018
-
1019
- ### `ready()` - wait for connection
1020
-
1021
- Returns a promise that resolves when the WebSocket connection is open:
1022
-
1023
- ```js
1024
- import { ready } from 'svelte-adapter-uws/client';
1025
-
1026
- await ready();
1027
- // connection is now open, safe to send messages
1028
- ```
1029
-
1030
- ### `connect(options?)` - power-user API
1031
-
1032
- Most users don't need this - `on()` and `status` auto-connect. Use `connect()` when you need `close()`, `send()`, or custom options:
1033
-
1034
- ```js
1035
- import { connect } from 'svelte-adapter-uws/client';
1036
-
1037
- const ws = connect({
1038
- path: '/ws', // default: '/ws'
1039
- reconnectInterval: 3000, // default: 3000 ms
1040
- maxReconnectInterval: 30000, // default: 30000 ms
1041
- maxReconnectAttempts: Infinity, // default: Infinity
1042
- debug: true // default: false - turn this on to see everything!
1043
- });
1044
-
1045
- // With debug: true, you'll see every WebSocket event in the browser console:
1046
- // [ws] connected
1047
- // [ws] subscribe -> todos
1048
- // [ws] <- todos created { id: 1, text: "Buy milk" }
1049
- // [ws] send -> { type: "ping" }
1050
- // [ws] disconnected
1051
- // [ws] queued -> { type: "important" }
1052
- // [ws] resubscribe -> todos
1053
- // [ws] flush -> { type: "important" }
1054
-
1055
- // Manual topic management
1056
- ws.subscribe('chat');
1057
- ws.unsubscribe('chat');
1058
-
1059
- // Send custom messages to the server
1060
- ws.send({ type: 'ping' });
1061
-
1062
- // Send with queue (messages queue up while disconnected, flush on reconnect)
1063
- ws.sendQueued({ type: 'important', data: '...' });
1064
-
1065
- // Permanent disconnect (won't auto-reconnect)
1066
- ws.close();
1067
- ```
1068
-
1069
- ---
1070
-
1071
- ## TypeScript setup
1072
-
1073
- Add the platform type to your `src/app.d.ts`:
1074
-
1075
- ```ts
1076
- import type { Platform as AdapterPlatform } from 'svelte-adapter-uws';
1077
-
1078
- declare global {
1079
- namespace App {
1080
- interface Platform extends AdapterPlatform {}
1081
- }
1082
- }
1083
-
1084
- export {};
1085
- ```
1086
-
1087
- Now `event.platform.publish()`, `event.platform.topic()`, etc. are fully typed.
1088
-
1089
- ---
1090
-
1091
- ## Svelte 4 support
1092
-
1093
- This 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:
1094
-
1095
- **Svelte 5 (used in examples)**
1096
- ```svelte
1097
- <script>
1098
- import { crud } from 'svelte-adapter-uws/client';
1099
-
1100
- let { data } = $props();
1101
- const todos = crud('todos', data.todos);
1102
- </script>
1103
- ```
1104
-
1105
- **Svelte 4 equivalent**
1106
- ```svelte
1107
- <script>
1108
- import { crud } from 'svelte-adapter-uws/client';
1109
-
1110
- export let data;
1111
- const todos = crud('todos', data.todos);
1112
- </script>
1113
- ```
1114
-
1115
- The 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.
1116
-
1117
- ---
1118
-
1119
- ## Deploying with Docker
1120
-
1121
- uWebSockets.js is a native C++ addon, so your Docker image needs to match the platform it was compiled for. Build inside the container to be safe.
1122
-
1123
- ```dockerfile
1124
- FROM node:22-trixie-slim AS build
1125
-
1126
- # git is required - uWebSockets.js is installed from GitHub, not npm
1127
- RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
1128
-
1129
- WORKDIR /app
1130
-
1131
- COPY package*.json ./
1132
- RUN npm ci
1133
-
1134
- COPY . .
1135
- RUN npm run build
1136
-
1137
- # Runtime stage - no git needed
1138
- FROM node:22-trixie-slim
1139
-
1140
- WORKDIR /app
1141
- COPY --from=build /app/build build/
1142
- COPY --from=build /app/node_modules node_modules/
1143
- COPY package.json .
1144
-
1145
- EXPOSE 3000
1146
- CMD ["node", "build"]
1147
- ```
1148
-
1149
- With TLS:
1150
- ```dockerfile
1151
- CMD ["sh", "-c", "SSL_CERT=/certs/cert.pem SSL_KEY=/certs/key.pem node build"]
1152
- ```
1153
-
1154
- With environment variables:
1155
- ```bash
1156
- docker run -p 3000:3000 \
1157
- -e PORT=3000 \
1158
- -e ORIGIN=https://example.com \
1159
- my-app
1160
- ```
1161
-
1162
- > **Important:** Use Debian Trixie or Ubuntu 24.04+ based images (glibc >= 2.38). Bookworm-based images (`node:*-slim`, `node:*-bookworm`) ship glibc 2.36 which is too old for uWebSockets.js. Don't use Alpine either - uWebSockets.js binaries are compiled against glibc, not musl.
1163
-
1164
- ---
1165
-
1166
- ## Clustering (Linux)
1167
-
1168
- On Linux, multiple processes can share the same port via `SO_REUSEPORT` - the kernel load-balances incoming connections across workers. This gives you near-linear scaling for HTTP/SSR workloads with zero coordination overhead.
1169
-
1170
- Set the `CLUSTER_WORKERS` environment variable to enable it:
1171
-
1172
- ```bash
1173
- # Use all available CPU cores
1174
- CLUSTER_WORKERS=auto node build
1175
-
1176
- # Fixed number of workers
1177
- CLUSTER_WORKERS=4 node build
1178
-
1179
- # Combined with other options
1180
- CLUSTER_WORKERS=auto PORT=8080 ORIGIN=https://example.com node build
1181
- ```
1182
-
1183
- The primary process forks N workers, each running their own uWS server on the same port. If a worker crashes, it is automatically restarted. On `SIGTERM`/`SIGINT`, the primary forwards the signal to all workers for graceful shutdown.
1184
-
1185
- ### Limitations
1186
-
1187
- - **Linux only** - `SO_REUSEPORT` is a Linux kernel feature. On other platforms, the variable is ignored with a warning.
1188
- - **HTTP/SSR only** - clustering is automatically disabled when WebSocket is enabled (`websocket: true` or `websocket: { ... }`). uWS pub/sub is per-process, so messages published in one worker would not reach clients connected to another worker. If you need clustered WebSocket, bring an external pub/sub backend (Redis, NATS, etc.) and manage multi-process coordination yourself.
1189
-
1190
- ---
1191
-
1192
- ## Performance
1193
-
1194
- ### Why uWebSockets.js?
1195
-
1196
- uWebSockets.js is a C++ HTTP and WebSocket server compiled to a native V8 addon. In benchmarks it consistently outperforms Node.js' built-in `http` module, Express, Fastify, and every other JavaScript HTTP server by a significant margin - often 5-10x more requests per second.
1197
-
1198
- ### Our overhead vs barebones uWS
1199
-
1200
- A barebones uWebSockets.js "hello world" can handle 500k+ requests per second on a single core. This adapter adds overhead that is unavoidable for what it does:
1201
-
1202
- 1. **SvelteKit SSR** - every non-static request goes through SvelteKit's `server.respond()`, which runs your load functions, renders components, and produces HTML. This is the biggest cost and it's the whole point of using SvelteKit.
1203
-
1204
- 2. **Static file cache** - on startup we walk the build output and load every static file into memory with its precompressed variants. This is a one-time cost. Serving static files is then a single `Map.get()` plus a `res.cork()` + `res.end()` - about as fast as it gets without sendfile.
1205
-
1206
- 3. **Request construction** - we build a standard `Request` object from uWS' stack-allocated `HttpRequest`. This means reading all headers synchronously (uWS requirement) and constructing a URL string. We read headers lazily for static files (only `accept-encoding` and `if-none-match`), but SSR requires the full set.
1207
-
1208
- 4. **Response streaming** - we read from the `Response.body` ReadableStream and write chunks through uWS with backpressure support (`onWritable`). Single-chunk responses (most SSR pages) are optimized into a single `res.cork()` + `res.end()` call.
1209
-
1210
- 5. **WebSocket envelope** - every pub/sub message is wrapped in `JSON.stringify({ topic, event, data })`. This is a few microseconds per message. The tradeoff is a clean, standardized format that the client store understands without configuration.
1211
-
1212
- **What we don't add:**
1213
- - No middleware chain
1214
- - No routing layer (uWS' native routing + SvelteKit's router)
1215
- - No per-request allocations beyond what's needed
1216
- - No Node.js `http.IncomingMessage` shim (we construct `Request` directly from uWS)
1217
-
1218
- ### The bottom line
1219
-
1220
- For static files, performance is very close to barebones uWS. For SSR, the bottleneck is your Svelte components and load functions, not the adapter. The adapter's job is to get out of the way as fast as possible - and it does.
1221
-
1222
- ---
1223
-
1224
- ## Full example: real-time todo list
1225
-
1226
- Here's a complete example tying everything together.
1227
-
1228
- **svelte.config.js**
1229
- ```js
1230
- import adapter from 'svelte-adapter-uws';
1231
-
1232
- export default {
1233
- kit: {
1234
- adapter: adapter({
1235
- websocket: true
1236
- })
1237
- }
1238
- };
1239
- ```
1240
-
1241
- **vite.config.js**
1242
- ```js
1243
- import { sveltekit } from '@sveltejs/kit/vite';
1244
- import uwsDev from 'svelte-adapter-uws/vite';
1245
-
1246
- export default {
1247
- plugins: [sveltekit(), uwsDev()]
1248
- };
1249
- ```
1250
-
1251
- **src/routes/todos/+page.server.js**
1252
- ```js
1253
- import { db } from '$lib/server/db.js';
1254
-
1255
- export async function load() {
1256
- return { todos: await db.getTodos() };
1257
- }
1258
-
1259
- export const actions = {
1260
- create: async ({ request, platform }) => {
1261
- const text = (await request.formData()).get('text');
1262
- const todo = await db.createTodo(text);
1263
- platform.topic('todos').created(todo);
1264
- },
1265
-
1266
- toggle: async ({ request, platform }) => {
1267
- const id = (await request.formData()).get('id');
1268
- const todo = await db.toggleTodo(id);
1269
- platform.topic('todos').updated(todo);
1270
- },
1271
-
1272
- delete: async ({ request, platform }) => {
1273
- const id = (await request.formData()).get('id');
1274
- await db.deleteTodo(id);
1275
- platform.topic('todos').deleted({ id });
1276
- }
1277
- };
1278
- ```
1279
-
1280
- **src/routes/todos/+page.svelte**
1281
- ```svelte
1282
- <script>
1283
- import { crud, status } from 'svelte-adapter-uws/client';
1284
-
1285
- let { data } = $props();
1286
- const todos = crud('todos', data.todos);
1287
- </script>
1288
-
1289
- {#if $status === 'open'}
1290
- <span>Live</span>
1291
- {/if}
1292
-
1293
- <form method="POST" action="?/create">
1294
- <input name="text" placeholder="New todo..." />
1295
- <button>Add</button>
1296
- </form>
1297
-
1298
- <ul>
1299
- {#each $todos as todo (todo.id)}
1300
- <li>
1301
- <form method="POST" action="?/toggle">
1302
- <input type="hidden" name="id" value={todo.id} />
1303
- <button>{todo.done ? 'Undo' : 'Done'}</button>
1304
- </form>
1305
- <span class:done={todo.done}>{todo.text}</span>
1306
- <form method="POST" action="?/delete">
1307
- <input type="hidden" name="id" value={todo.id} />
1308
- <button>Delete</button>
1309
- </form>
1310
- </li>
1311
- {/each}
1312
- </ul>
1313
- ```
1314
-
1315
- Open the page in two browser tabs. Create, toggle, or delete a todo in one tab - it appears in the other tab instantly.
1316
-
1317
- ---
1318
-
1319
- ## Troubleshooting
1320
-
1321
- ### "WebSocket works in production but not in dev"
1322
-
1323
- You need the Vite plugin. Without it, there's no WebSocket server running during `npm run dev`.
1324
-
1325
- **vite.config.js**
1326
- ```js
1327
- import { sveltekit } from '@sveltejs/kit/vite';
1328
- import uwsDev from 'svelte-adapter-uws/vite';
1329
-
1330
- export default {
1331
- plugins: [sveltekit(), uwsDev()]
1332
- };
1333
- ```
1334
-
1335
- Also make sure `ws` is installed:
1336
- ```bash
1337
- npm install -D ws
1338
- ```
1339
-
1340
- ### "Cannot read properties of undefined (reading 'publish')"
1341
-
1342
- This means `event.platform` is `undefined`. Two possible causes:
1343
-
1344
- **Cause 1: Missing Vite plugin in dev mode**
1345
-
1346
- Same fix as above - add `uwsDev()` to your `vite.config.js`.
1347
-
1348
- **Cause 2: Calling `platform` on the client side**
1349
-
1350
- `event.platform` only exists on the server. If you're calling it in a `+page.svelte` or `+layout.svelte` file, move that code to `+page.server.js` or `+server.js`.
1351
-
1352
- ```js
1353
- // WRONG - +page.svelte (client-side)
1354
- platform.publish('todos', 'created', todo);
1355
-
1356
- // RIGHT - +page.server.js (server-side)
1357
- export const actions = {
1358
- create: async ({ platform }) => {
1359
- platform.publish('todos', 'created', todo);
1360
- }
1361
- };
1362
- ```
1363
-
1364
- ### "WebSocket connects but immediately disconnects (and keeps reconnecting)"
1365
-
1366
- Your `upgrade` handler is returning `false`, which rejects the connection with 401. The client store's auto-reconnect then tries again, gets rejected again, and so on.
1367
-
1368
- **To debug**, enable debug mode on the client:
1369
- ```js
1370
- import { connect } from 'svelte-adapter-uws/client';
1371
- connect({ debug: true });
1372
- ```
1373
-
1374
- Then check the browser's Network tab → WS tab. You'll see the upgrade request and its 401 response.
1375
-
1376
- **Common causes:**
1377
- - The session cookie isn't being set (check your login action)
1378
- - The cookie name doesn't match (`cookies.session` vs `cookies.session_id`)
1379
- - The session expired or is invalid
1380
- - `sameSite: 'strict'` can block cookies on cross-origin navigations - try `'lax'` if you're redirecting from an external site
1381
-
1382
- ### "WebSocket doesn't work with `npm run preview`"
1383
-
1384
- This is expected. SvelteKit's preview server is Vite's built-in HTTP server - it doesn't know about WebSocket upgrades. Use `node build` instead:
1385
-
1386
- ```bash
1387
- npm run build
1388
- node build
1389
- ```
1390
-
1391
- ### "Could not load uWebSockets.js"
1392
-
1393
- uWebSockets.js is a native C++ addon. It's installed from GitHub, not npm, and needs to compile for your platform.
1394
-
1395
- ```bash
1396
- # Make sure you're using the right install command (no uWebSockets.js@ prefix)
1397
- npm install uNetworking/uWebSockets.js#v20.60.0
1398
- ```
1399
-
1400
- **On Windows:** Make sure you have the Visual C++ Build Tools installed. You can get them from the [Visual Studio Installer](https://visualstudio.microsoft.com/downloads/) (select "Desktop development with C++").
1401
-
1402
- **On Linux:** Make sure `build-essential` is installed:
1403
- ```bash
1404
- sudo apt install build-essential
1405
- ```
1406
-
1407
- **On Docker:** Use a Trixie-based image with git:
1408
- ```dockerfile
1409
- FROM node:22-trixie-slim
1410
- RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
1411
- ```
1412
-
1413
- ### "I can't see what's happening with WebSocket messages"
1414
-
1415
- Turn on debug mode. It logs every WebSocket event to the browser console:
1416
-
1417
- ```svelte
1418
- <script>
1419
- import { connect } from 'svelte-adapter-uws/client';
1420
-
1421
- // Call this once, anywhere - it's a singleton
1422
- connect({ debug: true });
1423
- </script>
1424
- ```
1425
-
1426
- You'll see output like:
1427
- ```
1428
- [ws] connected
1429
- [ws] subscribe -> todos
1430
- [ws] <- todos created {"id":1,"text":"Buy milk"}
1431
- [ws] disconnected
1432
- [ws] resubscribe -> todos
1433
- ```
1434
-
1435
- ### "Messages are arriving but my store isn't updating"
1436
-
1437
- Make sure the topic names match exactly between server and client:
1438
-
1439
- ```js
1440
- // Server
1441
- platform.publish('todos', 'created', todo); // topic: 'todos'
1442
-
1443
- // Client - must match exactly
1444
- const todos = on('todos'); // 'todos' - correct
1445
- const todos = on('Todos'); // 'Todos' - WRONG, case sensitive
1446
- const todos = on('todo'); // 'todo' - WRONG, singular vs plural
1447
- ```
1448
-
1449
- ### "How do I see what the message envelope looks like?"
1450
-
1451
- Every message sent through `platform.publish()` or `platform.topic().created()` arrives as JSON with this shape:
1452
-
1453
- ```json
1454
- {
1455
- "topic": "todos",
1456
- "event": "created",
1457
- "data": { "id": 1, "text": "Buy milk", "done": false }
1458
- }
1459
- ```
1460
-
1461
- The client store parses this automatically. When you use `on('todos')`, the store value is:
1462
- ```js
1463
- { topic: 'todos', event: 'created', data: { id: 1, text: 'Buy milk', done: false } }
1464
- ```
1465
-
1466
- When you use `on('todos', 'created')`, you get just the `data`:
1467
- ```js
1468
- { id: 1, text: 'Buy milk', done: false }
1469
- ```
1470
-
1471
- ### "WebSocket works locally but not behind nginx/Caddy"
1472
-
1473
- Your reverse proxy needs to forward WebSocket upgrade requests. Here's a complete nginx config that handles both your app and WebSocket:
1474
-
1475
- ```nginx
1476
- server {
1477
- listen 443 ssl;
1478
- server_name example.com;
1479
-
1480
- ssl_certificate /path/to/cert.pem;
1481
- ssl_certificate_key /path/to/key.pem;
1482
-
1483
- # WebSocket - must be listed before the catch-all
1484
- location /ws {
1485
- proxy_pass http://localhost:3000;
1486
- proxy_http_version 1.1;
1487
- proxy_set_header Upgrade $http_upgrade;
1488
- proxy_set_header Connection "upgrade";
1489
- proxy_set_header Host $host;
1490
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1491
- proxy_set_header X-Forwarded-Proto $scheme;
1492
- }
1493
-
1494
- # Everything else - your SvelteKit app
1495
- location / {
1496
- proxy_pass http://localhost:3000;
1497
- proxy_set_header Host $host;
1498
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1499
- proxy_set_header X-Forwarded-Proto $scheme;
1500
- }
1501
- }
1502
- ```
1503
-
1504
- Then run your app with:
1505
- ```bash
1506
- PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=host ADDRESS_HEADER=x-forwarded-for node build
1507
- ```
1508
-
1509
- For Caddy, it just works - Caddy proxies WebSocket upgrades automatically, no special config needed:
1510
-
1511
- ```
1512
- example.com {
1513
- reverse_proxy localhost:3000
1514
- }
1515
- ```
1516
-
1517
- ### "I want to use a different WebSocket path"
1518
-
1519
- Set it in both the adapter config and the client:
1520
-
1521
- **svelte.config.js**
1522
- ```js
1523
- adapter({
1524
- websocket: {
1525
- path: '/my-ws'
1526
- }
1527
- })
1528
- ```
1529
-
1530
- **Client**
1531
- ```js
1532
- import { connect } from 'svelte-adapter-uws/client';
1533
- connect({ path: '/my-ws' });
1534
- ```
1535
-
1536
- Or if you're using `on()` directly (which auto-connects), call `connect()` first:
1537
-
1538
- ```svelte
1539
- <script>
1540
- import { connect, on } from 'svelte-adapter-uws/client';
1541
-
1542
- // Set the path before any on() calls
1543
- connect({ path: '/my-ws' });
1544
-
1545
- const todos = on('todos');
1546
- </script>
1547
- ```
1548
-
1549
- ---
1550
-
1551
- ## License
1552
-
1553
- [MIT](LICENSE)
1
+ # svelte-adapter-uws
2
+
3
+ A 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.
4
+
5
+ I'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.
6
+
7
+ ## What you get
8
+
9
+ - **HTTP & HTTPS** - native TLS via uWebSockets.js `SSLApp`, no reverse proxy needed
10
+ - **WebSocket & WSS** - built-in pub/sub with a reactive Svelte client store
11
+ - **In-memory static file cache** - assets loaded once at startup, served from RAM with precompressed brotli/gzip variants
12
+ - **Backpressure handling** - streaming responses that won't blow up memory
13
+ - **Graceful shutdown** - waits for in-flight requests before exiting
14
+ - **Health check endpoint** - `/healthz` out of the box
15
+ - **Zero-config WebSocket** - just set `websocket: true` and go
16
+
17
+ ---
18
+
19
+ ## Table of contents
20
+
21
+ - [Installation](#installation)
22
+ - [Quick start: HTTP](#quick-start-http)
23
+ - [Quick start: HTTPS](#quick-start-https)
24
+ - [Quick start: WebSocket](#quick-start-websocket)
25
+ - [Quick start: WSS (secure WebSocket)](#quick-start-wss-secure-websocket)
26
+ - [Development, Preview & Production](#development-preview--production)
27
+ - [Adapter options](#adapter-options)
28
+ - [Environment variables](#environment-variables)
29
+ - [WebSocket handler (`hooks.ws`)](#websocket-handler-hooksws)
30
+ - [Authentication](#authentication)
31
+ - [Platform API (`event.platform`)](#platform-api-eventplatform)
32
+ - [Client store API](#client-store-api)
33
+ - [TypeScript setup](#typescript-setup)
34
+ - [Svelte 4 support](#svelte-4-support)
35
+ - [Deploying with Docker](#deploying-with-docker)
36
+ - [Clustering (Linux)](#clustering-linux)
37
+ - [Performance](#performance)
38
+ - [Troubleshooting](#troubleshooting)
39
+ - [License](#license)
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ ### Starting from scratch
46
+
47
+ If you don't have a SvelteKit project yet:
48
+
49
+ ```bash
50
+ npx sv create my-app
51
+ cd my-app
52
+ npm install
53
+ ```
54
+
55
+ ### Adding the adapter
56
+
57
+ ```bash
58
+ npm install svelte-adapter-uws
59
+ npm install uNetworking/uWebSockets.js#v20.60.0
60
+ ```
61
+
62
+ > **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.
63
+ >
64
+ > **Docker:** Use `node:22-trixie-slim` or another glibc >= 2.38 image. Bookworm-based images and Alpine won't work. See [Deploying with Docker](#deploying-with-docker).
65
+
66
+ If you plan to use WebSockets during development, also install `ws`:
67
+
68
+ ```bash
69
+ npm install -D ws
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Quick start: HTTP
75
+
76
+ The simplest setup - just swap the adapter and you're done.
77
+
78
+ **svelte.config.js**
79
+ ```js
80
+ import adapter from 'svelte-adapter-uws';
81
+
82
+ export default {
83
+ kit: {
84
+ adapter: adapter()
85
+ }
86
+ };
87
+ ```
88
+
89
+ **Build and run:**
90
+ ```bash
91
+ npm run build
92
+ node build
93
+ ```
94
+
95
+ Your app is now running on `http://localhost:3000`.
96
+
97
+ To change the host or port:
98
+ ```bash
99
+ HOST=0.0.0.0 PORT=8080 node build
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Quick start: HTTPS
105
+
106
+ No reverse proxy needed. uWebSockets.js handles TLS natively with its `SSLApp`.
107
+
108
+ **svelte.config.js** - same as HTTP, no changes needed:
109
+ ```js
110
+ import adapter from 'svelte-adapter-uws';
111
+
112
+ export default {
113
+ kit: {
114
+ adapter: adapter()
115
+ }
116
+ };
117
+ ```
118
+
119
+ **Build and run with TLS:**
120
+ ```bash
121
+ npm run build
122
+ SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build
123
+ ```
124
+
125
+ Your app is now running on `https://localhost:3000`.
126
+
127
+ > Both `SSL_CERT` and `SSL_KEY` must be set. Setting only one will throw an error.
128
+
129
+ ### Behind a reverse proxy (nginx, Caddy, etc.)
130
+
131
+ If your proxy terminates TLS and forwards to HTTP:
132
+
133
+ ```bash
134
+ ORIGIN=https://example.com node build
135
+ ```
136
+
137
+ Or if you want flexible header-based detection:
138
+ ```bash
139
+ PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build
140
+ ```
141
+
142
+ > **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.
143
+
144
+ ---
145
+
146
+ ## Quick start: WebSocket
147
+
148
+ Three things to do:
149
+
150
+ 1. **Enable WebSocket in the adapter**
151
+ 2. **Add the Vite plugin** (for dev mode)
152
+ 3. **Use the client store** in your Svelte components
153
+
154
+ ### Step 1: Enable WebSocket
155
+
156
+ **svelte.config.js**
157
+ ```js
158
+ import adapter from 'svelte-adapter-uws';
159
+
160
+ export default {
161
+ kit: {
162
+ adapter: adapter({
163
+ websocket: true
164
+ })
165
+ }
166
+ };
167
+ ```
168
+
169
+ That'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.
170
+
171
+ ### Step 2: Add the Vite plugin
172
+
173
+ This makes WebSockets work during `npm run dev`. Without this, `event.platform` won't have WebSocket methods in dev mode.
174
+
175
+ **vite.config.js**
176
+ ```js
177
+ import { sveltekit } from '@sveltejs/kit/vite';
178
+ import uwsDev from 'svelte-adapter-uws/vite';
179
+
180
+ export default {
181
+ plugins: [sveltekit(), uwsDev()]
182
+ };
183
+ ```
184
+
185
+ ### Step 3: Use the client store
186
+
187
+ **src/routes/+page.svelte**
188
+ ```svelte
189
+ <script>
190
+ import { on, status } from 'svelte-adapter-uws/client';
191
+
192
+ // Subscribe to the 'notifications' topic
193
+ // Auto-connects, auto-subscribes, auto-reconnects
194
+ const notifications = on('notifications');
195
+ </script>
196
+
197
+ {#if $status === 'open'}
198
+ <span>Connected</span>
199
+ {/if}
200
+
201
+ {#if $notifications}
202
+ <p>Event: {$notifications.event}</p>
203
+ <p>Data: {JSON.stringify($notifications.data)}</p>
204
+ {/if}
205
+ ```
206
+
207
+ ### Step 4: Publish from the server
208
+
209
+ **src/routes/api/notify/+server.js**
210
+ ```js
211
+ export async function POST({ request, platform }) {
212
+ const data = await request.json();
213
+
214
+ // This sends to ALL clients subscribed to 'notifications'
215
+ platform.publish('notifications', 'new-message', data);
216
+
217
+ return new Response('OK');
218
+ }
219
+ ```
220
+
221
+ **Build and run:**
222
+ ```bash
223
+ npm run build
224
+ node build
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Quick start: WSS (secure WebSocket)
230
+
231
+ WSS works automatically when you enable TLS. WebSocket connections upgrade over the same HTTPS port.
232
+
233
+ **svelte.config.js**
234
+ ```js
235
+ import adapter from 'svelte-adapter-uws';
236
+
237
+ export default {
238
+ kit: {
239
+ adapter: adapter({
240
+ websocket: true
241
+ })
242
+ }
243
+ };
244
+ ```
245
+
246
+ ```bash
247
+ npm run build
248
+ SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build
249
+ ```
250
+
251
+ The client store automatically uses `wss://` when the page is served over HTTPS - no configuration needed on the client side.
252
+
253
+ ---
254
+
255
+ ## Development, Preview & Production
256
+
257
+ ### `npm run dev` - works (with the Vite plugin)
258
+
259
+ Development works as expected. The Vite plugin (`svelte-adapter-uws/vite`) spins up a `ws` WebSocket server alongside Vite's dev server, so your client store and `event.platform` work identically to production.
260
+
261
+ **vite.config.js**
262
+ ```js
263
+ import { sveltekit } from '@sveltejs/kit/vite';
264
+ import uwsDev from 'svelte-adapter-uws/vite';
265
+
266
+ export default {
267
+ plugins: [sveltekit(), uwsDev()]
268
+ };
269
+ ```
270
+
271
+ Without the Vite plugin:
272
+ - HTTP routes work fine
273
+ - `event.platform` is `undefined` - any code calling `platform.publish()` will throw
274
+ - The client store will try to connect to `/ws` and fail silently (auto-reconnect will keep trying)
275
+
276
+ ### `npm run preview` - WebSockets don't work
277
+
278
+ SvelteKit'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**.
279
+
280
+ Use `node build` instead of preview for testing WebSocket features.
281
+
282
+ ### `node build` - production, everything works
283
+
284
+ This is the real deal. uWebSockets.js handles everything:
285
+
286
+ ```bash
287
+ npm run build
288
+ node build
289
+ ```
290
+
291
+ Or with environment variables:
292
+ ```bash
293
+ PORT=8080 HOST=0.0.0.0 node build
294
+ ```
295
+
296
+ Or with TLS:
297
+ ```bash
298
+ SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build
299
+ ```
300
+
301
+ ---
302
+
303
+ ## Adapter options
304
+
305
+ ```js
306
+ adapter({
307
+ // Output directory for the build
308
+ out: 'build', // default: 'build'
309
+
310
+ // Precompress static assets with brotli and gzip
311
+ precompress: true, // default: true
312
+
313
+ // Prefix for environment variables (e.g. 'MY_APP_' -> MY_APP_PORT)
314
+ envPrefix: '', // default: ''
315
+
316
+ // Health check endpoint (set to false to disable)
317
+ healthCheckPath: '/healthz', // default: '/healthz'
318
+
319
+ // WebSocket configuration
320
+ websocket: true // or false, or an options object (see below)
321
+ })
322
+ ```
323
+
324
+ ### WebSocket options
325
+
326
+ ```js
327
+ adapter({
328
+ websocket: {
329
+ // Path for WebSocket connections
330
+ path: '/ws', // default: '/ws'
331
+
332
+ // Path to your custom handler module (auto-discovers src/hooks.ws.js if omitted)
333
+ handler: './src/lib/server/websocket.js', // default: auto-discover
334
+
335
+ // Max message size in bytes (connections sending larger messages are closed)
336
+ maxPayloadLength: 16 * 1024, // default: 16 KB
337
+
338
+ // Seconds of inactivity before the connection is closed
339
+ idleTimeout: 120, // default: 120
340
+
341
+ // Max bytes of backpressure per connection before messages are dropped.
342
+ // uWS defaults to 64 KB; this adapter uses 1 MB to handle pub/sub spikes.
343
+ // Lower this if you expect many slow consumers.
344
+ maxBackpressure: 1024 * 1024, // default: 1 MB
345
+
346
+ // Enable per-message deflate compression
347
+ compression: false, // default: false
348
+
349
+ // Automatically send pings to keep the connection alive
350
+ sendPingsAutomatically: true, // default: true
351
+
352
+ // Seconds before an async upgrade handler is rejected with 504
353
+ upgradeTimeout: 10, // default: 10
354
+
355
+ // Allowed origins for WebSocket connections
356
+ // 'same-origin' - only accept where Origin matches Host and scheme (default)
357
+ // '*' - accept from any origin
358
+ // ['https://example.com'] - whitelist specific origins
359
+ allowedOrigins: 'same-origin' // default: 'same-origin'
360
+ }
361
+ })
362
+ ```
363
+
364
+ ---
365
+
366
+ ## Environment variables
367
+
368
+ All variables are set at **runtime** (when you run `node build`), not at build time.
369
+
370
+ If you set `envPrefix: 'MY_APP_'` in the adapter config, all variables are prefixed (e.g. `MY_APP_PORT` instead of `PORT`).
371
+
372
+ | Variable | Default | Description |
373
+ |---|---|---|
374
+ | `HOST` | `0.0.0.0` | Bind address |
375
+ | `PORT` | `3000` | Listen port |
376
+ | `ORIGIN` | *(derived)* | Fixed origin (e.g. `https://example.com`) |
377
+ | `SSL_CERT` | - | Path to TLS certificate file |
378
+ | `SSL_KEY` | - | Path to TLS private key file |
379
+ | `PROTOCOL_HEADER` | - | Header for protocol detection (e.g. `x-forwarded-proto`) |
380
+ | `HOST_HEADER` | - | Header for host detection (e.g. `x-forwarded-host`) |
381
+ | `PORT_HEADER` | - | Header for port override (e.g. `x-forwarded-port`) |
382
+ | `ADDRESS_HEADER` | - | Header for client IP (e.g. `x-forwarded-for`) |
383
+ | `XFF_DEPTH` | `1` | Position from right in `X-Forwarded-For` |
384
+ | `BODY_SIZE_LIMIT` | `512K` | Max request body size (supports `K`, `M`, `G` suffixes) |
385
+ | `SHUTDOWN_TIMEOUT` | `30` | Seconds to wait during graceful shutdown |
386
+ | `CLUSTER_WORKERS` | - | Number of worker processes (or `auto` for CPU count). Linux only, HTTP only |
387
+
388
+ ### Graceful shutdown
389
+
390
+ On `SIGTERM` or `SIGINT`, the server:
391
+ 1. Stops accepting new connections
392
+ 2. Emits a `sveltekit:shutdown` event on `process` (for cleanup hooks like closing database connections)
393
+ 3. Waits for in-flight SSR requests to complete (up to `SHUTDOWN_TIMEOUT` seconds)
394
+ 4. Exits
395
+
396
+ ```js
397
+ // Listen for shutdown in your server code (e.g. hooks.server.js)
398
+ process.on('sveltekit:shutdown', async (reason) => {
399
+ console.log(`Shutting down: ${reason}`);
400
+ await db.close();
401
+ });
402
+ ```
403
+
404
+ ### Examples
405
+
406
+ ```bash
407
+ # Simple HTTP
408
+ node build
409
+
410
+ # Custom port
411
+ PORT=8080 node build
412
+
413
+ # Behind nginx
414
+ ORIGIN=https://example.com node build
415
+
416
+ # Behind a proxy with forwarded headers
417
+ PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host ADDRESS_HEADER=x-forwarded-for node build
418
+
419
+ # Native TLS
420
+ SSL_CERT=./cert.pem SSL_KEY=./key.pem node build
421
+
422
+ # Everything at once
423
+ SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 HOST=0.0.0.0 BODY_SIZE_LIMIT=10M SHUTDOWN_TIMEOUT=60 node build
424
+ ```
425
+
426
+ ---
427
+
428
+ ## WebSocket handler (`hooks.ws`)
429
+
430
+ ### No handler needed (simplest)
431
+
432
+ With `websocket: true`, a built-in handler accepts all connections and handles subscribe/unsubscribe messages from the client store. No file needed.
433
+
434
+ > **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.
435
+
436
+ ### Auto-discovered handler
437
+
438
+ Create `src/hooks.ws.js` (or `.ts`, `.mjs`) and it will be automatically discovered - no config needed:
439
+
440
+ **src/hooks.ws.js**
441
+ ```js
442
+ // Called during the HTTP -> WebSocket upgrade handshake.
443
+ // Return an object to accept (becomes ws.getUserData()).
444
+ // Return false to reject with 401.
445
+ // Omit this export to accept all connections.
446
+ export async function upgrade({ headers, cookies, url, remoteAddress }) {
447
+ const sessionId = cookies.session_id;
448
+ if (!sessionId) return false;
449
+
450
+ const user = await validateSession(sessionId);
451
+ if (!user) return false;
452
+
453
+ // Whatever you return here is available as ws.getUserData()
454
+ return { userId: user.id, name: user.name };
455
+ }
456
+
457
+ // Called when a connection is established
458
+ export function open(ws) {
459
+ const { userId } = ws.getUserData();
460
+ console.log(`User ${userId} connected`);
461
+
462
+ // Subscribe this connection to a user-specific topic
463
+ ws.subscribe(`user:${userId}`);
464
+ }
465
+
466
+ // Called when a message is received
467
+ // Note: subscribe/unsubscribe messages from the client store are
468
+ // handled automatically BEFORE this function is called
469
+ export function message(ws, data, isBinary) {
470
+ const msg = JSON.parse(Buffer.from(data).toString());
471
+ console.log('Got message:', msg);
472
+ }
473
+
474
+ // Called when a client tries to subscribe to a topic (optional)
475
+ // Return false to deny the subscription
476
+ export function subscribe(ws, topic) {
477
+ const { role } = ws.getUserData();
478
+ // Only admins can subscribe to admin topics
479
+ if (topic.startsWith('admin') && role !== 'admin') return false;
480
+ }
481
+
482
+ // Called when the connection closes
483
+ export function close(ws, code, message) {
484
+ const { userId } = ws.getUserData();
485
+ console.log(`User ${userId} disconnected`);
486
+ }
487
+
488
+ // Called when backpressure has drained (optional, for flow control)
489
+ export function drain(ws) {
490
+ // You can resume sending large messages here
491
+ }
492
+ ```
493
+
494
+ ### Explicit handler path
495
+
496
+ If your handler is somewhere other than `src/hooks.ws.js`:
497
+
498
+ ```js
499
+ adapter({
500
+ websocket: {
501
+ handler: './src/lib/server/websocket.js'
502
+ }
503
+ })
504
+ ```
505
+
506
+ ### What the handler gets
507
+
508
+ The `upgrade` function receives an `UpgradeContext`:
509
+
510
+ ```js
511
+ {
512
+ headers: { 'cookie': '...', 'host': 'localhost:3000', ... }, // all lowercase
513
+ cookies: { session_id: 'abc123', theme: 'dark' }, // parsed from Cookie header
514
+ url: '/ws', // request path
515
+ remoteAddress: '127.0.0.1' // client IP
516
+ }
517
+ ```
518
+
519
+ The `subscribe` function receives `(ws, topic)` and can return `false` to deny a client's subscription request. Omit it to allow all subscriptions.
520
+
521
+ The `ws` object in `open`, `message`, `close`, and `drain` is a [uWebSockets.js WebSocket](https://github.com/uNetworking/uWebSockets.js). Key methods:
522
+
523
+ - `ws.getUserData()` - returns whatever `upgrade` returned
524
+ - `ws.subscribe(topic)` - subscribe to a topic for `app.publish()`
525
+ - `ws.unsubscribe(topic)` - unsubscribe from a topic
526
+ - `ws.send(data)` - send a message to this connection
527
+ - `ws.close()` - close the connection
528
+
529
+ ---
530
+
531
+ ## Authentication
532
+
533
+ WebSocket 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.
534
+
535
+ Here's the full flow from login to authenticated WebSocket:
536
+
537
+ ### Step 1: Login sets a cookie (standard SvelteKit)
538
+
539
+ **src/routes/login/+page.server.js**
540
+ ```js
541
+ import { authenticate, createSession } from '$lib/server/auth.js';
542
+
543
+ export const actions = {
544
+ default: async ({ request, cookies }) => {
545
+ const form = await request.formData();
546
+ const email = form.get('email');
547
+ const password = form.get('password');
548
+
549
+ const user = await authenticate(email, password);
550
+ if (!user) return { error: 'Invalid credentials' };
551
+
552
+ const sessionId = await createSession(user.id);
553
+
554
+ // This cookie is automatically sent on WebSocket upgrade requests
555
+ cookies.set('session', sessionId, {
556
+ path: '/',
557
+ httpOnly: true,
558
+ sameSite: 'strict',
559
+ secure: true,
560
+ maxAge: 60 * 60 * 24 * 7 // 1 week
561
+ });
562
+
563
+ return { success: true };
564
+ }
565
+ };
566
+ ```
567
+
568
+ ### Step 2: WebSocket handler reads the same cookie
569
+
570
+ **src/hooks.ws.js**
571
+ ```js
572
+ import { getSession } from '$lib/server/auth.js';
573
+
574
+ export async function upgrade({ cookies }) {
575
+ // Same cookie that SvelteKit set during login
576
+ const sessionId = cookies.session;
577
+ if (!sessionId) return false; // -> 401, connection rejected
578
+
579
+ const user = await getSession(sessionId);
580
+ if (!user) return false; // -> 401, expired or invalid session
581
+
582
+ // Attach user data to the socket - available via ws.getUserData()
583
+ return { userId: user.id, name: user.name, role: user.role };
584
+ }
585
+
586
+ export function open(ws) {
587
+ const { userId, role } = ws.getUserData();
588
+ console.log(`${userId} connected (${role})`);
589
+
590
+ // Subscribe to user-specific and role-based topics
591
+ ws.subscribe(`user:${userId}`);
592
+ if (role === 'admin') ws.subscribe('admin');
593
+ }
594
+
595
+ export function close(ws) {
596
+ const { userId } = ws.getUserData();
597
+ console.log(`${userId} disconnected`);
598
+ }
599
+ ```
600
+
601
+ ### Step 3: Client - nothing special needed
602
+
603
+ **src/routes/dashboard/+page.svelte**
604
+ ```svelte
605
+ <script>
606
+ import { on, status } from 'svelte-adapter-uws/client';
607
+
608
+ // The browser sends cookies automatically on the upgrade request.
609
+ // If the session is invalid, the connection is rejected and
610
+ // auto-reconnect will retry (useful if the user logs in later).
611
+ const notifications = on('notifications');
612
+ const userMessages = on('user-messages');
613
+ </script>
614
+
615
+ {#if $status === 'open'}
616
+ <span>Authenticated & connected</span>
617
+ {:else if $status === 'connecting'}
618
+ <span>Connecting...</span>
619
+ {:else}
620
+ <span>Disconnected (not logged in?)</span>
621
+ {/if}
622
+ ```
623
+
624
+ ### Step 4: Send messages to specific users from anywhere
625
+
626
+ **src/routes/api/notify/+server.js**
627
+ ```js
628
+ import { json } from '@sveltejs/kit';
629
+
630
+ export async function POST({ request, platform }) {
631
+ const { userId, message } = await request.json();
632
+
633
+ // Only that user receives this (they subscribed in open())
634
+ platform.publish(`user:${userId}`, 'notification', { message });
635
+
636
+ return json({ sent: true });
637
+ }
638
+ ```
639
+
640
+ ### Why this works
641
+
642
+ The 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.
643
+
644
+ | What | Where | Same cookies? |
645
+ |---|---|---|
646
+ | Page load | `+page.server.js` `load()` | Yes |
647
+ | Form action | `+page.server.js` `actions` | Yes |
648
+ | API route | `+server.js` | Yes |
649
+ | Server hook | `hooks.server.js` `handle()` | Yes |
650
+ | **WebSocket upgrade** | **`hooks.ws.js` `upgrade()`** | **Yes** |
651
+
652
+ ---
653
+
654
+ ## Platform API (`event.platform`)
655
+
656
+ Available in server hooks, load functions, form actions, and API routes.
657
+
658
+ ### `platform.publish(topic, event, data)`
659
+
660
+ Send a message to all WebSocket clients subscribed to a topic:
661
+
662
+ ```js
663
+ // src/routes/todos/+page.server.js
664
+ export const actions = {
665
+ create: async ({ request, platform }) => {
666
+ const formData = await request.formData();
667
+ const todo = await db.createTodo(formData.get('text'));
668
+
669
+ // Every client subscribed to 'todos' receives this
670
+ platform.publish('todos', 'created', todo);
671
+
672
+ return { success: true };
673
+ }
674
+ };
675
+ ```
676
+
677
+ ### `platform.send(ws, topic, event, data)`
678
+
679
+ Send a message to a single WebSocket connection. Wraps in the same `{ topic, event, data }` envelope as `publish()`.
680
+
681
+ This is useful when you store WebSocket references (e.g. in a `Map`) and need to message specific connections from SvelteKit handlers:
682
+
683
+ ```js
684
+ // src/hooks.ws.js - store connections by user ID
685
+ const userSockets = new Map();
686
+
687
+ export function open(ws) {
688
+ const { userId } = ws.getUserData();
689
+ userSockets.set(userId, ws);
690
+ }
691
+
692
+ export function close(ws) {
693
+ const { userId } = ws.getUserData();
694
+ userSockets.delete(userId);
695
+ }
696
+
697
+ // Export the map so SvelteKit handlers can access it
698
+ export { userSockets };
699
+ ```
700
+
701
+ ```js
702
+ // src/routes/api/dm/+server.js - send to a specific user
703
+ import { userSockets } from '../../hooks.ws.js';
704
+
705
+ export async function POST({ request, platform }) {
706
+ const { targetUserId, message } = await request.json();
707
+ const ws = userSockets.get(targetUserId);
708
+ if (ws) {
709
+ platform.send(ws, 'dm', 'new-message', { message });
710
+ }
711
+ return new Response('OK');
712
+ }
713
+ ```
714
+
715
+ To reply directly from inside `hooks.ws.js` (where `platform` isn't available), use `ws.send()` with the envelope format:
716
+
717
+ ```js
718
+ // src/hooks.ws.js
719
+ export function message(ws, rawData) {
720
+ const msg = JSON.parse(Buffer.from(rawData).toString());
721
+ // Reply to sender using the same envelope format the client store expects
722
+ ws.send(JSON.stringify({ topic: 'echo', event: 'reply', data: { got: msg } }));
723
+ }
724
+ ```
725
+
726
+ ### `platform.sendTo(filter, topic, event, data)`
727
+
728
+ Send a message to all connections whose `userData` matches a filter function. Returns the number of connections the message was sent to.
729
+
730
+ This is simpler than manually maintaining a `Map` of connections - no `hooks.ws.js` needed:
731
+
732
+ ```js
733
+ // src/routes/api/dm/+server.js - send to a specific user
734
+ export async function POST({ request, platform }) {
735
+ const { targetUserId, message } = await request.json();
736
+ const count = platform.sendTo(
737
+ (userData) => userData.userId === targetUserId,
738
+ 'dm', 'new-message', { message }
739
+ );
740
+ return new Response(count > 0 ? 'Sent' : 'User offline');
741
+ }
742
+ ```
743
+
744
+ ```js
745
+ // Send to all admins
746
+ platform.sendTo(
747
+ (userData) => userData.role === 'admin',
748
+ 'alerts', 'warning', { message: 'Server load high' }
749
+ );
750
+ ```
751
+
752
+ ### `platform.connections`
753
+
754
+ Number of active WebSocket connections:
755
+
756
+ ```js
757
+ // src/routes/api/stats/+server.js
758
+ import { json } from '@sveltejs/kit';
759
+
760
+ export async function GET({ platform }) {
761
+ return json({ online: platform.connections });
762
+ }
763
+ ```
764
+
765
+ ### `platform.subscribers(topic)`
766
+
767
+ Number of clients subscribed to a specific topic:
768
+
769
+ ```js
770
+ export async function GET({ platform, params }) {
771
+ return json({
772
+ viewers: platform.subscribers(`page:${params.id}`)
773
+ });
774
+ }
775
+ ```
776
+
777
+ ### `platform.topic(name)` - scoped helper
778
+
779
+ Reduces repetition when publishing multiple events to the same topic:
780
+
781
+ ```js
782
+ // src/routes/todos/+page.server.js
783
+ export const actions = {
784
+ create: async ({ request, platform }) => {
785
+ const todos = platform.topic('todos');
786
+ const todo = await db.create(await request.formData());
787
+ todos.created(todo); // shorthand for platform.publish('todos', 'created', todo)
788
+ },
789
+
790
+ update: async ({ request, platform }) => {
791
+ const todos = platform.topic('todos');
792
+ const todo = await db.update(await request.formData());
793
+ todos.updated(todo);
794
+ },
795
+
796
+ delete: async ({ request, platform }) => {
797
+ const todos = platform.topic('todos');
798
+ const id = (await request.formData()).get('id');
799
+ await db.delete(id);
800
+ todos.deleted({ id });
801
+ }
802
+ };
803
+ ```
804
+
805
+ The topic helper also has counter methods:
806
+
807
+ ```js
808
+ const online = platform.topic('online-users');
809
+ online.set(42); // -> { event: 'set', data: 42 }
810
+ online.increment(); // -> { event: 'increment', data: 1 }
811
+ online.increment(5); // -> { event: 'increment', data: 5 }
812
+ online.decrement(); // -> { event: 'decrement', data: 1 }
813
+ ```
814
+
815
+ ---
816
+
817
+ ## Client store API
818
+
819
+ Import from `svelte-adapter-uws/client`. Everything auto-connects - you don't need to call `connect()` first.
820
+
821
+ ### `on(topic)` - subscribe to a topic
822
+
823
+ The main function most users need. Returns a Svelte readable store that updates whenever a message is published to the topic.
824
+
825
+ > **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".
826
+
827
+ ```svelte
828
+ <script>
829
+ import { on } from 'svelte-adapter-uws/client';
830
+
831
+ // Full event envelope: { topic, event, data }
832
+ const todos = on('todos');
833
+ </script>
834
+
835
+ <!-- ALWAYS guard with {#if} - $todos is null until the first message arrives -->
836
+ {#if $todos}
837
+ <p>{$todos.event}: {JSON.stringify($todos.data)}</p>
838
+ {/if}
839
+
840
+ <!-- WRONG - will crash with "Cannot read properties of null" -->
841
+ <!-- <p>{$todos.event}</p> -->
842
+ ```
843
+
844
+ ### `on(topic, event)` - subscribe to a specific event
845
+
846
+ Filters to a single event name and wraps the payload in `{ data }`:
847
+
848
+ ```svelte
849
+ <script>
850
+ import { on } from 'svelte-adapter-uws/client';
851
+
852
+ // Only 'created' events, wrapped in { data }
853
+ const newTodo = on('todos', 'created');
854
+ </script>
855
+
856
+ {#if $newTodo}
857
+ <p>New todo: {$newTodo.data.text}</p>
858
+ {/if}
859
+ ```
860
+
861
+ ### `.scan(initial, reducer)` - accumulate state
862
+
863
+ Like `Array.reduce` but reactive. Each new event feeds through the reducer:
864
+
865
+ ```svelte
866
+ <script>
867
+ import { on } from 'svelte-adapter-uws/client';
868
+
869
+ const todos = on('todos').scan([], (list, { event, data }) => {
870
+ if (event === 'created') return [...list, data];
871
+ if (event === 'updated') return list.map(t => t.id === data.id ? data : t);
872
+ if (event === 'deleted') return list.filter(t => t.id !== data.id);
873
+ return list;
874
+ });
875
+ </script>
876
+
877
+ {#each $todos as todo (todo.id)}
878
+ <p>{todo.text}</p>
879
+ {/each}
880
+ ```
881
+
882
+ ### `crud(topic, initial?, options?)` - live CRUD list
883
+
884
+ One-liner for real-time collections. Handles `created`, `updated`, and `deleted` events automatically:
885
+
886
+ ```svelte
887
+ <script>
888
+ import { crud } from 'svelte-adapter-uws/client';
889
+
890
+ let { data } = $props(); // from +page.server.js load()
891
+
892
+ // $todos auto-updates when server publishes created/updated/deleted
893
+ const todos = crud('todos', data.todos);
894
+ </script>
895
+
896
+ {#each $todos as todo (todo.id)}
897
+ <p>{todo.text}</p>
898
+ {/each}
899
+ ```
900
+
901
+ Options:
902
+ - `key` - property to match items by (default: `'id'`)
903
+ - `prepend` - add new items to the beginning instead of end (default: `false`)
904
+
905
+ ```js
906
+ // Notifications, newest first
907
+ const notifications = crud('notifications', [], { prepend: true });
908
+
909
+ // Items keyed by 'slug' instead of 'id'
910
+ const posts = crud('posts', data.posts, { key: 'slug' });
911
+ ```
912
+
913
+ Pair with `platform.topic()` on the server:
914
+
915
+ ```js
916
+ // Server: +page.server.js
917
+ export const actions = {
918
+ create: async ({ request, platform }) => {
919
+ const todo = await db.create(await request.formData());
920
+ platform.topic('todos').created(todo); // client sees 'created'
921
+ },
922
+ update: async ({ request, platform }) => {
923
+ const todo = await db.update(await request.formData());
924
+ platform.topic('todos').updated(todo); // client sees 'updated'
925
+ },
926
+ delete: async ({ request, platform }) => {
927
+ await db.delete((await request.formData()).get('id'));
928
+ platform.topic('todos').deleted({ id }); // client sees 'deleted'
929
+ }
930
+ };
931
+ ```
932
+
933
+ ### `lookup(topic, initial?, options?)` - live keyed object
934
+
935
+ Like `crud()` but returns a `Record<string, T>` instead of an array. Better for dashboards and fast lookups:
936
+
937
+ ```svelte
938
+ <script>
939
+ import { lookup } from 'svelte-adapter-uws/client';
940
+
941
+ let { data } = $props();
942
+ const users = lookup('users', data.users);
943
+ </script>
944
+
945
+ {#if $users[selectedId]}
946
+ <UserCard user={$users[selectedId]} />
947
+ {/if}
948
+ ```
949
+
950
+ ### `latest(topic, max?, initial?)` - ring buffer
951
+
952
+ Keeps the last N events. Perfect for chat, activity feeds, notifications:
953
+
954
+ ```svelte
955
+ <script>
956
+ import { latest } from 'svelte-adapter-uws/client';
957
+
958
+ // Keep the last 100 chat messages
959
+ const messages = latest('chat', 100);
960
+ </script>
961
+
962
+ {#each $messages as msg}
963
+ <p><b>{msg.event}:</b> {msg.data.text}</p>
964
+ {/each}
965
+ ```
966
+
967
+ ### `count(topic, initial?)` - live counter
968
+
969
+ Handles `set`, `increment`, and `decrement` events:
970
+
971
+ ```svelte
972
+ <script>
973
+ import { count } from 'svelte-adapter-uws/client';
974
+
975
+ const online = count('online-users');
976
+ </script>
977
+
978
+ <p>{$online} users online</p>
979
+ ```
980
+
981
+ Server:
982
+ ```js
983
+ platform.topic('online-users').increment();
984
+ platform.topic('online-users').decrement();
985
+ platform.topic('online-users').set(42);
986
+ ```
987
+
988
+ ### `once(topic, event?, options?)` - wait for one event
989
+
990
+ Returns a promise that resolves with the first matching event and then unsubscribes:
991
+
992
+ ```js
993
+ import { once } from 'svelte-adapter-uws/client';
994
+
995
+ // Wait for any event on the 'jobs' topic
996
+ const event = await once('jobs');
997
+
998
+ // Wait for a specific event
999
+ const result = await once('jobs', 'completed');
1000
+
1001
+ // With a timeout (rejects if no event within 5 seconds)
1002
+ const result = await once('jobs', 'completed', { timeout: 5000 });
1003
+
1004
+ // Timeout without event filter
1005
+ const event = await once('jobs', { timeout: 5000 });
1006
+ ```
1007
+
1008
+ ### `status` - connection status
1009
+
1010
+ Readable store with the current connection state:
1011
+
1012
+ ```svelte
1013
+ <script>
1014
+ import { status } from 'svelte-adapter-uws/client';
1015
+ </script>
1016
+
1017
+ {#if $status === 'open'}
1018
+ <span class="badge green">Live</span>
1019
+ {:else if $status === 'connecting'}
1020
+ <span class="badge yellow">Connecting...</span>
1021
+ {:else}
1022
+ <span class="badge red">Disconnected</span>
1023
+ {/if}
1024
+ ```
1025
+
1026
+ ### `ready()` - wait for connection
1027
+
1028
+ Returns a promise that resolves when the WebSocket connection is open:
1029
+
1030
+ ```js
1031
+ import { ready } from 'svelte-adapter-uws/client';
1032
+
1033
+ await ready();
1034
+ // connection is now open, safe to send messages
1035
+ ```
1036
+
1037
+ ### `connect(options?)` - power-user API
1038
+
1039
+ Most users don't need this - `on()` and `status` auto-connect. Use `connect()` when you need `close()`, `send()`, or custom options:
1040
+
1041
+ ```js
1042
+ import { connect } from 'svelte-adapter-uws/client';
1043
+
1044
+ const ws = connect({
1045
+ path: '/ws', // default: '/ws'
1046
+ reconnectInterval: 3000, // default: 3000 ms
1047
+ maxReconnectInterval: 30000, // default: 30000 ms
1048
+ maxReconnectAttempts: Infinity, // default: Infinity
1049
+ debug: true // default: false - turn this on to see everything!
1050
+ });
1051
+
1052
+ // With debug: true, you'll see every WebSocket event in the browser console:
1053
+ // [ws] connected
1054
+ // [ws] subscribe -> todos
1055
+ // [ws] <- todos created { id: 1, text: "Buy milk" }
1056
+ // [ws] send -> { type: "ping" }
1057
+ // [ws] disconnected
1058
+ // [ws] queued -> { type: "important" }
1059
+ // [ws] resubscribe -> todos
1060
+ // [ws] flush -> { type: "important" }
1061
+
1062
+ // Manual topic management
1063
+ ws.subscribe('chat');
1064
+ ws.unsubscribe('chat');
1065
+
1066
+ // Send custom messages to the server
1067
+ ws.send({ type: 'ping' });
1068
+
1069
+ // Send with queue (messages queue up while disconnected, flush on reconnect)
1070
+ ws.sendQueued({ type: 'important', data: '...' });
1071
+
1072
+ // Permanent disconnect (won't auto-reconnect)
1073
+ ws.close();
1074
+ ```
1075
+
1076
+ ---
1077
+
1078
+ ## TypeScript setup
1079
+
1080
+ Add the platform type to your `src/app.d.ts`:
1081
+
1082
+ ```ts
1083
+ import type { Platform as AdapterPlatform } from 'svelte-adapter-uws';
1084
+
1085
+ declare global {
1086
+ namespace App {
1087
+ interface Platform extends AdapterPlatform {}
1088
+ }
1089
+ }
1090
+
1091
+ export {};
1092
+ ```
1093
+
1094
+ Now `event.platform.publish()`, `event.platform.topic()`, etc. are fully typed.
1095
+
1096
+ ---
1097
+
1098
+ ## Svelte 4 support
1099
+
1100
+ This 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:
1101
+
1102
+ **Svelte 5 (used in examples)**
1103
+ ```svelte
1104
+ <script>
1105
+ import { crud } from 'svelte-adapter-uws/client';
1106
+
1107
+ let { data } = $props();
1108
+ const todos = crud('todos', data.todos);
1109
+ </script>
1110
+ ```
1111
+
1112
+ **Svelte 4 equivalent**
1113
+ ```svelte
1114
+ <script>
1115
+ import { crud } from 'svelte-adapter-uws/client';
1116
+
1117
+ export let data;
1118
+ const todos = crud('todos', data.todos);
1119
+ </script>
1120
+ ```
1121
+
1122
+ The 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.
1123
+
1124
+ ---
1125
+
1126
+ ## Deploying with Docker
1127
+
1128
+ uWebSockets.js is a native C++ addon, so your Docker image needs to match the platform it was compiled for. Build inside the container to be safe.
1129
+
1130
+ ```dockerfile
1131
+ FROM node:22-trixie-slim AS build
1132
+
1133
+ # git is required - uWebSockets.js is installed from GitHub, not npm
1134
+ RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
1135
+
1136
+ WORKDIR /app
1137
+
1138
+ COPY package*.json ./
1139
+ RUN npm ci
1140
+
1141
+ COPY . .
1142
+ RUN npm run build
1143
+
1144
+ # Runtime stage - no git needed
1145
+ FROM node:22-trixie-slim
1146
+
1147
+ WORKDIR /app
1148
+ COPY --from=build /app/build build/
1149
+ COPY --from=build /app/node_modules node_modules/
1150
+ COPY package.json .
1151
+
1152
+ EXPOSE 3000
1153
+ CMD ["node", "build"]
1154
+ ```
1155
+
1156
+ With TLS:
1157
+ ```dockerfile
1158
+ CMD ["sh", "-c", "SSL_CERT=/certs/cert.pem SSL_KEY=/certs/key.pem node build"]
1159
+ ```
1160
+
1161
+ With environment variables:
1162
+ ```bash
1163
+ docker run -p 3000:3000 \
1164
+ -e PORT=3000 \
1165
+ -e ORIGIN=https://example.com \
1166
+ my-app
1167
+ ```
1168
+
1169
+ > **Important:** Use Debian Trixie or Ubuntu 24.04+ based images (glibc >= 2.38). Bookworm-based images (`node:*-slim`, `node:*-bookworm`) ship glibc 2.36 which is too old for uWebSockets.js. Don't use Alpine either - uWebSockets.js binaries are compiled against glibc, not musl.
1170
+
1171
+ ---
1172
+
1173
+ ## Clustering (Linux)
1174
+
1175
+ On Linux, multiple processes can share the same port via `SO_REUSEPORT` - the kernel load-balances incoming connections across workers. This gives you near-linear scaling for HTTP/SSR workloads with zero coordination overhead.
1176
+
1177
+ Set the `CLUSTER_WORKERS` environment variable to enable it:
1178
+
1179
+ ```bash
1180
+ # Use all available CPU cores
1181
+ CLUSTER_WORKERS=auto node build
1182
+
1183
+ # Fixed number of workers
1184
+ CLUSTER_WORKERS=4 node build
1185
+
1186
+ # Combined with other options
1187
+ CLUSTER_WORKERS=auto PORT=8080 ORIGIN=https://example.com node build
1188
+ ```
1189
+
1190
+ The primary process forks N workers, each running their own uWS server on the same port. If a worker crashes, it is automatically restarted. On `SIGTERM`/`SIGINT`, the primary forwards the signal to all workers for graceful shutdown.
1191
+
1192
+ ### Limitations
1193
+
1194
+ - **Linux only** - `SO_REUSEPORT` is a Linux kernel feature. On other platforms, the variable is ignored with a warning.
1195
+ - **HTTP/SSR only** - clustering is automatically disabled when WebSocket is enabled (`websocket: true` or `websocket: { ... }`). uWS pub/sub is per-process, so messages published in one worker would not reach clients connected to another worker. If you need clustered WebSocket, bring an external pub/sub backend (Redis, NATS, etc.) and manage multi-process coordination yourself.
1196
+
1197
+ ---
1198
+
1199
+ ## Performance
1200
+
1201
+ ### Why uWebSockets.js?
1202
+
1203
+ uWebSockets.js is a C++ HTTP and WebSocket server compiled to a native V8 addon. In benchmarks it consistently outperforms Node.js' built-in `http` module, Express, Fastify, and every other JavaScript HTTP server by a significant margin - often 5-10x more requests per second.
1204
+
1205
+ ### Our overhead vs barebones uWS
1206
+
1207
+ A barebones uWebSockets.js "hello world" can handle 500k+ requests per second on a single core. This adapter adds overhead that is unavoidable for what it does:
1208
+
1209
+ 1. **SvelteKit SSR** - every non-static request goes through SvelteKit's `server.respond()`, which runs your load functions, renders components, and produces HTML. This is the biggest cost and it's the whole point of using SvelteKit.
1210
+
1211
+ 2. **Static file cache** - on startup we walk the build output and load every static file into memory with its precompressed variants. This is a one-time cost. Serving static files is then a single `Map.get()` plus a `res.cork()` + `res.end()` - about as fast as it gets without sendfile.
1212
+
1213
+ 3. **Request construction** - we build a standard `Request` object from uWS' stack-allocated `HttpRequest`. This means reading all headers synchronously (uWS requirement) and constructing a URL string. We read headers lazily for static files (only `accept-encoding` and `if-none-match`), but SSR requires the full set.
1214
+
1215
+ 4. **Response streaming** - we read from the `Response.body` ReadableStream and write chunks through uWS with backpressure support (`onWritable`). Single-chunk responses (most SSR pages) are optimized into a single `res.cork()` + `res.end()` call.
1216
+
1217
+ 5. **WebSocket envelope** - every pub/sub message is wrapped in `JSON.stringify({ topic, event, data })`. This is a few microseconds per message. The tradeoff is a clean, standardized format that the client store understands without configuration.
1218
+
1219
+ **What we don't add:**
1220
+ - No middleware chain
1221
+ - No routing layer (uWS' native routing + SvelteKit's router)
1222
+ - No per-request allocations beyond what's needed
1223
+ - No Node.js `http.IncomingMessage` shim (we construct `Request` directly from uWS)
1224
+
1225
+ ### The bottom line
1226
+
1227
+ For static files, performance is very close to barebones uWS. For SSR, the bottleneck is your Svelte components and load functions, not the adapter. The adapter's job is to get out of the way as fast as possible - and it does.
1228
+
1229
+ ---
1230
+
1231
+ ## Full example: real-time todo list
1232
+
1233
+ Here's a complete example tying everything together.
1234
+
1235
+ **svelte.config.js**
1236
+ ```js
1237
+ import adapter from 'svelte-adapter-uws';
1238
+
1239
+ export default {
1240
+ kit: {
1241
+ adapter: adapter({
1242
+ websocket: true
1243
+ })
1244
+ }
1245
+ };
1246
+ ```
1247
+
1248
+ **vite.config.js**
1249
+ ```js
1250
+ import { sveltekit } from '@sveltejs/kit/vite';
1251
+ import uwsDev from 'svelte-adapter-uws/vite';
1252
+
1253
+ export default {
1254
+ plugins: [sveltekit(), uwsDev()]
1255
+ };
1256
+ ```
1257
+
1258
+ **src/routes/todos/+page.server.js**
1259
+ ```js
1260
+ import { db } from '$lib/server/db.js';
1261
+
1262
+ export async function load() {
1263
+ return { todos: await db.getTodos() };
1264
+ }
1265
+
1266
+ export const actions = {
1267
+ create: async ({ request, platform }) => {
1268
+ const text = (await request.formData()).get('text');
1269
+ const todo = await db.createTodo(text);
1270
+ platform.topic('todos').created(todo);
1271
+ },
1272
+
1273
+ toggle: async ({ request, platform }) => {
1274
+ const id = (await request.formData()).get('id');
1275
+ const todo = await db.toggleTodo(id);
1276
+ platform.topic('todos').updated(todo);
1277
+ },
1278
+
1279
+ delete: async ({ request, platform }) => {
1280
+ const id = (await request.formData()).get('id');
1281
+ await db.deleteTodo(id);
1282
+ platform.topic('todos').deleted({ id });
1283
+ }
1284
+ };
1285
+ ```
1286
+
1287
+ **src/routes/todos/+page.svelte**
1288
+ ```svelte
1289
+ <script>
1290
+ import { crud, status } from 'svelte-adapter-uws/client';
1291
+
1292
+ let { data } = $props();
1293
+ const todos = crud('todos', data.todos);
1294
+ </script>
1295
+
1296
+ {#if $status === 'open'}
1297
+ <span>Live</span>
1298
+ {/if}
1299
+
1300
+ <form method="POST" action="?/create">
1301
+ <input name="text" placeholder="New todo..." />
1302
+ <button>Add</button>
1303
+ </form>
1304
+
1305
+ <ul>
1306
+ {#each $todos as todo (todo.id)}
1307
+ <li>
1308
+ <form method="POST" action="?/toggle">
1309
+ <input type="hidden" name="id" value={todo.id} />
1310
+ <button>{todo.done ? 'Undo' : 'Done'}</button>
1311
+ </form>
1312
+ <span class:done={todo.done}>{todo.text}</span>
1313
+ <form method="POST" action="?/delete">
1314
+ <input type="hidden" name="id" value={todo.id} />
1315
+ <button>Delete</button>
1316
+ </form>
1317
+ </li>
1318
+ {/each}
1319
+ </ul>
1320
+ ```
1321
+
1322
+ Open the page in two browser tabs. Create, toggle, or delete a todo in one tab - it appears in the other tab instantly.
1323
+
1324
+ ---
1325
+
1326
+ ## Troubleshooting
1327
+
1328
+ ### "WebSocket works in production but not in dev"
1329
+
1330
+ You need the Vite plugin. Without it, there's no WebSocket server running during `npm run dev`.
1331
+
1332
+ **vite.config.js**
1333
+ ```js
1334
+ import { sveltekit } from '@sveltejs/kit/vite';
1335
+ import uwsDev from 'svelte-adapter-uws/vite';
1336
+
1337
+ export default {
1338
+ plugins: [sveltekit(), uwsDev()]
1339
+ };
1340
+ ```
1341
+
1342
+ Also make sure `ws` is installed:
1343
+ ```bash
1344
+ npm install -D ws
1345
+ ```
1346
+
1347
+ ### "Cannot read properties of undefined (reading 'publish')"
1348
+
1349
+ This means `event.platform` is `undefined`. Two possible causes:
1350
+
1351
+ **Cause 1: Missing Vite plugin in dev mode**
1352
+
1353
+ Same fix as above - add `uwsDev()` to your `vite.config.js`.
1354
+
1355
+ **Cause 2: Calling `platform` on the client side**
1356
+
1357
+ `event.platform` only exists on the server. If you're calling it in a `+page.svelte` or `+layout.svelte` file, move that code to `+page.server.js` or `+server.js`.
1358
+
1359
+ ```js
1360
+ // WRONG - +page.svelte (client-side)
1361
+ platform.publish('todos', 'created', todo);
1362
+
1363
+ // RIGHT - +page.server.js (server-side)
1364
+ export const actions = {
1365
+ create: async ({ platform }) => {
1366
+ platform.publish('todos', 'created', todo);
1367
+ }
1368
+ };
1369
+ ```
1370
+
1371
+ ### "WebSocket connects but immediately disconnects (and keeps reconnecting)"
1372
+
1373
+ Your `upgrade` handler is returning `false`, which rejects the connection with 401. The client store's auto-reconnect then tries again, gets rejected again, and so on.
1374
+
1375
+ **To debug**, enable debug mode on the client:
1376
+ ```js
1377
+ import { connect } from 'svelte-adapter-uws/client';
1378
+ connect({ debug: true });
1379
+ ```
1380
+
1381
+ Then check the browser's Network tab -> WS tab. You'll see the upgrade request and its 401 response.
1382
+
1383
+ **Common causes:**
1384
+ - The session cookie isn't being set (check your login action)
1385
+ - The cookie name doesn't match (`cookies.session` vs `cookies.session_id`)
1386
+ - The session expired or is invalid
1387
+ - `sameSite: 'strict'` can block cookies on cross-origin navigations - try `'lax'` if you're redirecting from an external site
1388
+
1389
+ ### "WebSocket doesn't work with `npm run preview`"
1390
+
1391
+ This is expected. SvelteKit's preview server is Vite's built-in HTTP server - it doesn't know about WebSocket upgrades. Use `node build` instead:
1392
+
1393
+ ```bash
1394
+ npm run build
1395
+ node build
1396
+ ```
1397
+
1398
+ ### "Could not load uWebSockets.js"
1399
+
1400
+ uWebSockets.js is a native C++ addon. It's installed from GitHub, not npm, and needs to compile for your platform.
1401
+
1402
+ ```bash
1403
+ # Make sure you're using the right install command (no uWebSockets.js@ prefix)
1404
+ npm install uNetworking/uWebSockets.js#v20.60.0
1405
+ ```
1406
+
1407
+ **On Windows:** Make sure you have the Visual C++ Build Tools installed. You can get them from the [Visual Studio Installer](https://visualstudio.microsoft.com/downloads/) (select "Desktop development with C++").
1408
+
1409
+ **On Linux:** Make sure `build-essential` is installed:
1410
+ ```bash
1411
+ sudo apt install build-essential
1412
+ ```
1413
+
1414
+ **On Docker:** Use a Trixie-based image with git:
1415
+ ```dockerfile
1416
+ FROM node:22-trixie-slim
1417
+ RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
1418
+ ```
1419
+
1420
+ ### "I can't see what's happening with WebSocket messages"
1421
+
1422
+ Turn on debug mode. It logs every WebSocket event to the browser console:
1423
+
1424
+ ```svelte
1425
+ <script>
1426
+ import { connect } from 'svelte-adapter-uws/client';
1427
+
1428
+ // Call this once, anywhere - it's a singleton
1429
+ connect({ debug: true });
1430
+ </script>
1431
+ ```
1432
+
1433
+ You'll see output like:
1434
+ ```
1435
+ [ws] connected
1436
+ [ws] subscribe -> todos
1437
+ [ws] <- todos created {"id":1,"text":"Buy milk"}
1438
+ [ws] disconnected
1439
+ [ws] resubscribe -> todos
1440
+ ```
1441
+
1442
+ ### "Messages are arriving but my store isn't updating"
1443
+
1444
+ Make sure the topic names match exactly between server and client:
1445
+
1446
+ ```js
1447
+ // Server
1448
+ platform.publish('todos', 'created', todo); // topic: 'todos'
1449
+
1450
+ // Client - must match exactly
1451
+ const todos = on('todos'); // 'todos' - correct
1452
+ const todos = on('Todos'); // 'Todos' - WRONG, case sensitive
1453
+ const todos = on('todo'); // 'todo' - WRONG, singular vs plural
1454
+ ```
1455
+
1456
+ ### "How do I see what the message envelope looks like?"
1457
+
1458
+ Every message sent through `platform.publish()` or `platform.topic().created()` arrives as JSON with this shape:
1459
+
1460
+ ```json
1461
+ {
1462
+ "topic": "todos",
1463
+ "event": "created",
1464
+ "data": { "id": 1, "text": "Buy milk", "done": false }
1465
+ }
1466
+ ```
1467
+
1468
+ The client store parses this automatically. When you use `on('todos')`, the store value is:
1469
+ ```js
1470
+ { topic: 'todos', event: 'created', data: { id: 1, text: 'Buy milk', done: false } }
1471
+ ```
1472
+
1473
+ When you use `on('todos', 'created')`, you get the payload wrapped in `{ data }`:
1474
+ ```js
1475
+ { data: { id: 1, text: 'Buy milk', done: false } }
1476
+ ```
1477
+
1478
+ ### "WebSocket works locally but not behind nginx/Caddy"
1479
+
1480
+ Your reverse proxy needs to forward WebSocket upgrade requests. Here's a complete nginx config that handles both your app and WebSocket:
1481
+
1482
+ ```nginx
1483
+ server {
1484
+ listen 443 ssl;
1485
+ server_name example.com;
1486
+
1487
+ ssl_certificate /path/to/cert.pem;
1488
+ ssl_certificate_key /path/to/key.pem;
1489
+
1490
+ # WebSocket - must be listed before the catch-all
1491
+ location /ws {
1492
+ proxy_pass http://localhost:3000;
1493
+ proxy_http_version 1.1;
1494
+ proxy_set_header Upgrade $http_upgrade;
1495
+ proxy_set_header Connection "upgrade";
1496
+ proxy_set_header Host $host;
1497
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1498
+ proxy_set_header X-Forwarded-Proto $scheme;
1499
+ }
1500
+
1501
+ # Everything else - your SvelteKit app
1502
+ location / {
1503
+ proxy_pass http://localhost:3000;
1504
+ proxy_set_header Host $host;
1505
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1506
+ proxy_set_header X-Forwarded-Proto $scheme;
1507
+ }
1508
+ }
1509
+ ```
1510
+
1511
+ Then run your app with:
1512
+ ```bash
1513
+ PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=host ADDRESS_HEADER=x-forwarded-for node build
1514
+ ```
1515
+
1516
+ For Caddy, it just works - Caddy proxies WebSocket upgrades automatically, no special config needed:
1517
+
1518
+ ```
1519
+ example.com {
1520
+ reverse_proxy localhost:3000
1521
+ }
1522
+ ```
1523
+
1524
+ ### "I want to use a different WebSocket path"
1525
+
1526
+ Set it in both the adapter config and the client:
1527
+
1528
+ **svelte.config.js**
1529
+ ```js
1530
+ adapter({
1531
+ websocket: {
1532
+ path: '/my-ws'
1533
+ }
1534
+ })
1535
+ ```
1536
+
1537
+ **Client**
1538
+ ```js
1539
+ import { connect } from 'svelte-adapter-uws/client';
1540
+ connect({ path: '/my-ws' });
1541
+ ```
1542
+
1543
+ Or if you're using `on()` directly (which auto-connects), call `connect()` first:
1544
+
1545
+ ```svelte
1546
+ <script>
1547
+ import { connect, on } from 'svelte-adapter-uws/client';
1548
+
1549
+ // Set the path before any on() calls
1550
+ connect({ path: '/my-ws' });
1551
+
1552
+ const todos = on('todos');
1553
+ </script>
1554
+ ```
1555
+
1556
+ ---
1557
+
1558
+ ## License
1559
+
1560
+ [MIT](LICENSE)