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/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "svelte-adapter-uws-extensions",
3
+ "version": "0.1.2",
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
+ }
@@ -0,0 +1,26 @@
1
+ import type { Pool, PoolConfig, QueryResult, Client } from 'pg';
2
+
3
+ export interface PgClientOptions {
4
+ /** Postgres connection string. Required. */
5
+ connectionString: string;
6
+ /** Listen for `sveltekit:shutdown` and disconnect. @default true */
7
+ autoShutdown?: boolean;
8
+ /** Extra pg Pool options. */
9
+ options?: PoolConfig;
10
+ }
11
+
12
+ export interface PgClient {
13
+ /** The underlying pg Pool. */
14
+ readonly pool: Pool;
15
+ /** Run a query. */
16
+ query(text: string, values?: any[]): Promise<QueryResult>;
17
+ /** Create a standalone pg.Client with the same connection config (not from the pool). */
18
+ createClient(): Client;
19
+ /** Gracefully close the pool. */
20
+ end(): Promise<void>;
21
+ }
22
+
23
+ /**
24
+ * Create a Postgres client with lifecycle management.
25
+ */
26
+ export function createPgClient(options: PgClientOptions): PgClient;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Postgres client factory for svelte-adapter-uws-extensions.
3
+ *
4
+ * Wraps pg Pool with lifecycle management and graceful shutdown
5
+ * via the SvelteKit `sveltekit:shutdown` event.
6
+ *
7
+ * @module svelte-adapter-uws-extensions/postgres
8
+ */
9
+
10
+ import pg from 'pg';
11
+ import { ConnectionError } from '../shared/errors.js';
12
+
13
+ const { Pool, Client } = pg;
14
+
15
+ /**
16
+ * @typedef {Object} PgClientOptions
17
+ * @property {string} connectionString - Postgres connection string
18
+ * @property {boolean} [autoShutdown=true] - Listen for `sveltekit:shutdown` and disconnect
19
+ * @property {import('pg').PoolConfig} [options] - Extra pg Pool options
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} PgClient
24
+ * @property {import('pg').Pool} pool - The underlying pg Pool
25
+ * @property {(text: string, values?: any[]) => Promise<import('pg').QueryResult>} query - Run a query
26
+ * @property {() => Promise<void>} end - Gracefully close the pool
27
+ */
28
+
29
+ /**
30
+ * Create a Postgres client.
31
+ *
32
+ * @param {PgClientOptions} opts
33
+ * @returns {PgClient}
34
+ */
35
+ export function createPgClient(opts) {
36
+ if (!opts || !opts.connectionString) {
37
+ throw new ConnectionError('postgres', 'connectionString is required');
38
+ }
39
+
40
+ const autoShutdown = opts.autoShutdown !== false;
41
+
42
+ let pool;
43
+ try {
44
+ pool = new Pool({
45
+ connectionString: opts.connectionString,
46
+ ...(opts.options || {})
47
+ });
48
+ } catch (err) {
49
+ throw new ConnectionError('postgres', 'failed to create pool', err);
50
+ }
51
+
52
+ pool.on('error', (err) => {
53
+ // Idle client errors should not crash the process.
54
+ // pg Pool handles reconnection automatically.
55
+ console.error('postgres: idle client error', err.message);
56
+ });
57
+
58
+ let ended = false;
59
+
60
+ async function end() {
61
+ if (ended) return;
62
+ ended = true;
63
+ await pool.end();
64
+ }
65
+
66
+ if (autoShutdown && typeof process !== 'undefined') {
67
+ process.once('sveltekit:shutdown', end);
68
+ }
69
+
70
+ const connectionConfig = {
71
+ connectionString: opts.connectionString,
72
+ ...(opts.options || {})
73
+ };
74
+
75
+ return {
76
+ pool,
77
+
78
+ query(text, values) {
79
+ return pool.query(text, values);
80
+ },
81
+
82
+ createClient() {
83
+ return new Client(connectionConfig);
84
+ },
85
+
86
+ end
87
+ };
88
+ }
@@ -0,0 +1,33 @@
1
+ import type { Platform } from 'svelte-adapter-uws';
2
+ import type { PgClient } from './index.js';
3
+
4
+ export interface NotifyBridgeOptions {
5
+ /** Postgres LISTEN channel name. Required. */
6
+ channel: string;
7
+
8
+ /**
9
+ * Parse the notification payload into a publish call.
10
+ * Return null to skip the notification.
11
+ * Defaults to JSON.parse expecting `{ topic, event, data }`.
12
+ */
13
+ parse?: (payload: string, channel: string) => { topic: string; event: string; data?: unknown } | null;
14
+
15
+ /** Reconnect on connection loss. @default true */
16
+ autoReconnect?: boolean;
17
+
18
+ /** ms between reconnect attempts. @default 3000 */
19
+ reconnectInterval?: number;
20
+ }
21
+
22
+ export interface NotifyBridge {
23
+ /** Start listening. Forwards notifications to platform.publish(). Idempotent. */
24
+ activate(platform: Platform): Promise<void>;
25
+
26
+ /** Stop listening and release the connection. */
27
+ deactivate(): Promise<void>;
28
+ }
29
+
30
+ /**
31
+ * Create a Postgres LISTEN/NOTIFY bridge.
32
+ */
33
+ export function createNotifyBridge(client: PgClient, options: NotifyBridgeOptions): NotifyBridge;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Postgres LISTEN/NOTIFY bridge for svelte-adapter-uws.
3
+ *
4
+ * Listens on a Postgres channel for notifications and forwards them to
5
+ * platform.publish(). The user is responsible for setting up the trigger
6
+ * that calls pg_notify() -- this module only handles the listening side.
7
+ *
8
+ * Uses a dedicated connection (not from the pool) since LISTEN requires
9
+ * a persistent connection that cannot be shared.
10
+ *
11
+ * @module svelte-adapter-uws-extensions/postgres/notify
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} NotifyBridgeOptions
16
+ * @property {string} channel - Postgres LISTEN channel name (required)
17
+ * @property {(payload: string, channel: string) => { topic: string, event: string, data?: unknown } | null} [parse] -
18
+ * Parse the notification payload into a publish call. Return null to skip.
19
+ * Defaults to JSON.parse expecting { topic, event, data }.
20
+ * @property {boolean} [autoReconnect=true] - Reconnect on connection loss
21
+ * @property {number} [reconnectInterval=3000] - ms between reconnect attempts
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} NotifyBridge
26
+ * @property {(platform: import('svelte-adapter-uws').Platform) => Promise<void>} activate -
27
+ * Start listening. Forwards notifications to platform.publish().
28
+ * @property {() => Promise<void>} deactivate -
29
+ * Stop listening and release the connection.
30
+ */
31
+
32
+ /**
33
+ * Create a Postgres LISTEN/NOTIFY bridge.
34
+ *
35
+ * @param {import('./index.js').PgClient} client
36
+ * @param {NotifyBridgeOptions} options
37
+ * @returns {NotifyBridge}
38
+ *
39
+ * @example
40
+ * ```js
41
+ * import { pg } from './pg.js';
42
+ * import { createNotifyBridge } from 'svelte-adapter-uws-extensions/postgres/notify';
43
+ *
44
+ * const bridge = createNotifyBridge(pg, {
45
+ * channel: 'table_changes',
46
+ * parse: (payload) => {
47
+ * const row = JSON.parse(payload);
48
+ * return { topic: row.table, event: row.op, data: row.data };
49
+ * }
50
+ * });
51
+ *
52
+ * // In your open hook (once):
53
+ * bridge.activate(platform);
54
+ * ```
55
+ *
56
+ * Then create a trigger on your table:
57
+ * ```sql
58
+ * CREATE OR REPLACE FUNCTION notify_table_change() RETURNS trigger AS $$
59
+ * BEGIN
60
+ * PERFORM pg_notify('table_changes', json_build_object(
61
+ * 'table', TG_TABLE_NAME,
62
+ * 'op', lower(TG_OP),
63
+ * 'data', CASE TG_OP
64
+ * WHEN 'DELETE' THEN row_to_json(OLD)
65
+ * ELSE row_to_json(NEW)
66
+ * END
67
+ * )::text);
68
+ * RETURN COALESCE(NEW, OLD);
69
+ * END;
70
+ * $$ LANGUAGE plpgsql;
71
+ *
72
+ * CREATE TRIGGER messages_notify
73
+ * AFTER INSERT OR UPDATE OR DELETE ON messages
74
+ * FOR EACH ROW EXECUTE FUNCTION notify_table_change();
75
+ * ```
76
+ */
77
+ export function createNotifyBridge(client, options) {
78
+ if (!options || typeof options.channel !== 'string' || options.channel.length === 0) {
79
+ throw new Error('notify bridge: channel must be a non-empty string');
80
+ }
81
+
82
+ const channel = options.channel;
83
+ const autoReconnect = options.autoReconnect !== false;
84
+ const reconnectInterval = options.reconnectInterval || 3000;
85
+ const parse = options.parse || defaultParse;
86
+
87
+ /** @type {import('pg').Client | null} */
88
+ let conn = null;
89
+ /** @type {import('svelte-adapter-uws').Platform | null} */
90
+ let activePlatform = null;
91
+ let active = false;
92
+ /** @type {ReturnType<typeof setTimeout> | null} */
93
+ let reconnectTimer = null;
94
+
95
+ function defaultParse(payload) {
96
+ try {
97
+ const obj = JSON.parse(payload);
98
+ if (!obj.topic || !obj.event) return null;
99
+ return { topic: obj.topic, event: obj.event, data: obj.data };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function onNotification(msg) {
106
+ if (msg.channel !== channel) return;
107
+ try {
108
+ const result = parse(msg.payload, msg.channel);
109
+ if (result && activePlatform) {
110
+ // relay: false -- in clustered mode each worker has its own
111
+ // LISTEN connection, so relaying would duplicate delivery.
112
+ activePlatform.publish(result.topic, result.event, result.data, { relay: false });
113
+ }
114
+ } catch {
115
+ // Parse errors are non-fatal -- skip the notification
116
+ }
117
+ }
118
+
119
+ async function connect() {
120
+ try {
121
+ // Use a standalone Client instead of pool.connect() to avoid
122
+ // permanently holding a pool connection. LISTEN needs a persistent
123
+ // connection that stays open for the lifetime of the bridge --
124
+ // borrowing from the pool would reduce available connections for queries.
125
+ conn = client.createClient();
126
+ conn.on('notification', onNotification);
127
+ conn.on('error', handleError);
128
+ await conn.connect();
129
+
130
+ // pg requires channel name to be a valid identifier or quoted
131
+ // Use double-quoting to handle any channel name safely
132
+ await conn.query(`LISTEN "${channel.replace(/"/g, '""')}"`);
133
+ } catch (err) {
134
+ if (conn) {
135
+ try { await conn.end(); } catch { /* ignore */ }
136
+ }
137
+ conn = null;
138
+ if (active && autoReconnect) {
139
+ scheduleReconnect();
140
+ }
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ function handleError() {
146
+ cleanup();
147
+ if (active && autoReconnect) {
148
+ scheduleReconnect();
149
+ }
150
+ }
151
+
152
+ function scheduleReconnect() {
153
+ if (reconnectTimer) return;
154
+ reconnectTimer = setTimeout(async () => {
155
+ reconnectTimer = null;
156
+ if (!active) return;
157
+ try {
158
+ await connect();
159
+ } catch {
160
+ // connect() already schedules another retry on failure
161
+ }
162
+ }, reconnectInterval);
163
+ if (reconnectTimer.unref) reconnectTimer.unref();
164
+ }
165
+
166
+ function cleanup() {
167
+ if (conn) {
168
+ conn.removeListener('notification', onNotification);
169
+ conn.removeListener('error', handleError);
170
+ conn.end().catch(() => { /* already closed */ });
171
+ conn = null;
172
+ }
173
+ }
174
+
175
+ return {
176
+ async activate(platform) {
177
+ // Always update the platform reference so notifications
178
+ // are forwarded through the latest platform, even if a
179
+ // previous activate() already started the listener.
180
+ activePlatform = platform;
181
+ if (active) return;
182
+ active = true;
183
+ try {
184
+ await connect();
185
+ } catch (err) {
186
+ if (!autoReconnect) {
187
+ // Without autoReconnect, reset so activate() can be retried
188
+ active = false;
189
+ activePlatform = null;
190
+ }
191
+ // With autoReconnect, connect() already scheduled a retry
192
+ throw err;
193
+ }
194
+ },
195
+
196
+ async deactivate() {
197
+ if (!active) return;
198
+ active = false;
199
+ activePlatform = null;
200
+ if (reconnectTimer) {
201
+ clearTimeout(reconnectTimer);
202
+ reconnectTimer = null;
203
+ }
204
+ if (conn) {
205
+ try {
206
+ await conn.query(`UNLISTEN "${channel.replace(/"/g, '""')}"`);
207
+ } catch {
208
+ // Connection may already be dead
209
+ }
210
+ cleanup();
211
+ }
212
+ }
213
+ };
214
+ }
@@ -0,0 +1,56 @@
1
+ import type { Platform } from 'svelte-adapter-uws';
2
+ import type { PgClient } from './index.js';
3
+
4
+ export interface PgReplayOptions {
5
+ /** Table name. @default 'ws_replay' */
6
+ table?: string;
7
+ /** Max messages per topic. @default 1000 */
8
+ size?: number;
9
+ /** TTL in seconds (0 = no expiry). @default 0 */
10
+ ttl?: number;
11
+ /** Auto-create table on first use. @default true */
12
+ autoMigrate?: boolean;
13
+ /** Cleanup interval in ms (0 to disable). @default 60000 */
14
+ cleanupInterval?: number;
15
+ }
16
+
17
+ export interface BufferedMessage {
18
+ seq: number;
19
+ topic: string;
20
+ event: string;
21
+ data: unknown;
22
+ }
23
+
24
+ export interface PgReplayBuffer {
25
+ /**
26
+ * Publish a message through the buffer. Stores it in Postgres with a
27
+ * sequence number, then calls platform.publish() as normal.
28
+ */
29
+ publish(platform: Platform, topic: string, event: string, data?: unknown): Promise<boolean>;
30
+
31
+ /** Get the current sequence number for a topic. Returns 0 if unknown. */
32
+ seq(topic: string): Promise<number>;
33
+
34
+ /** Get all buffered messages after a given sequence number. */
35
+ since(topic: string, since: number): Promise<BufferedMessage[]>;
36
+
37
+ /**
38
+ * Send buffered messages to a single connection. Sends each missed
39
+ * message on `__replay:{topic}`, then an end marker.
40
+ */
41
+ replay(ws: any, topic: string, sinceSeq: number, platform: Platform): Promise<void>;
42
+
43
+ /** Clear all replay data. */
44
+ clear(): Promise<void>;
45
+
46
+ /** Clear replay data for a single topic. */
47
+ clearTopic(topic: string): Promise<void>;
48
+
49
+ /** Stop the cleanup timer. */
50
+ destroy(): void;
51
+ }
52
+
53
+ /**
54
+ * Create a Postgres-backed replay buffer.
55
+ */
56
+ export function createReplay(client: PgClient, options?: PgReplayOptions): PgReplayBuffer;