svelte-adapter-uws-extensions 0.4.2 → 0.5.0-next.2

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.
Files changed (58) hide show
  1. package/README.md +2744 -1045
  2. package/package.json +57 -4
  3. package/postgres/_tasks-errors.js +76 -0
  4. package/postgres/_tasks-sql.js +221 -0
  5. package/postgres/_tasks-worker-pool.js +183 -0
  6. package/postgres/_worker-harness.js +82 -0
  7. package/postgres/idempotency.d.ts +41 -0
  8. package/postgres/idempotency.js +265 -0
  9. package/postgres/jobs.d.ts +99 -0
  10. package/postgres/jobs.js +287 -0
  11. package/postgres/notify.d.ts +31 -0
  12. package/postgres/notify.js +345 -242
  13. package/postgres/replay.d.ts +48 -1
  14. package/postgres/replay.js +88 -51
  15. package/postgres/tasks.d.ts +197 -0
  16. package/postgres/tasks.js +639 -0
  17. package/prometheus/index.d.ts +81 -0
  18. package/prometheus/index.js +195 -0
  19. package/redis/cursor.js +654 -695
  20. package/redis/fence.d.ts +31 -0
  21. package/redis/fence.js +125 -0
  22. package/redis/functions.d.ts +50 -0
  23. package/redis/functions.js +136 -0
  24. package/redis/groups.js +30 -36
  25. package/redis/idempotency.d.ts +58 -0
  26. package/redis/idempotency.js +182 -0
  27. package/redis/lock.d.ts +123 -0
  28. package/redis/lock.js +271 -0
  29. package/redis/presence.d.ts +49 -0
  30. package/redis/presence.js +520 -389
  31. package/redis/publish-rate.d.ts +116 -0
  32. package/redis/publish-rate.js +327 -0
  33. package/redis/pubsub.d.ts +30 -2
  34. package/redis/pubsub.js +124 -17
  35. package/redis/ratelimit.js +10 -57
  36. package/redis/registry.d.ts +220 -0
  37. package/redis/registry.js +1016 -0
  38. package/redis/replay-stream.js +366 -0
  39. package/redis/replay.d.ts +137 -0
  40. package/redis/replay.js +116 -69
  41. package/redis/session.d.ts +83 -0
  42. package/redis/session.js +202 -0
  43. package/redis/sharded-pubsub.d.ts +139 -0
  44. package/redis/sharded-pubsub.js +555 -0
  45. package/shared/admission.d.ts +87 -0
  46. package/shared/admission.js +169 -0
  47. package/shared/breaker.d.ts +8 -0
  48. package/shared/breaker.js +174 -160
  49. package/shared/pg-migrate.js +18 -0
  50. package/shared/redis-scan.js +21 -0
  51. package/shared/redis-version.js +15 -0
  52. package/shared/replay-helpers.js +105 -0
  53. package/shared/sensitive.js +79 -0
  54. package/testing/index.d.ts +22 -1
  55. package/testing/index.js +22 -1
  56. package/testing/mock-pg.js +544 -11
  57. package/testing/mock-platform.js +48 -0
  58. package/testing/mock-redis.js +432 -12
