svelte-adapter-uws-extensions 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Radziszewski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,697 @@
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
+
17
+ ---
18
+
19
+ ## Table of contents
20
+
21
+ **Getting started**
22
+ - [Installation](#installation)
23
+
24
+ **Clients**
25
+ - [Redis client](#redis-client)
26
+ - [Postgres client](#postgres-client)
27
+
28
+ **Redis extensions**
29
+ - [Pub/sub bus](#pubsub-bus)
30
+ - [Replay buffer (Redis)](#replay-buffer-redis)
31
+ - [Presence](#presence)
32
+ - [Rate limiting](#rate-limiting)
33
+ - [Broadcast groups](#broadcast-groups)
34
+ - [Cursor](#cursor)
35
+
36
+ **Postgres extensions**
37
+ - [Replay buffer (Postgres)](#replay-buffer-postgres)
38
+ - [LISTEN/NOTIFY bridge](#listennotify-bridge)
39
+
40
+ **Operations**
41
+ - [Graceful shutdown](#graceful-shutdown)
42
+ - [Testing](#testing)
43
+
44
+ **Help**
45
+ - [License](#license)
46
+
47
+ ---
48
+
49
+ **Getting started**
50
+
51
+ ## Installation
52
+
53
+ ```bash
54
+ npm install svelte-adapter-uws-extensions ioredis
55
+ ```
56
+
57
+ Postgres support is optional:
58
+
59
+ ```bash
60
+ npm install pg
61
+ ```
62
+
63
+ Requires `svelte-adapter-uws >= 0.2.0` as a peer dependency.
64
+
65
+ ---
66
+
67
+ **Clients**
68
+
69
+ ## Redis client
70
+
71
+ Factory that wraps [ioredis](https://github.com/redis/ioredis) with lifecycle management. All Redis extensions accept this client.
72
+
73
+ ```js
74
+ // src/lib/server/redis.js
75
+ import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
76
+
77
+ export const redis = createRedisClient({
78
+ url: 'redis://localhost:6379',
79
+ keyPrefix: 'myapp:' // optional, prefixes all keys
80
+ });
81
+ ```
82
+
83
+ #### Options
84
+
85
+ | Option | Default | Description |
86
+ |---|---|---|
87
+ | `url` | `'redis://localhost:6379'` | Redis connection URL |
88
+ | `keyPrefix` | `''` | Prefix for all keys |
89
+ | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
90
+ | `options` | `{}` | Extra ioredis options |
91
+
92
+ #### API
93
+
94
+ | Method | Description |
95
+ |---|---|
96
+ | `redis.redis` | The underlying ioredis instance |
97
+ | `redis.key(k)` | Returns `keyPrefix + k` |
98
+ | `redis.duplicate(overrides?)` | New connection with same config. Pass ioredis options to override defaults. |
99
+ | `redis.quit()` | Gracefully disconnect all connections |
100
+
101
+ ---
102
+
103
+ ## Postgres client
104
+
105
+ Factory that wraps [pg](https://github.com/brianc/node-postgres) Pool with lifecycle management.
106
+
107
+ ```js
108
+ // src/lib/server/pg.js
109
+ import { createPgClient } from 'svelte-adapter-uws-extensions/postgres';
110
+
111
+ export const pg = createPgClient({
112
+ connectionString: 'postgres://localhost:5432/mydb'
113
+ });
114
+ ```
115
+
116
+ #### Options
117
+
118
+ | Option | Default | Description |
119
+ |---|---|---|
120
+ | `connectionString` | *required* | Postgres connection string |
121
+ | `autoShutdown` | `true` | Disconnect on `sveltekit:shutdown` |
122
+ | `options` | `{}` | Extra pg Pool options |
123
+
124
+ #### API
125
+
126
+ | Method | Description |
127
+ |---|---|
128
+ | `pg.pool` | The underlying pg Pool |
129
+ | `pg.query(text, values?)` | Run a query |
130
+ | `pg.createClient()` | New standalone pg.Client with same config (not from the pool) |
131
+ | `pg.end()` | Gracefully close the pool |
132
+
133
+ ---
134
+
135
+ **Redis extensions**
136
+
137
+ ## Pub/sub bus
138
+
139
+ 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 from the same instance are ignored).
140
+
141
+ #### Setup
142
+
143
+ ```js
144
+ // src/lib/server/bus.js
145
+ import { redis } from './redis.js';
146
+ import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
147
+
148
+ export const bus = createPubSubBus(redis);
149
+ ```
150
+
151
+ #### Usage
152
+
153
+ ```js
154
+ // src/hooks.ws.js
155
+ import { bus } from '$lib/server/bus';
156
+
157
+ let distributed;
158
+
159
+ export function open(ws, { platform }) {
160
+ // Start subscriber (idempotent, only subscribes once)
161
+ bus.activate(platform);
162
+ // Get a wrapped platform that publishes to Redis + local
163
+ distributed = bus.wrap(platform);
164
+ }
165
+
166
+ export function message(ws, { data, platform }) {
167
+ const msg = JSON.parse(Buffer.from(data).toString());
168
+ // This publish reaches local clients AND all other instances
169
+ distributed.publish('chat', 'message', msg);
170
+ }
171
+ ```
172
+
173
+ #### Options
174
+
175
+ | Option | Default | Description |
176
+ |---|---|---|
177
+ | `channel` | `'uws:pubsub'` | Redis channel name |
178
+
179
+ #### API
180
+
181
+ | Method | Description |
182
+ |---|---|
183
+ | `bus.wrap(platform)` | Returns a new Platform whose `publish()` sends to Redis + local |
184
+ | `bus.activate(platform)` | Start the Redis subscriber (idempotent) |
185
+ | `bus.deactivate()` | Stop the subscriber |
186
+
187
+ ---
188
+
189
+ ## Replay buffer (Redis)
190
+
191
+ Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
192
+
193
+ #### Setup
194
+
195
+ ```js
196
+ // src/lib/server/replay.js
197
+ import { redis } from './redis.js';
198
+ import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
199
+
200
+ export const replay = createReplay(redis, {
201
+ size: 500,
202
+ ttl: 3600 // expire after 1 hour
203
+ });
204
+ ```
205
+
206
+ #### Usage
207
+
208
+ ```js
209
+ // In a form action or API route
210
+ export const actions = {
211
+ send: async ({ request, platform }) => {
212
+ const data = Object.fromEntries(await request.formData());
213
+ const msg = await db.createMessage(data);
214
+ await replay.publish(platform, 'chat', 'created', msg);
215
+ }
216
+ };
217
+ ```
218
+
219
+ ```js
220
+ // In +page.server.js
221
+ export async function load() {
222
+ const messages = await db.getRecentMessages();
223
+ return { messages, seq: await replay.seq('chat') };
224
+ }
225
+ ```
226
+
227
+ ```js
228
+ // In hooks.ws.js - handle replay requests
229
+ export async function message(ws, { data, platform }) {
230
+ const msg = JSON.parse(Buffer.from(data).toString());
231
+ if (msg.type === 'replay') {
232
+ await replay.replay(ws, msg.topic, msg.since, platform);
233
+ return;
234
+ }
235
+ }
236
+ ```
237
+
238
+ #### Options
239
+
240
+ | Option | Default | Description |
241
+ |---|---|---|
242
+ | `size` | `1000` | Max messages per topic |
243
+ | `ttl` | `0` | Key expiry in seconds (0 = never) |
244
+
245
+ #### API
246
+
247
+ All methods are async (they hit Redis). The API otherwise matches the core plugin exactly:
248
+
249
+ | Method | Description |
250
+ |---|---|
251
+ | `publish(platform, topic, event, data)` | Store + broadcast |
252
+ | `seq(topic)` | Current sequence number |
253
+ | `since(topic, seq)` | Messages after a sequence |
254
+ | `replay(ws, topic, sinceSeq, platform)` | Send missed messages to one client |
255
+ | `clear()` | Delete all replay data |
256
+ | `clearTopic(topic)` | Delete replay data for one topic |
257
+
258
+ ---
259
+
260
+ ## Presence
261
+
262
+ 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.
263
+
264
+ #### Setup
265
+
266
+ ```js
267
+ // src/lib/server/presence.js
268
+ import { redis } from './redis.js';
269
+ import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
270
+
271
+ export const presence = createPresence(redis, {
272
+ key: 'id',
273
+ select: (userData) => ({ id: userData.id, name: userData.name }),
274
+ heartbeat: 30000,
275
+ ttl: 90
276
+ });
277
+ ```
278
+
279
+ #### Usage
280
+
281
+ ```js
282
+ // src/hooks.ws.js
283
+ import { presence } from '$lib/server/presence';
284
+
285
+ export async function subscribe(ws, topic, { platform }) {
286
+ await presence.join(ws, topic, platform);
287
+ }
288
+
289
+ export async function close(ws, { platform }) {
290
+ await presence.leave(ws, platform);
291
+ }
292
+ ```
293
+
294
+ #### Options
295
+
296
+ | Option | Default | Description |
297
+ |---|---|---|
298
+ | `key` | `'id'` | Field for user dedup (multi-tab) |
299
+ | `select` | identity | Extract public fields from userData |
300
+ | `heartbeat` | `30000` | TTL refresh interval in ms |
301
+ | `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. |
302
+
303
+ #### API
304
+
305
+ | Method | Description |
306
+ |---|---|
307
+ | `join(ws, topic, platform)` | Add connection to presence |
308
+ | `leave(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
309
+ | `sync(ws, topic, platform)` | Send list without joining |
310
+ | `list(topic)` | Get current users |
311
+ | `count(topic)` | Count unique users |
312
+ | `clear()` | Reset all presence state |
313
+ | `destroy()` | Stop heartbeat and subscriber |
314
+ | `hooks` | `{ subscribe, close }` -- ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
315
+
316
+ #### Zero-config hooks
317
+
318
+ Instead of writing `subscribe` and `close` handlers manually, destructure `presence.hooks`:
319
+
320
+ ```js
321
+ // src/hooks.ws.js
322
+ import { presence } from '$lib/server/presence';
323
+ export const { subscribe, close } = presence.hooks;
324
+ ```
325
+
326
+ `subscribe` handles both regular topics (calls `join`) and `__presence:*` topics (calls `sync` so the client gets the current list). `close` calls `leave`.
327
+
328
+ If you need custom logic (auth gating, logging), wrap the hooks:
329
+
330
+ ```js
331
+ import { presence } from '$lib/server/presence';
332
+
333
+ export async function subscribe(ws, topic, ctx) {
334
+ if (!ctx.platform.getUserData(ws).authenticated) return;
335
+ await presence.hooks.subscribe(ws, topic, ctx);
336
+ }
337
+
338
+ export const { close } = presence.hooks;
339
+ ```
340
+
341
+ ---
342
+
343
+ ## Rate limiting
344
+
345
+ 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.
346
+
347
+ #### Setup
348
+
349
+ ```js
350
+ // src/lib/server/ratelimit.js
351
+ import { redis } from './redis.js';
352
+ import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
353
+
354
+ export const limiter = createRateLimit(redis, {
355
+ points: 10,
356
+ interval: 1000,
357
+ blockDuration: 30000
358
+ });
359
+ ```
360
+
361
+ #### Usage
362
+
363
+ ```js
364
+ // src/hooks.ws.js
365
+ import { limiter } from '$lib/server/ratelimit';
366
+
367
+ export async function message(ws, { data, platform }) {
368
+ const { allowed } = await limiter.consume(ws);
369
+ if (!allowed) return; // drop the message
370
+ // ... handle message
371
+ }
372
+ ```
373
+
374
+ #### Options
375
+
376
+ | Option | Default | Description |
377
+ |---|---|---|
378
+ | `points` | *required* | Tokens available per interval |
379
+ | `interval` | *required* | Refill interval in ms |
380
+ | `blockDuration` | `0` | Auto-ban duration in ms (0 = no ban) |
381
+ | `keyBy` | `'ip'` | `'ip'`, `'connection'`, or a function |
382
+
383
+ #### API
384
+
385
+ All methods are async (they hit Redis). The API otherwise matches the core plugin:
386
+
387
+ | Method | Description |
388
+ |---|---|
389
+ | `consume(ws, cost?)` | Attempt to consume tokens. `cost` must be a positive integer. |
390
+ | `reset(key)` | Clear the bucket for a key |
391
+ | `ban(key, duration?)` | Manually ban a key |
392
+ | `unban(key)` | Remove a ban |
393
+ | `clear()` | Reset all state |
394
+
395
+ ---
396
+
397
+ ## Broadcast groups
398
+
399
+ 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.
400
+
401
+ #### Setup
402
+
403
+ ```js
404
+ // src/lib/server/lobby.js
405
+ import { redis } from './redis.js';
406
+ import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
407
+
408
+ export const lobby = createGroup(redis, 'lobby', {
409
+ maxMembers: 50,
410
+ meta: { game: 'chess' }
411
+ });
412
+ ```
413
+
414
+ Note: the API signature is `createGroup(client, name, options)` instead of `createGroup(name, options)` -- the Redis client is the first argument.
415
+
416
+ #### Usage
417
+
418
+ ```js
419
+ // src/hooks.ws.js
420
+ import { lobby } from '$lib/server/lobby';
421
+
422
+ export async function subscribe(ws, topic, { platform }) {
423
+ if (topic === 'lobby') await lobby.join(ws, platform);
424
+ }
425
+
426
+ export async function close(ws, { platform }) {
427
+ await lobby.leave(ws, platform);
428
+ }
429
+ ```
430
+
431
+ #### Options
432
+
433
+ | Option | Default | Description |
434
+ |---|---|---|
435
+ | `maxMembers` | `Infinity` | Maximum members allowed (enforced atomically) |
436
+ | `meta` | `{}` | Initial group metadata |
437
+ | `memberTtl` | `120` | Member entry TTL in seconds. Entries from crashed instances expire after this period. |
438
+ | `onJoin` | - | Called after a member joins |
439
+ | `onLeave` | - | Called after a member leaves |
440
+ | `onFull` | - | Called when a join is rejected (full) |
441
+ | `onClose` | - | Called when the group is closed |
442
+
443
+ #### API
444
+
445
+ | Method | Description |
446
+ |---|---|
447
+ | `join(ws, platform, role?)` | Add a member (returns false if full/closed) |
448
+ | `leave(ws, platform)` | Remove a member |
449
+ | `publish(platform, event, data, role?)` | Broadcast to all or filter by role |
450
+ | `send(platform, ws, event, data)` | Send to a single member |
451
+ | `localMembers()` | Members on this instance |
452
+ | `count()` | Total members across all instances |
453
+ | `has(ws)` | Check membership on this instance |
454
+ | `getMeta()` / `setMeta(meta)` | Read/write group metadata |
455
+ | `close(platform)` | Dissolve the group |
456
+ | `destroy()` | Stop the Redis subscriber |
457
+
458
+ ---
459
+
460
+ ## Cursor
461
+
462
+ 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.
463
+
464
+ Hash entries have a TTL so stale cursors from crashed instances get cleaned up automatically.
465
+
466
+ #### Setup
467
+
468
+ ```js
469
+ // src/lib/server/cursors.js
470
+ import { redis } from './redis.js';
471
+ import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
472
+
473
+ export const cursors = createCursor(redis, {
474
+ throttle: 50,
475
+ select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color }),
476
+ ttl: 30
477
+ });
478
+ ```
479
+
480
+ #### Usage
481
+
482
+ ```js
483
+ // src/hooks.ws.js
484
+ import { cursors } from '$lib/server/cursors';
485
+
486
+ export function message(ws, { data, platform }) {
487
+ const msg = JSON.parse(Buffer.from(data).toString());
488
+ if (msg.type === 'cursor') {
489
+ cursors.update(ws, msg.topic, msg.position, platform);
490
+ }
491
+ }
492
+
493
+ export function close(ws, { platform }) {
494
+ cursors.remove(ws, platform);
495
+ }
496
+ ```
497
+
498
+ #### Options
499
+
500
+ | Option | Default | Description |
501
+ |---|---|---|
502
+ | `throttle` | `50` | Minimum ms between broadcasts per user per topic |
503
+ | `select` | identity | Extract user data to broadcast alongside position |
504
+ | `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. |
505
+
506
+ #### API
507
+
508
+ | Method | Description |
509
+ |---|---|
510
+ | `update(ws, topic, data, platform)` | Broadcast cursor position (throttled per user per topic) |
511
+ | `remove(ws, platform, topic?)` | Remove from a specific topic, or all topics if omitted |
512
+ | `list(topic)` | Get current positions across all instances |
513
+ | `clear()` | Reset all local and Redis state |
514
+ | `destroy()` | Stop the Redis subscriber and clear timers |
515
+
516
+ ---
517
+
518
+ **Postgres extensions**
519
+
520
+ ## Replay buffer (Postgres)
521
+
522
+ 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, so they are safe across multiple server instances.
523
+
524
+ #### Setup
525
+
526
+ ```js
527
+ // src/lib/server/replay.js
528
+ import { pg } from './pg.js';
529
+ import { createReplay } from 'svelte-adapter-uws-extensions/postgres/replay';
530
+
531
+ export const replay = createReplay(pg, {
532
+ table: 'ws_replay',
533
+ size: 1000,
534
+ ttl: 86400, // 24 hours
535
+ autoMigrate: true // auto-create table
536
+ });
537
+ ```
538
+
539
+ #### Schema
540
+
541
+ The table is created automatically on first use (if `autoMigrate` is true):
542
+
543
+ ```sql
544
+ CREATE TABLE IF NOT EXISTS ws_replay (
545
+ id BIGSERIAL PRIMARY KEY,
546
+ topic TEXT NOT NULL,
547
+ seq BIGINT NOT NULL,
548
+ event TEXT NOT NULL,
549
+ data JSONB,
550
+ created_at TIMESTAMPTZ DEFAULT now()
551
+ );
552
+ CREATE INDEX IF NOT EXISTS idx_ws_replay_topic_seq ON ws_replay (topic, seq);
553
+
554
+ CREATE TABLE IF NOT EXISTS ws_replay_seq (
555
+ topic TEXT PRIMARY KEY,
556
+ seq BIGINT NOT NULL DEFAULT 0
557
+ );
558
+ ```
559
+
560
+ #### Options
561
+
562
+ | Option | Default | Description |
563
+ |---|---|---|
564
+ | `table` | `'ws_replay'` | Table name |
565
+ | `size` | `1000` | Max messages per topic |
566
+ | `ttl` | `0` | Row expiry in seconds (0 = never) |
567
+ | `autoMigrate` | `true` | Auto-create table |
568
+ | `cleanupInterval` | `60000` | Periodic cleanup interval in ms (0 to disable) |
569
+
570
+ #### API
571
+
572
+ Same as [Replay buffer (Redis)](#api-3), plus:
573
+
574
+ | Method | Description |
575
+ |---|---|
576
+ | `destroy()` | Stop the cleanup timer |
577
+
578
+ ---
579
+
580
+ ## LISTEN/NOTIFY bridge
581
+
582
+ 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.
583
+
584
+ Uses a standalone connection (not from the pool) since LISTEN requires a persistent connection that stays open for the lifetime of the bridge.
585
+
586
+ #### Setup
587
+
588
+ ```js
589
+ // src/lib/server/notify.js
590
+ import { pg } from './pg.js';
591
+ import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
592
+
593
+ export const bridge = createNotifyBridge(pg, {
594
+ channel: 'table_changes',
595
+ parse: (payload) => {
596
+ const row = JSON.parse(payload);
597
+ return { topic: row.table, event: row.op, data: row.data };
598
+ }
599
+ });
600
+ ```
601
+
602
+ #### Usage
603
+
604
+ ```js
605
+ // src/hooks.ws.js
606
+ import { bridge } from '$lib/server/notify';
607
+
608
+ let activated = false;
609
+
610
+ export function open(ws, { platform }) {
611
+ if (!activated) {
612
+ activated = true;
613
+ bridge.activate(platform);
614
+ }
615
+ }
616
+ ```
617
+
618
+ #### Setting up the trigger
619
+
620
+ Create a trigger function and attach it to your table:
621
+
622
+ ```sql
623
+ CREATE OR REPLACE FUNCTION notify_table_change() RETURNS trigger AS $$
624
+ BEGIN
625
+ PERFORM pg_notify('table_changes', json_build_object(
626
+ 'table', TG_TABLE_NAME,
627
+ 'op', lower(TG_OP),
628
+ 'data', CASE TG_OP
629
+ WHEN 'DELETE' THEN row_to_json(OLD)
630
+ ELSE row_to_json(NEW)
631
+ END
632
+ )::text);
633
+ RETURN COALESCE(NEW, OLD);
634
+ END;
635
+ $$ LANGUAGE plpgsql;
636
+
637
+ CREATE TRIGGER messages_notify
638
+ AFTER INSERT OR UPDATE OR DELETE ON messages
639
+ FOR EACH ROW EXECUTE FUNCTION notify_table_change();
640
+ ```
641
+
642
+ 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.
643
+
644
+ The client side needs no changes -- the core `crud('messages')` store already handles `created`, `updated`, and `deleted` events.
645
+
646
+ #### Options
647
+
648
+ | Option | Default | Description |
649
+ |---|---|---|
650
+ | `channel` | *required* | Postgres LISTEN channel name |
651
+ | `parse` | JSON with `{ topic, event, data }` | Parse notification payload into a publish call. Return null to skip. |
652
+ | `autoReconnect` | `true` | Reconnect on connection loss |
653
+ | `reconnectInterval` | `3000` | ms between reconnect attempts |
654
+
655
+ #### API
656
+
657
+ | Method | Description |
658
+ |---|---|
659
+ | `activate(platform)` | Start listening (idempotent) |
660
+ | `deactivate()` | Stop listening and release the connection |
661
+
662
+ #### Limitations
663
+
664
+ - Payload is limited to 8KB by Postgres. For large rows, send the row ID in the notification and let the client fetch the full row.
665
+ - Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
666
+ - This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
667
+
668
+ ---
669
+
670
+ **Operations**
671
+
672
+ ## Graceful shutdown
673
+
674
+ 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.
675
+
676
+ ```js
677
+ // Manual shutdown
678
+ await redis.quit();
679
+ await pg.end();
680
+ presence.destroy();
681
+ ```
682
+
683
+ ---
684
+
685
+ ## Testing
686
+
687
+ ```bash
688
+ npm test
689
+ ```
690
+
691
+ Tests use in-memory mocks for Redis and Postgres, no running services needed.
692
+
693
+ ---
694
+
695
+ ## License
696
+
697
+ MIT