svelte-adapter-uws-extensions 0.1.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -3
- package/package.json +110 -99
- package/postgres/notify.d.ts +6 -0
- package/postgres/notify.js +30 -5
- package/postgres/replay.d.ts +67 -56
- package/postgres/replay.js +368 -279
- package/prometheus/index.d.ts +54 -0
- package/prometheus/index.js +406 -0
- package/redis/cursor.d.ts +107 -74
- package/redis/cursor.js +695 -493
- package/redis/groups.d.ts +89 -64
- package/redis/groups.js +243 -115
- package/redis/index.js +7 -3
- package/redis/presence.d.ts +71 -62
- package/redis/presence.js +513 -133
- package/redis/pubsub.d.ts +36 -30
- package/redis/pubsub.js +68 -11
- package/redis/ratelimit.d.ts +6 -0
- package/redis/ratelimit.js +106 -40
- package/redis/replay.d.ts +58 -47
- package/redis/replay.js +237 -156
- package/shared/breaker.d.ts +42 -0
- package/shared/breaker.js +160 -0
- package/shared/scripts.js +120 -0
- package/shared/time.js +20 -0
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,18 @@ 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
|
+
- [Circuit breaker](#circuit-breaker)
|
|
46
|
+
|
|
40
47
|
**Operations**
|
|
41
48
|
- [Graceful shutdown](#graceful-shutdown)
|
|
42
49
|
- [Testing](#testing)
|
|
43
50
|
|
|
44
|
-
**
|
|
51
|
+
**More**
|
|
52
|
+
- [Related projects](#related-projects)
|
|
45
53
|
- [License](#license)
|
|
46
54
|
|
|
47
55
|
---
|
|
@@ -296,7 +304,7 @@ export async function close(ws, { platform }) {
|
|
|
296
304
|
| Option | Default | Description |
|
|
297
305
|
|---|---|---|
|
|
298
306
|
| `key` | `'id'` | Field for user dedup (multi-tab) |
|
|
299
|
-
| `select` |
|
|
307
|
+
| `select` | strips `__`-prefixed keys | Extract public fields from userData |
|
|
300
308
|
| `heartbeat` | `30000` | TTL refresh interval in ms |
|
|
301
309
|
| `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
310
|
|
|
@@ -500,7 +508,7 @@ export function close(ws, { platform }) {
|
|
|
500
508
|
| Option | Default | Description |
|
|
501
509
|
|---|---|---|
|
|
502
510
|
| `throttle` | `50` | Minimum ms between broadcasts per user per topic |
|
|
503
|
-
| `select` |
|
|
511
|
+
| `select` | strips `__`-prefixed keys | Extract user data to broadcast alongside position |
|
|
504
512
|
| `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
513
|
|
|
506
514
|
#### API
|
|
@@ -667,6 +675,222 @@ The client side needs no changes -- the core `crud('messages')` store already ha
|
|
|
667
675
|
|
|
668
676
|
---
|
|
669
677
|
|
|
678
|
+
**Observability**
|
|
679
|
+
|
|
680
|
+
## Prometheus metrics
|
|
681
|
+
|
|
682
|
+
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.
|
|
683
|
+
|
|
684
|
+
#### Setup
|
|
685
|
+
|
|
686
|
+
```js
|
|
687
|
+
// src/lib/server/metrics.js
|
|
688
|
+
import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
|
|
689
|
+
|
|
690
|
+
export const metrics = createMetrics({
|
|
691
|
+
prefix: 'myapp_',
|
|
692
|
+
mapTopic: (topic) => topic.startsWith('room:') ? 'room:*' : topic
|
|
693
|
+
});
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
Pass the `metrics` object to any extension via its options:
|
|
697
|
+
|
|
698
|
+
```js
|
|
699
|
+
import { metrics } from './metrics.js';
|
|
700
|
+
import { redis } from './redis.js';
|
|
701
|
+
import { createPresence } from 'svelte-adapter-uws-extensions/redis/presence';
|
|
702
|
+
import { createPubSubBus } from 'svelte-adapter-uws-extensions/redis/pubsub';
|
|
703
|
+
import { createReplay } from 'svelte-adapter-uws-extensions/redis/replay';
|
|
704
|
+
import { createRateLimit } from 'svelte-adapter-uws-extensions/redis/ratelimit';
|
|
705
|
+
import { createGroup } from 'svelte-adapter-uws-extensions/redis/groups';
|
|
706
|
+
import { createCursor } from 'svelte-adapter-uws-extensions/redis/cursor';
|
|
707
|
+
|
|
708
|
+
export const bus = createPubSubBus(redis, { metrics });
|
|
709
|
+
export const presence = createPresence(redis, { metrics, key: 'id' });
|
|
710
|
+
export const replay = createReplay(redis, { metrics });
|
|
711
|
+
export const limiter = createRateLimit(redis, { points: 10, interval: 1000, metrics });
|
|
712
|
+
export const lobby = createGroup(redis, 'lobby', { metrics });
|
|
713
|
+
export const cursors = createCursor(redis, { metrics });
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
#### Mounting the endpoint
|
|
717
|
+
|
|
718
|
+
With uWebSockets.js:
|
|
719
|
+
|
|
720
|
+
```js
|
|
721
|
+
app.get('/metrics', metrics.handler);
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Or use `metrics.serialize()` to get the raw text and serve it however you like.
|
|
725
|
+
|
|
726
|
+
#### Options
|
|
727
|
+
|
|
728
|
+
| Option | Default | Description |
|
|
729
|
+
|---|---|---|
|
|
730
|
+
| `prefix` | `''` | Prefix for all metric names |
|
|
731
|
+
| `mapTopic` | identity | Map topic names to bounded label values for cardinality control |
|
|
732
|
+
| `defaultBuckets` | `[1, 5, 10, 25, 50, 100, 250, 500, 1000]` | Default histogram buckets |
|
|
733
|
+
|
|
734
|
+
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.
|
|
735
|
+
|
|
736
|
+
#### Cardinality control
|
|
737
|
+
|
|
738
|
+
If your topics are user-generated (e.g. `room:abc123`), per-topic labels will grow unbounded. Use `mapTopic` to collapse them:
|
|
739
|
+
|
|
740
|
+
```js
|
|
741
|
+
const metrics = createMetrics({
|
|
742
|
+
mapTopic: (topic) => {
|
|
743
|
+
if (topic.startsWith('room:')) return 'room:*';
|
|
744
|
+
if (topic.startsWith('user:')) return 'user:*';
|
|
745
|
+
return topic;
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
#### Metrics reference
|
|
751
|
+
|
|
752
|
+
**Pub/sub bus**
|
|
753
|
+
|
|
754
|
+
| Metric | Type | Description |
|
|
755
|
+
|---|---|---|
|
|
756
|
+
| `pubsub_messages_relayed_total` | counter | Messages relayed to Redis |
|
|
757
|
+
| `pubsub_messages_received_total` | counter | Messages received from Redis |
|
|
758
|
+
| `pubsub_echo_suppressed_total` | counter | Messages dropped by echo suppression |
|
|
759
|
+
| `pubsub_relay_batch_size` | histogram | Relay batch size per flush |
|
|
760
|
+
|
|
761
|
+
**Presence**
|
|
762
|
+
|
|
763
|
+
| Metric | Type | Labels | Description |
|
|
764
|
+
|---|---|---|---|
|
|
765
|
+
| `presence_joins_total` | counter | `topic` | Join events |
|
|
766
|
+
| `presence_leaves_total` | counter | `topic` | Leave events |
|
|
767
|
+
| `presence_heartbeats_total` | counter | | Heartbeat refresh cycles |
|
|
768
|
+
| `presence_stale_cleaned_total` | counter | | Stale entries removed by cleanup |
|
|
769
|
+
|
|
770
|
+
**Replay buffer (Redis and Postgres)**
|
|
771
|
+
|
|
772
|
+
| Metric | Type | Labels | Description |
|
|
773
|
+
|---|---|---|---|
|
|
774
|
+
| `replay_publishes_total` | counter | `topic` | Messages published |
|
|
775
|
+
| `replay_messages_replayed_total` | counter | `topic` | Messages replayed to clients |
|
|
776
|
+
| `replay_truncations_total` | counter | `topic` | Truncation events detected |
|
|
777
|
+
|
|
778
|
+
**Rate limiting**
|
|
779
|
+
|
|
780
|
+
| Metric | Type | Description |
|
|
781
|
+
|---|---|---|
|
|
782
|
+
| `ratelimit_allowed_total` | counter | Requests allowed |
|
|
783
|
+
| `ratelimit_denied_total` | counter | Requests denied |
|
|
784
|
+
| `ratelimit_bans_total` | counter | Bans applied |
|
|
785
|
+
|
|
786
|
+
**Broadcast groups**
|
|
787
|
+
|
|
788
|
+
| Metric | Type | Labels | Description |
|
|
789
|
+
|---|---|---|---|
|
|
790
|
+
| `group_joins_total` | counter | `group` | Join events |
|
|
791
|
+
| `group_joins_rejected_total` | counter | `group` | Joins rejected (full) |
|
|
792
|
+
| `group_leaves_total` | counter | `group` | Leave events |
|
|
793
|
+
| `group_publishes_total` | counter | `group` | Publish events |
|
|
794
|
+
|
|
795
|
+
**Cursor**
|
|
796
|
+
|
|
797
|
+
| Metric | Type | Labels | Description |
|
|
798
|
+
|---|---|---|---|
|
|
799
|
+
| `cursor_updates_total` | counter | `topic` | Cursor update calls |
|
|
800
|
+
| `cursor_broadcasts_total` | counter | `topic` | Broadcasts actually sent |
|
|
801
|
+
| `cursor_throttled_total` | counter | `topic` | Updates deferred by throttle |
|
|
802
|
+
|
|
803
|
+
**LISTEN/NOTIFY bridge**
|
|
804
|
+
|
|
805
|
+
| Metric | Type | Labels | Description |
|
|
806
|
+
|---|---|---|---|
|
|
807
|
+
| `notify_received_total` | counter | `channel` | Notifications received |
|
|
808
|
+
| `notify_parse_errors_total` | counter | `channel` | Parse failures |
|
|
809
|
+
| `notify_reconnects_total` | counter | | Reconnect attempts |
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
**Reliability**
|
|
814
|
+
|
|
815
|
+
## Circuit breaker
|
|
816
|
+
|
|
817
|
+
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.
|
|
818
|
+
|
|
819
|
+
Three states:
|
|
820
|
+
- **healthy** -- everything works, requests go through
|
|
821
|
+
- **broken** -- too many failures, requests fail fast via `CircuitBrokenError`
|
|
822
|
+
- **probing** -- one request is allowed through to test if the backend is back
|
|
823
|
+
|
|
824
|
+
#### Setup
|
|
825
|
+
|
|
826
|
+
```js
|
|
827
|
+
// src/lib/server/breaker.js
|
|
828
|
+
import { createCircuitBreaker } from 'svelte-adapter-uws-extensions/breaker';
|
|
829
|
+
|
|
830
|
+
export const breaker = createCircuitBreaker({
|
|
831
|
+
failureThreshold: 5,
|
|
832
|
+
resetTimeout: 30000,
|
|
833
|
+
onStateChange: (from, to) => console.log(`circuit: ${from} -> ${to}`)
|
|
834
|
+
});
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
Pass the same breaker to all extensions that share a backend:
|
|
838
|
+
|
|
839
|
+
```js
|
|
840
|
+
import { breaker } from './breaker.js';
|
|
841
|
+
|
|
842
|
+
export const bus = createPubSubBus(redis, { breaker });
|
|
843
|
+
export const presence = createPresence(redis, { breaker, key: 'id' });
|
|
844
|
+
export const replay = createReplay(redis, { breaker });
|
|
845
|
+
export const limiter = createRateLimit(redis, { points: 10, interval: 1000, breaker });
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
Failures from any extension contribute to the same breaker. When one trips it, all others fail fast.
|
|
849
|
+
|
|
850
|
+
#### Options
|
|
851
|
+
|
|
852
|
+
| Option | Default | Description |
|
|
853
|
+
|---|---|---|
|
|
854
|
+
| `failureThreshold` | `5` | Consecutive failures before breaking |
|
|
855
|
+
| `resetTimeout` | `30000` | Ms before transitioning from broken to probing |
|
|
856
|
+
| `onStateChange` | - | Called on state transitions: `(from, to) => void` |
|
|
857
|
+
|
|
858
|
+
#### API
|
|
859
|
+
|
|
860
|
+
| Method / Property | Description |
|
|
861
|
+
|---|---|
|
|
862
|
+
| `breaker.state` | `'healthy'`, `'broken'`, or `'probing'` |
|
|
863
|
+
| `breaker.isHealthy` | `true` only when state is `'healthy'` |
|
|
864
|
+
| `breaker.failures` | Current consecutive failure count |
|
|
865
|
+
| `breaker.guard()` | Throws `CircuitBrokenError` if the circuit is broken |
|
|
866
|
+
| `breaker.success()` | Record a successful operation |
|
|
867
|
+
| `breaker.failure()` | Record a failed operation |
|
|
868
|
+
| `breaker.reset()` | Force back to healthy |
|
|
869
|
+
| `breaker.destroy()` | Clear internal timers |
|
|
870
|
+
|
|
871
|
+
#### How extensions use it
|
|
872
|
+
|
|
873
|
+
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.
|
|
874
|
+
|
|
875
|
+
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.
|
|
876
|
+
|
|
877
|
+
#### Error handling
|
|
878
|
+
|
|
879
|
+
```js
|
|
880
|
+
import { CircuitBrokenError } from 'svelte-adapter-uws-extensions/breaker';
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
await replay.publish(platform, 'chat', 'msg', data);
|
|
884
|
+
} catch (err) {
|
|
885
|
+
if (err instanceof CircuitBrokenError) {
|
|
886
|
+
// Backend is down -- degrade gracefully
|
|
887
|
+
platform.publish('chat', 'msg', data); // local-only delivery
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
---
|
|
893
|
+
|
|
670
894
|
**Operations**
|
|
671
895
|
|
|
672
896
|
## Graceful shutdown
|
|
@@ -692,6 +916,14 @@ Tests use in-memory mocks for Redis and Postgres, no running services needed.
|
|
|
692
916
|
|
|
693
917
|
---
|
|
694
918
|
|
|
919
|
+
## Related projects
|
|
920
|
+
|
|
921
|
+
- [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.
|
|
922
|
+
- [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.
|
|
923
|
+
- [svelte-realtime-demo](https://github.com/lanteanio/svelte-realtime-demo) -- Live demo of svelte-realtime. [Try it here.](https://svelte-realtime-demo.lantean.io/)
|
|
924
|
+
|
|
925
|
+
---
|
|
926
|
+
|
|
695
927
|
## License
|
|
696
928
|
|
|
697
929
|
MIT
|
package/package.json
CHANGED
|
@@ -1,99 +1,110 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
},
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
},
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
},
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
|
|
99
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "svelte-adapter-uws-extensions",
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
+
}
|
package/postgres/notify.d.ts
CHANGED
|
@@ -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 {
|
package/postgres/notify.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|