spark-html-websocket 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/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # ⚡ spark-html-websocket
2
+
3
+ Declarative WebSocket for [spark-html](https://www.npmjs.com/package/spark-html)
4
+ — a live connection as a **reactive store**, with auto-reconnect, JSON
5
+ parsing, status, and `send()`. Zero dependencies. No more hand-rolled
6
+ connect/reconnect/parse/cleanup boilerplate in `onMount`.
7
+
8
+ ```js
9
+ import { ws } from 'spark-html-websocket';
10
+ ws('wss://stream.example.com/prices', { name: 'prices' });
11
+ ```
12
+
13
+ ```html
14
+ <!-- any component -->
15
+ <p :hidden="prices.status !== 'open'">● live</p>
16
+ <h1>{prices.data?.btc}</h1>
17
+ <button onclick="{() => prices.send({ subscribe: 'btc' })}">Subscribe</button>
18
+ <script>
19
+ const prices = useStore('prices');
20
+ </script>
21
+ ```
22
+
23
+ Or fully declarative, the way the router declares routes:
24
+
25
+ ```html
26
+ <template ws="wss://stream.example.com/prices" store="prices"></template>
27
+ <script type="module">
28
+ import { sockets } from 'spark-html-websocket';
29
+ sockets();
30
+ </script>
31
+ ```
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm install spark-html-websocket
37
+ ```
38
+
39
+ ## The store
40
+
41
+ | Key | Meaning |
42
+ |-----|---------|
43
+ | `data` | The last (post-filter) message — JSON-parsed when it looks like JSON. Survives reconnects. |
44
+ | `status` | `'connecting'` · `'open'` · `'closed'` · `'error'` |
45
+ | `error` | The last connection error, `null` when healthy. |
46
+ | `send(v)` | Send a message; objects are stringified. Queued until the socket opens. |
47
+ | `close()` | Deliberate close — never reconnects. |
48
+ | `open()` | Re-open after a `close()` (or exhausted retries). |
49
+
50
+ ## Options
51
+
52
+ ```js
53
+ ws('wss://x.dev/feed', {
54
+ name: 'feed', // store name (default "ws:x.dev/feed")
55
+ json: true, // parse messages as JSON when possible
56
+ filter: (d) => d.type === 'ticker', // only these land in `data`
57
+ onMessage: (d) => { store('candles').list.push(d); }, // feed ANY store
58
+ reconnect: { retries: Infinity, base: 500, max: 10000 }, // backoff (ms); false disables
59
+ protocols: ['v1'],
60
+ });
61
+ ```
62
+
63
+ - **Auto-reconnect** — a dropped connection retries with exponential backoff
64
+ (`base·2ⁿ` capped at `max`); `data` keeps rendering through the gap.
65
+ - **Shared handles** — `ws()` with the same name returns the existing store;
66
+ two components never open two sockets.
67
+ - **Prerender-safe** — during `spark-prerender` builds (or anywhere
68
+ `WebSocket` doesn't exist) the store is created inert with
69
+ `status: 'closed'`, so components render their fallback and the build never
70
+ hangs. No guard needed.
71
+
72
+ Declarative attributes: `ws` (url), `store` (name), `raw` (skip JSON),
73
+ `retries` / `backoff` / `backoff-max` (reconnect tuning, ms).
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "spark-html-websocket",
3
+ "version": "0.1.0",
4
+ "description": "Declarative WebSocket for spark-html — a reactive store with auto-reconnect, JSON parsing, status, and send(). Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "homepage": "https://wilkinnovo.github.io/spark",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "test": "node test/websocket.js"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/wilkinnovo/spark.git",
24
+ "directory": "packages/spark-html-websocket"
25
+ },
26
+ "peerDependencies": {
27
+ "spark-html": ">=0.23.1"
28
+ },
29
+ "keywords": [
30
+ "spark-html",
31
+ "websocket",
32
+ "realtime",
33
+ "reactive",
34
+ "store",
35
+ "reconnect"
36
+ ],
37
+ "license": "MIT"
38
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * spark-html-websocket — declarative WebSocket as a reactive store.
3
+ */
4
+
5
+ export type WsStatus = 'connecting' | 'open' | 'closed' | 'error';
6
+
7
+ export interface WsStore<T = unknown> {
8
+ /** The last (post-filter) message. Survives reconnects. */
9
+ data: T | null;
10
+ status: WsStatus;
11
+ error: Error | null;
12
+ /** Send a message; objects are JSON-stringified. Queued until open. */
13
+ send(value: unknown): void;
14
+ /** Deliberate close — never reconnects. */
15
+ close(): void;
16
+ /** Re-open after a close() (or after retries were exhausted). */
17
+ open(): void;
18
+ }
19
+
20
+ export interface WsOptions {
21
+ /** Store name (default derived from the URL: "ws:host/path"). */
22
+ name?: string;
23
+ /** Parse incoming messages as JSON when possible. Default true. */
24
+ json?: boolean;
25
+ /** Only messages passing this land in `data`. */
26
+ filter?: (data: unknown, event: MessageEvent) => boolean;
27
+ /** Runs for every (post-filter) message — write to any store you like. */
28
+ onMessage?: (data: unknown, event: MessageEvent) => void;
29
+ /** Backoff tuning ({ retries=Infinity, base=500, max=10000 } ms) or false to disable. */
30
+ reconnect?: { retries?: number; base?: number; max?: number } | false;
31
+ /** WebSocket subprotocols. */
32
+ protocols?: string[];
33
+ }
34
+
35
+ /**
36
+ * Open (or join) a reactive WebSocket store. Calling it again with the same
37
+ * name returns the existing handle (shared connection). Inert (status
38
+ * 'closed') during prerender or where WebSocket doesn't exist.
39
+ */
40
+ export function ws<T = unknown>(url: string, options?: WsOptions): WsStore<T>;
41
+
42
+ /**
43
+ * Declarative form: open a socket per inert `<template ws="wss://…">` block.
44
+ * Attributes: `ws` (url), `store` (name), `raw` (skip JSON parsing),
45
+ * `retries` / `backoff` / `backoff-max` (reconnect tuning, ms).
46
+ */
47
+ export function sockets(root?: ParentNode): WsStore[];
48
+
49
+ declare const _default: { ws: typeof ws; sockets: typeof sockets };
50
+ export default _default;
package/src/index.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * spark-html-websocket — declarative WebSocket as a reactive store.
3
+ *
4
+ * Every real-time app writes the same connect/reconnect/parse/cleanup
5
+ * boilerplate in onMount. This turns a socket into a named store any
6
+ * component reads with the `useStore` it already knows:
7
+ *
8
+ * import { ws } from 'spark-html-websocket';
9
+ * ws('wss://stream.example.com/prices', { name: 'prices' });
10
+ *
11
+ * // any component:
12
+ * // <script>const prices = useStore('prices');</script>
13
+ * // <p :hidden="prices.status !== 'open'">live</p>
14
+ * // <h1>{prices.data?.btc}</h1>
15
+ * // <button onclick="{() => prices.send({ subscribe: 'btc' })}">sub</button>
16
+ *
17
+ * Store shape: { data, status, error, send(v), close(), open() } where
18
+ * status ∈ 'connecting' | 'open' | 'closed' | 'error'. Messages parse as
19
+ * JSON when they look like it (raw string otherwise); objects passed to
20
+ * send() are stringified.
21
+ *
22
+ * Auto-reconnect: a dropped connection retries with exponential backoff
23
+ * (base·2ⁿ capped at max) until `retries` is exhausted; a deliberate
24
+ * close() never reconnects. `data` survives reconnects — subscribers keep
25
+ * rendering the last message during the gap.
26
+ *
27
+ * Declarative form — sockets() scans inert templates the same way the
28
+ * router scans <template route>:
29
+ *
30
+ * <template ws="wss://stream.example.com/prices" store="prices"></template>
31
+ * <script type="module">
32
+ * import { sockets } from 'spark-html-websocket';
33
+ * sockets();
34
+ * </script>
35
+ *
36
+ * Prerender-safe: at build time (or wherever WebSocket doesn't exist) the
37
+ * store is created inert with status 'closed' — components render their
38
+ * fallback state and nothing connects. Zero dependencies beyond spark-html.
39
+ */
40
+ import { store } from 'spark-html';
41
+
42
+ // One live handle per store name — calling ws() twice with the same name
43
+ // (e.g. from two components) shares the connection instead of duplicating it.
44
+ const handles = new Map();
45
+
46
+ const DEFAULTS = { retries: Infinity, base: 500, max: 10000 };
47
+
48
+ // Derive a stable store name from the URL when none is given:
49
+ // "wss://x.dev/prices?k=1" → "ws:x.dev/prices"
50
+ function nameFor(url) {
51
+ return 'ws:' + String(url).replace(/^[a-z]+:\/\//i, '').split(/[?#]/)[0].replace(/\/$/, '');
52
+ }
53
+
54
+ function parseMessage(raw, json) {
55
+ if (!json || typeof raw !== 'string') return raw;
56
+ try { return JSON.parse(raw); } catch { return raw; }
57
+ }
58
+
59
+ /**
60
+ * Open (or join) a reactive WebSocket store.
61
+ *
62
+ * @param {string} url ws:// or wss:// endpoint.
63
+ * @param {object} [options]
64
+ * @param {string} [options.name] Store name (default derived from the URL).
65
+ * @param {boolean} [options.json=true] Parse incoming messages as JSON when possible.
66
+ * @param {(data, event) => boolean} [options.filter] Only messages passing this land in `data`.
67
+ * @param {(data, event) => void} [options.onMessage] Every (post-filter) message — write to any store you like.
68
+ * @param {object} [options.reconnect] { retries=Infinity, base=500, max=10000 } — backoff in ms; `false` disables.
69
+ * @param {string[]} [options.protocols] WebSocket subprotocols.
70
+ * @returns the reactive store proxy: { data, status, error, send, close, open }
71
+ */
72
+ export function ws(url, options = {}) {
73
+ const name = options.name || nameFor(url);
74
+ if (handles.has(name)) return handles.get(name);
75
+
76
+ const json = options.json !== false;
77
+ const backoff = options.reconnect === false
78
+ ? { ...DEFAULTS, retries: 0 }
79
+ : { ...DEFAULTS, ...(options.reconnect || {}) };
80
+
81
+ const s = store(name, { data: null, status: 'connecting', error: null });
82
+ // Tag for tooling (spark-html-devtools) — non-enumerable, never in dumps.
83
+ try {
84
+ Object.defineProperty(s, Symbol.for('spark.storeKind'), { value: 'ws', configurable: true });
85
+ } catch { /* ignore */ }
86
+
87
+ let socket = null;
88
+ let attempts = 0;
89
+ let timer = null;
90
+ let closed = false; // a deliberate close() — never reconnect
91
+ const queue = []; // send() before open — flushed on connect
92
+
93
+ // No WebSocket here (prerender / old Node): the store stays inert so
94
+ // components render their fallback state and the build never hangs.
95
+ const WS = typeof globalThis.WebSocket === 'function' ? globalThis.WebSocket : null;
96
+ const inert = !WS || globalThis.__SPARK_PRERENDER__;
97
+
98
+ function connect() {
99
+ if (inert || closed) return;
100
+ s.status = 'connecting'; // no-op write when already connecting (store dedupes)
101
+ socket = options.protocols ? new WS(url, options.protocols) : new WS(url);
102
+ socket.onopen = () => {
103
+ attempts = 0;
104
+ s.status = 'open';
105
+ s.error = null;
106
+ while (queue.length) socket.send(queue.shift());
107
+ };
108
+ socket.onmessage = (event) => {
109
+ const data = parseMessage(event.data, json);
110
+ if (options.filter && !options.filter(data, event)) return;
111
+ if (options.onMessage) {
112
+ try { options.onMessage(data, event); }
113
+ catch (e) { console.warn(`[spark-ws] onMessage for "${name}" threw: ${e.message}`); }
114
+ }
115
+ s.data = data;
116
+ };
117
+ socket.onerror = (event) => {
118
+ s.error = (event && event.error) || new Error('WebSocket error');
119
+ s.status = 'error';
120
+ };
121
+ socket.onclose = () => {
122
+ socket = null;
123
+ if (closed) { s.status = 'closed'; return; }
124
+ if (attempts >= backoff.retries) { s.status = 'closed'; return; }
125
+ // Exponential backoff: base·2ⁿ capped at max.
126
+ const delay = Math.min(backoff.base * 2 ** attempts, backoff.max);
127
+ attempts++;
128
+ s.status = 'connecting';
129
+ timer = setTimeout(connect, delay);
130
+ if (timer && typeof timer.unref === 'function') timer.unref();
131
+ };
132
+ }
133
+
134
+ s.send = (value) => {
135
+ const raw = typeof value === 'string' ? value : JSON.stringify(value);
136
+ if (socket && socket.readyState === 1) socket.send(raw);
137
+ else queue.push(raw); // flushed when the (re)connect opens
138
+ };
139
+ s.close = () => {
140
+ closed = true;
141
+ if (timer) { clearTimeout(timer); timer = null; }
142
+ if (socket) socket.close();
143
+ else s.status = 'closed';
144
+ handles.delete(name);
145
+ };
146
+ s.open = () => {
147
+ if (!closed && (socket || timer)) return; // already live/scheduled
148
+ closed = false;
149
+ attempts = 0;
150
+ s.status = 'connecting';
151
+ connect();
152
+ };
153
+
154
+ if (inert) s.status = 'closed';
155
+ else connect();
156
+
157
+ handles.set(name, s);
158
+ return s;
159
+ }
160
+
161
+ /**
162
+ * Declarative form: open a socket per inert <template ws="…"> block.
163
+ *
164
+ * <template ws="wss://x/prices" store="prices"></template>
165
+ *
166
+ * Attributes: `ws` (url), `store` (name), `raw` (skip JSON parsing),
167
+ * `retries` / `backoff` / `backoff-max` (reconnect tuning, ms).
168
+ * Returns the opened store proxies.
169
+ */
170
+ export function sockets(root) {
171
+ if (typeof document === 'undefined') return [];
172
+ const scope = root || document;
173
+ const out = [];
174
+ for (const t of scope.querySelectorAll('template[ws]')) {
175
+ const url = t.getAttribute('ws');
176
+ if (!url) continue;
177
+ const reconnect = {};
178
+ if (t.hasAttribute('retries')) reconnect.retries = Number(t.getAttribute('retries'));
179
+ if (t.hasAttribute('backoff')) reconnect.base = Number(t.getAttribute('backoff'));
180
+ if (t.hasAttribute('backoff-max')) reconnect.max = Number(t.getAttribute('backoff-max'));
181
+ out.push(ws(url, {
182
+ name: t.getAttribute('store') || undefined,
183
+ json: !t.hasAttribute('raw'),
184
+ reconnect: Object.keys(reconnect).length ? reconnect : undefined,
185
+ }));
186
+ }
187
+ return out;
188
+ }
189
+
190
+ export default { ws, sockets };