svelte-adapter-uws-extensions 0.1.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@ The core adapter keeps everything in-process memory. That works great for single
13
13
  - **Distributed broadcast groups** - named groups with membership and roles that span instances
14
14
  - **Shared cursor state** - ephemeral positions (cursors, selections, drawing strokes) visible across instances
15
15
  - **Database change notifications** - Postgres LISTEN/NOTIFY forwarded straight to WebSocket clients
16
+ - **Prometheus metrics** - expose extension metrics for scraping, zero overhead when disabled
16
17
 
17
18
  ---
18
19
 
@@ -37,11 +38,19 @@ The core adapter keeps everything in-process memory. That works great for single
37
38
  - [Replay buffer (Postgres)](#replay-buffer-postgres)
38
39
  - [LISTEN/NOTIFY bridge](#listennotify-bridge)
39
40
 
41
+ **Observability**
42
+ - [Prometheus metrics](#prometheus-metrics)
43
+
44
+ **Reliability**
45
+ - [Failure handling](#failure-handling)
46
+ - [Circuit breaker](#circuit-breaker)
47
+
40
48
  **Operations**
41
49
  - [Graceful shutdown](#graceful-shutdown)
42
50
  - [Testing](#testing)
43
51
 
44
- **Help**
52
+ **More**
53
+ - [Related projects](#related-projects)
45
54
  - [License](#license)
46
55
 
47
56
  ---
@@ -136,7 +145,9 @@ export const pg = createPgClient({
136
145
 
137
146
  ## Pub/sub bus
138
147
 
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).
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.
140
151
 
141
152
  #### Setup
142
153
 
@@ -190,6 +201,10 @@ export function message(ws, { data, platform }) {
190
201
 
191
202
  Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
192
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
+
193
208
  #### Setup
194
209
 
195
210
  ```js
@@ -261,6 +276,12 @@ All methods are async (they hit Redis). The API otherwise matches the core plugi
261
276
 
262
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.
263
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
+
264
285
  #### Setup
265
286
 
266
287
  ```js
@@ -296,7 +317,7 @@ export async function close(ws, { platform }) {
296
317
  | Option | Default | Description |
297
318
  |---|---|---|
298
319
  | `key` | `'id'` | Field for user dedup (multi-tab) |
299
- | `select` | identity | Extract public fields from userData |
320
+ | `select` | strips `__`-prefixed keys | Extract public fields from userData |
300
321
  | `heartbeat` | `30000` | TTL refresh interval in ms |
301
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. |
302
323
 
@@ -500,7 +521,7 @@ export function close(ws, { platform }) {
500
521
  | Option | Default | Description |
501
522
  |---|---|---|
502
523
  | `throttle` | `50` | Minimum ms between broadcasts per user per topic |
503
- | `select` | identity | Extract user data to broadcast alongside position |
524
+ | `select` | strips `__`-prefixed keys | Extract user data to broadcast alongside position |
504
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. |
505
526
 
506
527
  #### API
@@ -519,7 +540,11 @@ export function close(ws, { platform }) {
519
540
 
520
541
  ## Replay buffer (Postgres)
521
542
 
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.
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.
523
548
 
524
549
  #### Setup
525
550
 
@@ -661,10 +686,248 @@ The client side needs no changes -- the core `crud('messages')` store already ha
661
686
 
662
687
  #### Limitations
663
688
 
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.
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.
665
690
  - Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
666
691
  - This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
667
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
+ ---
853
+
854
+ ## Circuit breaker
855
+
856
+ 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.
857
+
858
+ Three states:
859
+ - **healthy** -- everything works, requests go through
860
+ - **broken** -- too many failures, requests fail fast via `CircuitBrokenError`
861
+ - **probing** -- one request is allowed through to test if the backend is back
862
+
863
+ #### Setup
864
+
865
+ ```js
866
+ // src/lib/server/breaker.js
867
+ import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
868
+
869
+ export const breaker = createCircuitBreaker({
870
+ failureThreshold: 5,
871
+ resetTimeout: 30000,
872
+ onStateChange: (from, to) => console.log(`circuit: ${from} -> ${to}`)
873
+ });
874
+ ```
875
+
876
+ Pass the same breaker to all extensions that share a backend:
877
+
878
+ ```js
879
+ import { breaker } from './breaker.js';
880
+
881
+ export const bus = createPubSubBus(redis, { breaker });
882
+ export const presence = createPresence(redis, { breaker, key: 'id' });
883
+ export const replay = createReplay(redis, { breaker });
884
+ export const limiter = createRateLimit(redis, { points: 10, interval: 1000, breaker });
885
+ ```
886
+
887
+ Failures from any extension contribute to the same breaker. When one trips it, all others fail fast.
888
+
889
+ #### Options
890
+
891
+ | Option | Default | Description |
892
+ |---|---|---|
893
+ | `failureThreshold` | `5` | Consecutive failures before breaking |
894
+ | `resetTimeout` | `30000` | Ms before transitioning from broken to probing |
895
+ | `onStateChange` | - | Called on state transitions: `(from, to) => void` |
896
+
897
+ #### API
898
+
899
+ | Method / Property | Description |
900
+ |---|---|
901
+ | `breaker.state` | `'healthy'`, `'broken'`, or `'probing'` |
902
+ | `breaker.isHealthy` | `true` only when state is `'healthy'` |
903
+ | `breaker.failures` | Current consecutive failure count |
904
+ | `breaker.guard()` | Throws `CircuitBrokenError` if the circuit is broken |
905
+ | `breaker.success()` | Record a successful operation |
906
+ | `breaker.failure()` | Record a failed operation |
907
+ | `breaker.reset()` | Force back to healthy |
908
+ | `breaker.destroy()` | Clear internal timers |
909
+
910
+ #### How extensions use it
911
+
912
+ 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.
913
+
914
+ 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.
915
+
916
+ #### Error handling
917
+
918
+ ```js
919
+ import { CircuitBrokenError } from 'svelte-adapter-uws-extensions/breaker';
920
+
921
+ try {
922
+ await replay.publish(platform, 'chat', 'msg', data);
923
+ } catch (err) {
924
+ if (err instanceof CircuitBrokenError) {
925
+ // Backend is down -- degrade gracefully
926
+ platform.publish('chat', 'msg', data); // local-only delivery
927
+ }
928
+ }
929
+ ```
930
+
668
931
  ---
669
932
 
670
933
  **Operations**
@@ -692,6 +955,14 @@ Tests use in-memory mocks for Redis and Postgres, no running services needed.
692
955
 
693
956
  ---
694
957
 
958
+ ## Related projects
959
+
960
+ - [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.
961
+ - [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.
962
+ - [svelte-realtime-demo](https://github.com/lanteanio/svelte-realtime-demo) -- Live demo of svelte-realtime. [Try it here.](https://svelte-realtime-demo.lantean.io/)
963
+
964
+ ---
965
+
695
966
  ## License
696
967
 
697
968
  MIT
package/package.json CHANGED
@@ -1,99 +1,110 @@
1
- {
2
- "name": "svelte-adapter-uws-extensions",
3
- "version": "0.1.9",
4
- "description": "Redis and Postgres extensions for svelte-adapter-uws - distributed pub/sub, replay buffers, presence tracking, rate limiting, groups, and DB change notifications",
5
- "author": "Kevin Radziszewski",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/lanteanio/svelte-adapter-uws-extensions.git"
10
- },
11
- "homepage": "https://github.com/lanteanio/svelte-adapter-uws-extensions#readme",
12
- "bugs": {
13
- "url": "https://github.com/lanteanio/svelte-adapter-uws-extensions/issues"
14
- },
15
- "type": "module",
16
- "exports": {
17
- "./redis": {
18
- "types": "./redis/index.d.ts",
19
- "default": "./redis/index.js"
20
- },
21
- "./redis/pubsub": {
22
- "types": "./redis/pubsub.d.ts",
23
- "default": "./redis/pubsub.js"
24
- },
25
- "./redis/replay": {
26
- "types": "./redis/replay.d.ts",
27
- "default": "./redis/replay.js"
28
- },
29
- "./redis/presence": {
30
- "types": "./redis/presence.d.ts",
31
- "default": "./redis/presence.js"
32
- },
33
- "./postgres": {
34
- "types": "./postgres/index.d.ts",
35
- "default": "./postgres/index.js"
36
- },
37
- "./postgres/replay": {
38
- "types": "./postgres/replay.d.ts",
39
- "default": "./postgres/replay.js"
40
- },
41
- "./postgres/notify": {
42
- "types": "./postgres/notify.d.ts",
43
- "default": "./postgres/notify.js"
44
- },
45
- "./redis/ratelimit": {
46
- "types": "./redis/ratelimit.d.ts",
47
- "default": "./redis/ratelimit.js"
48
- },
49
- "./redis/groups": {
50
- "types": "./redis/groups.d.ts",
51
- "default": "./redis/groups.js"
52
- },
53
- "./redis/cursor": {
54
- "types": "./redis/cursor.d.ts",
55
- "default": "./redis/cursor.js"
56
- }
57
- },
58
- "files": [
59
- "redis",
60
- "postgres",
61
- "shared",
62
- "LICENSE",
63
- "README.md"
64
- ],
65
- "scripts": {
66
- "test": "vitest run",
67
- "test:watch": "vitest"
68
- },
69
- "engines": {
70
- "node": ">=20.0.0"
71
- },
72
- "peerDependencies": {
73
- "svelte-adapter-uws": ">=0.2.0"
74
- },
75
- "dependencies": {
76
- "ioredis": "^5.0.0"
77
- },
78
- "optionalDependencies": {
79
- "pg": "^8.0.0"
80
- },
81
- "devDependencies": {
82
- "vitest": "^4.0.18"
83
- },
84
- "keywords": [
85
- "svelte",
86
- "sveltekit",
87
- "uwebsockets",
88
- "redis",
89
- "postgres",
90
- "pubsub",
91
- "websocket",
92
- "presence",
93
- "replay",
94
- "ratelimit",
95
- "groups",
96
- "cursor",
97
- "notify"
98
- ]
99
- }
1
+ {
2
+ "name": "svelte-adapter-uws-extensions",
3
+ "version": "0.4.1",
4
+ "description": "Redis and Postgres extensions for svelte-adapter-uws - distributed pub/sub, replay buffers, presence tracking, rate limiting, groups, and DB change notifications",
5
+ "author": "Kevin Radziszewski",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lanteanio/svelte-adapter-uws-extensions.git"
10
+ },
11
+ "homepage": "https://github.com/lanteanio/svelte-adapter-uws-extensions#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/lanteanio/svelte-adapter-uws-extensions/issues"
14
+ },
15
+ "type": "module",
16
+ "exports": {
17
+ "./redis": {
18
+ "types": "./redis/index.d.ts",
19
+ "default": "./redis/index.js"
20
+ },
21
+ "./redis/pubsub": {
22
+ "types": "./redis/pubsub.d.ts",
23
+ "default": "./redis/pubsub.js"
24
+ },
25
+ "./redis/replay": {
26
+ "types": "./redis/replay.d.ts",
27
+ "default": "./redis/replay.js"
28
+ },
29
+ "./redis/presence": {
30
+ "types": "./redis/presence.d.ts",
31
+ "default": "./redis/presence.js"
32
+ },
33
+ "./postgres": {
34
+ "types": "./postgres/index.d.ts",
35
+ "default": "./postgres/index.js"
36
+ },
37
+ "./postgres/replay": {
38
+ "types": "./postgres/replay.d.ts",
39
+ "default": "./postgres/replay.js"
40
+ },
41
+ "./postgres/notify": {
42
+ "types": "./postgres/notify.d.ts",
43
+ "default": "./postgres/notify.js"
44
+ },
45
+ "./redis/ratelimit": {
46
+ "types": "./redis/ratelimit.d.ts",
47
+ "default": "./redis/ratelimit.js"
48
+ },
49
+ "./redis/groups": {
50
+ "types": "./redis/groups.d.ts",
51
+ "default": "./redis/groups.js"
52
+ },
53
+ "./redis/cursor": {
54
+ "types": "./redis/cursor.d.ts",
55
+ "default": "./redis/cursor.js"
56
+ },
57
+ "./prometheus": {
58
+ "types": "./prometheus/index.d.ts",
59
+ "default": "./prometheus/index.js"
60
+ },
61
+ "./breaker": {
62
+ "types": "./shared/breaker.d.ts",
63
+ "default": "./shared/breaker.js"
64
+ }
65
+ },
66
+ "files": [
67
+ "redis",
68
+ "postgres",
69
+ "prometheus",
70
+ "shared",
71
+ "LICENSE",
72
+ "README.md"
73
+ ],
74
+ "scripts": {
75
+ "test": "vitest run",
76
+ "test:watch": "vitest"
77
+ },
78
+ "engines": {
79
+ "node": ">=20.0.0"
80
+ },
81
+ "peerDependencies": {
82
+ "svelte-adapter-uws": ">=0.4.0"
83
+ },
84
+ "dependencies": {
85
+ "ioredis": "^5.0.0"
86
+ },
87
+ "optionalDependencies": {
88
+ "pg": "^8.0.0"
89
+ },
90
+ "devDependencies": {
91
+ "vitest": "^4.0.18"
92
+ },
93
+ "keywords": [
94
+ "svelte",
95
+ "sveltekit",
96
+ "uwebsockets",
97
+ "redis",
98
+ "postgres",
99
+ "pubsub",
100
+ "websocket",
101
+ "presence",
102
+ "replay",
103
+ "ratelimit",
104
+ "groups",
105
+ "cursor",
106
+ "notify",
107
+ "prometheus",
108
+ "metrics"
109
+ ]
110
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Platform } from 'svelte-adapter-uws';
2
2
  import type { PgClient } from './index.js';
3
+ import type { MetricsRegistry } from '../prometheus/index.js';
4
+ import type { CircuitBreaker } from '../shared/breaker.js';
3
5
 
4
6
  export interface NotifyBridgeOptions {
5
7
  /** Postgres LISTEN channel name. Required. */
@@ -17,6 +19,10 @@ export interface NotifyBridgeOptions {
17
19
 
18
20
  /** ms between reconnect attempts. @default 3000 */
19
21
  reconnectInterval?: number;
22
+ /** Prometheus metrics registry. */
23
+ metrics?: MetricsRegistry;
24
+ /** Circuit breaker instance. */
25
+ breaker?: CircuitBreaker;
20
26
  }
21
27
 
22
28
  export interface NotifyBridge {
@@ -81,9 +81,22 @@ export function createNotifyBridge(client, options) {
81
81
 
82
82
  const channel = options.channel;
83
83
  const autoReconnect = options.autoReconnect !== false;
84
- const reconnectInterval = options.reconnectInterval || 3000;
84
+ const reconnectInterval = options.reconnectInterval ?? 3000;
85
+ if (typeof reconnectInterval !== 'number' || !Number.isFinite(reconnectInterval) || reconnectInterval < 0) {
86
+ throw new Error('notify bridge: reconnectInterval must be a non-negative number');
87
+ }
88
+ if (options.parse != null && typeof options.parse !== 'function') {
89
+ throw new Error('notify bridge: parse must be a function');
90
+ }
91
+ const isDefaultParser = !options.parse;
85
92
  const parse = options.parse || defaultParse;
86
93
 
94
+ const b = options.breaker;
95
+ const m = options.metrics;
96
+ const mReceived = m?.counter('notify_received_total', 'Notifications received', ['channel']);
97
+ const mParseErrors = m?.counter('notify_parse_errors_total', 'Notification parse failures', ['channel']);
98
+ const mReconnects = m?.counter('notify_reconnects_total', 'Connection reconnect attempts');
99
+
87
100
  /** @type {import('pg').Client | null} */
88
101
  let conn = null;
89
102
  /** @type {import('svelte-adapter-uws').Platform | null} */
@@ -104,19 +117,27 @@ export function createNotifyBridge(client, options) {
104
117
 
105
118
  function onNotification(msg) {
106
119
  if (msg.channel !== channel) return;
120
+ mReceived?.inc({ channel });
121
+ if (msg.payload && msg.payload.length > 7500) {
122
+ console.warn(
123
+ `[postgres/notify] payload on "${channel}" is ${msg.payload.length} bytes — ` +
124
+ 'approaching the ~8000 byte Postgres NOTIFY limit'
125
+ );
126
+ }
107
127
  try {
108
128
  const result = parse(msg.payload, msg.channel);
109
129
  if (result && activePlatform) {
110
- // relay: false -- in clustered mode each worker has its own
111
- // LISTEN connection, so relaying would duplicate delivery.
112
130
  activePlatform.publish(result.topic, result.event, result.data, { relay: false });
131
+ } else if (!result && isDefaultParser) {
132
+ mParseErrors?.inc({ channel });
113
133
  }
114
134
  } catch {
115
- // Parse errors are non-fatal -- skip the notification
135
+ mParseErrors?.inc({ channel });
116
136
  }
117
137
  }
118
138
 
119
139
  async function connect() {
140
+ b?.guard();
120
141
  try {
121
142
  // Use a standalone Client instead of pool.connect() to avoid
122
143
  // permanently holding a pool connection. LISTEN needs a persistent
@@ -130,7 +151,9 @@ export function createNotifyBridge(client, options) {
130
151
  // pg requires channel name to be a valid identifier or quoted
131
152
  // Use double-quoting to handle any channel name safely
132
153
  await conn.query(`LISTEN "${channel.replace(/"/g, '""')}"`);
154
+ b?.success();
133
155
  } catch (err) {
156
+ b?.failure(err);
134
157
  if (conn) {
135
158
  try { await conn.end(); } catch { /* ignore */ }
136
159
  }
@@ -142,7 +165,8 @@ export function createNotifyBridge(client, options) {
142
165
  }
143
166
  }
144
167
 
145
- function handleError() {
168
+ function handleError(err) {
169
+ b?.failure(err);
146
170
  cleanup();
147
171
  if (active && autoReconnect) {
148
172
  scheduleReconnect();
@@ -151,6 +175,7 @@ export function createNotifyBridge(client, options) {
151
175
 
152
176
  function scheduleReconnect() {
153
177
  if (reconnectTimer) return;
178
+ mReconnects?.inc();
154
179
  reconnectTimer = setTimeout(async () => {
155
180
  reconnectTimer = null;
156
181
  if (!active) return;