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/LICENSE +21 -0
- package/README.md +1543 -0
- package/client.d.ts +356 -0
- package/client.js +571 -0
- package/files/cookies.js +25 -0
- package/files/env.js +41 -0
- package/files/handler.js +898 -0
- package/files/index.js +116 -0
- package/files/shims.js +21 -0
- package/files/utils.js +136 -0
- package/index.d.ts +396 -0
- package/index.js +224 -0
- package/package.json +81 -0
- package/vite.d.ts +48 -0
- package/vite.js +310 -0
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
|
+
}
|
package/files/cookies.js
ADDED
|
@@ -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
|
+
}
|