svelte-adapter-uws 0.1.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/client.js ADDED
@@ -0,0 +1,571 @@
1
+ import { writable } from 'svelte/store';
2
+
3
+ /** @type {ReturnType<typeof createConnection> | null} */
4
+ let singleton = null;
5
+
6
+ /**
7
+ * Ensure the singleton connection exists.
8
+ * @param {import('./client.js').ConnectOptions} [options]
9
+ * @returns {ReturnType<typeof createConnection>}
10
+ */
11
+ function ensureConnection(options) {
12
+ if (!singleton) singleton = createConnection(options || {});
13
+ return singleton;
14
+ }
15
+
16
+ /**
17
+ * Connect to the WebSocket server.
18
+ *
19
+ * Returns a singleton - calling `connect()` multiple times returns the same
20
+ * connection. Safe to call from any component or module.
21
+ *
22
+ * Most users don't need this - use `on()` and `status` directly instead.
23
+ *
24
+ * @param {import('./client.js').ConnectOptions} [options]
25
+ * @returns {import('./client.js').WSConnection}
26
+ */
27
+ export function connect(options = {}) {
28
+ return ensureConnection(options);
29
+ }
30
+
31
+ /**
32
+ * Get a reactive Svelte store for a topic (and optionally a specific event).
33
+ * Auto-connects and auto-subscribes - this is the only function most users need.
34
+ *
35
+ * @overload
36
+ * @param {string} topic - Topic to subscribe to
37
+ * @returns {import('svelte/store').Readable<import('./client.js').WSEvent | null>}
38
+ * Full event envelope `{ topic, event, data }`.
39
+ *
40
+ * @overload
41
+ * @param {string} topic - Topic to subscribe to
42
+ * @param {string} event - Filter to a specific event name
43
+ * @returns {import('svelte/store').Readable<unknown>}
44
+ * Just the `data` payload - no envelope.
45
+ *
46
+ * @param {string} topic
47
+ * @param {string} [event]
48
+ */
49
+ export function on(topic, event) {
50
+ const conn = ensureConnection();
51
+ if (event !== undefined) {
52
+ return conn._onEvent(topic, event);
53
+ }
54
+ const store = conn.on(topic);
55
+ return store;
56
+ }
57
+
58
+ /**
59
+ * Readable store - connection status: `'connecting'` | `'open'` | `'closed'`.
60
+ * Auto-connects on first access.
61
+ *
62
+ * @type {import('svelte/store').Readable<'connecting' | 'open' | 'closed'>}
63
+ */
64
+ export const status = {
65
+ subscribe(fn) {
66
+ return ensureConnection().status.subscribe(fn);
67
+ }
68
+ };
69
+
70
+ /**
71
+ * Returns a promise that resolves when the WebSocket connection is open.
72
+ * Auto-connects if not already connected.
73
+ *
74
+ * @returns {Promise<void>}
75
+ */
76
+ export function ready() {
77
+ const conn = ensureConnection();
78
+ return new Promise((resolve) => {
79
+ const unsub = conn.status.subscribe((s) => {
80
+ if (s === 'open') {
81
+ // Defer unsubscribe to avoid removing during subscribe callback
82
+ queueMicrotask(() => unsub());
83
+ resolve();
84
+ }
85
+ });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Live CRUD list - one line for real-time collections.
91
+ * Auto-connects, auto-subscribes, and auto-handles created/updated/deleted events.
92
+ *
93
+ * @template T
94
+ * @param {string} topic - Topic to subscribe to
95
+ * @param {T[]} [initial] - Starting data (e.g. from a load function)
96
+ * @param {{ key?: string, prepend?: boolean }} [options] - Options
97
+ * @returns {import('svelte/store').Readable<T[]>}
98
+ */
99
+ export function crud(topic, initial = [], options = {}) {
100
+ const key = options.key || 'id';
101
+ const prepend = options.prepend || false;
102
+ return on(topic).scan(/** @type {any[]} */ (initial), (list, { event, data }) => {
103
+ if (event === 'created') return prepend ? [data, ...list] : [...list, data];
104
+ if (event === 'updated') return list.map((item) => item[key] === data[key] ? data : item);
105
+ if (event === 'deleted') return list.filter((item) => item[key] !== data[key]);
106
+ return list;
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Live keyed object - like `crud()` but returns a `Record` keyed by ID.
112
+ * Better for dashboards and fast lookups.
113
+ *
114
+ * @template T
115
+ * @param {string} topic - Topic to subscribe to
116
+ * @param {T[]} [initial] - Starting data (e.g. from a load function)
117
+ * @param {{ key?: string }} [options] - Options
118
+ * @returns {import('svelte/store').Readable<Record<string, T>>}
119
+ */
120
+ export function lookup(topic, initial = [], options = {}) {
121
+ const key = options.key || 'id';
122
+ /** @type {Record<string, any>} */
123
+ const initialMap = {};
124
+ for (const item of initial) {
125
+ initialMap[/** @type {any} */ (item)[key]] = item;
126
+ }
127
+ return on(topic).scan(initialMap, (map, { event, data }) => {
128
+ const id = data[key];
129
+ if (event === 'created' || event === 'updated') return { ...map, [id]: data };
130
+ if (event === 'deleted') {
131
+ const { [id]: _, ...rest } = map;
132
+ return rest;
133
+ }
134
+ return map;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Ring buffer of the last N events on a topic.
140
+ * Perfect for chat, activity feeds, and notifications.
141
+ *
142
+ * @template T
143
+ * @param {string} topic - Topic to subscribe to
144
+ * @param {number} [max] - Maximum number of events to keep
145
+ * @param {T[]} [initial] - Starting data
146
+ * @returns {import('svelte/store').Readable<import('./client.js').WSEvent<T>[]>}
147
+ */
148
+ export function latest(topic, max = 50, initial = []) {
149
+ return on(topic).scan(/** @type {any[]} */ (initial), (buffer, event) => {
150
+ const next = [...buffer, event];
151
+ return next.length > max ? next.slice(next.length - max) : next;
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Live counter store - handles set/increment/decrement events.
157
+ *
158
+ * @param {string} topic - Topic to subscribe to
159
+ * @param {number} [initial] - Starting value
160
+ * @returns {import('svelte/store').Readable<number>}
161
+ */
162
+ export function count(topic, initial = 0) {
163
+ return on(topic).scan(initial, (n, { event, data }) => {
164
+ if (event === 'set') return typeof data === 'number' ? data : n;
165
+ if (event === 'increment') return n + (typeof data === 'number' ? data : 1);
166
+ if (event === 'decrement') return n - (typeof data === 'number' ? data : 1);
167
+ return n;
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Wait for a specific event on a topic. Resolves once and unsubscribes.
173
+ *
174
+ * @param {string} topic - Topic to listen on
175
+ * @param {string} [event] - Optional event name to filter on
176
+ * @param {{ timeout?: number }} [options] - Options
177
+ * @returns {Promise<unknown>}
178
+ */
179
+ export function once(topic, event, options) {
180
+ // Allow once(topic, { timeout }) shorthand (skip event)
181
+ if (typeof event === 'object' && event !== null) {
182
+ options = event;
183
+ event = undefined;
184
+ }
185
+ const timeout = options?.timeout;
186
+ const conn = ensureConnection();
187
+
188
+ return new Promise((resolve, reject) => {
189
+ const store = event !== undefined ? conn._onEvent(topic, event) : conn.on(topic);
190
+ let settled = false;
191
+ let timer;
192
+
193
+ function cleanup() {
194
+ if (settled) return;
195
+ settled = true;
196
+ if (timer) clearTimeout(timer);
197
+ queueMicrotask(() => unsub());
198
+ }
199
+
200
+ const unsub = store.subscribe((data) => {
201
+ if (data !== null) {
202
+ cleanup();
203
+ resolve(data);
204
+ }
205
+ });
206
+ if (timeout !== undefined) {
207
+ timer = setTimeout(() => {
208
+ cleanup();
209
+ reject(new Error(`once('${topic}'${event ? `, '${event}'` : ''}) timed out after ${timeout}ms`));
210
+ }, timeout);
211
+ }
212
+ });
213
+ }
214
+
215
+ /**
216
+ * @param {import('./client.js').ConnectOptions} options
217
+ * @returns {import('./client.js').WSConnection & { _onEvent: (topic: string, event: string) => import('svelte/store').Readable<unknown> }}
218
+ */
219
+ function createConnection(options) {
220
+ const {
221
+ path = '/ws',
222
+ reconnectInterval = 3000,
223
+ maxReconnectInterval = 30000,
224
+ maxReconnectAttempts = Infinity,
225
+ debug = false
226
+ } = options;
227
+
228
+ /** @type {WebSocket | null} */
229
+ let ws = null;
230
+
231
+ /** @type {ReturnType<typeof setTimeout> | null} */
232
+ let reconnectTimer = null;
233
+
234
+ let attempt = 0;
235
+ let intentionallyClosed = false;
236
+
237
+ /** @type {Set<string>} */
238
+ const subscribedTopics = new Set();
239
+
240
+ /** @type {Map<string, number>} */
241
+ const topicRefCounts = new Map();
242
+
243
+ /** @type {string[]} */
244
+ const sendQueue = [];
245
+ const MAX_QUEUE_SIZE = 1000;
246
+
247
+ /** @type {import('svelte/store').Writable<import('./client.js').WSEvent | null>} */
248
+ const eventsStore = writable(null);
249
+
250
+ /** @type {Map<string, import('svelte/store').Writable<import('./client.js').WSEvent | null>>} */
251
+ const topicStores = new Map();
252
+
253
+ /** @type {Map<string, import('svelte/store').Writable<unknown>>} */
254
+ const eventStores = new Map();
255
+
256
+ /** @type {import('svelte/store').Writable<'connecting' | 'open' | 'closed'>} */
257
+ const statusStore = writable('closed');
258
+
259
+ function getUrl() {
260
+ if (typeof window === 'undefined') return '';
261
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
262
+ return `${protocol}//${window.location.host}${path}`;
263
+ }
264
+
265
+ function doConnect() {
266
+ if (typeof window === 'undefined') return;
267
+ if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
268
+
269
+ statusStore.set('connecting');
270
+
271
+ try {
272
+ ws = new WebSocket(getUrl());
273
+ } catch {
274
+ scheduleReconnect();
275
+ return;
276
+ }
277
+
278
+ ws.onopen = () => {
279
+ attempt = 0;
280
+ statusStore.set('open');
281
+ if (debug) console.log('[ws] connected');
282
+
283
+ // Re-subscribe to all topics after reconnect
284
+ for (const topic of subscribedTopics) {
285
+ if (debug) console.log('[ws] resubscribe ->', topic);
286
+ ws?.send(JSON.stringify({ type: 'subscribe', topic }));
287
+ }
288
+
289
+ // Flush queued messages
290
+ while (sendQueue.length > 0) {
291
+ const msg = sendQueue.shift();
292
+ if (debug) console.log('[ws] flush ->', msg);
293
+ ws?.send(/** @type {string} */ (msg));
294
+ }
295
+ };
296
+
297
+ ws.onmessage = (rawEvent) => {
298
+ try {
299
+ const msg = JSON.parse(rawEvent.data);
300
+ if (msg.topic && msg.event !== undefined) {
301
+ /** @type {import('./client.js').WSEvent} */
302
+ const wsEvent = { topic: msg.topic, event: msg.event, data: msg.data };
303
+ if (debug) console.log('[ws] <-', msg.topic, msg.event, msg.data);
304
+
305
+ // Update global events store
306
+ eventsStore.set(wsEvent);
307
+
308
+ // Update topic-level store
309
+ const tStore = topicStores.get(msg.topic);
310
+ if (tStore) tStore.set(wsEvent);
311
+
312
+ // Update topic+event filtered stores (data only)
313
+ const eStore = eventStores.get(`${msg.topic}\0${msg.event}`);
314
+ if (eStore) eStore.set(msg.data);
315
+ }
316
+ } catch {
317
+ // Not a valid envelope - ignore
318
+ }
319
+ };
320
+
321
+ ws.onclose = () => {
322
+ statusStore.set('closed');
323
+ ws = null;
324
+ if (debug) console.log('[ws] disconnected');
325
+ if (!intentionallyClosed) {
326
+ scheduleReconnect();
327
+ }
328
+ };
329
+
330
+ ws.onerror = () => {
331
+ // onclose fires after this - reconnect is handled there
332
+ };
333
+ }
334
+
335
+ function scheduleReconnect() {
336
+ if (reconnectTimer) return;
337
+ if (attempt >= maxReconnectAttempts) {
338
+ statusStore.set('closed');
339
+ return;
340
+ }
341
+ const delay = Math.min(
342
+ reconnectInterval * Math.pow(1.5, attempt) + Math.random() * 1000,
343
+ maxReconnectInterval
344
+ );
345
+ attempt++;
346
+ reconnectTimer = setTimeout(() => {
347
+ reconnectTimer = null;
348
+ doConnect();
349
+ }, delay);
350
+ }
351
+
352
+ /**
353
+ * Subscribe to a topic (ref-counted).
354
+ * Multiple callers can subscribe; the WS subscription is sent on the first ref.
355
+ * @param {string} topic
356
+ */
357
+ function subscribe(topic) {
358
+ const count = topicRefCounts.get(topic) || 0;
359
+ topicRefCounts.set(topic, count + 1);
360
+ if (count > 0) return; // Already subscribed at WS level
361
+ subscribedTopics.add(topic);
362
+ if (debug) console.log('[ws] subscribe ->', topic);
363
+ if (ws?.readyState === WebSocket.OPEN) {
364
+ ws.send(JSON.stringify({ type: 'subscribe', topic }));
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Release a ref-counted subscription. Unsubscribes at WS level when count hits 0.
370
+ * @param {string} topic
371
+ */
372
+ function release(topic) {
373
+ const count = topicRefCounts.get(topic) || 0;
374
+ if (count <= 1) {
375
+ topicRefCounts.delete(topic);
376
+ doUnsubscribe(topic);
377
+ } else {
378
+ topicRefCounts.set(topic, count - 1);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Force-unsubscribe from a topic (public API - ignores ref count).
384
+ * @param {string} topic
385
+ */
386
+ function unsubscribe(topic) {
387
+ topicRefCounts.delete(topic);
388
+ doUnsubscribe(topic);
389
+ }
390
+
391
+ /**
392
+ * Internal: actually send unsubscribe and clean up stores.
393
+ * @param {string} topic
394
+ */
395
+ function doUnsubscribe(topic) {
396
+ subscribedTopics.delete(topic);
397
+ topicStores.delete(topic);
398
+ // Clean up topic+event filtered stores for this topic
399
+ for (const key of eventStores.keys()) {
400
+ if (key.startsWith(topic + '\0')) eventStores.delete(key);
401
+ }
402
+ if (debug) console.log('[ws] unsubscribe ->', topic);
403
+ if (ws?.readyState === WebSocket.OPEN) {
404
+ ws.send(JSON.stringify({ type: 'unsubscribe', topic }));
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Create a .scan() method bound to a source store.
410
+ * @param {{ subscribe: (fn: (value: any) => void) => () => void }} source
411
+ */
412
+ function makeScan(source) {
413
+ /**
414
+ * @template A
415
+ * @param {A} initial
416
+ * @param {(acc: A, value: any) => A} reducer
417
+ * @returns {import('svelte/store').Readable<A>}
418
+ */
419
+ return function scan(initial, reducer) {
420
+ let acc = initial;
421
+ const accumulated = writable(initial);
422
+ /** @type {(() => void) | null} */
423
+ let sourceUnsub = null;
424
+ let subCount = 0;
425
+
426
+ return {
427
+ subscribe(fn) {
428
+ // Start listening to source when first subscriber arrives
429
+ if (subCount === 0) {
430
+ sourceUnsub = source.subscribe((value) => {
431
+ if (value !== null) {
432
+ acc = reducer(acc, value);
433
+ accumulated.set(acc);
434
+ }
435
+ });
436
+ }
437
+ subCount++;
438
+ const unsub = accumulated.subscribe(fn);
439
+ return () => {
440
+ unsub();
441
+ subCount--;
442
+ // Stop listening when last subscriber leaves
443
+ if (subCount === 0 && sourceUnsub) {
444
+ sourceUnsub();
445
+ sourceUnsub = null;
446
+ }
447
+ };
448
+ }
449
+ };
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Get a reactive store for a topic (all events).
455
+ * @param {string} topic
456
+ * @returns {import('./client.js').TopicStore<import('./client.js').WSEvent>}
457
+ */
458
+ function onTopic(topic) {
459
+ let store = topicStores.get(topic);
460
+ if (!store) {
461
+ store = writable(null);
462
+ topicStores.set(topic, store);
463
+ }
464
+
465
+ // Ref-counted: subscribes to WS topic when first Svelte subscriber
466
+ // arrives, releases when last leaves
467
+ let subs = 0;
468
+ function wrappedSubscribe(fn) {
469
+ if (subs++ === 0) subscribe(topic);
470
+ const unsub = store.subscribe(fn);
471
+ return () => {
472
+ unsub();
473
+ if (--subs === 0) release(topic);
474
+ };
475
+ }
476
+
477
+ const wrapped = { subscribe: wrappedSubscribe };
478
+ return { subscribe: wrappedSubscribe, scan: makeScan(wrapped) };
479
+ }
480
+
481
+ /**
482
+ * Get a reactive store for a specific topic+event combo (data only).
483
+ * @param {string} topic
484
+ * @param {string} event
485
+ * @returns {import('./client.js').TopicStore<unknown>}
486
+ */
487
+ function onEvent(topic, event) {
488
+ const key = `${topic}\0${event}`;
489
+ let store = eventStores.get(key);
490
+ if (!store) {
491
+ store = writable(null);
492
+ eventStores.set(key, store);
493
+ }
494
+
495
+ let subs = 0;
496
+ function wrappedSubscribe(fn) {
497
+ if (subs++ === 0) subscribe(topic);
498
+ const unsub = store.subscribe(fn);
499
+ return () => {
500
+ unsub();
501
+ if (--subs === 0) release(topic);
502
+ };
503
+ }
504
+
505
+ const wrapped = { subscribe: wrappedSubscribe };
506
+ return { subscribe: wrappedSubscribe, scan: makeScan(wrapped) };
507
+ }
508
+
509
+ /**
510
+ * Send a custom message to the server. Dropped if not connected.
511
+ * @param {unknown} data
512
+ */
513
+ function send(data) {
514
+ if (ws?.readyState === WebSocket.OPEN) {
515
+ if (debug) console.log('[ws] send ->', data);
516
+ ws.send(JSON.stringify(data));
517
+ } else if (debug) {
518
+ console.warn('[ws] send dropped (not connected) - use sendQueued() to queue messages for reconnect:', data);
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Send a message, queuing it if not currently connected.
524
+ * Queued messages flush automatically on reconnect (FIFO).
525
+ * @param {unknown} data
526
+ */
527
+ function sendQueued(data) {
528
+ if (ws?.readyState === WebSocket.OPEN) {
529
+ if (debug) console.log('[ws] send ->', data);
530
+ ws.send(JSON.stringify(data));
531
+ } else {
532
+ if (sendQueue.length >= MAX_QUEUE_SIZE) {
533
+ if (debug) console.warn('[ws] queue full, dropping oldest message');
534
+ sendQueue.shift();
535
+ }
536
+ if (debug) console.log('[ws] queued ->', data);
537
+ sendQueue.push(JSON.stringify(data));
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Close the connection permanently.
543
+ */
544
+ function close() {
545
+ intentionallyClosed = true;
546
+ if (reconnectTimer) {
547
+ clearTimeout(reconnectTimer);
548
+ reconnectTimer = null;
549
+ }
550
+ ws?.close();
551
+ ws = null;
552
+ singleton = null;
553
+ statusStore.set('closed');
554
+ }
555
+
556
+ // Auto-connect on creation
557
+ doConnect();
558
+
559
+ return {
560
+ events: { subscribe: eventsStore.subscribe },
561
+ status: { subscribe: statusStore.subscribe },
562
+ on: onTopic,
563
+ _onEvent: onEvent,
564
+ _release: release,
565
+ subscribe,
566
+ unsubscribe,
567
+ send,
568
+ sendQueued,
569
+ close
570
+ };
571
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Parse cookies from a Cookie header string.
3
+ * @param {string} [cookieHeader]
4
+ * @returns {Record<string, string>}
5
+ */
6
+ export function parseCookies(cookieHeader) {
7
+ /** @type {Record<string, string>} */
8
+ const cookies = {};
9
+ if (!cookieHeader) return cookies;
10
+ for (const pair of cookieHeader.split(';')) {
11
+ const eq = pair.indexOf('=');
12
+ if (eq !== -1) {
13
+ const value = pair.substring(eq + 1).trim();
14
+ // Strip RFC 6265 optional quotes
15
+ const unquoted = value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"'
16
+ ? value.slice(1, -1) : value;
17
+ try {
18
+ cookies[pair.substring(0, eq).trim()] = decodeURIComponent(unquoted);
19
+ } catch {
20
+ cookies[pair.substring(0, eq).trim()] = unquoted;
21
+ }
22
+ }
23
+ }
24
+ return cookies;
25
+ }
package/files/env.js ADDED
@@ -0,0 +1,41 @@
1
+ import process from 'node:process';
2
+
3
+ /* global ENV_PREFIX */
4
+
5
+ const expected = new Set([
6
+ 'HOST',
7
+ 'PORT',
8
+ 'ORIGIN',
9
+ 'XFF_DEPTH',
10
+ 'ADDRESS_HEADER',
11
+ 'PROTOCOL_HEADER',
12
+ 'HOST_HEADER',
13
+ 'PORT_HEADER',
14
+ 'BODY_SIZE_LIMIT',
15
+ 'SHUTDOWN_TIMEOUT',
16
+ 'SSL_CERT',
17
+ 'SSL_KEY',
18
+ 'CLUSTER_WORKERS'
19
+ ]);
20
+
21
+ if (ENV_PREFIX) {
22
+ for (const name in process.env) {
23
+ if (name.startsWith(ENV_PREFIX)) {
24
+ const unprefixed = name.slice(ENV_PREFIX.length);
25
+ if (!expected.has(unprefixed)) {
26
+ throw new Error(
27
+ `You should change envPrefix (${ENV_PREFIX}) to avoid conflicts with existing environment variables - unexpectedly saw ${name}`
28
+ );
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {string} name
36
+ * @param {any} fallback
37
+ */
38
+ export function env(name, fallback) {
39
+ const prefixed = ENV_PREFIX + name;
40
+ return prefixed in process.env ? process.env[prefixed] : fallback;
41
+ }