package/README.md CHANGED
@@ -1,1045 +1,2744 @@
1
- # svelte-adapter-uws-extensions
2
-
3
- Redis and Postgres extensions for [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws).
4
-
5
- The core adapter keeps everything in-process memory. That works great for single-server deployments, but the moment you scale to multiple instances you need shared state. This package provides drop-in replacements backed by Redis and Postgres, with the same API shapes you already know from the core plugins.
6
-
7
- ## What you get
8
-
9
- - **Distributed pub/sub** - `platform.publish()` reaches all instances, not just the local one
10
- - **Persistent replay buffers** - messages survive restarts, backed by Redis sorted sets or a Postgres table
11
- - **Cross-instance presence** - who's online across your entire fleet, with multi-tab dedup
12
- - **Distributed rate limiting** - token bucket enforced across all instances via atomic Lua script
13
- - **Distributed broadcast groups** - named groups with membership and roles that span instances
14
- - **Shared cursor state** - ephemeral positions (cursors, selections, drawing strokes) visible across instances
15
- - **Database change notifications** - Postgres LISTEN/NOTIFY forwarded straight to WebSocket clients
16
- - **Prometheus metrics** - expose extension metrics for scraping, zero overhead when disabled
17
-
18
- ---
19
-
20
- ## Table of contents
21
-
22
- **Getting started**
23
- - [Installation](#installation)
24
-
25
- **Clients**
26
- - [Redis client](#redis-client)
27
- - [Postgres client](#postgres-client)
28
-
29
- **Redis extensions**
30
- - [Pub/sub bus](#pubsub-bus)
31
- - [Replay buffer (Redis)](#replay-buffer-redis)
32
- - [Presence](#presence)
33
- - [Rate limiting](#rate-limiting)
34
- - [Broadcast groups](#broadcast-groups)
35
- - [Cursor](#cursor)
36
-
37
- **Postgres extensions**
38
- - [Replay buffer (Postgres)](#replay-buffer-postgres)
39
- - [LISTEN/NOTIFY bridge](#listennotify-bridge)
40
-
41
- **Observability**
42
- - [Prometheus metrics](#prometheus-metrics)
43
-
44
- **Reliability**
45
- - [Failure handling](#failure-handling)
46
- - [Circuit breaker](#circuit-breaker)
47
-
48
- **Operations**
49
- - [Graceful shutdown](#graceful-shutdown)
50
- - [Testing](#testing)
51
-
52
- **More**
53
- - [Related projects](#related-projects)
54
- - [License](#license)
55
-
56
- ---
57
-
58
- **Getting started**
59
-
60
- ## Installation
61
-
62
- ```bash
63
- npm install svelte-adapter-uws-extensions ioredis
64
- ```
65
-
66
- Postgres support is optional:
67
-
68
- ```bash
69
- npm install pg
70
- ```
71
-
72
- Requires `svelte-adapter-uws >= 0.2.0` as a peer dependency.
73
-
74
- ---
75
-
76
- **Clients**
77
-
78
- ## Redis client
79
-
80
- Factory that wraps [ioredis](https://github.com/redis/ioredis) with lifecycle management. All Redis extensions accept this client.
81
-
82
- ```js
83
- // src/lib/server/redis.js
84
- import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
85
-
86
- export const redis = createRedisClient({
87
- url: 'redis://localhost:6379',
88
- keyPrefix: 'myapp:' // optional, prefixes all keys
89
- });
90
- ```
91
-
92
- #### Options
93
-
94
- | Option | Default | Description |
95
- |---|---|---|
96
- | `url` | `'redis://localhost:6379'` | Redis connection URL |
97
- | `keyPrefix` | `''` | Prefix for all keys |
98
- | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
99
- | `options` | `{}` | Extra ioredis options |
100
-
101
- #### API
102
-
103
- | Method | Description |
104
- |---|---|
105
- | `redis.redis` | The underlying ioredis instance |
106
- | `redis.key(k)` | Returns `keyPrefix + k` |
107
- | `redis.duplicate(overrides?)` | New connection with same config. Pass ioredis options to override defaults. |
108
- | `redis.quit()` | Gracefully disconnect all connections |
109
-
110
- ---
111
-
112
- ## Postgres client
113
-
114
- Factory that wraps [pg](https://github.com/brianc/node-postgres) Pool with lifecycle management.
115
-
116
- ```js
117
- // src/lib/server/pg.js
118
- import { createPgClient } from 'svelte-adapter-uws-extensions/postgres';
119
-
120
- export const pg = createPgClient({
121
- connectionString: 'postgres://localhost:5432/mydb'
122
- });
123
- ```
124
-
125
- #### Options
126
-
127
- | Option | Default | Description |
128
- |---|---|---|
129
- | `connectionString` | *required* | Postgres connection string |
130
- | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
131
- | `options` | `{}` | Extra pg Pool options |
132
-
133
- #### API
134
-
135
- | Method | Description |
136
- |---|---|
137
- | `pg.pool` | The underlying pg Pool |
138
- | `pg.query(text, values?)` | Run a query |
139
- | `pg.createClient()` | New standalone pg.Client with same config (not from the pool) |
140
- | `pg.end()` | Gracefully close the pool |
141
-
142
- ---
143
-
144
- **Redis extensions**
145
-
146
- ## Pub/sub bus
147
-
148
- Distributes `platform.publish()` calls across multiple server instances via Redis pub/sub. Each instance publishes locally AND to Redis. Incoming Redis messages are forwarded to the local platform with echo suppression (messages originating from the same instance are dropped on receive, keyed by a per-process instance ID).
149
-
150
- Multiple `publish()` calls within the same event-loop tick are coalesced into a single Redis pipeline via microtask batching. This means a form action that publishes to three topics results in one pipelined round trip, not three independent commands.
151
-
152
- #### Setup
153
-
154
- ```js
155
- // src/lib/server/bus.js
156
- import { redis } from './redis.js';
157
- import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
158
-
159
- export const bus = createPubSubBus(redis);
160
- ```
161
-
162
- #### Usage
163
-
164
- ```js
165
- // src/hooks.ws.js
166
- import { bus } from '$lib/server/bus';
167
-
168
- let distributed;
169
-
170
- export function open(ws, { platform }) {
171
- // Start subscriber (idempotent, only subscribes once)
172
- bus.activate(platform);
173
- // Get a wrapped platform that publishes to Redis + local
174
- distributed = bus.wrap(platform);
175
- }
176
-
177
- export function message(ws, { data, platform }) {
178
- const msg = JSON.parse(Buffer.from(data).toString());
179
- // This publish reaches local clients AND all other instances
180
- distributed.publish('chat', 'message', msg);
181
- }
182
- ```
183
-
184
- #### Options
185
-
186
- | Option | Default | Description |
187
- |---|---|---|
188
- | `channel` | `'uws:pubsub'` | Redis channel name |
189
-
190
- #### API
191
-
192
- | Method | Description |
193
- |---|---|
194
- | `bus.wrap(platform)` | Returns a new Platform whose `publish()` sends to Redis + local |
195
- | `bus.activate(platform)` | Start the Redis subscriber (idempotent) |
196
- | `bus.deactivate()` | Stop the subscriber |
197
-
198
- ---
199
-
200
- ## Replay buffer (Redis)
201
-
202
- Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
203
-
204
- Sequence numbers are incremented atomically via a Lua script (`INCR` + `ZADD` + trim in a single `EVAL`), so concurrent publishes from multiple instances produce strictly ordered, gap-free sequences per topic. When the buffer exceeds `size`, the oldest entries are removed inside the same Lua script -- no second round trip required.
205
-
206
- When a client requests replay, the buffer checks whether the client's last-seen sequence is older than the oldest buffered entry. If it is (the buffer was trimmed past the client's position), a `truncated` event fires on `__replay:{topic}` before any `msg` events, so the client knows it missed messages and can do a full reload. This also fires when the buffer is completely empty but the sequence counter has advanced past the client's position (e.g. all entries expired via TTL).
207
-
208
- #### Setup
209
-
210
- ```js
211
- // src/lib/server/replay.js
212
- import { redis } from './redis.js';
213
- import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
214
-
215
- export const replay = createReplay(redis, {
216
- size: 500,
217
- ttl: 3600 // expire after 1 hour
218
- });
219
- ```
220
-
221
- #### Usage
222
-
223
- ```js
224
- // In a form action or API route
225
- export const actions = {
226
- send: async ({ request, platform }) => {
227
- const data = Object.fromEntries(await request.formData());
228
- const msg = await db.createMessage(data);
229
- await replay.publish(platform, 'chat', 'created', msg);
230
- }
231
- };
232
- ```
233
-
234
- ```js
235
- // In +page.server.js
236
- export async function load() {
237
- const messages = await db.getRecentMessages();
238
- return { messages, seq: await replay.seq('chat') };
239
- }
240
- ```
241
-
242
- ```js
243
- // In hooks.ws.js - handle replay requests
244
- export async function message(ws, { data, platform }) {
245
- const msg = JSON.parse(Buffer.from(data).toString());
246
- if (msg.type === 'replay') {
247
- await replay.replay(ws, msg.topic, msg.since, platform);
248
- return;
249
- }
250
- }
251
- ```
252
-
253
- #### Options
254
-
255
- | Option | Default | Description |
256
- |---|---|---|
257
- | `size` | `1000` | Max messages per topic |
258
- | `ttl` | `0` | Key expiry in seconds (0 = never) |
259
-
260
- #### API
261
-
262
- All methods are async (they hit Redis). The API otherwise matches the core plugin exactly:
263
-
264
- | Method | Description |
265
- |---|---|
266
- | `publish(platform, topic, event, data)` | Store + broadcast |
267
- | `seq(topic)` | Current sequence number |
268
- | `since(topic, seq)` | Messages after a sequence |
269
- | `replay(ws, topic, sinceSeq, platform)` | Send missed messages to one client |
270
- | `clear()` | Delete all replay data |
271
- | `clearTopic(topic)` | Delete replay data for one topic |
272
-
273
- ---
274
-
275
- ## Presence
276
-
277
- Same API as the core `createPresence` plugin, but backed by Redis hashes. Presence state is shared across instances with cross-instance join/leave notifications via Redis pub/sub.
278
-
279
- Joins are staged with full rollback on failure: local state is set up first, then the Redis hash field is written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone -- local maps, the Redis field, and any broadcast join event are reversed. This prevents ghost entries that would show a user as online when they never fully connected.
280
-
281
- Leaves use an atomic Lua script (`LEAVE_SCRIPT`) that removes this instance's field from the hash and then scans remaining fields for the same user key, ignoring stale entries. Leave is only broadcast when no other instance holds a live entry for that user, preventing premature "user left" notifications in multi-instance deployments.
282
-
283
- Zombie cleanup runs on the heartbeat interval. Each tick, every tracked WebSocket is probed via `getBufferedAmount()` -- if the call throws, the socket is dead and its presence is removed synchronously before the heartbeat writes to Redis. The heartbeat then refreshes timestamps on all live entries via a Redis pipeline and runs a server-side Lua cleanup script (`CLEANUP_SCRIPT`) that scans the hash and removes any fields whose timestamp exceeds the TTL. This handles crashed instances whose close handlers never fired.
284
-
285
- #### Setup
286
-
287
- ```js
288
- // src/lib/server/presence.js
289
- import { redis } from './redis.js';
290
- import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
291
-
292
- export const presence = createPresence(redis, {
293
- key: 'id',
294
- select: (userData) => ({ id: userData.id, name: userData.name }),
295
- heartbeat: 30000,
296
- ttl: 90
297
- });
298
- ```
299
-
300
- #### Usage
301
-
302
- ```js
303
- // src/hooks.ws.js
304
- import { presence } from '$lib/server/presence';
305
-
306
- export async function subscribe(ws, topic, { platform }) {
307
- await presence.join(ws, topic, platform);
308
- }
309
-
310
- export async function close(ws, { platform }) {
311
- await presence.leave(ws, platform);
312
- }
313
- ```
314
-
315
- #### Options
316
-
317
- | Option | Default | Description |
318
- |---|---|---|
319
- | `key` | `'id'` | Field for user dedup (multi-tab) |
320
- | `select` | strips `__`-prefixed keys | Extract public fields from userData |
321
- | `heartbeat` | `30000` | TTL refresh interval in ms |
322
- | `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
323
-
324
- #### API
325
-
326
- | Method | Description |
327
- |---|---|
328
- | `join(ws, topic, platform)` | Add connection to presence |
329
- | `leave(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
330
- | `sync(ws, topic, platform)` | Send list without joining |
331
- | `list(topic)` | Get current users |
332
- | `count(topic)` | Count unique users |
333
- | `clear()` | Reset all presence state |
334
- | `destroy()` | Stop heartbeat and subscriber |
335
- | `hooks` | `{ subscribe, close }` -- ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
336
-
337
- #### Zero-config hooks
338
-
339
- Instead of writing `subscribe` and `close` handlers manually, destructure `presence.hooks`:
340
-
341
- ```js
342
- // src/hooks.ws.js
343
- import { presence } from '$lib/server/presence';
344
- export const { subscribe, close } = presence.hooks;
345
- ```
346
-
347
- `subscribe` handles both regular topics (calls `join`) and `__presence:*` topics (calls `sync` so the client gets the current list). `close` calls `leave`.
348
-
349
- If you need custom logic (auth gating, logging), wrap the hooks:
350
-
351
- ```js
352
- import { presence } from '$lib/server/presence';
353
-
354
- export async function subscribe(ws, topic, ctx) {
355
- if (!ctx.platform.getUserData(ws).authenticated) return;
356
- await presence.hooks.subscribe(ws, topic, ctx);
357
- }
358
-
359
- export const { close } = presence.hooks;
360
- ```
361
-
362
- ---
363
-
364
- ## Rate limiting
365
-
366
- Same API as the core `createRateLimit` plugin, but backed by Redis using an atomic Lua script. Rate limits are enforced across all server instances with exactly one Redis roundtrip per `consume()` call.
367
-
368
- #### Setup
369
-
370
- ```js
371
- // src/lib/server/ratelimit.js
372
- import { redis } from './redis.js';
373
- import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
374
-
375
- export const limiter = createRateLimit(redis, {
376
- points: 10,
377
- interval: 1000,
378
- blockDuration: 30000
379
- });
380
- ```
381
-
382
- #### Usage
383
-
384
- ```js
385
- // src/hooks.ws.js
386
- import { limiter } from '$lib/server/ratelimit';
387
-
388
- export async function message(ws, { data, platform }) {
389
- const { allowed } = await limiter.consume(ws);
390
- if (!allowed) return; // drop the message
391
- // ... handle message
392
- }
393
- ```
394
-
395
- #### Options
396
-
397
- | Option | Default | Description |
398
- |---|---|---|
399
- | `points` | *required* | Tokens available per interval |
400
- | `interval` | *required* | Refill interval in ms |
401
- | `blockDuration` | `0` | Auto-ban duration in ms (0 = no ban) |
402
- | `keyBy` | `'ip'` | `'ip'`, `'connection'`, or a function |
403
-
404
- #### API
405
-
406
- All methods are async (they hit Redis). The API otherwise matches the core plugin:
407
-
408
- | Method | Description |
409
- |---|---|
410
- | `consume(ws, cost?)` | Attempt to consume tokens. `cost` must be a positive integer. |
411
- | `reset(key)` | Clear the bucket for a key |
412
- | `ban(key, duration?)` | Manually ban a key |
413
- | `unban(key)` | Remove a ban |
414
- | `clear()` | Reset all state |
415
-
416
- ---
417
-
418
- ## Broadcast groups
419
-
420
- Same API as the core `createGroup` plugin, but membership is stored in Redis so groups work across multiple server instances. Local WebSocket tracking is maintained per-instance, and cross-instance events are relayed via Redis pub/sub.
421
-
422
- #### Setup
423
-
424
- ```js
425
- // src/lib/server/lobby.js
426
- import { redis } from './redis.js';
427
- import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
428
-
429
- export const lobby = createGroup(redis, 'lobby', {
430
- maxMembers: 50,
431
- meta: { game: 'chess' }
432
- });
433
- ```
434
-
435
- Note: the API signature is `createGroup(client, name, options)` instead of `createGroup(name, options)` -- the Redis client is the first argument.
436
-
437
- #### Usage
438
-
439
- ```js
440
- // src/hooks.ws.js
441
- import { lobby } from '$lib/server/lobby';
442
-
443
- export async function subscribe(ws, topic, { platform }) {
444
- if (topic === 'lobby') await lobby.join(ws, platform);
445
- }
446
-
447
- export async function close(ws, { platform }) {
448
- await lobby.leave(ws, platform);
449
- }
450
- ```
451
-
452
- #### Options
453
-
454
- | Option | Default | Description |
455
- |---|---|---|
456
- | `maxMembers` | `Infinity` | Maximum members allowed (enforced atomically) |
457
- | `meta` | `{}` | Initial group metadata |
458
- | `memberTtl` | `120` | Member entry TTL in seconds. Entries from crashed instances expire after this period. |
459
- | `onJoin` | - | Called after a member joins |
460
- | `onLeave` | - | Called after a member leaves |
461
- | `onFull` | - | Called when a join is rejected (full) |
462
- | `onClose` | - | Called when the group is closed |
463
-
464
- #### API
465
-
466
- | Method | Description |
467
- |---|---|
468
- | `join(ws, platform, role?)` | Add a member (returns false if full/closed) |
469
- | `leave(ws, platform)` | Remove a member |
470
- | `publish(platform, event, data, role?)` | Broadcast to all or filter by role |
471
- | `send(platform, ws, event, data)` | Send to a single member |
472
- | `localMembers()` | Members on this instance |
473
- | `count()` | Total members across all instances |
474
- | `has(ws)` | Check membership on this instance |
475
- | `getMeta()` / `setMeta(meta)` | Read/write group metadata |
476
- | `close(platform)` | Dissolve the group |
477
- | `destroy()` | Stop the Redis subscriber |
478
-
479
- ---
480
-
481
- ## Cursor
482
-
483
- Same API as the core `createCursor` plugin, but cursor positions are shared across instances via Redis. Each instance throttles locally (same leading/trailing edge logic as the core), then relays broadcasts through Redis pub/sub so subscribers on other instances see cursor updates.
484
-
485
- Hash entries have a TTL so stale cursors from crashed instances get cleaned up automatically.
486
-
487
- #### Setup
488
-
489
- ```js
490
- // src/lib/server/cursors.js
491
- import { redis } from './redis.js';
492
- import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
493
-
494
- export const cursors = createCursor(redis, {
495
- throttle: 50,
496
- select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color }),
497
- ttl: 30
498
- });
499
- ```
500
-
501
- #### Usage
502
-
503
- ```js
504
- // src/hooks.ws.js
505
- import { cursors } from '$lib/server/cursors';
506
-
507
- export function message(ws, { data, platform }) {
508
- const msg = JSON.parse(Buffer.from(data).toString());
509
- if (msg.type === 'cursor') {
510
- cursors.update(ws, msg.topic, msg.position, platform);
511
- }
512
- }
513
-
514
- export function close(ws, { platform }) {
515
- cursors.remove(ws, platform);
516
- }
517
- ```
518
-
519
- #### Options
520
-
521
- | Option | Default | Description |
522
- |---|---|---|
523
- | `throttle` | `50` | Minimum ms between broadcasts per user per topic |
524
- | `select` | strips `__`-prefixed keys | Extract user data to broadcast alongside position |
525
- | `ttl` | `30` | Per-entry TTL in seconds (auto-refreshed on each broadcast). Stale entries from crashed instances are filtered out individually, even if other instances are still active on the same topic. |
526
-
527
- #### API
528
-
529
- | Method | Description |
530
- |---|---|
531
- | `update(ws, topic, data, platform)` | Broadcast cursor position (throttled per user per topic) |
532
- | `remove(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
533
- | `list(topic)` | Get current positions across all instances |
534
- | `clear()` | Reset all local and Redis state |
535
- | `destroy()` | Stop the Redis subscriber and clear timers |
536
-
537
- ---
538
-
539
- **Postgres extensions**
540
-
541
- ## Replay buffer (Postgres)
542
-
543
- Same API as the Redis replay buffer, but backed by a Postgres table. Best suited for durable audit trails or history that needs to survive longer than Redis TTLs. Sequence numbers are generated atomically via a dedicated `_seq` table using `INSERT ... ON CONFLICT DO UPDATE`, so concurrent publishes from multiple instances produce strictly ordered sequences with no duplicates or gaps.
544
-
545
- Buffer trimming runs after each publish by deleting rows with `seq <= currentSeq - maxSize`. If the trim query fails, the publish still succeeds -- the periodic background cleanup (configurable via `cleanupInterval`) catches any excess rows later.
546
-
547
- Same gap detection behavior as the Redis replay buffer: if the client's last-seen sequence is older than the oldest buffered row, or the buffer is empty but the sequence counter has advanced, a `truncated` event fires before replay.
548
-
549
- #### Setup
550
-
551
- ```js
552
- // src/lib/server/replay.js
553
- import { pg } from './pg.js';
554
- import { createReplay } from 'svelte-adapter-uws-extensions/postgres/replay';
555
-
556
- export const replay = createReplay(pg, {
557
- table: 'ws_replay',
558
- size: 1000,
559
- ttl: 86400, // 24 hours
560
- autoMigrate: true // auto-create table
561
- });
562
- ```
563
-
564
- #### Schema
565
-
566
- The table is created automatically on first use (if `autoMigrate` is true):
567
-
568
- ```sql
569
- CREATE TABLE IF NOT EXISTS ws_replay (
570
- id BIGSERIAL PRIMARY KEY,
571
- topic TEXT NOT NULL,
572
- seq BIGINT NOT NULL,
573
- event TEXT NOT NULL,
574
- data JSONB,
575
- created_at TIMESTAMPTZ DEFAULT now()
576
- );
577
- CREATE INDEX IF NOT EXISTS idx_ws_replay_topic_seq ON ws_replay (topic, seq);
578
-
579
- CREATE TABLE IF NOT EXISTS ws_replay_seq (
580
- topic TEXT PRIMARY KEY,
581
- seq BIGINT NOT NULL DEFAULT 0
582
- );
583
- ```
584
-
585
- #### Options
586
-
587
- | Option | Default | Description |
588
- |---|---|---|
589
- | `table` | `'ws_replay'` | Table name |
590
- | `size` | `1000` | Max messages per topic |
591
- | `ttl` | `0` | Row expiry in seconds (0 = never) |
592
- | `autoMigrate` | `true` | Auto-create table |
593
- | `cleanupInterval` | `60000` | Periodic cleanup interval in ms (0 to disable) |
594
-
595
- #### API
596
-
597
- Same as [Replay buffer (Redis)](#api-3), plus:
598
-
599
- | Method | Description |
600
- |---|---|
601
- | `destroy()` | Stop the cleanup timer |
602
-
603
- ---
604
-
605
- ## LISTEN/NOTIFY bridge
606
-
607
- Listens on a Postgres channel for notifications and forwards them to `platform.publish()`. You provide the trigger on your table -- this module handles the listening side.
608
-
609
- Uses a standalone connection (not from the pool) since LISTEN requires a persistent connection that stays open for the lifetime of the bridge.
610
-
611
- #### Setup
612
-
613
- ```js
614
- // src/lib/server/notify.js
615
- import { pg } from './pg.js';
616
- import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
617
-
618
- export const bridge = createNotifyBridge(pg, {
619
- channel: 'table_changes',
620
- parse: (payload) => {
621
- const row = JSON.parse(payload);
622
- return { topic: row.table, event: row.op, data: row.data };
623
- }
624
- });
625
- ```
626
-
627
- #### Usage
628
-
629
- ```js
630
- // src/hooks.ws.js
631
- import { bridge } from '$lib/server/notify';
632
-
633
- let activated = false;
634
-
635
- export function open(ws, { platform }) {
636
- if (!activated) {
637
- activated = true;
638
- bridge.activate(platform);
639
- }
640
- }
641
- ```
642
-
643
- #### Setting up the trigger
644
-
645
- Create a trigger function and attach it to your table:
646
-
647
- ```sql
648
- CREATE OR REPLACE FUNCTION notify_table_change() RETURNS trigger AS $$
649
- BEGIN
650
- PERFORM pg_notify('table_changes', json_build_object(
651
- 'table', TG_TABLE_NAME,
652
- 'op', lower(TG_OP),
653
- 'data', CASE TG_OP
654
- WHEN 'DELETE' THEN row_to_json(OLD)
655
- ELSE row_to_json(NEW)
656
- END
657
- )::text);
658
- RETURN COALESCE(NEW, OLD);
659
- END;
660
- $$ LANGUAGE plpgsql;
661
-
662
- CREATE TRIGGER messages_notify
663
- AFTER INSERT OR UPDATE OR DELETE ON messages
664
- FOR EACH ROW EXECUTE FUNCTION notify_table_change();
665
- ```
666
-
667
- Now any INSERT, UPDATE, or DELETE on the `messages` table will fire a notification. The bridge parses it and calls `platform.publish()`, which reaches all connected WebSocket clients subscribed to the topic.
668
-
669
- The client side needs no changes -- the core `crud('messages')` store already handles `created`, `updated`, and `deleted` events.
670
-
671
- #### Options
672
-
673
- | Option | Default | Description |
674
- |---|---|---|
675
- | `channel` | *required* | Postgres LISTEN channel name |
676
- | `parse` | JSON with `{ topic, event, data }` | Parse notification payload into a publish call. Return null to skip. |
677
- | `autoReconnect` | `true` | Reconnect on connection loss |
678
- | `reconnectInterval` | `3000` | ms between reconnect attempts |
679
-
680
- #### API
681
-
682
- | Method | Description |
683
- |---|---|
684
- | `activate(platform)` | Start listening (idempotent) |
685
- | `deactivate()` | Stop listening and release the connection |
686
-
687
- #### Limitations
688
-
689
- - **Payload is hard-limited to ~8000 bytes by Postgres** (`pg_notify` silently truncates or errors above this). This is a Postgres constraint, not a library limitation. The bridge warns at 7500 bytes. For large rows, send the row ID in the notification and let the client fetch the full row via an API call.
690
- - Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
691
- - This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
692
-
693
- #### When to use this instead of Redis pub/sub
694
-
695
- If your real-time events are driven by database writes and you do not need Redis for other extensions (presence, rate limiting, groups, cursors), the LISTEN/NOTIFY bridge is a simpler deployment: no Redis infrastructure, no separate pub/sub channel management, and your notifications are inherently tied to committed transactions. Use the Redis pub/sub bus when you need to broadcast events that do not originate from database writes, or when you are already running Redis for other extensions.
696
-
697
- ---
698
-
699
- **Observability**
700
-
701
- ## Prometheus metrics
702
-
703
- Exposes extension metrics in Prometheus text exposition format. No external dependencies. Zero overhead when not enabled -- every metric call uses optional chaining on a nullish reference, so V8 short-circuits on a single pointer check.
704
-
705
- #### Setup
706
-
707
- ```js
708
- // src/lib/server/metrics.js
709
- import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
710
-
711
- export const metrics = createMetrics({
712
- prefix: 'myapp_',
713
- mapTopic: (topic) => topic.startsWith('room:') ? 'room:*' : topic
714
- });
715
- ```
716
-
717
- Pass the `metrics` object to any extension via its options:
718
-
719
- ```js
720
- import { metrics } from './metrics.js';
721
- import { redis } from './redis.js';
722
- import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
723
- import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
724
- import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
725
- import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
726
- import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
727
- import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
728
-
729
- export const bus = createPubSubBus(redis, { metrics });
730
- export const presence = createPresence(redis, { metrics, key: 'id' });
731
- export const replay = createReplay(redis, { metrics });
732
- export const limiter = createRateLimit(redis, { points: 10, interval: 1000, metrics });
733
- export const lobby = createGroup(redis, 'lobby', { metrics });
734
- export const cursors = createCursor(redis, { metrics });
735
- ```
736
-
737
- #### Mounting the endpoint
738
-
739
- With uWebSockets.js:
740
-
741
- ```js
742
- app.get('/metrics', metrics.handler);
743
- ```
744
-
745
- Or use `metrics.serialize()` to get the raw text and serve it however you like.
746
-
747
- #### Options
748
-
749
- | Option | Default | Description |
750
- |---|---|---|
751
- | `prefix` | `''` | Prefix for all metric names |
752
- | `mapTopic` | identity | Map topic names to bounded label values for cardinality control |
753
- | `defaultBuckets` | `[1, 5, 10, 25, 50, 100, 250, 500, 1000]` | Default histogram buckets |
754
-
755
- Metric names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` and label names must match `[a-zA-Z_][a-zA-Z0-9_]*` (no `__` prefix). Invalid names throw at registration time. HELP text containing backslashes or newlines is escaped automatically.
756
-
757
- #### Cardinality control
758
-
759
- If your topics are user-generated (e.g. `room:abc123`), per-topic labels will grow unbounded. Use `mapTopic` to collapse them:
760
-
761
- ```js
762
- const metrics = createMetrics({
763
- mapTopic: (topic) => {
764
- if (topic.startsWith('room:')) return 'room:*';
765
- if (topic.startsWith('user:')) return 'user:*';
766
- return topic;
767
- }
768
- });
769
- ```
770
-
771
- #### Metrics reference
772
-
773
- **Pub/sub bus**
774
-
775
- | Metric | Type | Description |
776
- |---|---|---|
777
- | `pubsub_messages_relayed_total` | counter | Messages relayed to Redis |
778
- | `pubsub_messages_received_total` | counter | Messages received from Redis |
779
- | `pubsub_echo_suppressed_total` | counter | Messages dropped by echo suppression |
780
- | `pubsub_relay_batch_size` | histogram | Relay batch size per flush |
781
-
782
- **Presence**
783
-
784
- | Metric | Type | Labels | Description |
785
- |---|---|---|---|
786
- | `presence_joins_total` | counter | `topic` | Join events |
787
- | `presence_leaves_total` | counter | `topic` | Leave events |
788
- | `presence_heartbeats_total` | counter | | Heartbeat refresh cycles |
789
- | `presence_stale_cleaned_total` | counter | | Stale entries removed by cleanup |
790
-
791
- **Replay buffer (Redis and Postgres)**
792
-
793
- | Metric | Type | Labels | Description |
794
- |---|---|---|---|
795
- | `replay_publishes_total` | counter | `topic` | Messages published |
796
- | `replay_messages_replayed_total` | counter | `topic` | Messages replayed to clients |
797
- | `replay_truncations_total` | counter | `topic` | Truncation events detected |
798
-
799
- **Rate limiting**
800
-
801
- | Metric | Type | Description |
802
- |---|---|---|
803
- | `ratelimit_allowed_total` | counter | Requests allowed |
804
- | `ratelimit_denied_total` | counter | Requests denied |
805
- | `ratelimit_bans_total` | counter | Bans applied |
806
-
807
- **Broadcast groups**
808
-
809
- | Metric | Type | Labels | Description |
810
- |---|---|---|---|
811
- | `group_joins_total` | counter | `group` | Join events |
812
- | `group_joins_rejected_total` | counter | `group` | Joins rejected (full) |
813
- | `group_leaves_total` | counter | `group` | Leave events |
814
- | `group_publishes_total` | counter | `group` | Publish events |
815
-
816
- **Cursor**
817
-
818
- | Metric | Type | Labels | Description |
819
- |---|---|---|---|
820
- | `cursor_updates_total` | counter | `topic` | Cursor update calls |
821
- | `cursor_broadcasts_total` | counter | `topic` | Broadcasts actually sent |
822
- | `cursor_throttled_total` | counter | `topic` | Updates deferred by throttle |
823
-
824
- **LISTEN/NOTIFY bridge**
825
-
826
- | Metric | Type | Labels | Description |
827
- |---|---|---|---|
828
- | `notify_received_total` | counter | `channel` | Notifications received |
829
- | `notify_parse_errors_total` | counter | `channel` | Parse failures |
830
- | `notify_reconnects_total` | counter | | Reconnect attempts |
831
-
832
- ---
833
-
834
- **Reliability**
835
-
836
- ## Failure handling
837
-
838
- Every Redis and Postgres extension accepts an optional `breaker` option -- a shared [circuit breaker](#circuit-breaker) that tracks backend health across all extensions wired to it. When the breaker trips, each extension degrades differently depending on whether the operation is critical or best-effort:
839
-
840
- | Extension | Awaited operations (join, consume, publish) | Fire-and-forget operations |
841
- |---|---|---|
842
- | **Pub/sub bus** | `wrap().publish()` queues to local platform only; relay to Redis is skipped silently | Microtask relay flush is skipped entirely |
843
- | **Presence** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh and stale cleanup are skipped |
844
- | **Replay buffer** | `publish()` / `replay()` / `seq()` throw `CircuitBrokenError` | -- |
845
- | **Rate limiting** | `consume()` throws `CircuitBrokenError` (fail-closed -- requests are blocked, not allowed through) | -- |
846
- | **Broadcast groups** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh is skipped |
847
- | **Cursor** | -- | Hash writes and cross-instance relay are skipped; local throttle continues |
848
- | **LISTEN/NOTIFY** | `activate()` throws; auto-reconnect retries on its own interval | -- |
849
-
850
- The breaker is a three-state machine: **healthy** (all requests pass through) -> **broken** after N consecutive failures (all requests fail fast via `CircuitBrokenError`) -> **probing** after a timeout (one request is allowed through to test recovery) -> back to **healthy** on success. See [Circuit breaker](#circuit-breaker) for configuration.
851
-
852
- #### Notifying clients of degradation
853
-
854
- When Redis pub/sub fails, live streams on other replicas stop receiving updates. Connected clients continue showing stale data with no indication that the stream is degraded. Use the `onStateChange` callback to publish a system-level event so clients can surface this:
855
-
856
- ```js
857
- import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
858
-
859
- let distributed; // the bus.wrap(platform) reference
860
-
861
- export const breaker = createCircuitBreaker({
862
- failureThreshold: 5,
863
- resetTimeout: 30000,
864
- onStateChange: (from, to) => {
865
- if (!distributed) return;
866
- if (to === 'broken') {
867
- // Local-only publish -- Redis is down, but local clients still receive it
868
- distributed.publish('__system', 'degraded', { reason: 'backend unavailable' });
869
- } else if (from === 'broken' && to === 'healthy') {
870
- distributed.publish('__system', 'recovered', null);
871
- }
872
- }
873
- });
874
- ```
875
-
876
- On the client side, subscribe to `__system` and show a banner when the `degraded` event fires. On `recovered`, dismiss the banner and refetch stale data.
877
-
878
- ---
879
-
880
- ## Circuit breaker
881
-
882
- Prevents thundering herd when a backend goes down. When Redis or Postgres becomes unreachable, every extension that uses the breaker fails fast instead of queueing up timeouts, and fire-and-forget operations (heartbeats, relay flushes, cursor broadcasts) are skipped entirely.
883
-
884
- Three states:
885
- - **healthy** -- everything works, requests go through
886
- - **broken** -- too many failures, requests fail fast via `CircuitBrokenError`
887
- - **probing** -- one request is allowed through to test if the backend is back
888
-
889
- #### Setup
890
-
891
- ```js
892
- // src/lib/server/breaker.js
893
- import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
894
-
895
- export const breaker = createCircuitBreaker({
896
- failureThreshold: 5,
897
- resetTimeout: 30000,
898
- onStateChange: (from, to) => console.log(`circuit: ${from} -> ${to}`)
899
- });
900
- ```
901
-
902
- Pass the same breaker to all extensions that share a backend:
903
-
904
- ```js
905
- import { breaker } from './breaker.js';
906
-
907
- export const bus = createPubSubBus(redis, { breaker });
908
- export const presence = createPresence(redis, { breaker, key: 'id' });
909
- export const replay = createReplay(redis, { breaker });
910
- export const limiter = createRateLimit(redis, { points: 10, interval: 1000, breaker });
911
- ```
912
-
913
- Failures from any extension contribute to the same breaker. When one trips it, all others fail fast.
914
-
915
- #### Options
916
-
917
- | Option | Default | Description |
918
- |---|---|---|
919
- | `failureThreshold` | `5` | Consecutive failures before breaking |
920
- | `resetTimeout` | `30000` | Ms before transitioning from broken to probing |
921
- | `onStateChange` | - | Called on state transitions: `(from, to) => void` |
922
-
923
- #### API
924
-
925
- | Method / Property | Description |
926
- |---|---|
927
- | `breaker.state` | `'healthy'`, `'broken'`, or `'probing'` |
928
- | `breaker.isHealthy` | `true` only when state is `'healthy'` |
929
- | `breaker.failures` | Current consecutive failure count |
930
- | `breaker.guard()` | Throws `CircuitBrokenError` if the circuit is broken |
931
- | `breaker.success()` | Record a successful operation |
932
- | `breaker.failure()` | Record a failed operation |
933
- | `breaker.reset()` | Force back to healthy |
934
- | `breaker.destroy()` | Clear internal timers |
935
-
936
- #### How extensions use it
937
-
938
- Awaited operations (join, consume, publish) call `guard()` before the Redis/Postgres call, `success()` after, and `failure()` in the catch block. When the circuit is broken, `guard()` throws `CircuitBrokenError` and the operation never reaches the backend.
939
-
940
- Fire-and-forget operations (heartbeat refresh, relay flush, cursor broadcast) check `isHealthy` and skip entirely when the circuit is not healthy. This prevents piling up commands on a dead connection.
941
-
942
- #### Error handling
943
-
944
- ```js
945
- import { CircuitBrokenError } from 'svelte-adapter-uws-extensions/breaker';
946
-
947
- try {
948
- await replay.publish(platform, 'chat', 'msg', data);
949
- } catch (err) {
950
- if (err instanceof CircuitBrokenError) {
951
- // Backend is down -- degrade gracefully
952
- platform.publish('chat', 'msg', data); // local-only delivery
953
- }
954
- }
955
- ```
956
-
957
- ---
958
-
959
- **Operations**
960
-
961
- ## Graceful shutdown
962
-
963
- All clients listen for the `sveltekit:shutdown` event and disconnect cleanly by default. You can disable this with `autoShutdown: false` and manage the lifecycle yourself.
964
-
965
- ```js
966
- // Manual shutdown
967
- await redis.quit();
968
- await pg.end();
969
- presence.destroy();
970
- ```
971
-
972
- ---
973
-
974
- ## Testing
975
-
976
- ```bash
977
- npm test
978
- ```
979
-
980
- Tests use in-memory mocks for Redis and Postgres, no running services needed.
981
-
982
- ### Testing your own code
983
-
984
- The `svelte-adapter-uws-extensions/testing` entry point exports the same in-memory mocks used by the extensions' own test suite. Use them to test your extension-consuming code without running Redis or Postgres:
985
-
986
- ```js
987
- import { mockRedisClient, mockPlatform, mockWs } from 'svelte-adapter-uws-extensions/testing';
988
- import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
989
- import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
990
- import { describe, it, expect } from 'vitest';
991
-
992
- describe('presence', () => {
993
- it('tracks users across topics', async () => {
994
- const client = mockRedisClient();
995
- const platform = mockPlatform();
996
- const presence = createPresence(client, { key: 'id' });
997
-
998
- const ws = mockWs({ id: 'user-1', name: 'Alice' });
999
- await presence.join(ws, 'room:lobby', platform);
1000
-
1001
- expect(await presence.count('room:lobby')).toBe(1);
1002
- expect(platform.published.some(p => p.event === 'join')).toBe(true);
1003
-
1004
- presence.destroy();
1005
- });
1006
- });
1007
-
1008
- describe('rate limiting', () => {
1009
- it('blocks after exhausting points', async () => {
1010
- const client = mockRedisClient();
1011
- const limiter = createRateLimit(client, { points: 3, interval: 10000 });
1012
- const ws = mockWs({ remoteAddress: '1.2.3.4' });
1013
-
1014
- for (let i = 0; i < 3; i++) {
1015
- expect((await limiter.consume(ws)).allowed).toBe(true);
1016
- }
1017
- expect((await limiter.consume(ws)).allowed).toBe(false);
1018
- });
1019
- });
1020
- ```
1021
-
1022
- #### Available mocks
1023
-
1024
- | Export | What it mocks | Supports |
1025
- |---|---|---|
1026
- | `mockRedisClient(prefix?)` | `createRedisClient()` | Strings, hashes, sorted sets, pub/sub, pipelines, scan, Lua eval for all extension scripts |
1027
- | `mockPlatform()` | Platform API | `publish()`, `send()`, `batch()`, `topic()` -- records all calls in `.published` and `.sent` |
1028
- | `mockWs(userData?)` | uWS WebSocket | `subscribe()`, `unsubscribe()`, `getUserData()`, `getBufferedAmount()`, `close()` |
1029
- | `mockPgClient()` | `createPgClient()` | SQL parsing for replay buffer operations, sequence counters |
1030
-
1031
- The circuit breaker (`createCircuitBreaker()`) is pure logic with no I/O -- use it directly in tests, no mock needed.
1032
-
1033
- ---
1034
-
1035
- ## Related projects
1036
-
1037
- - [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws) -- The core adapter this package extends. Single-process WebSocket pub/sub, presence, replay, and more for SvelteKit on uWebSockets.js.
1038
- - [svelte-realtime](https://github.com/lanteanio/svelte-realtime) -- Opinionated full-stack starter built on the adapter. Auth, database, real-time CRUD, and deployment config out of the box.
1039
- - [svelte-realtime-demo](https://github.com/lanteanio/svelte-realtime-demo) -- Live demo of svelte-realtime. [Try it here.](https://svelte-realtime-demo.lantean.io/)
1040
-
1041
- ---
1042
-
1043
- ## License
1044
-
1045
- MIT
1
+ # svelte-adapter-uws-extensions
2
+
3
+ Redis and Postgres extensions for [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws).
4
+
5
+ The core adapter keeps everything in-process memory. That works great for single-server deployments, but the moment you scale to multiple instances you need shared state. This package provides drop-in replacements backed by Redis and Postgres, with the same API shapes you already know from the core plugins.
6
+
7
+ ## What you get
8
+
9
+ - **Distributed pub/sub** - `platform.publish()` reaches all instances, not just the local one
10
+ - **Persistent replay buffers** - messages survive restarts, backed by Redis sorted sets or a Postgres table
11
+ - **Cross-instance presence** - who's online across your entire fleet, with multi-tab dedup
12
+ - **Distributed rate limiting** - token bucket enforced across all instances via atomic Lua script
13
+ - **Distributed broadcast groups** - named groups with membership and roles that span instances
14
+ - **Shared cursor state** - ephemeral positions (cursors, selections, drawing strokes) visible across instances
15
+ - **Database change notifications** - Postgres LISTEN/NOTIFY forwarded straight to WebSocket clients
16
+ - **Prometheus metrics** - expose extension metrics for scraping, zero overhead when disabled
17
+
18
+ ---
19
+
20
+ ## Table of contents
21
+
22
+ **Getting started**
23
+ - [Installation](#installation)
24
+
25
+ **Clients**
26
+ - [Redis client](#redis-client)
27
+ - [Postgres client](#postgres-client)
28
+
29
+ **Redis extensions**
30
+ - [Pub/sub bus](#pubsub-bus)
31
+ - [Sharded pub/sub bus](#sharded-pubsub-bus)
32
+ - [Replay buffer (Redis)](#replay-buffer-redis)
33
+ - [Presence](#presence)
34
+ - [Rate limiting](#rate-limiting)
35
+ - [Broadcast groups](#broadcast-groups)
36
+ - [Cursor](#cursor)
37
+
38
+ **Postgres extensions**
39
+ - [Replay buffer (Postgres)](#replay-buffer-postgres)
40
+ - [LISTEN/NOTIFY bridge](#listennotify-bridge)
41
+ - [Job queue](#job-queue)
42
+
43
+ **Cross-backend**
44
+ - [Idempotency store](#idempotency-store)
45
+ - [Distributed lock](#distributed-lock)
46
+ - [Distributed session](#distributed-session)
47
+ - [Task runner](#task-runner)
48
+
49
+ **Observability**
50
+ - [Prometheus metrics](#prometheus-metrics)
51
+
52
+ **Reliability**
53
+ - [Failure handling](#failure-handling)
54
+ - [Circuit breaker](#circuit-breaker)
55
+ - [Admission control](#admission-control)
56
+ - [Redis Functions](#redis-functions)
57
+
58
+ **Operations**
59
+ - [Graceful shutdown](#graceful-shutdown)
60
+ - [Testing](#testing)
61
+
62
+ **More**
63
+ - [Related projects](#related-projects)
64
+ - [License](#license)
65
+
66
+ ---
67
+
68
+ **Getting started**
69
+
70
+ ## Installation
71
+
72
+ ```bash
73
+ npm install svelte-adapter-uws-extensions ioredis
74
+ ```
75
+
76
+ Postgres support is optional:
77
+
78
+ ```bash
79
+ npm install pg
80
+ ```
81
+
82
+ Requires `svelte-adapter-uws >= 0.2.0` as a peer dependency.
83
+
84
+ ---
85
+
86
+ **Clients**
87
+
88
+ ## Redis client
89
+
90
+ Factory that wraps [ioredis](https://github.com/redis/ioredis) with lifecycle management. All Redis extensions accept this client.
91
+
92
+ ```js
93
+ // src/lib/server/redis.js
94
+ import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
95
+
96
+ export const redis = createRedisClient({
97
+ url: 'redis://localhost:6379',
98
+ keyPrefix: 'myapp:' // optional, prefixes all keys
99
+ });
100
+ ```
101
+
102
+ #### Options
103
+
104
+ | Option | Default | Description |
105
+ |---|---|---|
106
+ | `url` | `'redis://localhost:6379'` | Redis connection URL |
107
+ | `keyPrefix` | `''` | Prefix for all keys |
108
+ | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
109
+ | `options` | `{}` | Extra ioredis options |
110
+
111
+ #### API
112
+
113
+ | Method | Description |
114
+ |---|---|
115
+ | `redis.redis` | The underlying ioredis instance |
116
+ | `redis.key(k)` | Returns `keyPrefix + k` |
117
+ | `redis.duplicate(overrides?)` | New connection with same config. Pass ioredis options to override defaults. |
118
+ | `redis.quit()` | Gracefully disconnect all connections |
119
+
120
+ ---
121
+
122
+ ## Postgres client
123
+
124
+ Factory that wraps [pg](https://github.com/brianc/node-postgres) Pool with lifecycle management.
125
+
126
+ ```js
127
+ // src/lib/server/pg.js
128
+ import { createPgClient } from 'svelte-adapter-uws-extensions/postgres';
129
+
130
+ export const pg = createPgClient({
131
+ connectionString: 'postgres://localhost:5432/mydb'
132
+ });
133
+ ```
134
+
135
+ #### Options
136
+
137
+ | Option | Default | Description |
138
+ |---|---|---|
139
+ | `connectionString` | *required* | Postgres connection string |
140
+ | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
141
+ | `options` | `{}` | Extra pg Pool options |
142
+
143
+ #### API
144
+
145
+ | Method | Description |
146
+ |---|---|
147
+ | `pg.pool` | The underlying pg Pool |
148
+ | `pg.query(text, values?)` | Run a query |
149
+ | `pg.createClient()` | New standalone pg.Client with same config (not from the pool) |
150
+ | `pg.end()` | Gracefully close the pool |
151
+
152
+ ---
153
+
154
+ **Redis extensions**
155
+
156
+ ## Pub/sub bus
157
+
158
+ Distributes `platform.publish()` calls across multiple server instances via Redis pub/sub. Each instance publishes locally AND to Redis. Incoming Redis messages are forwarded to the local platform with echo suppression (messages originating from the same instance are dropped on receive, keyed by a per-process instance ID).
159
+
160
+ Multiple `publish()` calls within the same event-loop tick are coalesced into a single Redis pipeline via microtask batching. This means a form action that publishes to three topics results in one pipelined round trip, not three independent commands.
161
+
162
+ #### Setup
163
+
164
+ ```js
165
+ // src/lib/server/bus.js
166
+ import { redis } from './redis.js';
167
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
168
+
169
+ export const bus = createPubSubBus(redis);
170
+ ```
171
+
172
+ #### Usage
173
+
174
+ ```js
175
+ // src/hooks.ws.js
176
+ import { bus } from '$lib/server/bus';
177
+
178
+ let distributed;
179
+
180
+ export function open(ws, { platform }) {
181
+ // Start subscriber (idempotent, only subscribes once)
182
+ bus.activate(platform);
183
+ // Get a wrapped platform that publishes to Redis + local
184
+ distributed = bus.wrap(platform);
185
+ }
186
+
187
+ export function message(ws, { data, platform }) {
188
+ const msg = JSON.parse(Buffer.from(data).toString());
189
+ // This publish reaches local clients AND all other instances
190
+ distributed.publish('chat', 'message', msg);
191
+ }
192
+ ```
193
+
194
+ #### Wire-batched publish (`publishBatched`)
195
+
196
+ When a single request publishes many events at once -- bulk imports, room state resets, audit fanouts -- use `publishBatched` instead of a `publish` loop. It ships **one** Redis envelope for the whole batch, and receivers fan out via `platform.publishBatched` so each subscriber sees **one** WebSocket frame per call.
197
+
198
+ ```js
199
+ distributed.publishBatched([
200
+ { topic: 'org:42:items', event: 'updated', data: a },
201
+ { topic: 'org:42:items', event: 'updated', data: b },
202
+ { topic: 'org:42:audit', event: 'created', data: c }
203
+ ]);
204
+ ```
205
+
206
+ Trade-offs vs `wrapped.publish` in a tight loop:
207
+
208
+ - **Redis publish count drops linearly with batch size.** A 50-message batch is one PUBLISH on the wire instead of 50 -- around 50x reduction on the bulk-import profile, ~3x on small disjoint batches (measured in `bench/01-publish-batched-bus.mjs`).
209
+ - **Per-subscriber wire frames drop too.** A subscriber receives one frame containing N events instead of N frames containing one event each.
210
+ - **Per-message `relay: false` is honored.** Flagged messages still publish locally but are excluded from the Redis envelope.
211
+
212
+ `wrapped.batch(messages)` is left in place for callers that explicitly want N independent publishes; it does not produce wire batching. Use `publishBatched` whenever you want one frame per subscriber per call.
213
+
214
+ #### Options
215
+
216
+ | Option | Default | Description |
217
+ |---|---|---|
218
+ | `channel` | `'uws:pubsub'` | Redis channel name |
219
+ | `systemChannel` | `'__realtime'` | Topic for auto-emitted `degraded` / `recovered` events. `null` or `false` to disable. Requires a `breaker` |
220
+ | `onDegraded` | -- | Server-side handler invoked once when the breaker leaves the healthy state |
221
+ | `onRecovered` | -- | Server-side handler invoked once when the breaker returns to the healthy state |
222
+
223
+ See [Notifying clients of degradation](#notifying-clients-of-degradation) for the full pattern.
224
+
225
+ #### API
226
+
227
+ | Method | Description |
228
+ |---|---|
229
+ | `bus.wrap(platform)` | Returns a new Platform whose `publish()`, `batch()`, and `publishBatched()` send to Redis + local. Other Platform methods (`send`, `sendCoalesced`, `request`, `pressure`, etc.) pass through unchanged |
230
+ | `bus.activate(platform)` | Start the Redis subscriber (idempotent) |
231
+ | `bus.deactivate()` | Stop the subscriber |
232
+
233
+ ---
234
+
235
+ ## Sharded pub/sub bus
236
+
237
+ `createPubSubBus` uses one channel for every message, so in a Redis Cluster every node receives every publish via the cluster bus. For deployments with many fine-grained topics where each subscriber only cares about a small subset (chat with millions of rooms, per-document collaboration, per-user feeds), most of that fan-out is wasted bandwidth.
238
+
239
+ `createShardedBus` (`svelte-adapter-uws-extensions/redis/sharded-pubsub`) is the SPUBLISH/SSUBSCRIBE variant: per-topic channels, dynamic subscription via `follow(topic)` / `unfollow(topic)`, no wildcards. In Redis Cluster, messages stay on the shard that owns each channel rather than fanning out to every node.
240
+
241
+ **Requires Redis 7+.** `activate()` runs `INFO server` and throws on older servers; use `createPubSubBus` for Redis 6 / older Valkey.
242
+
243
+ #### Setup
244
+
245
+ ```js
246
+ import { createShardedBus } from 'svelte-adapter-uws-extensions/redis/sharded-pubsub';
247
+
248
+ export const bus = createShardedBus(redis, {
249
+ shardKey: (topic) => topic.split(':')[0] // optional grouping
250
+ });
251
+ ```
252
+
253
+ #### Usage
254
+
255
+ ```js
256
+ // src/hooks.ws.js
257
+ import { bus } from '$lib/server/bus';
258
+
259
+ let distributed;
260
+
261
+ export async function open(ws, { platform }) {
262
+ await bus.activate(platform);
263
+ distributed = bus.wrap(platform);
264
+ }
265
+
266
+ // Wire follow / unfollow against WebSocket subscribe / unsubscribe:
267
+ export const { subscribe, unsubscribe, close } = bus.hooks;
268
+
269
+ // Or manually:
270
+ // await bus.follow('chat:room-7');
271
+ // distributed.publish('chat:room-7', 'msg', { text: 'hi' });
272
+ // await bus.unfollow('chat:room-7');
273
+ ```
274
+
275
+ `bus.hooks` is the recommended path -- it tracks per-`ws` subscription state and refcounts so the bus only `SSUBSCRIBE`s on the first follower per channel and `SUNSUBSCRIBE`s on the last one out.
276
+
277
+ #### Bulk follow (`followBatch`, `bus.hooks.subscribeBatch`)
278
+
279
+ `bus.followBatch(topics)` groups the input topics by shard channel and `SSUBSCRIBE`s any new channels in one round trip. Pairs with the adapter's `subscribeBatch` hook (`hooks.ws.subscribeBatch`) so an N-topic subscribe batch lands as one round-trip-per-channel rather than one round-trip-per-topic. With the adapter's client-side coalescing (next.7+), the win covers initial-mount subscribes too, not just reconnect resubscribes.
280
+
281
+ ```js
282
+ // Today (works, but N round trips):
283
+ export const subscribeBatch = async (ws, topics) => {
284
+ for (const topic of topics) await bus.follow(topic);
285
+ };
286
+
287
+ // Recommended -- one round trip per shard channel:
288
+ export const { subscribeBatch } = bus.hooks;
289
+ ```
290
+
291
+ Single `follow` / `unfollow` keep their existing semantics; `followBatch` is purely additive. Refcount semantics for individual topics match `follow`: each call to `followBatch` bumps every input topic's refcount by 1, and only channel transitions trigger Redis traffic. Empty arrays no-op; duplicate topics in the input collapse to one refcount bump.
292
+
293
+ `bus.hooks.subscribeBatch` skips `__`-prefixed topics like the per-topic `subscribe` hook does, and skips topics this `ws` is already following so a duplicate batch from a flaky reconnect doesn't leak refcount.
294
+
295
+ #### Wire-batched publish (`publishBatched`)
296
+
297
+ `distributed.publishBatched(messages)` ships **one** SPUBLISH envelope per shard channel per call. Receivers fan out via `platform.publishBatched` so each follower sees **one** WebSocket frame per call.
298
+
299
+ ```js
300
+ distributed.publishBatched([
301
+ { topic: 'chat:room1', event: 'msg', data: a },
302
+ { topic: 'chat:room2', event: 'msg', data: b },
303
+ { topic: 'audit:org1', event: 'created', data: c }
304
+ ]);
305
+ // With shardKey: (t) => t.split(':')[0], this is two SPUBLISH envelopes
306
+ // (one to the 'chat' shard channel, one to 'audit') instead of three.
307
+ ```
308
+
309
+ Same trade-offs as the unsharded bus: linear Redis-publish-count reduction with batch size, per-subscriber wire frames drop from N to 1, per-message `relay: false` honored. Use `publishBatched` for bulk operations; `wrapped.batch(messages)` remains a per-event loop for callers that explicitly want N separate publishes.
310
+
311
+ #### Options
312
+
313
+ | Option | Default | Description |
314
+ |---|---|---|
315
+ | `channelPrefix` | `'uws:sharded:'` | Prefix for sharded pub/sub channels |
316
+ | `shardKey` | `(topic) => topic` | Map a topic to a shard label. The channel is `channelPrefix + shardKey(topic)`. Default: identity (one channel per topic). |
317
+
318
+ #### When to use which bus
319
+
320
+ | | `createPubSubBus` | `createShardedBus` |
321
+ |---|---|---|
322
+ | Redis version | any | 7+ |
323
+ | Topology | standalone or cluster | meaningful only in cluster |
324
+ | Channel model | one shared channel | per-topic (or per shard) |
325
+ | Subscription | every instance auto-subscribes | dynamic via `follow` / `hooks` |
326
+ | Best fit | most apps; broad-interest topics | many fine-grained topics with narrow audiences |
327
+
328
+ If you don't have a concrete cluster + fine-grained-topics use case, `createPubSubBus` is simpler and sufficient.
329
+
330
+ ---
331
+
332
+ ## Replay buffer (Redis)
333
+
334
+ Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
335
+
336
+ Sequence numbers are incremented atomically via a Lua script (`INCR` + `ZADD` + trim in a single `EVAL`), so concurrent publishes from multiple instances produce strictly ordered, gap-free sequences per topic. When the buffer exceeds `size`, the oldest entries are removed inside the same Lua script -- no second round trip required.
337
+
338
+ When a client requests replay, the buffer checks whether the client's last-seen sequence is older than the oldest buffered entry. If it is (the buffer was trimmed past the client's position), a `truncated` event fires on `__replay:{topic}` before any `msg` events, so the client knows it missed messages and can do a full reload. This also fires when the buffer is completely empty but the sequence counter has advanced past the client's position (e.g. all entries expired via TTL).
339
+
340
+ The same gap state is exposed as a callable: `gap(topic, lastSeenSeq)` returns `{ truncated, missingFrom }` without driving a full WebSocket replay. Useful for SSR loaders that want to decide between an incremental `since()` fetch and a full reload before the page even opens its socket.
341
+
342
+ #### Aggregate vs broadcast topics
343
+
344
+ Replay buffers track one sequence per topic, so the topic boundary is also the gap-detection boundary. Map topics to **aggregates** (`auction:a1b2`, `chat:room-7`, `doc:abc123`) rather than broadcast channels (`auctions:all`, `chat:everyone`). With one topic per aggregate, the buffer size budget covers a real history window per aggregate and gap detection is meaningful: if a client missed seq 14 of `chat:room-7`, you know exactly which room to refetch. With one broadcast topic, a hot aggregate can rotate the buffer past every other aggregate's history within seconds, so any reconnecting client looks "truncated" even when the aggregate they care about hasn't changed in an hour.
345
+
346
+ #### Setup
347
+
348
+ ```js
349
+ // src/lib/server/replay.js
350
+ import { redis } from './redis.js';
351
+ import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
352
+
353
+ export const replay = createReplay(redis, {
354
+ size: 500,
355
+ ttl: 3600 // expire after 1 hour
356
+ });
357
+ ```
358
+
359
+ #### Usage
360
+
361
+ ```js
362
+ // In a form action or API route
363
+ export const actions = {
364
+ send: async ({ request, platform }) => {
365
+ const data = Object.fromEntries(await request.formData());
366
+ const msg = await db.createMessage(data);
367
+ await replay.publish(platform, 'chat', 'created', msg);
368
+ }
369
+ };
370
+ ```
371
+
372
+ ```js
373
+ // In +page.server.js
374
+ export async function load() {
375
+ const messages = await db.getRecentMessages();
376
+ return { messages, seq: await replay.seq('chat') };
377
+ }
378
+ ```
379
+
380
+ ```js
381
+ // In hooks.ws.js - handle replay requests
382
+ export async function message(ws, { data, platform }) {
383
+ const msg = JSON.parse(Buffer.from(data).toString());
384
+ if (msg.type === 'replay') {
385
+ await replay.replay(ws, msg.topic, msg.since, platform);
386
+ return;
387
+ }
388
+ }
389
+ ```
390
+
391
+ #### Session resumption (`resumeHook`)
392
+
393
+ The adapter's WebSocket `resume` hook fires on reconnect when the client presents per-topic `lastSeenSeqs` from `sessionStorage`. `resumeHook()` returns a hook function that drives gap-fill across every topic the client cared about, in one line:
394
+
395
+ ```js
396
+ // src/lib/server/replay.js
397
+ export const replay = createReplay(redis);
398
+
399
+ // src/hooks.ws.js
400
+ import { replay } from '$lib/server/replay';
401
+ export const resume = replay.resumeHook();
402
+ ```
403
+
404
+ The returned hook iterates the client's `lastSeenSeqs` and calls `replay.replay(ws, topic, sinceSeq, platform)` per topic. Per-topic truncation detection still happens inside `replay()` -- a client whose buffer rolled gets a `truncated` event on `__replay:{topic}` so it can do a full reload for that aggregate while other topics continue with incremental gap-fill.
405
+
406
+ For finer control -- custom truncation handling, gathering several gap-fills before flushing, mixing in other resume work -- compose by hand:
407
+
408
+ ```js
409
+ export async function resume(ws, { lastSeenSeqs, platform }) {
410
+ for (const [topic, sinceSeq] of Object.entries(lastSeenSeqs)) {
411
+ await replay.replay(ws, topic, sinceSeq, platform);
412
+ }
413
+ // ... your own resume work alongside replay
414
+ }
415
+ ```
416
+
417
+ The same `resumeHook()` is available on the Postgres backend; behavior is identical.
418
+
419
+ #### Options
420
+
421
+ | Option | Default | Description |
422
+ |---|---|---|
423
+ | `storage` | `'sortedset'` | Backend: `'sortedset'` (default) uses ZADD; `'stream'` uses XADD. See [Stream backend](#stream-backend). |
424
+ | `size` | `1000` | Max messages per topic |
425
+ | `ttl` | `0` | Key expiry in seconds (0 = never) |
426
+ | `durability` | -- | Set to `'replicated'` for per-publish replication signalling. See [Replicated durability](#replicated-durability). |
427
+ | `minReplicas` | `1` | Minimum replicas that must ack (only with `durability: 'replicated'`). |
428
+ | `replicationTimeoutMs` | `1000` | Per-publish replication timeout in ms. `0` blocks indefinitely (Redis WAIT semantics). |
429
+
430
+ #### Stream backend
431
+
432
+ `storage: 'stream'` dispatches to a Redis Streams implementation (`XADD`/`XRANGE`) instead of the default sorted-set one. Same external contract -- the same `publish` / `seq` / `gap` / `since` / `replay` / `clear` methods, same `durability: 'replicated'` mode, same metrics. Two changes under the hood:
433
+
434
+ - Listpack encoding is more compact than sorted-set encoding for typical message shapes -- meaningfully smaller memory for buffers in the thousands of entries per topic.
435
+ - Stream IDs are `<seq>-0` where `seq` is the same INCR counter the sorted-set backend uses. `XRANGE` against `(seq-0` filters natively by sequence number, so range queries skip the app-side scan the sorted-set backend does for some paths.
436
+
437
+ ```js
438
+ const replay = createReplay(redis, {
439
+ storage: 'stream',
440
+ size: 10000
441
+ });
442
+ ```
443
+
444
+ Both backends use the same seq-counter key (`{prefix}replay:seq:{topic}`) but different buf-key prefixes (`replay:buf:{topic}` for sorted-set, `replay:streambuf:{topic}` for stream), so they can coexist on the same Redis without WRONGTYPE collisions. A single topic should pick one backend at startup and stay there -- there is no built-in migration helper for switching an existing topic from one backend to the other (greenfield deployments don't need it; if you have one in flight and need to migrate, drain consumers, copy entries with a one-off script, and switch).
445
+
446
+ The stream backend works on Redis 5+; listpack encoding is the Redis 7+ default that delivers the memory win.
447
+
448
+ #### Idempotent publish (stream backend only)
449
+
450
+ For producers that need at-most-once semantics under retry, the stream backend exposes `publishIdempotent`:
451
+
452
+ ```js
453
+ const replay = createReplay(redis, { storage: 'stream' });
454
+
455
+ const { seq, isDuplicate } = await replay.publishIdempotent(platform, 'orders', 'created', order, {
456
+ producerId: 'order-service',
457
+ requestId: order.clientOrderId // stable per-operation id supplied by the caller
458
+ });
459
+ ```
460
+
461
+ On a fresh `(producerId, requestId)` tuple, the call performs the same INCR + XADD + broadcast as `publish()` and stashes `seq` in a per-(producer, topic) dedup hash. On a repeat tuple within `idempotencyTtl` (default 48 hours), the call returns the cached seq, skips the XADD, and skips the local broadcast -- the original publish already broadcast to live consumers, and reconnecting consumers pick the entry up via `replay()` from the buffer.
462
+
463
+ The `seq` counter only advances on fresh writes, so duplicate retries do not introduce gaps that would trigger false-positive truncation events on consumers.
464
+
465
+ The dedup cache key is `{prefix}replay:idmp:{producerId}:{topic}` -- topic-scoped so the same `requestId` can be reused across topics without collision. Override the TTL per call via `opts.idempotencyTtl`, or globally via `idempotencyTtl` on `createReplay`.
466
+
467
+ This pairs with the durable task runner (`postgres/tasks`): a task that publishes to the replay buffer can pass its task id as `requestId` so worker-crash retries don't double-publish.
468
+
469
+ #### Replicated durability
470
+
471
+ For loss-sensitive flows (audit logs, financial events) opt in with `durability: 'replicated'`. After the write to the master, `publish()` runs `WAIT minReplicas replicationTimeoutMs`. If fewer than `minReplicas` replicas ack within the timeout, `publish()` throws `ReplicationTimeoutError` and skips the local broadcast -- the data is on the master only, and broadcasting would commit live consumers to state that could be lost if the master fails before replicas catch up.
472
+
473
+ ```js
474
+ import { createReplay, ReplicationTimeoutError } from 'svelte-adapter-uws-extensions/redis/replay';
475
+
476
+ const replay = createReplay(redis, {
477
+ durability: 'replicated',
478
+ minReplicas: 1,
479
+ replicationTimeoutMs: 1000
480
+ });
481
+
482
+ try {
483
+ await replay.publish(platform, 'orders', 'created', order);
484
+ } catch (err) {
485
+ if (err instanceof ReplicationTimeoutError) {
486
+ // err.ack, err.minReplicas, err.timeoutMs available for logging
487
+ // Caller decides: retry, fail the request, or accept best-effort
488
+ }
489
+ throw err;
490
+ }
491
+ ```
492
+
493
+ The data is in the buffer on the master regardless of the WAIT outcome -- other instances doing `replay()` will still see it. Only the local broadcast is suppressed when the durability signal fails. WAIT command-level errors (network/protocol) bubble up as the original error and DO count as a circuit-breaker failure; an under-acked timeout is a separate signal layer and does NOT trip the breaker.
494
+
495
+ #### API
496
+
497
+ All methods are async (they hit Redis). The API otherwise matches the core plugin exactly:
498
+
499
+ | Method | Description |
500
+ |---|---|
501
+ | `publish(platform, topic, event, data)` | Store + broadcast. May throw `ReplicationTimeoutError` when `durability: 'replicated'`. |
502
+ | `seq(topic)` | Current sequence number |
503
+ | `gap(topic, lastSeenSeq)` | Probe for a buffer gap. Returns `{ truncated, missingFrom }` |
504
+ | `since(topic, seq)` | Messages after a sequence |
505
+ | `replay(ws, topic, sinceSeq, platform)` | Send missed messages to one client |
506
+ | `clear()` | Delete all replay data |
507
+ | `clearTopic(topic)` | Delete replay data for one topic |
508
+
509
+ ---
510
+
511
+ ## Presence
512
+
513
+ Same API as the core `createPresence` plugin, but backed by Redis hashes. Presence state is shared across instances with cross-instance join/leave notifications via Redis pub/sub.
514
+
515
+ #### Wire shape
516
+
517
+ Clients see three event types on `__presence:{topic}`. Mirrors the adapter's bundled `createPresence` plugin so a single client decoder handles both single-instance and cluster deployments:
518
+
519
+ | Event | When | Payload | Direction |
520
+ |---|---|---|---|
521
+ | `presence_state` | Once on subscribe | `{[userKey]: data}` -- flat snapshot of current presence | Server -> single connection |
522
+ | `presence_diff` | Microtask-batched after joins / leaves / updates | `{joins: {[key]: data}, leaves: {[key]: data}}` | Server -> topic subscribers |
523
+ | `heartbeat` | Per heartbeat interval | `string[]` -- array of currently-known user keys | Server -> topic subscribers |
524
+
525
+ `presence_diff` collapses by key per-tick: if the same user joins and leaves in the same microtask, only the latest op survives on the wire. An update (same user re-joins with different data) appears as a `joins` entry carrying the new data, since clients overwrite their `Map.set` on the same key.
526
+
527
+ Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `presence_state` / `presence_diff` shape regardless of which instance the change originated on.
528
+
529
+ Joins are staged with full rollback on failure: local state is set up first, then the Redis hash field is written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone -- local maps, the Redis field, and any buffered diff entry are reversed. Compensating join+leave ops on the same key in the same tick collapse to nothing on the wire.
530
+
531
+ Leaves use an atomic Lua script (`LEAVE_SCRIPT`) that removes this instance's field from the hash and then scans remaining fields for the same user key, ignoring stale entries. Leave is only buffered into the diff when no other instance holds a live entry for that user, preventing premature "user left" notifications in multi-instance deployments.
532
+
533
+ Zombie cleanup runs on the heartbeat interval. Each tick, every tracked WebSocket is probed via `getBufferedAmount()` -- if the call throws, the socket is dead and its presence is removed synchronously before the heartbeat writes to Redis. The heartbeat then refreshes timestamps on all live entries via a Redis pipeline and runs a server-side Lua cleanup script (`CLEANUP_SCRIPT`) that scans the hash and removes any fields whose timestamp exceeds the TTL. This handles crashed instances whose close handlers never fired.
534
+
535
+ #### Setup
536
+
537
+ ```js
538
+ // src/lib/server/presence.js
539
+ import { redis } from './redis.js';
540
+ import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
541
+
542
+ export const presence = createPresence(redis, {
543
+ key: 'id',
544
+ select: (userData) => ({ id: userData.id, name: userData.name }),
545
+ heartbeat: 30000,
546
+ ttl: 90
547
+ });
548
+ ```
549
+
550
+ #### Usage
551
+
552
+ ```js
553
+ // src/hooks.ws.js
554
+ import { presence } from '$lib/server/presence';
555
+
556
+ export async function subscribe(ws, topic, { platform }) {
557
+ await presence.join(ws, topic, platform);
558
+ }
559
+
560
+ export async function close(ws, { platform }) {
561
+ await presence.leave(ws, platform);
562
+ }
563
+ ```
564
+
565
+ #### Options
566
+
567
+ | Option | Default | Description |
568
+ |---|---|---|
569
+ | `key` | `'id'` | Field for user dedup (multi-tab) |
570
+ | `select` | strips `__`-prefixed keys | Extract public fields from userData |
571
+ | `heartbeat` | `30000` | TTL refresh interval in ms |
572
+ | `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
573
+ | `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `presence_state` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
574
+
575
+ #### API
576
+
577
+ | Method | Description |
578
+ |---|---|
579
+ | `join(ws, topic, platform)` | Add connection to presence |
580
+ | `leave(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
581
+ | `sync(ws, topic, platform)` | Send list without joining |
582
+ | `list(topic)` | Get current users |
583
+ | `count(topic)` | Count unique users |
584
+ | `metrics()` | Synchronous snapshot: `{ totalOnline, heartbeatLatencyMs, staleCleanedTotal }`. See [Metrics snapshot](#metrics-snapshot). |
585
+ | `flushDiffs()` | Drain the pending `presence_diff` buffer synchronously. Use in graceful-shutdown paths or tests that need the diff to land before the await chain continues. |
586
+ | `clear()` | Reset all presence state |
587
+ | `destroy()` | Stop heartbeat and subscriber |
588
+ | `hooks` | `{ subscribe, close }` -- ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
589
+
590
+ #### Metrics snapshot
591
+
592
+ `presence.metrics()` returns a synchronous snapshot of in-memory state:
593
+
594
+ | Field | Description |
595
+ |---|---|
596
+ | `totalOnline` | Sum of unique-users-per-topic across all topics this instance is locally tracking. The same user in two topics counts twice; per-topic counts sum cleanly. |
597
+ | `heartbeatLatencyMs` | Duration of the most recent heartbeat tick in milliseconds. Useful as a rough Redis-health indicator -- a tick that suddenly takes longer than usual is likely waiting on a slow Redis. |
598
+ | `staleCleanedTotal` | Cumulative count of stale fields removed by the heartbeat-driven cleanup script since this instance started. A non-zero rate means crashed sibling instances' presence is being cleaned up; a zero rate is the healthy steady state. |
599
+
600
+ The same numbers are exposed as Prometheus when a `metrics` registry is attached: `presence_total_online{topic="..."}` (gauge), `presence_heartbeat_latency_ms` (gauge), `presence_stale_cleaned_total` (counter, already shipped pre-0.5.0).
601
+
602
+ Two additional counters track the diff-protocol behavior:
603
+
604
+ | Metric | Description |
605
+ |---|---|
606
+ | `presence_diff_frames_total{topic="..."}` | `presence_diff` frames published to topic subscribers. Compared against `presence_joins_total` + `presence_leaves_total` it tells you how much per-tick coalescing the buffer is doing -- the bigger the gap, the more bandwidth saved versus per-event broadcast. |
607
+ | `presence_diff_coalesced_total{topic="..."}` | Buffered diff entries overwritten by a later op in the same tick. A non-zero rate confirms the same-key collapse is working (e.g. a user reconnecting fast enough to leave-then-join in one tick). Zero is also a valid state under steady traffic. |
608
+
609
+ #### Keyspace cleanup mode
610
+
611
+ By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `presence_diff` with the user in `leaves`. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
612
+
613
+ `keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore -- typically because the only tracker crashed), this instance emits an empty `presence_state` event on `__presence:<topic>` so local subscribers can replace their entire local map with "no one here."
614
+
615
+ ```js
616
+ const presence = createPresence(redis, {
617
+ key: 'id',
618
+ keyspaceNotifications: true
619
+ });
620
+ ```
621
+
622
+ **Operator burden:** Redis must be configured to publish keyspace events:
623
+
624
+ ```
625
+ CONFIG SET notify-keyspace-events Ex
626
+ ```
627
+
628
+ (or any flagset that includes both `K`/`E` and `x` -- e.g. `Ex`, `KEA`, etc.) If the `psubscribe` call fails because keyspace events are off, the failure is logged once and the rest of the tracker keeps working without the keyspace branch.
629
+
630
+ **Scope:** this is hash-key expiry (whole topic gone), not per-field expiry. Per-field cleanup of stale entries from crashed instances continues to run via the heartbeat-driven cleanup script. Per-field hash TTLs would require Redis 7.4+ `HEXPIRE` and a different storage layout; that's a future evolution, not part of this mode.
631
+
632
+ #### Zero-config hooks
633
+
634
+ Instead of writing `subscribe` and `close` handlers manually, destructure `presence.hooks`:
635
+
636
+ ```js
637
+ // src/hooks.ws.js
638
+ import { presence } from '$lib/server/presence';
639
+ export const { subscribe, close } = presence.hooks;
640
+ ```
641
+
642
+ `subscribe` handles both regular topics (calls `join`) and `__presence:*` topics (calls `sync` so the client gets the current list). `close` calls `leave`.
643
+
644
+ If you need custom logic (auth gating, logging), wrap the hooks:
645
+
646
+ ```js
647
+ import { presence } from '$lib/server/presence';
648
+
649
+ export async function subscribe(ws, topic, ctx) {
650
+ if (!ctx.platform.getUserData(ws).authenticated) return;
651
+ await presence.hooks.subscribe(ws, topic, ctx);
652
+ }
653
+
654
+ export const { close } = presence.hooks;
655
+ ```
656
+
657
+ ---
658
+
659
+ ## Connection registry
660
+
661
+ The adapter's `platform.request(ws, ...)` is single-instance: it takes a local `ws` reference, so it only works against connections owned by the calling instance. `createConnectionRegistry` is the cluster-routed counterpart -- a `userId -> {instanceId, sessionId, ts}` map in Redis plus a per-instance push channel that lets any instance route a request to whichever one currently owns a given user's WebSocket.
662
+
663
+ #### Setup
664
+
665
+ ```js
666
+ // src/lib/server/registry.js
667
+ import { redis } from './redis.js';
668
+ import { createConnectionRegistry } from 'svelte-adapter-uws-extensions/redis/registry';
669
+
670
+ export const registry = createConnectionRegistry(redis, {
671
+ identify: (ws) => ws.getUserData()?.userId
672
+ });
673
+ ```
674
+
675
+ Wire the open / close hooks so each connection is tracked cluster-wide:
676
+
677
+ ```js
678
+ // src/hooks.ws.js
679
+ import { registry } from '$lib/server/registry';
680
+
681
+ export const open = registry.hooks.open;
682
+ export const close = registry.hooks.close;
683
+ ```
684
+
685
+ The `identify` function returns the user identity for a WebSocket (return `null` / `undefined` for anonymous connections; the registry skips them). The registry reads the per-connection `WS_SESSION_ID` slot the adapter stamps on userData, so no other configuration is required.
686
+
687
+ #### Cluster-routed request/reply
688
+
689
+ ```js
690
+ // From any instance:
691
+ const reply = await registry.request('user-123', 'confirm-action', { op: 'delete' }, {
692
+ timeoutMs: 5000
693
+ });
694
+ if (reply.confirmed) await actuallyDelete();
695
+ ```
696
+
697
+ The lookup resolves which instance currently owns `user-123`'s connection. If that's the calling instance, the request short-circuits to a local `platform.request(ws, ...)` -- no Redis hop. Otherwise the request envelope ships across the per-instance push channel, the owning instance calls `platform.request` locally, and the reply ships back on the origin's push channel.
698
+
699
+ Wire envelopes (internal):
700
+
701
+ | Direction | Channel | Payload |
702
+ |---|---|---|
703
+ | Origin -> owner | `{prefix}__push:{ownerInstanceId}` | `{type:'request', ref, sessionId, event, data, replyTo, timeoutMs}` |
704
+ | Owner -> origin | `{prefix}__push:{originInstanceId}` | `{type:'reply', ref, data}` or `{type:'reply', ref, error}` |
705
+
706
+ `request(...)` rejects on:
707
+
708
+ - the target user being offline (no entry in Redis)
709
+ - the request timing out (`timeoutMs` exceeded)
710
+ - the owning instance reporting a handler error from its `platform.request`
711
+
712
+ Mid-flight migration (user reconnects to a different instance between lookup and reply) surfaces as a timeout on the origin; the owning instance's late reply lands on a missing pending entry and is dropped with a `push_late_replies_total` increment. See [Edge cases](#registry-edge-cases) below.
713
+
714
+ #### Storage shape
715
+
716
+ | Key | Shape | Notes |
717
+ |---|---|---|
718
+ | `{prefix}conns:{userId}` | Hash `{instanceId, sessionId, ts, attrs?}` | Most-recent-connection-wins. A second device on the same `userId` replaces the first; targeting from `request(...)` always reaches the most recent connection. `attrs` is a JSON-encoded snapshot of the optional `attributes(ws)` callback's return value -- present only when `attributes` is wired and returns at least one value. |
719
+ | `{prefix}__push:{instanceId}` | Pub/sub channel | Each instance subscribes to its own channel. Inbound messages dispatch by `envelope.type`. |
720
+ | `{prefix}__registry-events` | Pub/sub channel | Shared across all instances. Carries `{type:'open' | 'close', userId, instanceId, attrs?}` envelopes so each instance can maintain a live secondary index for `sendTo(...)`. Skipped when no `attributes` option was supplied. |
721
+
722
+ Compare-and-delete on `close`: a Lua-atomic check ensures the close hook only removes the registry entry when the stored `instanceId` still matches this instance. Prevents a stale close from clobbering a registration that already migrated to another instance via a fast laptop-then-phone reconnect.
723
+
724
+ #### Options
725
+
726
+ | Option | Default | Description |
727
+ |---|---|---|
728
+ | `identify` | (required) | `(ws) => userId | null`. Anonymous connections are skipped. |
729
+ | `attributes` | -- | `(ws) => Record<string, string | number | boolean>`. Required for `sendTo(...)`. Captures per-user attributes at registration time for tenant- / role- / cohort-scoped broadcasts. Numbers and booleans are coerced to strings for index-key consistency; nested objects, arrays, and `null` values are dropped (shallow values only per credo rule 1). |
730
+ | `keyPrefix` | `''` | Prefix prepended to all registry keys and channels. Stacks with the underlying client's `keyPrefix`. |
731
+ | `ttl` | `90` | Expiry on registry entries in seconds. Should be > `heartbeat * 3` so a missed beat doesn't drop a live user. |
732
+ | `heartbeat` | `30000` | TTL refresh interval in ms. Each tick `EXPIRE`s every locally-owned entry. |
733
+ | `requestTimeoutMs` | `5000` | Default timeout for `request(...)` calls. Overridable per call via `options.timeoutMs`. |
734
+ | `breaker` | -- | Optional circuit breaker for Redis ops. |
735
+ | `metrics` | -- | Optional Prometheus metrics registry. |
736
+
737
+ #### API
738
+
739
+ | Method | Description |
740
+ |---|---|
741
+ | `lookup(userId)` | Resolve a userId to its current entry (`{instanceId, sessionId, ts}`) or `null`. |
742
+ | `request(target, event, data?, opts?)` | Cluster-routed request/reply. Resolves with the reply. |
743
+ | `send(target, topic, event, data?)` | Cluster-routed `platform.send` counterpart. Fire-and-forget. See [Targeted sends](#registry-targeted-sends) below. |
744
+ | `sendCoalesced(target, message)` | Cluster-routed coalesce-by-key send. Fire-and-forget. See [Coalesced sends](#registry-coalesced-sends) below. |
745
+ | `sendTo(criteria, topic, event, data?)` | Attribute-targeted broadcast across the cluster. Requires `attributes` option. See [Attribute-targeted broadcast](#registry-sendto) below. |
746
+ | `size()` | Count of users registered to THIS instance (local view, scrape-time). |
747
+ | `instanceId` | Stable id for this instance, also the name of its push channel. |
748
+ | `hooks.open` / `hooks.close` | Wire as ready-made WebSocket hooks. |
749
+ | `destroy()` | Stop the heartbeat timer and Redis subscriber. |
750
+
751
+ #### Targeted sends {#registry-targeted-sends}
752
+
753
+ `registry.send(target, topic, event, data)` is the cluster-routed counterpart to the adapter's `platform.send(ws, topic, event, data)`. Lookup resolves the owning instance, self-targeting short-circuits to a local `platform.send`, otherwise a fire-and-forget envelope `{type:'send', sessionId, topic, event, data}` ships on the owner's push channel.
754
+
755
+ ```js
756
+ registry.send('user-123', 'notifications', 'incoming', { id: 42 });
757
+ ```
758
+
759
+ Fire-and-forget: no Promise<reply>, no acknowledgement. Callers who need a delivery signal should use `request(...)` instead. A user offline at lookup-time silently drops with `push_sends_total{result="offline"}`. Mid-flight migration (user disconnects between lookup and arrival) drops on the receiver with `push_sends_total{result="late"}`.
760
+
761
+ #### Coalesced sends {#registry-coalesced-sends}
762
+
763
+ `registry.sendCoalesced(target, { key, topic, event, data })` is the cluster-routed counterpart to the adapter's `platform.sendCoalesced(ws, ...)` -- one slot per `(connection, key)` tuple, latest-value-wins. Fire-and-forget; no reply path, no `Promise<reply>`.
764
+
765
+ ```js
766
+ registry.sendCoalesced('user-123', {
767
+ key: 'cursor:doc-7',
768
+ topic: 'doc:7',
769
+ event: 'cursor',
770
+ data: { x: 410, y: 220 }
771
+ });
772
+ ```
773
+
774
+ Routing follows the same shape as `request(...)`: lookup the owning instance, self-target short-circuits to a local `platform.sendCoalesced(ws, ...)`, otherwise a fire-and-forget envelope `{type:'coalesced', sessionId, key, topic, event, data}` ships on the owner's push channel and the receiver calls `platform.sendCoalesced` locally.
775
+
776
+ Per-`(connection, key)` replacement happens on the receiver via the adapter's existing coalesce semantics, so a duplicate or out-of-order envelope from a flaky link is collapsed on arrival rather than producing a stutter on the wire. Ordering is preserved within a `(user, key)` tuple as long as the user does not move instances mid-flight; instance migration triggers one transient out-of-order moment that the per-connection coalesce collapses on the new instance.
777
+
778
+ Best fit: targeted latest-value streams where the target is a *user*, not a topic. Cursor positions inside a doc, typing indicators between two users, presence-state pushes from a moderator to a single subscriber. Topic-broadcast coalesce (every subscriber sees the same stream) already works cluster-wide via `bus.wrap(platform).publish(...)` on either bus and per-receiver A1 logic; this method covers the remaining gap.
779
+
780
+ #### Attribute-targeted broadcast {#registry-sendto}
781
+
782
+ `registry.sendTo(criteria, topic, event, data)` is the cluster-routed counterpart to the adapter's `platform.sendTo(filter, ...)`. Captures per-user attributes at registration time via the `attributes(ws)` option, indexes them in memory on every instance, and resolves a match into one envelope per owning instance. Keys are entirely user-defined -- whatever you return from `attributes(ws)` is what `sendTo` can match against. The adapter's `platform.sendTo` examples use `userData.role === 'admin'`; the cluster version follows the same shape:
783
+
784
+ ```js
785
+ import { createConnectionRegistry } from 'svelte-adapter-uws-extensions/redis/registry';
786
+
787
+ const registry = createConnectionRegistry(redis, {
788
+ identify: (ws) => ws.getUserData()?.userId,
789
+ attributes: (ws) => {
790
+ const ud = ws.getUserData();
791
+ return { role: ud.role, region: ud.region };
792
+ }
793
+ });
794
+
795
+ // Broadcast to every connection where attributes.role === 'admin':
796
+ registry.sendTo({ role: 'admin' }, 'alerts', 'warning', { message: 'High load' });
797
+
798
+ // Compound match (AND across keys):
799
+ registry.sendTo({ role: 'admin', region: 'eu' }, 'audit', 'created', payload);
800
+ ```
801
+
802
+ `platform.sendTo(filter, topic, event, data)` accepts a filter function -- functions don't serialize across instances, so the filter-function escape hatch is deliberately not lifted here. The cluster shape is shallow equality only: one literal value per attribute key, AND across keys. No regex, no array containment, no nested-object queries. Apps that need richer queries should publish to a dedicated topic and route their own broadcasts.
803
+
804
+ How it works:
805
+
806
+ 1. Each instance maintains an in-memory secondary index: `Map<attrKey, Map<attrValue, Set<userId>>>` plus a shadow `userId -> attrs` map and a `userId -> instanceId` map.
807
+ 2. Updates ride a shared `{prefix}__registry-events` Redis pub/sub channel. Every successful `hooks.open` publishes `{type:'open', userId, instanceId, attrs}`; every successful close publishes `{type:'close', userId, instanceId}` (compare-and-delete already protects against a stale close from a previous owner).
808
+ 3. When a new instance starts up, it bootstraps the index by SCAN-ing the existing `{prefix}conns:*` hashes and calling HGETALL on each to populate from the live registry state. Subscribe-first / SCAN-second keeps the bootstrap race-tolerant under set semantics.
809
+ 4. `sendTo(criteria, ...)` resolves matching userIds via the local index, groups them by their owning instance, fires once per owning instance on the existing `{prefix}__push:` channel: `{type:'sendTo', criteria, topic, event, data}`.
810
+ 5. Each receiving instance re-resolves matches against its own authoritative local index and calls `platform.send(ws, topic, event, data)` for each match. Authoritative-on-receiver matching tolerates one round of sender-index staleness from a fast user migration.
811
+
812
+ `sendTo` is fire-and-forget. No reply, no acknowledgement, no per-target outcome. The single `push_sendto_total` counter records sender-side outcomes:
813
+
814
+ | `result` label | Meaning |
815
+ |---|---|
816
+ | `ok` | At least one match resolved; envelopes published successfully (or only self-matches that delivered locally without Redis traffic). |
817
+ | `empty` | No matches resolved by the local index. The call no-ops. |
818
+ | `error` | At least one Redis publish failed; partial delivery still occurred for the successful publishes and any local self-matches. |
819
+
820
+ Eventual consistency caveats:
821
+
822
+ - **Mid-flight migration.** A user reconnecting on a different instance between the sender's index lookup and the receive can produce a single best-effort missed delivery while the registry-events channel propagates the migration.
823
+ - **Pubsub disconnect.** If the registry's subscriber connection drops, in-memory indexes will not update until reconnect; the `attrs` field on the Redis hash stays authoritative for `lookup(userId)`. The next call to `ensureSubscriber()` (e.g., on the next `hooks.open`) re-bootstraps via SCAN.
824
+ - **Topics outside the bus.** Only topics published via `platform.send`/`platform.publish` reach the receiving connections; matched users with no active subscription on the targeted topic still receive the event since `platform.send` is per-connection, not per-topic.
825
+
826
+ For exact targeting (audit log, billing, transactional broadcasts), use `request(...)` per userId or fan out via topic subscribers with the bus.
827
+
828
+ #### Metrics
829
+
830
+ | Metric | Description |
831
+ |---|---|
832
+ | `push_requests_total{result="ok|offline|timeout|error"}` | Outcomes for `request(...)` calls. `ok` is a successful reply; `offline` is a missing or local-ws-gone entry; `timeout` is no reply within `timeoutMs`; `error` is a handler-thrown error or a Redis publish failure. |
833
+ | `push_reply_latency_ms` | Histogram of request-publish to reply-receive in milliseconds (success path). |
834
+ | `push_registry_size` | Gauge: connections registered to this instance. Scrape-time, no continuous accounting. |
835
+ | `push_late_replies_total` | Counter: replies that arrived after their request expired or migrated. |
836
+ | `push_coalesced_total{result="ok|self|offline|late|error"}` | Outcomes for `sendCoalesced(...)`. `ok` is a successful cross-instance publish; `self` is a successful self-target; `offline` is a missing entry or local-ws-gone; `late` is a receive-side miss (sessionId not in the local map -- target migrated/closed between dispatch and arrival); `error` is a Redis publish failure or a thrown `platform.sendCoalesced` on either side. |
837
+ | `push_sends_total{result="ok|self|offline|late|error"}` | Outcomes for `send(...)`. Same result space as `push_coalesced_total` -- `ok` cross-instance, `self` short-circuited locally, `offline` entry missing or local-ws-gone, `late` receive-side miss after migration, `error` Redis publish or local `platform.send` threw. |
838
+ | `push_sendto_total{result="ok|empty|error"}` | Outcomes for `sendTo(...)`. `ok` is "at least one match resolved, all publishes succeeded (or only self-matches)"; `empty` is "no matches resolved by the local index"; `error` is "at least one Redis publish failed." Per-call counter, not per-target -- one `sendTo` produces exactly one increment regardless of how many matches it fans out to. |
839
+
840
+ #### Registry edge cases
841
+
842
+ - **User reconnects to a different instance mid-request.** The origin's pending entry waits on the OLD instance's reply channel. The user's new connection won't reply on that channel; the request times out. Any late reply from the old instance after teardown lands on a missing pending entry and is silently dropped (`push_late_replies_total` increments).
843
+ - **Owning instance crashes between request publish and local `platform.request`.** Same shape as above -- request times out. The Redis entry remains until the TTL expires (sliding heartbeat cleared by the dead instance), after which subsequent `request(...)` calls see `result="offline"` from the lookup.
844
+ - **Self-targeting** (the origin instance owns the user). Short-circuits to a local `platform.request(ws, ...)` without round-tripping Redis. One conditional in the dispatcher; not a special case at the API surface.
845
+ - **Anonymous connections.** `identify(ws)` returning `null` / `undefined` makes the open / close hooks no-op. Anonymous users are not addressable through the registry by design.
846
+
847
+ ---
848
+
849
+ ## Rate limiting
850
+
851
+ Same API as the core `createRateLimit` plugin, but backed by Redis using an atomic Lua script. Rate limits are enforced across all server instances with exactly one Redis roundtrip per `consume()` call.
852
+
853
+ #### Setup
854
+
855
+ ```js
856
+ // src/lib/server/ratelimit.js
857
+ import { redis } from './redis.js';
858
+ import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
859
+
860
+ export const limiter = createRateLimit(redis, {
861
+ points: 10,
862
+ interval: 1000,
863
+ blockDuration: 30000
864
+ });
865
+ ```
866
+
867
+ #### Usage
868
+
869
+ ```js
870
+ // src/hooks.ws.js
871
+ import { limiter } from '$lib/server/ratelimit';
872
+
873
+ export async function message(ws, { data, platform }) {
874
+ const { allowed } = await limiter.consume(ws);
875
+ if (!allowed) return; // drop the message
876
+ // ... handle message
877
+ }
878
+ ```
879
+
880
+ #### Options
881
+
882
+ | Option | Default | Description |
883
+ |---|---|---|
884
+ | `points` | *required* | Tokens available per interval |
885
+ | `interval` | *required* | Refill interval in ms |
886
+ | `blockDuration` | `0` | Auto-ban duration in ms (0 = no ban) |
887
+ | `keyBy` | `'ip'` | `'ip'`, `'connection'`, or a function |
888
+
889
+ #### API
890
+
891
+ All methods are async (they hit Redis). The API otherwise matches the core plugin:
892
+
893
+ | Method | Description |
894
+ |---|---|
895
+ | `consume(ws, cost?)` | Attempt to consume tokens. `cost` must be a positive integer. |
896
+ | `reset(key)` | Clear the bucket for a key |
897
+ | `ban(key, duration?)` | Manually ban a key |
898
+ | `unban(key)` | Remove a ban |
899
+ | `clear()` | Reset all state |
900
+
901
+ ---
902
+
903
+ ## Broadcast groups
904
+
905
+ Same API as the core `createGroup` plugin, but membership is stored in Redis so groups work across multiple server instances. Local WebSocket tracking is maintained per-instance, and cross-instance events are relayed via Redis pub/sub.
906
+
907
+ #### Setup
908
+
909
+ ```js
910
+ // src/lib/server/lobby.js
911
+ import { redis } from './redis.js';
912
+ import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
913
+
914
+ export const lobby = createGroup(redis, 'lobby', {
915
+ maxMembers: 50,
916
+ meta: { game: 'chess' }
917
+ });
918
+ ```
919
+
920
+ Note: the API signature is `createGroup(client, name, options)` instead of `createGroup(name, options)` -- the Redis client is the first argument.
921
+
922
+ #### Usage
923
+
924
+ ```js
925
+ // src/hooks.ws.js
926
+ import { lobby } from '$lib/server/lobby';
927
+
928
+ export async function subscribe(ws, topic, { platform }) {
929
+ if (topic === 'lobby') await lobby.join(ws, platform);
930
+ }
931
+
932
+ export async function close(ws, { platform }) {
933
+ await lobby.leave(ws, platform);
934
+ }
935
+ ```
936
+
937
+ #### Options
938
+
939
+ | Option | Default | Description |
940
+ |---|---|---|
941
+ | `maxMembers` | `Infinity` | Maximum members allowed (enforced atomically) |
942
+ | `meta` | `{}` | Initial group metadata |
943
+ | `memberTtl` | `120` | Member entry TTL in seconds. Entries from crashed instances expire after this period. |
944
+ | `onJoin` | - | Called after a member joins |
945
+ | `onLeave` | - | Called after a member leaves |
946
+ | `onFull` | - | Called when a join is rejected (full) |
947
+ | `onClose` | - | Called when the group is closed |
948
+
949
+ #### API
950
+
951
+ | Method | Description |
952
+ |---|---|
953
+ | `join(ws, platform, role?)` | Add a member (returns false if full/closed) |
954
+ | `leave(ws, platform)` | Remove a member |
955
+ | `publish(platform, event, data, role?)` | Broadcast to all or filter by role |
956
+ | `send(platform, ws, event, data)` | Send to a single member |
957
+ | `localMembers()` | Members on this instance |
958
+ | `count()` | Total members across all instances |
959
+ | `has(ws)` | Check membership on this instance |
960
+ | `getMeta()` / `setMeta(meta)` | Read/write group metadata |
961
+ | `close(platform)` | Dissolve the group |
962
+ | `destroy()` | Stop the Redis subscriber |
963
+
964
+ ---
965
+
966
+ ## Cursor
967
+
968
+ Same API as the core `createCursor` plugin, but cursor positions are shared across instances via Redis. Each instance throttles locally (same leading/trailing edge logic as the core), then relays broadcasts through Redis pub/sub so subscribers on other instances see cursor updates.
969
+
970
+ Hash entries have a TTL so stale cursors from crashed instances get cleaned up automatically.
971
+
972
+ #### Setup
973
+
974
+ ```js
975
+ // src/lib/server/cursors.js
976
+ import { redis } from './redis.js';
977
+ import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
978
+
979
+ export const cursors = createCursor(redis, {
980
+ throttle: 50,
981
+ select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color }),
982
+ ttl: 30
983
+ });
984
+ ```
985
+
986
+ #### Usage
987
+
988
+ ```js
989
+ // src/hooks.ws.js
990
+ import { cursors } from '$lib/server/cursors';
991
+
992
+ export function message(ws, { data, platform }) {
993
+ const msg = JSON.parse(Buffer.from(data).toString());
994
+ if (msg.type === 'cursor') {
995
+ cursors.update(ws, msg.topic, msg.position, platform);
996
+ }
997
+ }
998
+
999
+ export function close(ws, { platform }) {
1000
+ cursors.remove(ws, platform);
1001
+ }
1002
+ ```
1003
+
1004
+ #### Options
1005
+
1006
+ | Option | Default | Description |
1007
+ |---|---|---|
1008
+ | `throttle` | `50` | Minimum ms between broadcasts per user per topic |
1009
+ | `select` | strips `__`-prefixed keys | Extract user data to broadcast alongside position |
1010
+ | `ttl` | `30` | Per-entry TTL in seconds (auto-refreshed on each broadcast). Stale entries from crashed instances are filtered out individually, even if other instances are still active on the same topic. |
1011
+
1012
+ #### API
1013
+
1014
+ | Method | Description |
1015
+ |---|---|
1016
+ | `update(ws, topic, data, platform)` | Broadcast cursor position (throttled per user per topic) |
1017
+ | `remove(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
1018
+ | `list(topic)` | Get current positions across all instances |
1019
+ | `clear()` | Reset all local and Redis state |
1020
+ | `destroy()` | Stop the Redis subscriber and clear timers |
1021
+
1022
+ ---
1023
+
1024
+ **Postgres extensions**
1025
+
1026
+ ## Replay buffer (Postgres)
1027
+
1028
+ Same API as the Redis replay buffer, but backed by a Postgres table. Best suited for durable audit trails or history that needs to survive longer than Redis TTLs. Sequence numbers are generated atomically via a dedicated `_seq` table using `INSERT ... ON CONFLICT DO UPDATE`, so concurrent publishes from multiple instances produce strictly ordered sequences with no duplicates or gaps.
1029
+
1030
+ Buffer trimming runs after each publish by deleting rows with `seq <= currentSeq - maxSize`. If the trim query fails, the publish still succeeds -- the periodic background cleanup (configurable via `cleanupInterval`) catches any excess rows later.
1031
+
1032
+ Same gap detection behavior as the Redis replay buffer: if the client's last-seen sequence is older than the oldest buffered row, or the buffer is empty but the sequence counter has advanced, a `truncated` event fires before replay. The standalone `gap(topic, lastSeenSeq)` probe is also available with the same `{ truncated, missingFrom }` shape; the gap query uses the `(topic, seq)` index for an O(log n) seek rather than scanning the buffer.
1033
+
1034
+ The aggregate-vs-broadcast guidance from the [Redis replay section](#aggregate-vs-broadcast-topics) applies equally here -- one topic per aggregate keeps the buffer size budget meaningful and gap detection actionable.
1035
+
1036
+ `resumeHook()` is available with identical semantics to the Redis backend; see [Session resumption](#session-resumption-resumehook).
1037
+
1038
+ #### Setup
1039
+
1040
+ ```js
1041
+ // src/lib/server/replay.js
1042
+ import { pg } from './pg.js';
1043
+ import { createReplay } from 'svelte-adapter-uws-extensions/postgres/replay';
1044
+
1045
+ export const replay = createReplay(pg, {
1046
+ table: 'svti_replay',
1047
+ size: 1000,
1048
+ ttl: 86400, // 24 hours
1049
+ autoMigrate: true // auto-create table
1050
+ });
1051
+ ```
1052
+
1053
+ #### Schema
1054
+
1055
+ The table is created automatically on first use (if `autoMigrate` is true):
1056
+
1057
+ ```sql
1058
+ CREATE TABLE IF NOT EXISTS svti_replay (
1059
+ svti_replay_id BIGSERIAL PRIMARY KEY,
1060
+ topic TEXT NOT NULL,
1061
+ seq BIGINT NOT NULL,
1062
+ event TEXT NOT NULL,
1063
+ data JSONB,
1064
+ created_at TIMESTAMPTZ DEFAULT now()
1065
+ );
1066
+ CREATE INDEX IF NOT EXISTS idx_svti_replay_topic_seq ON svti_replay (topic, seq);
1067
+
1068
+ CREATE TABLE IF NOT EXISTS svti_replay_seq (
1069
+ topic TEXT PRIMARY KEY,
1070
+ seq BIGINT NOT NULL DEFAULT 0
1071
+ );
1072
+ ```
1073
+
1074
+ #### Options
1075
+
1076
+ | Option | Default | Description |
1077
+ |---|---|---|
1078
+ | `table` | `'svti_replay'` | Table name |
1079
+ | `size` | `1000` | Max messages per topic |
1080
+ | `ttl` | `0` | Row expiry in seconds (0 = never) |
1081
+ | `autoMigrate` | `true` | Auto-create table |
1082
+ | `cleanupInterval` | `60000` | Periodic cleanup interval in ms (0 to disable) |
1083
+
1084
+ #### API
1085
+
1086
+ Same as [Replay buffer (Redis)](#api-3), plus:
1087
+
1088
+ | Method | Description |
1089
+ |---|---|
1090
+ | `destroy()` | Stop the cleanup timer |
1091
+
1092
+ ---
1093
+
1094
+ ## LISTEN/NOTIFY bridge
1095
+
1096
+ Listens on a Postgres channel for notifications and forwards them to `platform.publish()`. You provide the trigger on your table -- this module handles the listening side.
1097
+
1098
+ Uses a standalone connection (not from the pool) since LISTEN requires a persistent connection that stays open for the lifetime of the bridge.
1099
+
1100
+ #### Setup
1101
+
1102
+ ```js
1103
+ // src/lib/server/notify.js
1104
+ import { pg } from './pg.js';
1105
+ import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
1106
+
1107
+ export const bridge = createNotifyBridge(pg, {
1108
+ channel: 'table_changes',
1109
+ parse: (payload) => {
1110
+ const row = JSON.parse(payload);
1111
+ return { topic: row.table, event: row.op, data: row.data };
1112
+ }
1113
+ });
1114
+ ```
1115
+
1116
+ #### Usage
1117
+
1118
+ ```js
1119
+ // src/hooks.ws.js
1120
+ import { bridge } from '$lib/server/notify';
1121
+
1122
+ let activated = false;
1123
+
1124
+ export function open(ws, { platform }) {
1125
+ if (!activated) {
1126
+ activated = true;
1127
+ bridge.activate(platform);
1128
+ }
1129
+ }
1130
+ ```
1131
+
1132
+ #### Setting up the trigger
1133
+
1134
+ Create a trigger function and attach it to your table:
1135
+
1136
+ ```sql
1137
+ CREATE OR REPLACE FUNCTION notify_table_change() RETURNS trigger AS $$
1138
+ BEGIN
1139
+ PERFORM pg_notify('table_changes', json_build_object(
1140
+ 'table', TG_TABLE_NAME,
1141
+ 'op', lower(TG_OP),
1142
+ 'data', CASE TG_OP
1143
+ WHEN 'DELETE' THEN row_to_json(OLD)
1144
+ ELSE row_to_json(NEW)
1145
+ END
1146
+ )::text);
1147
+ RETURN COALESCE(NEW, OLD);
1148
+ END;
1149
+ $$ LANGUAGE plpgsql;
1150
+
1151
+ CREATE TRIGGER messages_notify
1152
+ AFTER INSERT OR UPDATE OR DELETE ON messages
1153
+ FOR EACH ROW EXECUTE FUNCTION notify_table_change();
1154
+ ```
1155
+
1156
+ Now any INSERT, UPDATE, or DELETE on the `messages` table will fire a notification. The bridge parses it and calls `platform.publish()`, which reaches all connected WebSocket clients subscribed to the topic.
1157
+
1158
+ The client side needs no changes -- the core `crud('messages')` store already handles `created`, `updated`, and `deleted` events.
1159
+
1160
+ #### Options
1161
+
1162
+ | Option | Default | Description |
1163
+ |---|---|---|
1164
+ | `channel` | *required* | Postgres LISTEN channel name |
1165
+ | `parse` | JSON with `{ topic, event, data }` | Parse notification payload into a publish call. Return null to skip. |
1166
+ | `autoReconnect` | `true` | Reconnect on connection loss |
1167
+ | `reconnectInterval` | `3000` | ms between reconnect attempts |
1168
+ | `multiListener` | `'all'` | `'all'`: every replica opens its own LISTEN (current default). `'advisory'`: leader-elected via `pg_try_advisory_lock`. See [Single-listener mode](#single-listener-mode). |
1169
+ | `lockId` | -- | Advisory lock id. Required when `multiListener: 'advisory'`. |
1170
+ | `pollInterval` | `5000` | ms between leader-election polls (advisory mode only). |
1171
+
1172
+ #### Single-listener mode
1173
+
1174
+ By default each replica in an N-replica deployment opens its own LISTEN connection. That's N persistent Postgres connections doing the same work, plus N copies of the same notification.
1175
+
1176
+ `multiListener: 'advisory'` elects a single leader via Postgres advisory locks. One replica wins `pg_try_advisory_lock(lockId)` and holds the LISTEN connection; others poll for the lock every `pollInterval` ms. If the leader's connection drops, the session-scoped lock auto-releases and another replica picks it up on its next poll.
1177
+
1178
+ ```js
1179
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
1180
+ import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
1181
+
1182
+ const bus = createPubSubBus(redis);
1183
+
1184
+ const bridge = createNotifyBridge(pg, {
1185
+ channel: 'table_changes',
1186
+ multiListener: 'advisory',
1187
+ lockId: 0x6e6f7466 // any stable 32-bit id; e.g. CRC32 of the channel name
1188
+ });
1189
+
1190
+ export function open(ws, { platform }) {
1191
+ bus.activate(platform);
1192
+ bridge.activate(bus.wrap(platform));
1193
+ }
1194
+ ```
1195
+
1196
+ **Requires a cross-instance pub/sub bus.** In `'all'` mode the bridge passes `relay: false` because every replica's local LISTEN already delivers the notification. In `'advisory'` mode only the leader has LISTEN active, so the leader publishes *with* relay -- the bus fans out to non-leader replicas via Redis. Without a bus the leader's local clients receive notifications but other replicas' clients don't.
1197
+
1198
+ **Choosing a `lockId`.** Pick a stable 32-bit signed integer that's unique per channel within your deployment. CRC32 of the channel name is a reasonable hash; multiple channels in the same database need distinct ids.
1199
+
1200
+ #### API
1201
+
1202
+ | Method | Description |
1203
+ |---|---|
1204
+ | `activate(platform)` | Start listening (idempotent) |
1205
+ | `deactivate()` | Stop listening and release the connection |
1206
+
1207
+ #### Limitations
1208
+
1209
+ - **Payload is hard-limited to ~8000 bytes by Postgres** (`pg_notify` silently truncates or errors above this). This is a Postgres constraint, not a library limitation. The bridge warns at 7500 bytes. For large rows, send the row ID in the notification and let the client fetch the full row via an API call.
1210
+ - Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
1211
+ - This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
1212
+
1213
+ #### When to use this instead of Redis pub/sub
1214
+
1215
+ If your real-time events are driven by database writes and you do not need Redis for other extensions (presence, rate limiting, groups, cursors), the LISTEN/NOTIFY bridge is a simpler deployment: no Redis infrastructure, no separate pub/sub channel management, and your notifications are inherently tied to committed transactions. Use the Redis pub/sub bus when you need to broadcast events that do not originate from database writes, or when you are already running Redis for other extensions.
1216
+
1217
+ ---
1218
+
1219
+ ## Job queue
1220
+
1221
+ `createJobQueue` (`svelte-adapter-uws-extensions/postgres/jobs`) is a minimal `SELECT ... FOR UPDATE SKIP LOCKED` queue that works on vanilla Postgres 9.5+ -- no extensions required.
1222
+
1223
+ The shape: enqueue jobs into a named queue, claim batches atomically, mark complete (delete) or fail (release for retry). Visibility timeout means a worker that crashes mid-processing has its claim auto-expire so another worker can pick the job up. Max-attempts and dead-letter behavior are intentionally NOT baked in -- the `attempts` counter is exposed on every claim, callers track it and decide when to give up.
1224
+
1225
+ #### Setup
1226
+
1227
+ ```js
1228
+ // src/lib/server/jobs.js
1229
+ import { pg } from './pg.js';
1230
+ import { createJobQueue } from 'svelte-adapter-uws-extensions/postgres/jobs';
1231
+
1232
+ export const jobs = createJobQueue(pg, {
1233
+ visibilityTimeout: 60000 // 60s default; per-call override on claim()
1234
+ });
1235
+ ```
1236
+
1237
+ #### Producer
1238
+
1239
+ ```js
1240
+ // In a request handler:
1241
+ const id = await jobs.enqueue(
1242
+ 'email',
1243
+ { to: 'user@example.com', subject: 'Welcome' },
1244
+ { platform } // captures platform.requestId onto the row, surfaced as job.requestId on claim
1245
+ );
1246
+ ```
1247
+
1248
+ The third argument is an options bag; `platform` (the SvelteKit `event.platform`) auto-captures the originating request id, or pass `requestId` explicitly to override. The captured id surfaces on `job.requestId` when the row is claimed, so the worker can correlate logs back to the request that enqueued the job.
1249
+
1250
+ `enqueue()` returns the row id verbatim from `pg`, which serialises `BIGINT`/`BIGSERIAL` columns as **strings** by default to avoid precision loss past `Number.MAX_SAFE_INTEGER`. Pass it through to `claim()`/`complete()`/`fail()`/`extend()` as-is. If you want a JS number for logging or comparison, coerce explicitly with `Number(id)` (safe up to 2^53 -- still ~9 quadrillion rows of headroom).
1251
+
1252
+ #### Consumer (worker loop)
1253
+
1254
+ ```js
1255
+ // In a separate worker process or background loop:
1256
+ async function workerLoop() {
1257
+ while (running) {
1258
+ const batch = await jobs.claim('email', { batchSize: 5, visibilityTimeoutMs: 30000 });
1259
+ if (batch.length === 0) {
1260
+ await new Promise((r) => setTimeout(r, 1000));
1261
+ continue;
1262
+ }
1263
+ for (const job of batch) {
1264
+ try {
1265
+ await sendEmail(job.payload);
1266
+ await jobs.complete(job.id);
1267
+ } catch (err) {
1268
+ if (job.attempts >= 5) {
1269
+ await jobs.complete(job.id); // give up after 5 tries
1270
+ await logToDeadLetter(job, err);
1271
+ } else {
1272
+ await jobs.fail(job.id); // release for retry
1273
+ }
1274
+ }
1275
+ }
1276
+ }
1277
+ }
1278
+ ```
1279
+
1280
+ For long-running jobs that need more visibility headroom, call `jobs.extend(id, additionalMs)` periodically while processing.
1281
+
1282
+ #### Schema
1283
+
1284
+ The table is created automatically on first use (if `autoMigrate` is true):
1285
+
1286
+ ```sql
1287
+ CREATE TABLE IF NOT EXISTS svti_jobs (
1288
+ svti_jobs_id BIGSERIAL PRIMARY KEY,
1289
+ queue TEXT NOT NULL,
1290
+ payload JSONB,
1291
+ request_id TEXT, -- originating platform.requestId, or null
1292
+ claimed_at TIMESTAMPTZ,
1293
+ claimed_until TIMESTAMPTZ,
1294
+ attempts INTEGER NOT NULL DEFAULT 0,
1295
+ created_at TIMESTAMPTZ DEFAULT now()
1296
+ );
1297
+ CREATE INDEX IF NOT EXISTS idx_svti_jobs_queue_pending
1298
+ ON svti_jobs (queue, svti_jobs_id) WHERE claimed_at IS NULL;
1299
+ CREATE INDEX IF NOT EXISTS idx_svti_jobs_visibility
1300
+ ON svti_jobs (claimed_until) WHERE claimed_at IS NOT NULL;
1301
+ ```
1302
+
1303
+ Existing 0.5.0-next.1 deployments forward-migrate via `ALTER TABLE ... ADD COLUMN IF NOT EXISTS request_id TEXT` on first use; idempotent and zero-downtime.
1304
+
1305
+ #### Options
1306
+
1307
+ | Option | Default | Description |
1308
+ |---|---|---|
1309
+ | `table` | `'svti_jobs'` | Table name |
1310
+ | `autoMigrate` | `true` | Auto-create the table on first use |
1311
+ | `visibilityTimeout` | `30000` | Default ms a claim is held before another worker can re-claim |
1312
+
1313
+ #### API
1314
+
1315
+ | Method | Description |
1316
+ |---|---|
1317
+ | `enqueue(queue, payload, opts?)` | Insert a job; returns the job id. Opts: `{ requestId?, platform? }` -- `platform.requestId` is captured automatically when `platform` is passed |
1318
+ | `claim(queue, opts?)` | `SELECT ... FOR UPDATE SKIP LOCKED` claim; opts: `{ batchSize?, visibilityTimeoutMs? }`. Each returned job carries `id, queue, payload, requestId, attempts, created_at` |
1319
+ | `complete(idOrIds)` | Delete the job(s) on success |
1320
+ | `fail(idOrIds)` | Release the claim for retry |
1321
+ | `extend(idOrIds, ms)` | Push back the visibility deadline |
1322
+ | `pending(queue?)` | Count of unclaimed jobs |
1323
+ | `clear(queue?)` | Delete all jobs (useful for tests) |
1324
+
1325
+ #### When to use this vs createTaskRunner
1326
+
1327
+ | | `createJobQueue` | `createTaskRunner` |
1328
+ |---|---|---|
1329
+ | Surface | minimal: claim / complete / fail | full state machine: idempotency + fence + retry + result tracking |
1330
+ | Best fit | "ingest event, defer work" with caller-driven retry | tasks that must complete exactly once with cross-instance recovery |
1331
+ | Result tracking | none (caller tracks via DB writes) | yes (`run` / `await`) |
1332
+
1333
+ If your handler must run exactly once and you want the runtime to track the result, reach for `createTaskRunner`. If you want a lighter producer/consumer split with caller-driven retry, this is the simpler shape.
1334
+
1335
+ ---
1336
+
1337
+ **Cross-backend**
1338
+
1339
+ ## Idempotency store
1340
+
1341
+ Caches the result of an effectful operation under a stable key so retries within `ttl` return the original outcome rather than re-executing. Use it for HTTP/RPC retries, webhook redeliveries, and any handler where the caller may legitimately repeat a request that must execute at most once -- charge-customer, send-email, create-order.
1342
+
1343
+ The store exposes three states via `acquire(key)`:
1344
+
1345
+ - **acquired** -- you own the slot. Run the work, then call `commit(result)` on success or `abort()` on failure.
1346
+ - **pending** -- another caller acquired the slot and has not committed yet. Decide locally whether to return a 409, retry later, or wait.
1347
+ - **result** -- a previous run committed. The cached value is returned.
1348
+
1349
+ A short `acquireTtl` (default 60 seconds) bounds how long a pending slot can hold the key, so a crashed owner cannot deadlock retries forever. On `commit` the longer `ttl` (default 48 hours) replaces the sentinel and governs the cache lifetime.
1350
+
1351
+ Two backends share the same contract: pick whichever your stack already runs. The adapter's in-memory `Dedup` plugin is the zero-config fallback for single-instance deployments.
1352
+
1353
+ #### Setup (Redis)
1354
+
1355
+ ```js
1356
+ // src/lib/server/idempotency.js
1357
+ import { redis } from './redis.js';
1358
+ import { createIdempotencyStore } from 'svelte-adapter-uws-extensions/redis/idempotency';
1359
+
1360
+ export const idempotency = createIdempotencyStore(redis, {
1361
+ keyPrefix: 'idem:',
1362
+ ttl: 48 * 3600, // result cache lifetime (48h)
1363
+ acquireTtl: 60 // pending-slot lifetime (60s)
1364
+ });
1365
+ ```
1366
+
1367
+ Backed by a single Redis string per key. The acquire path is one Lua-script round trip.
1368
+
1369
+ #### Setup (Postgres)
1370
+
1371
+ ```js
1372
+ // src/lib/server/idempotency.js
1373
+ import { pg } from './pg.js';
1374
+ import { createIdempotencyStore } from 'svelte-adapter-uws-extensions/postgres/idempotency';
1375
+
1376
+ export const idempotency = createIdempotencyStore(pg, {
1377
+ table: 'svti_idempotency',
1378
+ ttl: 48 * 3600,
1379
+ acquireTtl: 60,
1380
+ autoMigrate: true
1381
+ });
1382
+ ```
1383
+
1384
+ The Postgres backend periodically deletes expired rows (configurable via `cleanupInterval`, default 60s, 0 to disable). Stale pending rows clear on the next sweep without manual intervention.
1385
+
1386
+ The Postgres table is created automatically on first use:
1387
+
1388
+ ```sql
1389
+ CREATE TABLE IF NOT EXISTS svti_idempotency (
1390
+ svti_idempotency_key TEXT PRIMARY KEY,
1391
+ status TEXT NOT NULL,
1392
+ result JSONB,
1393
+ expires_at TIMESTAMPTZ NOT NULL
1394
+ );
1395
+ CREATE INDEX IF NOT EXISTS idx_svti_idempotency_expires_at ON svti_idempotency (expires_at);
1396
+ ```
1397
+
1398
+ #### Usage
1399
+
1400
+ ```js
1401
+ // Wrap an effectful handler. The caller passes a stable key per logical
1402
+ // operation; identical retries return the cached result.
1403
+ export async function placeOrder(input, ctx) {
1404
+ const idempotencyKey = `order:${ctx.user.id}:${input.clientOrderId}`;
1405
+
1406
+ const slot = await idempotency.acquire(idempotencyKey);
1407
+ if (slot.acquired) {
1408
+ try {
1409
+ const order = await db.createOrder(input);
1410
+ await slot.commit(order);
1411
+ return order;
1412
+ } catch (err) {
1413
+ await slot.abort();
1414
+ throw err;
1415
+ }
1416
+ }
1417
+ if (slot.pending) {
1418
+ throw new Error('duplicate request in flight');
1419
+ }
1420
+ return slot.result;
1421
+ }
1422
+ ```
1423
+
1424
+ #### Options
1425
+
1426
+ | Option | Default | Description |
1427
+ |---|---|---|
1428
+ | `keyPrefix` (Redis) | `'idem:'` | Prepended to every Redis key after the client keyPrefix |
1429
+ | `table` (Postgres) | `'svti_idempotency'` | Table name |
1430
+ | `ttl` | `172800` (48h) | Result cache lifetime in seconds |
1431
+ | `acquireTtl` | `60` | Pending-slot lifetime in seconds (anti-deadlock) |
1432
+ | `autoMigrate` (Postgres) | `true` | Auto-create the table on first use |
1433
+ | `cleanupInterval` (Postgres) | `60000` | Periodic expired-row cleanup interval in ms (0 to disable) |
1434
+ | `breaker` | -- | Circuit breaker; bypassed when broken |
1435
+ | `metrics` | -- | Prometheus registry; emits `idempotency_*_total` counters |
1436
+
1437
+ #### API
1438
+
1439
+ | Method | Description |
1440
+ |---|---|
1441
+ | `acquire(key)` | Returns `{acquired, commit, abort}` or `{acquired: false, pending: true}` or `{acquired: false, result}` |
1442
+ | `purge(key)` | Drop a single cached result |
1443
+ | `clear()` | Drop every key under the configured prefix / table |
1444
+ | `destroy()` (Postgres only) | Stop the cleanup timer |
1445
+
1446
+ #### Choosing acquireTtl
1447
+
1448
+ `acquireTtl` is the upper bound on how long a single execution of the wrapped operation can run before retries see the slot as available again. Set it longer than your worst expected latency for the wrapped handler, but short enough that a crashed instance does not block retries for too long. The default (60 seconds) suits most HTTP and RPC handlers; bump it for long-running tasks (large file uploads, multi-step workflows) and trim it for tight read-heavy paths.
1449
+
1450
+ #### Pairing with the Dedup plugin
1451
+
1452
+ The adapter's in-memory `createDedup` plugin (`svelte-adapter-uws/plugins/dedup`) is the single-instance fallback for the same contract. The shape is identical, so swapping the backend is a one-line change. Use the in-memory plugin for tests and single-process deployments; reach for this store the moment a second instance enters the picture.
1453
+
1454
+ ---
1455
+
1456
+ ## Distributed lock
1457
+
1458
+ Cluster-wide mutual-exclusion primitive. The adapter ships an in-memory `Lock` plugin that serializes `withLock(key, fn)` per key on a single instance via a `Map<string, Promise>`; this is the Redis-backed swap for multi-instance deployments. Distinct from `redis/fence` (B2c): the fence module is task-runner-specific (one fence per `taskId`, paired with the Postgres state machine); this is a general-purpose mutex any user code can grab.
1459
+
1460
+ #### Setup
1461
+
1462
+ ```js
1463
+ import { redis } from './redis.js';
1464
+ import { createDistributedLock } from 'svelte-adapter-uws-extensions/redis/lock';
1465
+
1466
+ export const lock = createDistributedLock(redis, {
1467
+ defaultTtlMs: 30_000,
1468
+ maxWaitMs: 5000
1469
+ });
1470
+ ```
1471
+
1472
+ #### Use
1473
+
1474
+ ```js
1475
+ import { lock } from '$lib/server/lock';
1476
+
1477
+ await lock.withLock('order-42', async (signal) => {
1478
+ // serialized cluster-wide. Bail when `signal.aborted` if the heartbeat
1479
+ // detects we lost ownership mid-flight.
1480
+ if (signal.aborted) return;
1481
+ await processOrder(42);
1482
+ });
1483
+ ```
1484
+
1485
+ `withLock(key, fn, options?)` runs `fn` while holding a cluster-wide mutex on `key`. The user fn cannot forget to release -- the lock module owns the release path. `fn`'s return value is forwarded through; errors thrown by `fn` propagate after the lock is released.
1486
+
1487
+ #### How it works
1488
+
1489
+ 1. **Acquire.** `SET <fullKey> <fenceToken> NX PX <ttlMs>` -- atomic test-and-set with a TTL. The fence token is a per-call random UUID so a stale heartbeat or release from a previous holder can never affect a new holder.
1490
+ 2. **Retry.** On collision (someone else holds the key), sleep `retryDelayMs` and try again. After `maxWaitMs` total wait, throw `LockAcquireTimeoutError`.
1491
+ 3. **Hold.** While `fn` is running, a heartbeat tick every `heartbeatMs` (defaults to `defaultTtlMs / 3`) refreshes the TTL via Lua-atomic `if get == fenceToken then pexpire end`. If the heartbeat returns 0 (we no longer own the key -- operator force-takeover, TTL elapsed before we could refresh), the supplied `AbortSignal` fires with a `LockLostError` and the heartbeat stops.
1492
+ 4. **Release.** On `fn`'s completion (success or error), Lua-atomic `if get == fenceToken then del end` releases the key. Skipped if we already lost ownership (no-op via the compare guard regardless).
1493
+
1494
+ The `AbortSignal` shape is the cluster-correctness story: when the heartbeat detects loss, your code learns immediately and can bail instead of continuing to mutate state another holder now thinks it owns. Listen for the `abort` event or check `signal.aborted` at long-running checkpoints.
1495
+
1496
+ #### Options
1497
+
1498
+ | Option | Default | Description |
1499
+ |---|---|---|
1500
+ | `keyPrefix` | `'lock:'` | Prefix prepended (after the client `keyPrefix`) to every lock key. |
1501
+ | `defaultTtlMs` | `30000` | TTL on a held lock in milliseconds. The heartbeat refreshes this back to the original value before it elapses; if the holder dies and stops heartbeating, the lock auto-expires after at most `ttlMs` so cluster work is not permanently blocked. |
1502
+ | `retryDelayMs` | `50` | Sleep between acquire retries when the key is held by another instance. Constant retry; no exponential backoff. |
1503
+ | `maxWaitMs` | `5000` | Total time to wait before throwing `LockAcquireTimeoutError`. Override per call via `withLock(key, fn, { maxWaitMs })`. |
1504
+ | `heartbeatMs` | `defaultTtlMs / 3` | Heartbeat refresh interval. Default keeps margin for one missed beat. |
1505
+ | `mapKey` | identity | Map lock key names to bounded label values for metric cardinality. |
1506
+ | `breaker` | -- | Optional circuit breaker for the Redis ops. |
1507
+ | `metrics` | -- | Optional Prometheus metrics registry. |
1508
+
1509
+ #### Per-call options
1510
+
1511
+ | Option | Description |
1512
+ |---|---|
1513
+ | `ttlMs` | Override the holder TTL for this call. |
1514
+ | `maxWaitMs` | Override the maximum acquire wait for this call. |
1515
+ | `signal` | External cancellation signal. Aborts the acquire loop and the inner-`fn` execution; the lock is still released cleanly on the way out (we held it; we give it back). |
1516
+
1517
+ #### Errors
1518
+
1519
+ - **`LockAcquireTimeoutError`** -- thrown by `withLock` when `maxWaitMs` elapses without a successful acquire. Properties: `key`, `waitedMs`.
1520
+ - **`LockLostError`** -- surfaced via `signal.reason` (and `controller.abort(...)` on the user fn's signal) when the heartbeat detects we no longer own the key. The user fn keeps running through this; it's up to your code to react. Property: `key`.
1521
+
1522
+ #### Metrics
1523
+
1524
+ | Metric | Description |
1525
+ |---|---|
1526
+ | `lock_acquired_total{key_class}` | Counter of successful acquires. `key_class` is `mapKey(key)`. |
1527
+ | `lock_acquire_wait_ms` | Histogram of time waited from `withLock` call to successful acquire (ms). Observed only on success. |
1528
+ | `lock_acquire_timeouts_total{key_class}` | Counter of `withLock` calls that exceeded `maxWaitMs` without acquiring. |
1529
+ | `lock_lost_total{key_class}` | Counter of locks lost mid-flight via heartbeat detection. A non-zero rate signals operator force-takeover or holder TTL elapsing -- both indicate `defaultTtlMs` should be raised or work should be split into smaller chunks. |
1530
+
1531
+ #### When to use which
1532
+
1533
+ - **`createDistributedLock`** when business logic needs "only one instance runs this critical section at a time" -- dedicated lookups against rate-limited APIs, periodic cluster work that must not double-fire, transactional state machines that don't fit `createTaskRunner`'s shape.
1534
+ - **`createTaskRunner`** when the work is a durable side-effect that must finish exactly once across crashes (charge customer, send email). The runner pairs a Postgres state machine with the Redis fence to guarantee at-most-one and at-least-once delivery.
1535
+ - **`createIdempotencyStore`** when the contract is "this operation has a result, and a retry within the TTL must return the same result." Mutex semantics are not the goal -- caching the outcome is.
1536
+
1537
+ ---
1538
+
1539
+ ## Distributed session
1540
+
1541
+ Cluster-wide session store with sliding TTL. The adapter ships an in-memory `Session` plugin (`svelte-adapter-uws/plugins/session`) that holds `Map<token, data>` in process memory; this is the Redis-backed swap for multi-instance deployments where a session created on instance A must be readable from instance B after a load-balancer hop.
1542
+
1543
+ Pairs with [Connection registry](#connection-registry): when both are wired, the session provides the durable per-user state (survives disconnect, persists across reconnect, available before a WS even opens), while the registry provides the live "where is this user right now" pointer (only meaningful while the WS is connected). They answer different questions and compose without overlap.
1544
+
1545
+ #### Setup
1546
+
1547
+ ```js
1548
+ import { redis } from './redis.js';
1549
+ import { createDistributedSession } from 'svelte-adapter-uws-extensions/redis/session';
1550
+
1551
+ export const sessions = createDistributedSession(redis, {
1552
+ ttlMs: 30 * 60 * 1000 // 30 minutes
1553
+ });
1554
+ ```
1555
+
1556
+ #### Use
1557
+
1558
+ ```js
1559
+ // On login (HTTP handler):
1560
+ await sessions.set(token, { userId: 42, role: 'admin' });
1561
+
1562
+ // On WS upgrade or HTTP request:
1563
+ const data = await sessions.get(token);
1564
+ if (!data) return error(401, 'session expired');
1565
+
1566
+ // Refresh without reading data:
1567
+ await sessions.touch(token);
1568
+
1569
+ // Logout:
1570
+ await sessions.delete(token);
1571
+ ```
1572
+
1573
+ #### Storage shape
1574
+
1575
+ Each session is one Redis string at `{prefix}sess:{token}` containing the JSON-encoded data, with a TTL of `ttlMs`. Single round-trip for every operation: `SET key json PX ttl` to write, `GET key` plus `PEXPIRE key ttl` to read with sliding refresh, `PEXPIRE` to touch, `UNLINK` to delete. JSON blob keeps the whole record atomic on replace; partial-field updates are not a feature -- callers do `set(token, { ...await get(token), changed: 'value' })` for that.
1576
+
1577
+ #### Sliding TTL
1578
+
1579
+ Every `set` resets the TTL to `ttlMs`. By default `get` and `touch` also extend the TTL on a hit (the adapter's bundled `Session` plugin does the same). Disable read-time refresh via `refreshOnGet: false` for read-only flows where reads should not act as liveness signals.
1580
+
1581
+ `touch(token)` and `delete(token)` return `true` if the entry was present and the operation succeeded, `false` if the entry was missing or already expired. `get(token)` returns `null` for missing, expired, or corrupt (non-JSON) entries; the corrupt-entry path is treated as a miss so the next `set` cleanly overwrites.
1582
+
1583
+ #### Options
1584
+
1585
+ | Option | Default | Description |
1586
+ |---|---|---|
1587
+ | `keyPrefix` | `'sess:'` | Prefix prepended (after the client `keyPrefix`) to every session key. |
1588
+ | `ttlMs` | `86_400_000` (24h) | Sliding TTL window in milliseconds. |
1589
+ | `refreshOnGet` | `true` | Whether `get(token)` extends the TTL on a hit. |
1590
+ | `breaker` | -- | Optional circuit breaker for the Redis ops. |
1591
+ | `metrics` | -- | Optional Prometheus metrics registry. |
1592
+
1593
+ #### Pairing with the bundled Session plugin
1594
+
1595
+ The shape is identical: same `get` / `set` / `delete` / `touch` / `clear` names, same sliding-TTL semantics. The Redis-backed methods return `Promise<T>` while the bundled plugin's are synchronous, so a swap requires `await` on the call sites -- but otherwise the contract is shared. Use the bundled plugin for tests and single-process deployments; reach for this store the moment a second instance enters the picture.
1596
+
1597
+ #### Pairing with the connection registry
1598
+
1599
+ Sessions and the [Connection registry](#connection-registry) answer different questions:
1600
+
1601
+ - **Session:** "What does the system know about this user across HTTP and WS?" Survives disconnect; persists across reconnect; available even when the user has no live WS.
1602
+ - **Registry:** "Which instance currently owns this user's WS, and what attributes did the connection register with?" Lives only while the WS is connected; cleared on close (compare-and-delete).
1603
+
1604
+ Wire them together by storing the auth token both on the WS userData (so the registry can identify the user) and using the same token to look up session data:
1605
+
1606
+ ```js
1607
+ // hooks.ws.js
1608
+ import { sessions } from '$lib/server/sessions';
1609
+ import { registry } from '$lib/server/registry';
1610
+
1611
+ export async function upgrade({ cookies }) {
1612
+ const token = cookies.get('session_id');
1613
+ if (!token) return false;
1614
+ const session = await sessions.get(token);
1615
+ if (!session) return false;
1616
+ return { token, userId: session.userId };
1617
+ }
1618
+
1619
+ export const open = registry.hooks.open;
1620
+ export const close = registry.hooks.close;
1621
+
1622
+ export async function message(ws) {
1623
+ await sessions.touch(ws.getUserData().token);
1624
+ }
1625
+ ```
1626
+
1627
+ #### Metrics
1628
+
1629
+ | Metric | Description |
1630
+ |---|---|
1631
+ | `session_get_total{result="hit|miss"}` | Counter of get calls. Hit rate signals cache freshness; a miss spike under steady traffic suggests TTL is too short. |
1632
+ | `session_set_total` | Counter of set calls. |
1633
+ | `session_delete_total{result="present|absent"}` | Counter of delete calls. `present` = the entry existed; `absent` = the token was already gone (idempotent logout). |
1634
+ | `session_touch_total{result="present|absent"}` | Counter of touch calls. `absent` = the entry was missing or already expired. |
1635
+
1636
+ #### `clear()` and operational notes
1637
+
1638
+ `clear()` removes every session under the configured `keyPrefix` via SCAN + UNLINK. Cluster-wide cost scales with total session count -- not a hot-path operation. Use for graceful-shutdown teardowns, test harnesses, or operator-initiated wipes (e.g., post-incident "log everyone out"). Other keys outside the prefix are untouched.
1639
+
1640
+ There is intentionally no `size()` method: a cluster-wide count of session keys requires SCAN every time, and exposing it as a synchronous-looking accessor would be misleading. Apps that want session counts should track a separate Redis SET on `set` / `delete` and `SCARD` it.
1641
+
1642
+ ---
1643
+
1644
+ ## Task runner
1645
+
1646
+ Wraps an effectful operation in a state machine that survives process crashes and naturally fans across cluster instances. Use it for background work that absolutely must finish exactly once: charging a customer, sending a transactional email, posting to a webhook, kicking off a long-running pipeline.
1647
+
1648
+ **Requires Postgres 13+.** Uses the built-in `gen_random_uuid()` function (added to core in 13; older versions need the `pgcrypto` extension explicitly enabled, which the runner does not do for you).
1649
+
1650
+ **Task names must match `/^[a-zA-Z][a-zA-Z0-9_-]*$/`** -- start with a letter, then letters/digits/underscores/hyphens. Names starting with `_` or a digit are rejected at `register()` time. Trips test fixtures most often (`__noop` -> `noop`).
1651
+
1652
+ Three guarantees:
1653
+
1654
+ - **Caller-retry idempotency.** Pair the runner with the idempotency store via the `idempotency` option. When a caller passes the same `idempotencyKey` twice, the second call returns the cached result instead of re-running the handler.
1655
+ - **Worker-crash recovery.** Every attempt holds a fence UUID and a `fence_expires_at` timestamp. The conditional commit `UPDATE ... WHERE fence = $current_fence` is atomic, so a stuck attempt that comes back from the dead cannot overwrite a completed attempt's result. A periodic recovery sweep reclaims rows whose fence has expired and re-drives the handler in any live instance.
1656
+ - **External-service idempotency.** The `idempotencyKey` is forwarded to the handler, where you pass it on to Stripe / SendGrid / S3 so the side-effect target de-duplicates retries too.
1657
+
1658
+ #### Setup
1659
+
1660
+ ```js
1661
+ // src/lib/server/tasks.js
1662
+ import { pg } from './pg.js';
1663
+ import { idempotency } from './idempotency.js';
1664
+ import { createTaskRunner } from 'svelte-adapter-uws-extensions/postgres/tasks';
1665
+
1666
+ export const tasks = createTaskRunner(pg, {
1667
+ idempotency, // optional but recommended
1668
+ fenceTtl: 60, // seconds; per-attempt fence lifetime
1669
+ recoveryInterval: 30000,
1670
+ cleanupInterval: 3600000,
1671
+ rowTtl: 7 * 24 * 3600 // keep terminal rows for 7 days
1672
+ });
1673
+
1674
+ tasks.register('charge-customer', async ({ input, idempotencyKey, requestId, signal }) => {
1675
+ log.info({ requestId, customerId: input.customerId }, 'charging customer');
1676
+ return await stripe.paymentIntents.create(
1677
+ { amount: input.amount, customer: input.customerId },
1678
+ { idempotencyKey, signal }
1679
+ );
1680
+ }, {
1681
+ retry: {
1682
+ maxAttempts: 5,
1683
+ backoff: (attempt) => Math.min(1000 * 2 ** (attempt - 1), 60000),
1684
+ on: (err) => err.type === 'StripeAPIError'
1685
+ }
1686
+ });
1687
+ ```
1688
+
1689
+ #### Usage
1690
+
1691
+ ```js
1692
+ // In a form action, RPC handler, anywhere with an awaited result
1693
+ import { tasks } from '$lib/server/tasks';
1694
+
1695
+ export const actions = {
1696
+ pay: async ({ request, locals, platform }) => {
1697
+ const { amount } = Object.fromEntries(await request.formData());
1698
+ const result = await tasks.run('charge-customer', {
1699
+ input: { amount, customerId: locals.user.stripeCustomerId },
1700
+ idempotencyKey: `charge-${locals.user.id}-${request.headers.get('idempotency-key')}`,
1701
+ platform // captures platform.requestId onto the row, exposed as ctx.requestId in the handler
1702
+ });
1703
+ return { success: true, paymentIntentId: result.id };
1704
+ }
1705
+ };
1706
+ ```
1707
+
1708
+ Pass `platform` (the SvelteKit `event.platform`) to capture the originating request id automatically -- it lands on `svti_tasks.request_id` and surfaces as `ctx.requestId` in the handler so logs from inside the task correlate back to the WS / HTTP request that started it. Override explicitly via the `requestId` option for non-request contexts (cron, recovery, manual invocation).
1709
+
1710
+ #### Schema
1711
+
1712
+ The table is created automatically on first use (if `autoMigrate` is true):
1713
+
1714
+ ```sql
1715
+ CREATE TABLE IF NOT EXISTS svti_tasks (
1716
+ svti_tasks_id UUID PRIMARY KEY,
1717
+ name TEXT NOT NULL,
1718
+ input JSONB,
1719
+ svti_idempotency_key TEXT,
1720
+ request_id TEXT, -- originating platform.requestId, or null
1721
+ status TEXT NOT NULL, -- 'running' | 'committed' | 'failed'
1722
+ result JSONB,
1723
+ error JSONB,
1724
+ fence UUID NOT NULL,
1725
+ fence_expires_at TIMESTAMPTZ NOT NULL,
1726
+ attempts INT NOT NULL DEFAULT 1,
1727
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1728
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
1729
+ );
1730
+ CREATE INDEX IF NOT EXISTS idx_svti_tasks_running_fence
1731
+ ON svti_tasks (fence_expires_at) WHERE status = 'running';
1732
+ CREATE INDEX IF NOT EXISTS idx_svti_tasks_terminal_updated
1733
+ ON svti_tasks (updated_at) WHERE status IN ('committed', 'failed');
1734
+ ```
1735
+
1736
+ Existing 0.5.0-next.1 deployments forward-migrate via `ALTER TABLE ... ADD COLUMN IF NOT EXISTS request_id TEXT` on first use; idempotent and zero-downtime.
1737
+
1738
+ #### Options
1739
+
1740
+ | Option | Default | Description |
1741
+ |---|---|---|
1742
+ | `table` | `'svti_tasks'` | Table name |
1743
+ | `idempotency` | -- | An idempotency store ([above](#idempotency-store)). When provided, results are cached per `idempotencyKey`. Strongly recommended. |
1744
+ | `fenceTtl` | `60` | Per-attempt fence lifetime in seconds. Heartbeat extends it while the handler runs. |
1745
+ | `heartbeatInterval` | `fenceTtl * 1000 / 3` | ms between fence heartbeats |
1746
+ | `recoveryInterval` | `30000` | ms between recovery sweeps. 0 disables. |
1747
+ | `recoveryBatchSize` | `10` | Max rows reclaimed per sweep |
1748
+ | `dispatchInterval` | `5000` | ms between dispatch sweeps that claim `enqueue`d pending rows. 0 disables. |
1749
+ | `dispatchBatchSize` | `10` | Max pending rows claimed per dispatch sweep |
1750
+ | `awaitPollInterval` | `500` | ms between row reads while `await()` waits |
1751
+ | `awaitTimeout` | `60000` | ms after which `await()` rejects if the task is still not terminal. 0 = no timeout. |
1752
+ | `cleanupInterval` | `3600000` | ms between cleanup sweeps. 0 disables. |
1753
+ | `rowTtl` | `604800` (7 days) | Seconds to keep terminal rows before deletion |
1754
+ | `autoMigrate` | `true` | Auto-create the table on first use |
1755
+ | `breaker` | -- | Circuit breaker; bypassed when broken |
1756
+ | `metrics` | -- | Prometheus registry; emits `tasks_*_total` counters |
1757
+
1758
+ #### Per-handler retry config
1759
+
1760
+ Retry is declared at registration so the policy travels with the handler, not with each call site:
1761
+
1762
+ ```js
1763
+ tasks.register('flaky-webhook', handler, {
1764
+ retry: {
1765
+ maxAttempts: 5,
1766
+ backoff: (attempt, err) => Math.min(1000 * 2 ** (attempt - 1), 60000),
1767
+ on: (err) => !(err instanceof PermanentError)
1768
+ }
1769
+ });
1770
+ ```
1771
+
1772
+ Default is no retry on handler-thrown errors -- safe for non-idempotent tasks. Stuck recovery (fence-expired-while-running) is always on; it is not the same thing as retry-on-failure.
1773
+
1774
+ #### Handler context
1775
+
1776
+ ```ts
1777
+ {
1778
+ input: TInput, // the input passed to run()
1779
+ idempotencyKey: string | undefined, // forward to external services
1780
+ fence: string, // this attempt's fence UUID, read-only
1781
+ signal: AbortSignal, // aborts when the fence is lost
1782
+ attempt: number // 1-based attempt counter
1783
+ }
1784
+ ```
1785
+
1786
+ The `signal` fires when the heartbeat detects another worker has reclaimed the row (your fence_expires_at passed and the recovery sweep took over). Pass it to `fetch`, Stripe, anything that supports cancellation -- the handler should bail gracefully when it fires rather than racing the new owner.
1787
+
1788
+ #### Errors
1789
+
1790
+ `run()` throws three error shapes:
1791
+
1792
+ - `UnknownTaskError` -- no handler registered for that name in this process. Recovery does not throw on unknown names because the handler may live on a different deployment.
1793
+ - `TaskInFlightError` -- the idempotency store reports the slot as pending (another caller is mid-flight for the same key). Caller may surface a 409 to the upstream HTTP request or retry after a backoff.
1794
+ - The handler's own thrown error, after retries are exhausted. Errors are serialised to `{name, message, stack, code, cause}` for the failed-row record and reconstructed as a plain `Error` if a sibling caller reads the row.
1795
+
1796
+ #### Async path: `enqueue` + `await`
1797
+
1798
+ `run()` blocks the calling process until the handler finishes. Two more verbs let you decouple submission from completion:
1799
+
1800
+ - **`enqueue(name, opts)`** -- fire-and-forget. Inserts the row with `status='pending'` and returns the `taskId` immediately. A dispatch sweep on any live instance picks up the row and runs the handler in the background.
1801
+ - **`await(taskId, opts?)`** -- block until the task reaches a terminal status. Returns the committed result, throws the stored error, or rejects with a timeout if the task is still pending/running past `awaitTimeout`.
1802
+
1803
+ ```js
1804
+ import { tasks } from '$lib/server/tasks';
1805
+
1806
+ // Submit a job that will be processed elsewhere
1807
+ const taskId = await tasks.enqueue('send-welcome-email', {
1808
+ input: { userId: locals.user.id },
1809
+ idempotencyKey: `welcome-${locals.user.id}`
1810
+ });
1811
+
1812
+ // Optionally block on completion (or fire-and-forget by skipping this)
1813
+ const result = await tasks.await(taskId, { timeout: 30000 });
1814
+ ```
1815
+
1816
+ Use cases:
1817
+
1818
+ - **HTTP handler returns 202 quickly**: `enqueue` and respond with the `taskId`. The client polls a status endpoint that reads the row.
1819
+ - **Cross-instance work distribution**: enqueue from a web tier; a worker tier with the handlers registered picks the row up via dispatch.
1820
+ - **Decoupling submission from result**: the dispatch sweep also picks up rows whose handler is unknown locally and leaves them in `running` until another instance reclaims them.
1821
+
1822
+ The dispatch loop runs every `dispatchInterval` ms (default 5000) and claims up to `dispatchBatchSize` pending rows per sweep via `FOR UPDATE SKIP LOCKED`. Set `dispatchInterval: 0` to disable dispatch entirely (use only `run()` paths).
1823
+
1824
+ `await` polls the task row every `awaitPollInterval` ms (default 500) until terminal or `awaitTimeout` ms (default 60000). For most use cases the runner-level defaults are fine; per-call overrides are available:
1825
+
1826
+ ```js
1827
+ await tasks.await(taskId, { pollInterval: 100, timeout: 10000 });
1828
+ ```
1829
+
1830
+ Errors thrown by the handler are reconstructed from the stored row (`name`, `message`, `stack`, `code`, `cause`) when `await` reads a `failed` row. The reconstructed error is a plain `Error`; the original prototype chain does not survive the JSON round-trip.
1831
+
1832
+ #### Worker thread execution
1833
+
1834
+ By default the handler runs in the current process. For CPU-bound work that would otherwise block the event loop -- image resize, hashing, large JSON parse, anything that genuinely consumes a CPU core for long enough to matter -- you can opt in to running the handler in a worker thread.
1835
+
1836
+ The handler lives in a separate file whose default export is the handler. The runner spawns a thread pool per task; each thread imports the handler file once at startup and reuses the import across runs. Database and Redis clients cannot be shared across worker threads (native handles do not cross thread boundaries), so the worker file boots its own.
1837
+
1838
+ ```js
1839
+ // src/lib/server/workers/resize.js
1840
+ import sharp from 'sharp';
1841
+
1842
+ export default async function resize({ input, signal }) {
1843
+ return await sharp(input.imageBuffer, { signal })
1844
+ .resize(input.width, input.height)
1845
+ .toBuffer();
1846
+ }
1847
+ ```
1848
+
1849
+ ```js
1850
+ // src/lib/server/tasks.js
1851
+ import { tasks } from './tasks.js';
1852
+
1853
+ tasks.register('resize-image', null, {
1854
+ worker: new URL('./workers/resize.js', import.meta.url)
1855
+ });
1856
+
1857
+ // Or with explicit pool config:
1858
+ tasks.register('resize-image', null, {
1859
+ worker: {
1860
+ path: new URL('./workers/resize.js', import.meta.url),
1861
+ pool: { size: 4, idleTimeout: 30000 }
1862
+ }
1863
+ });
1864
+ ```
1865
+
1866
+ When `worker` is set, the `handler` argument to `register` must be `null` or omitted -- the handler argument exists in the worker file, not at the registration site. Pool defaults: `size: 1`, `idleTimeout: 30000` ms (set to `0` to keep workers warm forever). Workers spawn lazily on first run; idle workers past `idleTimeout` are terminated.
1867
+
1868
+ The `signal` in the handler context fires when the runner detects a fence loss, exactly as for in-process handlers. The runner forwards an abort message to the worker; the worker translates it into a local `AbortController.abort()` so the handler can bail.
1869
+
1870
+ When *not* to use this:
1871
+
1872
+ - I/O-bound tasks (HTTP, database, Stripe). Workers add startup cost (~10ms cold, ~50MB memory) and connection-pool duplication; the event loop already handles I/O concurrency natively.
1873
+ - Anything that needs to share in-memory state with the main process. Workers have separate memory.
1874
+
1875
+ When this *is* the right tool: a single handler that synchronously consumes the event loop for tens of milliseconds or more, where blocking the cluster instance's other work is unacceptable.
1876
+
1877
+ #### Redis fence provider (force-takeover detection)
1878
+
1879
+ Pass an optional `fence` provider to add a second source of truth for "is this attempt's fence still alive". The Postgres row remains the canonical record of task state; the provider mirrors the fence value to an external store with a short TTL refreshed by heartbeat. On every heartbeat tick the runner consults both sources -- either reporting "lost" aborts the handler.
1880
+
1881
+ The primary value is force-takeover detection. If an operator manually deletes the fence key (or another instance forcibly releases it), the heartbeat sees the divergence and bails immediately, even if the Postgres `fence_expires_at` would still pass. Useful for ops scenarios like "drain this instance, kick its in-flight tasks off so the recovery sweep on a healthy instance picks them up faster than waiting for the Postgres deadline."
1882
+
1883
+ ```js
1884
+ import { createRedisFence } from 'svelte-adapter-uws-extensions/redis/fence';
1885
+ import { createTaskRunner } from 'svelte-adapter-uws-extensions/postgres/tasks';
1886
+
1887
+ export const tasks = createTaskRunner(pg, {
1888
+ idempotency,
1889
+ fence: createRedisFence(redis, { keyPrefix: 'fence:' })
1890
+ });
1891
+ ```
1892
+
1893
+ The provider exposes `acquire`/`heartbeat`/`release`. The runner pairs each with the matching Postgres operation: `acquire` runs after the row is inserted/rearmed; `heartbeat` runs before the Postgres heartbeat each tick and short-circuits the abort path on its own; `release` is best-effort after a terminal commit/fail.
1894
+
1895
+ `createRedisFence` options:
1896
+
1897
+ | Option | Default | Description |
1898
+ |---|---|---|
1899
+ | `keyPrefix` | `'fence:'` | Prefix prepended (after the client keyPrefix) to every fence key |
1900
+
1901
+ The Redis side uses two atomic Lua scripts: heartbeat is `if get == fence then pexpire end`, release is `if get == fence then del end`. No fence held by another owner can be released or refreshed by accident.
1902
+
1903
+ ---
1904
+
1905
+ **Observability**
1906
+
1907
+ ## Prometheus metrics
1908
+
1909
+ Exposes extension metrics in Prometheus text exposition format. No external dependencies. Zero overhead when not enabled -- every metric call uses optional chaining on a nullish reference, so V8 short-circuits on a single pointer check.
1910
+
1911
+ #### Setup
1912
+
1913
+ ```js
1914
+ // src/lib/server/metrics.js
1915
+ import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
1916
+
1917
+ export const metrics = createMetrics({
1918
+ prefix: 'myapp_',
1919
+ mapTopic: (topic) => topic.startsWith('room:') ? 'room:*' : topic
1920
+ });
1921
+ ```
1922
+
1923
+ Pass the `metrics` object to any extension via its options:
1924
+
1925
+ ```js
1926
+ import { metrics } from './metrics.js';
1927
+ import { redis } from './redis.js';
1928
+ import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
1929
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
1930
+ import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
1931
+ import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
1932
+ import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
1933
+ import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
1934
+
1935
+ export const bus = createPubSubBus(redis, { metrics });
1936
+ export const presence = createPresence(redis, { metrics, key: 'id' });
1937
+ export const replay = createReplay(redis, { metrics });
1938
+ export const limiter = createRateLimit(redis, { points: 10, interval: 1000, metrics });
1939
+ export const lobby = createGroup(redis, 'lobby', { metrics });
1940
+ export const cursors = createCursor(redis, { metrics });
1941
+ ```
1942
+
1943
+ #### Mounting the endpoint
1944
+
1945
+ With uWebSockets.js:
1946
+
1947
+ ```js
1948
+ app.get('/metrics', metrics.handler);
1949
+ ```
1950
+
1951
+ Or use `metrics.serialize()` to get the raw text and serve it however you like.
1952
+
1953
+ #### Options
1954
+
1955
+ | Option | Default | Description |
1956
+ |---|---|---|
1957
+ | `prefix` | `''` | Prefix for all metric names |
1958
+ | `mapTopic` | identity | Map topic names to bounded label values for cardinality control |
1959
+ | `defaultBuckets` | `[1, 5, 10, 25, 50, 100, 250, 500, 1000]` | Default histogram buckets |
1960
+
1961
+ Metric names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` and label names must match `[a-zA-Z_][a-zA-Z0-9_]*` (no `__` prefix). Invalid names throw at registration time. HELP text containing backslashes or newlines is escaped automatically.
1962
+
1963
+ #### Cardinality control
1964
+
1965
+ If your topics are user-generated (e.g. `room:abc123`), per-topic labels will grow unbounded. Use `mapTopic` to collapse them:
1966
+
1967
+ ```js
1968
+ const metrics = createMetrics({
1969
+ mapTopic: (topic) => {
1970
+ if (topic.startsWith('room:')) return 'room:*';
1971
+ if (topic.startsWith('user:')) return 'user:*';
1972
+ return topic;
1973
+ }
1974
+ });
1975
+ ```
1976
+
1977
+ #### WebSocket observability helpers
1978
+
1979
+ Two drop-in wirers for adapter telemetry:
1980
+
1981
+ ```js
1982
+ import {
1983
+ createMetrics,
1984
+ wirePublishRateMetrics,
1985
+ connectionMetricsHook
1986
+ } from 'svelte-adapter-uws-extensions/prometheus';
1987
+
1988
+ export const metrics = createMetrics({ prefix: 'myapp_' });
1989
+
1990
+ // In setup, once you have a `platform`:
1991
+ wirePublishRateMetrics(platform, metrics, { topN: 10 });
1992
+
1993
+ // In hooks.ws.js:
1994
+ export const close = connectionMetricsHook(metrics);
1995
+ ```
1996
+
1997
+ `wirePublishRateMetrics` registers `ws_topic_publish_rate{topic="..."}` and `ws_topic_publish_bytes{topic="..."}` gauges that read `platform.pressure.topPublishers` at scrape time -- no continuous accounting on the publish hot path. The `topN` cap (default 10) bounds gauge cardinality; the registry's `mapTopic` (or an inline `mapTopic` option) can further collapse user-generated topic names.
1998
+
1999
+ `connectionMetricsHook(metrics, userClose?)` returns a close-hook that emits per-connection histograms (`ws_connection_duration_seconds`, `ws_connection_messages_in` / `_out`, `ws_connection_bytes_in` / `_out`) plus a `ws_connection_close_total{code}` counter from the close-ctx fields the adapter populates. Compose with your own close logic by passing a function as the second argument; it runs after the metrics are recorded:
2000
+
2001
+ ```js
2002
+ export const close = connectionMetricsHook(metrics, async (ws, ctx) => {
2003
+ // your own teardown -- runs after metrics, with the same ctx
2004
+ });
2005
+ ```
2006
+
2007
+ Requires `svelte-adapter-uws >= 0.5.0-next.4`: the `topPublishers` field on the pressure snapshot and the duration / messages / bytes fields on the close ctx are only populated by that version.
2008
+
2009
+ #### Metrics reference
2010
+
2011
+ **Pub/sub bus**
2012
+
2013
+ | Metric | Type | Description |
2014
+ |---|---|---|
2015
+ | `pubsub_messages_relayed_total` | counter | Messages relayed to Redis |
2016
+ | `pubsub_messages_received_total` | counter | Messages received from Redis |
2017
+ | `pubsub_echo_suppressed_total` | counter | Messages dropped by echo suppression |
2018
+ | `pubsub_parse_errors_total` | counter | Malformed envelopes dropped on receive |
2019
+ | `pubsub_relay_batch_size` | histogram | Relay batch size per flush |
2020
+ | `pubsub_degraded_total` | counter | Auto-emitted `degraded` events |
2021
+ | `pubsub_recovered_total` | counter | Auto-emitted `recovered` events |
2022
+
2023
+ **Sharded pub/sub bus**
2024
+
2025
+ | Metric | Type | Labels | Description |
2026
+ |---|---|---|---|
2027
+ | `sharded_pubsub_messages_relayed_total` | counter | `topic` | Messages SPUBLISHed |
2028
+ | `sharded_pubsub_messages_received_total` | counter | `topic` | Messages received via SSUBSCRIBE |
2029
+ | `sharded_pubsub_echo_suppressed_total` | counter | | Sharded messages dropped by echo suppression |
2030
+ | `sharded_pubsub_parse_errors_total` | counter | | Malformed envelopes dropped on receive |
2031
+ | `sharded_pubsub_ssubscribes_total` | counter | | SSUBSCRIBE calls (first follower per channel) |
2032
+ | `sharded_pubsub_sunsubscribes_total` | counter | | SUNSUBSCRIBE calls (last follower out) |
2033
+
2034
+ **Adapter telemetry (`wirePublishRateMetrics` + `connectionMetricsHook`)**
2035
+
2036
+ | Metric | Type | Labels | Description |
2037
+ |---|---|---|---|
2038
+ | `ws_topic_publish_rate` | gauge | `topic` | Messages per second sampled from `platform.pressure.topPublishers` (top N) |
2039
+ | `ws_topic_publish_bytes` | gauge | `topic` | Bytes per second sampled from `platform.pressure.topPublishers` (top N) |
2040
+ | `ws_connection_duration_seconds` | histogram | | Connection duration in seconds at close |
2041
+ | `ws_connection_messages_in` | histogram | | Messages received per connection at close |
2042
+ | `ws_connection_messages_out` | histogram | | Messages sent per connection at close |
2043
+ | `ws_connection_bytes_in` | histogram | | Bytes received per connection at close |
2044
+ | `ws_connection_bytes_out` | histogram | | Bytes sent per connection at close |
2045
+ | `ws_connection_close_total` | counter | `code` | Connections closed by close code |
2046
+
2047
+ **Presence**
2048
+
2049
+ | Metric | Type | Labels | Description |
2050
+ |---|---|---|---|
2051
+ | `presence_joins_total` | counter | `topic` | Join events |
2052
+ | `presence_leaves_total` | counter | `topic` | Leave events |
2053
+ | `presence_heartbeats_total` | counter | | Heartbeat refresh cycles |
2054
+ | `presence_stale_cleaned_total` | counter | | Stale entries removed by cleanup |
2055
+ | `presence_total_online` | gauge | `topic` | Unique users present per topic on this instance |
2056
+ | `presence_heartbeat_latency_ms` | gauge | | Duration of the most recent heartbeat tick in ms |
2057
+ | `presence_keyspace_cleanups_total` | counter | | Topic hash expiries that triggered an empty-list emit (keyspace mode only) |
2058
+
2059
+ **Replay buffer (Redis and Postgres)**
2060
+
2061
+ | Metric | Type | Labels | Description |
2062
+ |---|---|---|---|
2063
+ | `replay_publishes_total` | counter | `topic` | Messages published |
2064
+ | `replay_messages_replayed_total` | counter | `topic` | Messages replayed to clients |
2065
+ | `replay_truncations_total` | counter | `topic` | Truncation events detected |
2066
+ | `replay_replications_total` | counter | | Publishes confirmed replicated within timeout (Redis only, `durability: 'replicated'` mode) |
2067
+ | `replay_replication_timeouts_total` | counter | | Publishes that did not reach `minReplicas` within timeout |
2068
+ | `replay_idmp_hits_total` | counter | `topic` | `publishIdempotent` calls served from the dedup cache (no XADD) |
2069
+ | `replay_idmp_writes_total` | counter | `topic` | `publishIdempotent` calls that produced a new entry |
2070
+
2071
+ **Rate limiting**
2072
+
2073
+ | Metric | Type | Description |
2074
+ |---|---|---|
2075
+ | `ratelimit_allowed_total` | counter | Requests allowed |
2076
+ | `ratelimit_denied_total` | counter | Requests denied |
2077
+ | `ratelimit_bans_total` | counter | Bans applied |
2078
+
2079
+ **Broadcast groups**
2080
+
2081
+ | Metric | Type | Labels | Description |
2082
+ |---|---|---|---|
2083
+ | `group_joins_total` | counter | `group` | Join events |
2084
+ | `group_joins_rejected_total` | counter | `group` | Joins rejected (full) |
2085
+ | `group_leaves_total` | counter | `group` | Leave events |
2086
+ | `group_publishes_total` | counter | `group` | Publish events |
2087
+
2088
+ **Cursor**
2089
+
2090
+ | Metric | Type | Labels | Description |
2091
+ |---|---|---|---|
2092
+ | `cursor_updates_total` | counter | `topic` | Cursor update calls |
2093
+ | `cursor_broadcasts_total` | counter | `topic` | Broadcasts actually sent |
2094
+ | `cursor_throttled_total` | counter | `topic` | Updates deferred by throttle |
2095
+
2096
+ **LISTEN/NOTIFY bridge**
2097
+
2098
+ | Metric | Type | Labels | Description |
2099
+ |---|---|---|---|
2100
+ | `notify_received_total` | counter | `channel` | Notifications received |
2101
+ | `notify_parse_errors_total` | counter | `channel` | Parse failures |
2102
+ | `notify_reconnects_total` | counter | | Reconnect attempts |
2103
+
2104
+ **Admission control**
2105
+
2106
+ | Metric | Type | Labels | Description |
2107
+ |---|---|---|---|
2108
+ | `admission_accepted_total` | counter | `class` | `shouldAccept` calls that returned `true` |
2109
+ | `admission_rejected_total` | counter | `class`, `reason` | `shouldAccept` calls that returned `false`, labeled with the pressure reason that caused rejection |
2110
+
2111
+ **Job queue**
2112
+
2113
+ | Metric | Type | Labels | Description |
2114
+ |---|---|---|---|
2115
+ | `jobs_enqueued_total` | counter | `queue` | Jobs enqueued |
2116
+ | `jobs_claimed_total` | counter | `queue` | Jobs claimed (rows returned by `claim`) |
2117
+ | `jobs_completed_total` | counter | `queue` | Jobs completed (deleted) |
2118
+ | `jobs_failed_total` | counter | `queue` | Jobs released via `fail()` for retry |
2119
+
2120
+ **Redis Functions**
2121
+
2122
+ | Metric | Type | Labels | Description |
2123
+ |---|---|---|---|
2124
+ | `redis_function_loads_total` | counter | `library` | `FUNCTION LOAD` calls |
2125
+ | `redis_function_calls_total` | counter | `library`, `function` | `FCALL` calls |
2126
+ | `redis_function_errors_total` | counter | `library`, `function` | `FCALL` calls that threw |
2127
+
2128
+ ---
2129
+
2130
+ **Reliability**
2131
+
2132
+ ## Failure handling
2133
+
2134
+ Every Redis and Postgres extension accepts an optional `breaker` option -- a shared [circuit breaker](#circuit-breaker) that tracks backend health across all extensions wired to it. When the breaker trips, each extension degrades differently depending on whether the operation is critical or best-effort:
2135
+
2136
+ | Extension | Awaited operations (join, consume, publish) | Fire-and-forget operations |
2137
+ |---|---|---|
2138
+ | **Pub/sub bus** | `wrap().publish()` queues to local platform only; relay to Redis is skipped silently | Microtask relay flush is skipped entirely |
2139
+ | **Presence** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh and stale cleanup are skipped |
2140
+ | **Replay buffer** | `publish()` / `replay()` / `seq()` throw `CircuitBrokenError` | -- |
2141
+ | **Rate limiting** | `consume()` throws `CircuitBrokenError` (fail-closed -- requests are blocked, not allowed through) | -- |
2142
+ | **Broadcast groups** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh is skipped |
2143
+ | **Cursor** | -- | Hash writes and cross-instance relay are skipped; local throttle continues |
2144
+ | **LISTEN/NOTIFY** | `activate()` throws; auto-reconnect retries on its own interval | -- |
2145
+
2146
+ The breaker is a three-state machine: **healthy** (all requests pass through) -> **broken** after N consecutive failures (all requests fail fast via `CircuitBrokenError`) -> **probing** after a timeout (one request is allowed through to test recovery) -> back to **healthy** on success. See [Circuit breaker](#circuit-breaker) for configuration.
2147
+
2148
+ #### Notifying clients of degradation
2149
+
2150
+ When Redis pub/sub fails, live streams on other replicas stop receiving updates. Connected clients continue showing stale data with no indication that the stream is degraded. The pub/sub bus emits this directly: when the shared breaker leaves the healthy state, a `degraded` event fires on the bus's `systemChannel` (default `'__realtime'`); when it returns to healthy, a `recovered` event fires.
2151
+
2152
+ ```js
2153
+ import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
2154
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
2155
+
2156
+ export const breaker = createCircuitBreaker({ failureThreshold: 5, resetTimeout: 30000 });
2157
+
2158
+ export const bus = createPubSubBus(redis, {
2159
+ breaker,
2160
+ // optional handlers for server-side reactions (logging, alerts):
2161
+ onDegraded: () => console.warn('pubsub bus degraded'),
2162
+ onRecovered: () => console.info('pubsub bus recovered')
2163
+ });
2164
+ ```
2165
+
2166
+ On the client side, subscribe to the `__realtime` topic and show a banner when the `degraded` event fires. On `recovered`, dismiss the banner and refetch stale data. The event payload is `{ at: <epoch ms> }` so a client can show "lost connection 12s ago".
2167
+
2168
+ Both the topic name and the auto-emission are configurable:
2169
+
2170
+ | Option | Default | Description |
2171
+ |---|---|---|
2172
+ | `systemChannel` | `'__realtime'` | Topic used for `degraded` / `recovered` events. Set to `null` or `false` to disable auto-emission. |
2173
+ | `onDegraded` | -- | Server-side handler invoked once on the healthy -> non-healthy transition |
2174
+ | `onRecovered` | -- | Server-side handler invoked once on the non-healthy -> healthy transition |
2175
+
2176
+ Auto-emission is local-only -- Redis is what's degraded, so the event reaches local clients via the underlying platform without attempting a relay. Each instance reports its own breaker state to its own clients. If you need different semantics (cross-instance forwarding, custom payload, filtering by failure type), use `breaker.subscribe(handler)` to register your own listener and emit through whichever channel you prefer.
2177
+
2178
+ ---
2179
+
2180
+ ## Cluster publish-rate aggregator
2181
+
2182
+ `createPublishRateAggregator` (`svelte-adapter-uws-extensions/redis/publish-rate`) gives every instance a cluster-wide view of which topics are hottest across the whole deployment. Each instance broadcasts its own `platform.pressure.topPublishers` slice on a Redis pub/sub channel; every instance maintains a sliding-window view of all instances' slices and merges them into a cluster-wide top-N. No leader election -- each instance is its own aggregator. Storage cost is `O(instances * topN)` per instance, bounded and small.
2183
+
2184
+ #### Setup
2185
+
2186
+ ```js
2187
+ // src/lib/server/publish-rate.js
2188
+ import { redis } from './redis.js';
2189
+ import { createPublishRateAggregator } from 'svelte-adapter-uws-extensions/redis/publish-rate';
2190
+
2191
+ export const aggregator = createPublishRateAggregator(redis, {
2192
+ publishInterval: 5000,
2193
+ staleAfter: 12000,
2194
+ topN: 20
2195
+ });
2196
+
2197
+ // In your open hook (or any startup path with a platform reference):
2198
+ export async function open(ws, { platform }) {
2199
+ await aggregator.activate(platform);
2200
+ }
2201
+ ```
2202
+
2203
+ #### API
2204
+
2205
+ | Member | Description |
2206
+ |---|---|
2207
+ | `instanceId` | Stable id for this instance, used as the from-tag on outbound slice envelopes. |
2208
+ | `topPublishers` | Cluster-wide top publishers, merged from this instance's local slice (read fresh from `platform.pressure.topPublishers`) and the cached remote slices (stale entries dropped). Sorted descending by `messagesPerSec`, capped at `topN`. Each entry is `{topic, messagesPerSec, bytesPerSec, contributingInstances}`. Pure memory computation. |
2209
+ | `rateOf(topic)` | Cluster-wide messagesPerSec for a topic, or 0 if not in the merged top-N. Used by the `clusterTopPublisher` admission rule. |
2210
+ | `subscribersOf(topic)` | Cluster-wide subscriber count for a topic, summed across this instance's live local count (from the optional `subjects` callback) and cached non-stale remote contributions. Returns 0 when no `subjects` is wired and no remote instance has reported the topic. The sharded bus's `bus.subscribers(topic)` delegates here when an aggregator is wired. |
2211
+ | `activate(platform)` | Open the subscriber and start the broadcast timer. Idempotent. |
2212
+ | `deactivate()` | Stop the timer, drop the subscriber, clear cached slices. |
2213
+
2214
+ #### Wire envelope (internal)
2215
+
2216
+ ```
2217
+ Channel: {channel} (default: 'uws:pressure:rates')
2218
+ Payload: {instanceId, ts, slice: [{topic, messagesPerSec, bytesPerSec}, ...], subs?: [{topic, count}, ...]}
2219
+ ```
2220
+
2221
+ Receivers merge into a per-`instanceId` map keyed on the broadcasting instance; entries older than `staleAfter` are dropped on the next merge. The `subs` field is omitted when no `subjects` callback is configured. Aggregators on either side of a version skew tolerate envelopes with or without `subs` (forward and backward compatible).
2222
+
2223
+ #### Options
2224
+
2225
+ | Option | Default | Description |
2226
+ |---|---|---|
2227
+ | `channel` | `'uws:pressure:rates'` | Redis channel for slice broadcasts. |
2228
+ | `publishInterval` | `5000` | How often this instance broadcasts its slice (ms). |
2229
+ | `staleAfter` | `12000` | Drop a remote instance's slice if no fresher one arrives within this window (ms). Should be at least `2 * publishInterval`. |
2230
+ | `topN` | `20` | Cap on per-instance slice and merged result. Bounds storage cost. Also caps the `subs` slice (sorted descending by `count`). |
2231
+ | `subjects` | -- | Optional `() => Array<{topic, count}>` contributor for cluster-wide subscriber counts. Called fresh on every broadcast tick. When wired, the envelope grows a `subs` field and `subscribersOf(topic)` returns the merged sum. Pair with the sharded bus via `subjects: () => bus.localSubjects(platform)`. |
2232
+ | `breaker` | -- | Optional circuit breaker for the publish call. |
2233
+ | `metrics` | -- | Optional Prometheus metrics registry. |
2234
+
2235
+ #### Pairing with admission control
2236
+
2237
+ `createAdmissionControl({ aggregator, classes: { hot: { clusterTopPublisher: { threshold } } } })` consults `aggregator.rateOf(topic)` on every `shouldAccept` call. Memory-only lookup, no Redis traffic on the hot path. See [Cluster-aware shedding](#cluster-aware-shedding-clustertoppublisher-rule).
2238
+
2239
+ #### Pairing with the sharded bus (cluster subscriber counts)
2240
+
2241
+ The aggregator's `subjects` option is the channel for cluster-wide subscriber counts. Wire the sharded bus's `localSubjects(platform)` helper as the contributor; the bus exposes a matching `bus.subscribers(topic)` that delegates to `aggregator.subscribersOf(topic)`:
2242
+
2243
+ ```js
2244
+ import { createShardedBus } from 'svelte-adapter-uws-extensions/redis/sharded-pubsub';
2245
+ import { createPublishRateAggregator } from 'svelte-adapter-uws-extensions/redis/publish-rate';
2246
+
2247
+ const bus = createShardedBus(redis);
2248
+ const aggregator = createPublishRateAggregator(redis, {
2249
+ subjects: () => bus.localSubjects(platform)
2250
+ });
2251
+
2252
+ // One option to also pass subscribersAggregator into the bus so
2253
+ // bus.subscribers(topic) returns the cluster-wide count rather than
2254
+ // just the local one:
2255
+ const busWithCluster = createShardedBus(redis, { subscribersAggregator: aggregator });
2256
+
2257
+ await bus.activate(platform);
2258
+ await aggregator.activate(platform);
2259
+
2260
+ const cluster = busWithCluster.subscribers('chat:room-7');
2261
+ // Local count + sum from non-stale remote instances.
2262
+ ```
2263
+
2264
+ Without an aggregator wired, `bus.subscribers(topic)` returns the local count only -- same number `platform.subscribers(topic)` reports. Eventually-consistent within `publishInterval` for the remote contribution; the local read is always live. For exact counts (audit log, billing), track a Redis SET cluster-wide on subscribe / unsubscribe and `SCARD` it.
2265
+
2266
+ The unsharded `createPubSubBus` does not track per-topic state (it forwards every topic through a single Redis channel), so it does not expose `localSubjects` / `subscribers`. Apps that need cluster-wide subscriber counts on the unsharded bus thread their own per-topic state into the aggregator's `subjects` callback.
2267
+
2268
+ #### Pairing with prometheus
2269
+
2270
+ `wireClusterPublishRateMetrics(aggregator, metrics, { topN })` registers two gauges that scrape the merged top-N at collect time:
2271
+
2272
+ - `cluster_topic_publish_rate{topic}` -- cluster-wide messagesPerSec, summed across instances
2273
+ - `cluster_topic_publish_bytes{topic}` -- cluster-wide bytesPerSec, summed across instances
2274
+
2275
+ Both wirers (per-instance via `wirePublishRateMetrics`, cluster via `wireClusterPublishRateMetrics`) can be active simultaneously. The local view shows hot-shard pressure; the cluster view shows global capacity.
2276
+
2277
+ #### Aggregator metrics
2278
+
2279
+ | Metric | Description |
2280
+ |---|---|
2281
+ | `cluster_publish_rate_broadcasts_total` | Slice envelopes published by this instance. |
2282
+ | `cluster_publish_rate_received_total` | Slice envelopes received from sibling instances. |
2283
+ | `cluster_publish_rate_parse_errors_total` | Malformed envelopes dropped on receive. |
2284
+ | `cluster_publish_rate_instance_count` | Gauge: sibling instances contributing slices (excluding self) at scrape time. Useful for cluster-size monitoring. |
2285
+
2286
+ ---
2287
+
2288
+ ## Circuit breaker
2289
+
2290
+ Prevents thundering herd when a backend goes down. When Redis or Postgres becomes unreachable, every extension that uses the breaker fails fast instead of queueing up timeouts, and fire-and-forget operations (heartbeats, relay flushes, cursor broadcasts) are skipped entirely.
2291
+
2292
+ Three states:
2293
+ - **healthy** -- everything works, requests go through
2294
+ - **broken** -- too many failures, requests fail fast via `CircuitBrokenError`
2295
+ - **probing** -- one request is allowed through to test if the backend is back
2296
+
2297
+ #### Setup
2298
+
2299
+ ```js
2300
+ // src/lib/server/breaker.js
2301
+ import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
2302
+
2303
+ export const breaker = createCircuitBreaker({
2304
+ failureThreshold: 5,
2305
+ resetTimeout: 30000,
2306
+ onStateChange: (from, to) => console.log(`circuit: ${from} -> ${to}`)
2307
+ });
2308
+ ```
2309
+
2310
+ Pass the same breaker to all extensions that share a backend:
2311
+
2312
+ ```js
2313
+ import { breaker } from './breaker.js';
2314
+
2315
+ export const bus = createPubSubBus(redis, { breaker });
2316
+ export const presence = createPresence(redis, { breaker, key: 'id' });
2317
+ export const replay = createReplay(redis, { breaker });
2318
+ export const limiter = createRateLimit(redis, { points: 10, interval: 1000, breaker });
2319
+ ```
2320
+
2321
+ Failures from any extension contribute to the same breaker. When one trips it, all others fail fast.
2322
+
2323
+ #### Options
2324
+
2325
+ | Option | Default | Description |
2326
+ |---|---|---|
2327
+ | `failureThreshold` | `5` | Consecutive failures before breaking |
2328
+ | `resetTimeout` | `30000` | Ms before transitioning from broken to probing |
2329
+ | `onStateChange` | - | Called on state transitions: `(from, to) => void` |
2330
+
2331
+ #### API
2332
+
2333
+ | Method / Property | Description |
2334
+ |---|---|
2335
+ | `breaker.state` | `'healthy'`, `'broken'`, or `'probing'` |
2336
+ | `breaker.isHealthy` | `true` only when state is `'healthy'` |
2337
+ | `breaker.failures` | Current consecutive failure count |
2338
+ | `breaker.guard()` | Throws `CircuitBrokenError` if the circuit is broken |
2339
+ | `breaker.success()` | Record a successful operation |
2340
+ | `breaker.failure()` | Record a failed operation |
2341
+ | `breaker.reset()` | Force back to healthy |
2342
+ | `breaker.destroy()` | Clear internal timers |
2343
+
2344
+ #### How extensions use it
2345
+
2346
+ Awaited operations (join, consume, publish) call `guard()` before the Redis/Postgres call, `success()` after, and `failure()` in the catch block. When the circuit is broken, `guard()` throws `CircuitBrokenError` and the operation never reaches the backend.
2347
+
2348
+ Fire-and-forget operations (heartbeat refresh, relay flush, cursor broadcast) check `isHealthy` and skip entirely when the circuit is not healthy. This prevents piling up commands on a dead connection.
2349
+
2350
+ #### Error handling
2351
+
2352
+ ```js
2353
+ import { CircuitBrokenError } from 'svelte-adapter-uws-extensions/breaker';
2354
+
2355
+ try {
2356
+ await replay.publish(platform, 'chat', 'msg', data);
2357
+ } catch (err) {
2358
+ if (err instanceof CircuitBrokenError) {
2359
+ // Backend is down -- degrade gracefully
2360
+ platform.publish('chat', 'msg', data); // local-only delivery
2361
+ }
2362
+ }
2363
+ ```
2364
+
2365
+ ---
2366
+
2367
+ ## Admission control
2368
+
2369
+ Pressure-aware companion to the [circuit breaker](#circuit-breaker). Where the breaker answers "is the backend up?", admission control answers "are we OK to take more work right now?" -- using the adapter's `platform.pressure` signal (memory, publish rate, subscriber ratio) to gate non-critical work before it ever reaches a backend.
2370
+
2371
+ Requires `svelte-adapter-uws >= 0.5.0-next.1` (the version that ships `platform.pressure`).
2372
+
2373
+ #### Setup
2374
+
2375
+ ```js
2376
+ // src/lib/server/admission.js
2377
+ import { createAdmissionControl } from 'svelte-adapter-uws-extensions/admission';
2378
+
2379
+ export const ac = createAdmissionControl({
2380
+ classes: {
2381
+ critical: ['MEMORY'], // refuse only on memory pressure
2382
+ normal: ['MEMORY', 'PUBLISH_RATE'], // refuse on memory or publish rate
2383
+ background: ['MEMORY', 'PUBLISH_RATE', 'SUBSCRIBERS'] // refuse on any pressure
2384
+ }
2385
+ });
2386
+ ```
2387
+
2388
+ #### Usage
2389
+
2390
+ ```js
2391
+ // In a server endpoint or RPC handler:
2392
+ import { ac } from '$lib/server/admission';
2393
+
2394
+ export async function POST({ platform, request }) {
2395
+ if (!ac.shouldAccept('background', platform)) {
2396
+ return new Response('busy', { status: 503 });
2397
+ }
2398
+ // ...proceed with the request...
2399
+ }
2400
+ ```
2401
+
2402
+ Each class is independently configured. The adapter has already collapsed concurrent signals (memory, publish rate, subscribers) into a single most-urgent `reason` -- this controller just maps the resolved reason to a per-class accept/reject decision.
2403
+
2404
+ #### Class rule shapes
2405
+
2406
+ A class rule is either an array of pressure reasons that should block this class, or a predicate function:
2407
+
2408
+ ```js
2409
+ classes: {
2410
+ // Array form: block when reason is in this list
2411
+ critical: ['MEMORY'],
2412
+
2413
+ // Predicate form: block when the predicate returns truthy
2414
+ streaming: (snapshot) => snapshot.subscriberRatio > 50
2415
+ }
2416
+ ```
2417
+
2418
+ Predicates receive the full `PressureSnapshot` so they can apply custom thresholds (e.g. block above a specific publish-rate that's tighter than the adapter's). Array form is the simple-case shorthand and is what 90% of callers should use.
2419
+
2420
+ Valid reason strings: `'NONE'`, `'PUBLISH_RATE'`, `'SUBSCRIBERS'`, `'MEMORY'`. Including `'NONE'` in a block list means "always block this class," which is occasionally useful for kill-switching a class without removing the wiring.
2421
+
2422
+ #### Options
2423
+
2424
+ | Option | Default | Description |
2425
+ |---|---|---|
2426
+ | `classes` | (required) | Map of class name to admission rule. Must define at least one class. |
2427
+ | `metrics` | -- | Prometheus metrics registry. |
2428
+
2429
+ #### API
2430
+
2431
+ | Method | Description |
2432
+ |---|---|
2433
+ | `shouldAccept(className, platform)` | Returns `true` to admit, `false` to shed. Throws on unknown class name (typo defense) or missing `platform.pressure`. |
2434
+
2435
+ `shouldAccept` reads `platform.pressure` via a property access -- no I/O, safe to call on every request hot path. The reason-precedence math (memory > publish rate > subscribers > none) lives in the adapter; this method only checks the resolved `reason` against the configured rule.
2436
+
2437
+ #### Composition with the breaker
2438
+
2439
+ Admission control and the circuit breaker check independent signals. Use them together:
2440
+
2441
+ ```js
2442
+ export async function POST({ platform, request }) {
2443
+ // Local pressure check first -- cheaper, no Redis call.
2444
+ if (!ac.shouldAccept('normal', platform)) {
2445
+ return new Response('busy', { status: 503 });
2446
+ }
2447
+ // Then attempt the backend call. CircuitBrokenError surfaces if the
2448
+ // breaker is open.
2449
+ try {
2450
+ await replay.publish(platform, 'chat', 'msg', data);
2451
+ } catch (err) {
2452
+ if (err instanceof CircuitBrokenError) {
2453
+ return new Response('backend unavailable', { status: 503 });
2454
+ }
2455
+ throw err;
2456
+ }
2457
+ }
2458
+ ```
2459
+
2460
+ The two layers complement each other: admission control prevents new work from piling up under server-local pressure; the breaker prevents thundering-herd retries against a dead backend.
2461
+
2462
+ #### Cluster-aware shedding (`clusterTopPublisher` rule)
2463
+
2464
+ `platform.pressure.topPublishers` is per-instance. In an N-instance cluster, a topic that's hot across every instance simultaneously looks the same locally as one that's only hot here -- but it warrants a different response. The `clusterTopPublisher` rule consults a `createPublishRateAggregator` (`redis/publish-rate`) to shed at the cluster layer:
2465
+
2466
+ ```js
2467
+ import { createPublishRateAggregator } from 'svelte-adapter-uws-extensions/redis/publish-rate';
2468
+ import { createAdmissionControl } from 'svelte-adapter-uws-extensions/admission';
2469
+
2470
+ const aggregator = createPublishRateAggregator(redis);
2471
+ await aggregator.activate(platform);
2472
+
2473
+ export const ac = createAdmissionControl({
2474
+ aggregator,
2475
+ classes: {
2476
+ nonCritical: { clusterTopPublisher: { threshold: 5000 } }
2477
+ }
2478
+ });
2479
+
2480
+ // In a request handler that publishes to a hot topic:
2481
+ if (!ac.shouldAccept('nonCritical', platform, { topic: 'org:42:audit' })) {
2482
+ return new Response('busy', { status: 503 });
2483
+ }
2484
+ ```
2485
+
2486
+ The rule's check is a memory-only lookup (`aggregator.rateOf(topic)` against the merged top-N); no Redis traffic on the hot path. Rejected admissions surface in `admission_rejected_total{class, reason="CLUSTER_TOP_PUBLISHER"}`. See [Cluster publish-rate aggregator](#cluster-publish-rate-aggregator) for aggregator setup.
2487
+
2488
+ #### Two-tier admission (handshake + message)
2489
+
2490
+ The adapter ships a separate admission layer at the WebSocket handshake path -- before any TLS / header work -- via the `upgradeAdmission` option on its `wsOptions` (and on `createTestServer` for test harnesses). The two layers operate at different points in the connection lifecycle and are configured independently:
2491
+
2492
+ | Layer | Where | Sheds | Configured via |
2493
+ |---|---|---|---|
2494
+ | Handshake | Adapter, before `res.upgrade()` | Concurrent in-flight upgrades and per-tick handshake budget | `wsOptions.upgradeAdmission = { maxConcurrent, perTickBudget }` |
2495
+ | Message / RPC | Extensions, in your handler | Per-class load-shedding against `platform.pressure` | `createAdmissionControl({ classes })` plus a `shouldAccept(...)` check |
2496
+
2497
+ Wire both for full coverage of "new connections under storm" and "established connections under pressure":
2498
+
2499
+ ```js
2500
+ // svelte.config.js (or wherever you configure the adapter)
2501
+ import adapter from 'svelte-adapter-uws';
2502
+
2503
+ export default {
2504
+ kit: {
2505
+ adapter: adapter({
2506
+ websocket: {
2507
+ upgradeAdmission: { maxConcurrent: 200, perTickBudget: 50 }
2508
+ }
2509
+ })
2510
+ }
2511
+ };
2512
+ ```
2513
+
2514
+ ```js
2515
+ // In your message / RPC handler
2516
+ import { ac } from '$lib/server/admission';
2517
+
2518
+ export function message(ws, { data, platform }) {
2519
+ if (!ac.shouldAccept('background', platform)) {
2520
+ ws.send(JSON.stringify({ error: 'overloaded' }));
2521
+ return;
2522
+ }
2523
+ // ...handle the message...
2524
+ }
2525
+ ```
2526
+
2527
+ Connections that make it past the handshake are not exempt from message-tier shedding, and message-tier shedding cannot rescue a connection that lost the handshake race -- the layers compose without overlap. See the adapter's [Layered admission](https://github.com/lanteanio/svelte-adapter-uws#layered-admission) section for the handshake-tier reference.
2528
+
2529
+ ---
2530
+
2531
+ ## Redis Functions
2532
+
2533
+ `createFunctionLibrary` (`svelte-adapter-uws-extensions/redis/functions`) is a thin wrapper over Redis 7+ `FUNCTION LOAD` / `FCALL`. Versioned, hot-reloadable server-side scripts: ship a new library version and `load()` swaps it in atomically without an app redeploy.
2534
+
2535
+ The library code is plain Lua and must start with `#!lua name=<libname>` -- the wrapper parses the name from the shebang. Inside the library, declare functions via `redis.register_function(...)`. Function names are global on the Redis server (not namespaced by library), which is why `call(funcName, ...)` keys on function name only.
2536
+
2537
+ ```js
2538
+ import { createFunctionLibrary } from 'svelte-adapter-uws-extensions/redis/functions';
2539
+
2540
+ const lib = createFunctionLibrary(redis, `#!lua name=ws-presence
2541
+ redis.register_function('cleanup', function(keys, args)
2542
+ -- args[1] = now (ms), args[2] = ttl (ms)
2543
+ local now = tonumber(args[1])
2544
+ local ttl = tonumber(args[2])
2545
+ local removed = 0
2546
+ -- ... iterate hash fields, HDEL stale ...
2547
+ return removed
2548
+ end)
2549
+ `);
2550
+
2551
+ await lib.load();
2552
+ const removed = await lib.call('cleanup', {
2553
+ keys: ['presence:room1'],
2554
+ args: [Date.now(), 90000]
2555
+ });
2556
+
2557
+ await lib.delete(); // FUNCTION DELETE
2558
+ ```
2559
+
2560
+ #### Options
2561
+
2562
+ | Option | Default | Description |
2563
+ |---|---|---|
2564
+ | `metrics` | -- | Prometheus metrics registry. |
2565
+ | `breaker` | -- | Circuit breaker instance. |
2566
+
2567
+ #### API
2568
+
2569
+ | Method / Property | Description |
2570
+ |---|---|
2571
+ | `lib.name` | Library name parsed from the shebang |
2572
+ | `lib.load()` | `FUNCTION LOAD REPLACE`. Runs `INFO server` on first call and throws on Redis < 7. Idempotent. |
2573
+ | `lib.call(funcName, { keys?, args? })` | `FCALL` -- returns the function's return value |
2574
+ | `lib.delete()` | `FUNCTION DELETE <libname>` |
2575
+
2576
+ #### When to use this vs `redis.eval`
2577
+
2578
+ `redis.eval` is fine for one-off scripts that ship inside the app code. Use `createFunctionLibrary` when:
2579
+
2580
+ - Scripts have meaningful versions and ops want to roll forward without an app deploy.
2581
+ - Multiple scripts belong together as a coherent library (shared helpers, etc.).
2582
+ - Scripts are large enough that parsing + caching them per `eval` becomes a measurable cost.
2583
+
2584
+ Requires Redis 7+. There is no built-in fallback to `EVALSHA` for older servers because that would require maintaining each function in two forms (library function + plain Lua); on Redis 6 just call `redis.eval` directly with your own SHA caching.
2585
+
2586
+ ---
2587
+
2588
+ **Operations**
2589
+
2590
+ ## Graceful shutdown
2591
+
2592
+ All clients listen for the `sveltekit:shutdown` event and disconnect cleanly by default. You can disable this with `autoShutdown: false` and manage the lifecycle yourself.
2593
+
2594
+ ```js
2595
+ // Manual shutdown
2596
+ await redis.quit();
2597
+ await pg.end();
2598
+ presence.destroy();
2599
+ ```
2600
+
2601
+ ---
2602
+
2603
+ ## Testing
2604
+
2605
+ This repo runs tests in two layers. Both stay green; you can run either independently.
2606
+
2607
+ ```bash
2608
+ npm test # mock layer (24 files, 861 tests, no services needed)
2609
+ npm run test:integration # integration layer (real Redis 7 + Postgres 16 in Docker)
2610
+ ```
2611
+
2612
+ ### Mock layer (`test/`)
2613
+
2614
+ In-memory mocks for Redis and Postgres that mirror the public APIs closely enough to drive the extensions through their happy paths and edge cases. Fast feedback (~15s for the full suite), no Docker required.
2615
+
2616
+ The mocks live at `testing/mock-redis.js` and `testing/mock-pg.js` and are exported as `svelte-adapter-uws-extensions/testing` so consumers of this package can use them too. See [Testing your own code](#testing-your-own-code) below.
2617
+
2618
+ ### Integration layer (`test/integration/`)
2619
+
2620
+ Exercises the same modules against real services in `docker compose`. Picks up cases the mocks can only approximate: Lua atomicity inside Redis EVAL, Postgres LISTEN/NOTIFY cross-connection delivery, real TTL/EXPIRE behaviour, partial-index plans on the job queue.
2621
+
2622
+ The compose stack at [`test/integration/docker-compose.yml`](test/integration/docker-compose.yml) binds non-default host ports so it does not clash with a locally running Postgres/Redis: **Postgres on `55432`**, **Redis on `56379`**. `test/integration/global-setup.js` runs `docker compose up -d --wait` before the suite, exports `INTEGRATION_REDIS_URL` / `INTEGRATION_POSTGRES_URL` for the tests to read, and tears the stack down with `docker compose down -v` afterwards.
2623
+
2624
+ The host ports and compose project name are env-var overridable for running multiple stacks side-by-side on the same machine:
2625
+
2626
+ ```bash
2627
+ INTEGRATION_REDIS_HOST_PORT=56380 \
2628
+ INTEGRATION_POSTGRES_HOST_PORT=55433 \
2629
+ INTEGRATION_COMPOSE_PROJECT=my-slice \
2630
+ npm run test:integration
2631
+ ```
2632
+
2633
+ Project name auto-derives from the port pair when overridden, so unique ports also mean unique container names.
2634
+
2635
+ #### Adding a new integration test
2636
+
2637
+ 1. Drop a `*.test.js` file under `test/integration/redis/` or `test/integration/postgres/`.
2638
+ 2. In `beforeAll`, build a real client:
2639
+
2640
+ ```js
2641
+ import { createRedisClient } from '../../../redis/index.js';
2642
+ // or: import { createPgClient } from '../../../postgres/index.js';
2643
+
2644
+ beforeAll(() => {
2645
+ client = createRedisClient({
2646
+ url: process.env.INTEGRATION_REDIS_URL,
2647
+ keyPrefix: 'inttest-yourmodule:', // namespace per test file
2648
+ autoShutdown: false // tests own the lifecycle
2649
+ });
2650
+ });
2651
+ ```
2652
+ 3. In `beforeEach`, wipe state under your prefix (Redis: `SCAN MATCH prefix* + UNLINK`; Postgres: `TRUNCATE` your test tables, or use distinct channels for LISTEN/NOTIFY).
2653
+ 4. In `afterAll`, `await client.quit()` / `await client.end()`.
2654
+
2655
+ The integration layer is additive: the mock-based test for a module stays in place when you add the integration counterpart. They cover different failure modes.
2656
+
2657
+ ### Testing your own code
2658
+
2659
+ The `svelte-adapter-uws-extensions/testing` entry point exports the same in-memory mocks used by the extensions' own test suite. Use them to test your extension-consuming code without running Redis or Postgres:
2660
+
2661
+ ```js
2662
+ import { mockRedisClient, mockPlatform, mockWs } from 'svelte-adapter-uws-extensions/testing';
2663
+ import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
2664
+ import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
2665
+ import { describe, it, expect } from 'vitest';
2666
+
2667
+ describe('presence', () => {
2668
+ it('tracks users across topics', async () => {
2669
+ const client = mockRedisClient();
2670
+ const platform = mockPlatform();
2671
+ const presence = createPresence(client, { key: 'id' });
2672
+
2673
+ const ws = mockWs({ id: 'user-1', name: 'Alice' });
2674
+ await presence.join(ws, 'room:lobby', platform);
2675
+
2676
+ expect(await presence.count('room:lobby')).toBe(1);
2677
+ expect(platform.published.some(p => p.event === 'join')).toBe(true);
2678
+
2679
+ presence.destroy();
2680
+ });
2681
+ });
2682
+
2683
+ describe('rate limiting', () => {
2684
+ it('blocks after exhausting points', async () => {
2685
+ const client = mockRedisClient();
2686
+ const limiter = createRateLimit(client, { points: 3, interval: 10000 });
2687
+ const ws = mockWs({ remoteAddress: '1.2.3.4' });
2688
+
2689
+ for (let i = 0; i < 3; i++) {
2690
+ expect((await limiter.consume(ws)).allowed).toBe(true);
2691
+ }
2692
+ expect((await limiter.consume(ws)).allowed).toBe(false);
2693
+ });
2694
+ });
2695
+ ```
2696
+
2697
+ #### Available mocks
2698
+
2699
+ | Export | What it mocks | Supports |
2700
+ |---|---|---|
2701
+ | `mockRedisClient(prefix?)` | `createRedisClient()` | Strings, hashes, sorted sets, pub/sub, pipelines, scan, Lua eval for all extension scripts |
2702
+ | `mockPlatform()` | Platform API | `publish()`, `send()`, `batch()`, `topic()` -- records all calls in `.published` and `.sent` |
2703
+ | `mockWs(userData?)` | uWS WebSocket | `subscribe()`, `unsubscribe()`, `getUserData()`, `getBufferedAmount()`, `close()` |
2704
+ | `mockPgClient()` | `createPgClient()` | SQL parsing for replay buffer operations, sequence counters |
2705
+
2706
+ The circuit breaker (`createCircuitBreaker()`) is pure logic with no I/O -- use it directly in tests, no mock needed.
2707
+
2708
+ #### Adapter wire-shape helpers
2709
+
2710
+ `svelte-adapter-uws-extensions/testing` also re-exports the curated wire-protocol helpers and `userData` slot constants the adapter exposes from `svelte-adapter-uws/testing`, so test code asserting on the wire format or per-connection state has one import location alongside the mocks:
2711
+
2712
+ ```js
2713
+ import {
2714
+ // Wire-protocol helpers
2715
+ esc, completeEnvelope, wrapBatchEnvelope, isValidWireTopic, createScopedTopic,
2716
+ // Behavior helpers
2717
+ collapseByCoalesceKey, resolveRequestId, createChaosState,
2718
+ // userData slot constants
2719
+ WS_SUBSCRIPTIONS, WS_COALESCED, WS_SESSION_ID, WS_PENDING_REQUESTS,
2720
+ WS_STATS, WS_PLATFORM, WS_CAPS, WS_REQUEST_ID_KEY,
2721
+ // Plus the in-memory mocks
2722
+ mockRedisClient, mockPlatform, mockWs, mockPgClient
2723
+ } from 'svelte-adapter-uws-extensions/testing';
2724
+ ```
2725
+
2726
+ The re-exported names are the exact same identities as the adapter source (`expect(extensionsTesting.wrapBatchEnvelope).toBe(adapterTesting.wrapBatchEnvelope)`); a surface-lock test in this package pins the set so a future adapter refactor that drops one fails here. See the adapter's [testing entry point docs](https://github.com/lanteanio/svelte-adapter-uws#testing-your-own-handlers) for the per-helper reference.
2727
+
2728
+ `createTestServer` is intentionally not re-exported -- it boots a real uWebSockets.js instance, which is the adapter's responsibility; import it directly from `svelte-adapter-uws/testing` if you need it.
2729
+
2730
+ The adapter's `__chaos` harness on `createTestServer` covers the WS-frame outbound path (drop / delay frames going to connected clients). It does **not** reach traffic on other transports -- ioredis, pg, NATS, custom HTTP backends -- because each of those goes through its own client, not through the test server's outbound chokepoint. To inject faults at those wires, wrap the transport client in a chaos proxy: `createChaosState` is re-exported above and composes with any client method via a small `Proxy` wrapper. See the adapter's [Wrap your own transport for cross-wire chaos](https://github.com/lanteanio/svelte-adapter-uws#wrap-your-own-transport-for-cross-wire-chaos) section for the pattern. The `__chaos` JSDoc on the adapter's [testing.d.ts](https://github.com/lanteanio/svelte-adapter-uws/blob/main/testing.d.ts) names the WS-only scope explicitly so tests reaching for cross-wire coverage see the boundary at the type level.
2731
+
2732
+ ---
2733
+
2734
+ ## Related projects
2735
+
2736
+ - [svelte-adapter-uws](https://github.com/lanteanio/svelte-adapter-uws) -- The core adapter this package extends. Single-process WebSocket pub/sub, presence, replay, and more for SvelteKit on uWebSockets.js.
2737
+ - [svelte-realtime](https://github.com/lanteanio/svelte-realtime) -- Opinionated full-stack starter built on the adapter. Auth, database, real-time CRUD, and deployment config out of the box.
2738
+ - [svelte-realtime-demo](https://github.com/lanteanio/svelte-realtime-demo) -- Live demo of svelte-realtime. [Try it here.](https://svelte-realtime-demo.lantean.io/)
2739
+
2740
+ ---
2741
+
2742
+ ## License
2743
+
2744
+ MIT