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 +73 -0
- package/package.json +38 -0
- package/src/index.d.ts +50 -0
- package/src/index.js +190 -0
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 };
|