@hashxltd/liveframe-vue 0.0.1

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.
@@ -0,0 +1,212 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Owns the raw WebSocket lifecycle: HTTP→WS upgrade, send, receive, close.
3
+ // Everything above this layer works with decoded Envelope objects only.
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+
6
+ import { WS_SUBPROTOCOL, CloseCodes } from "../protocol/constants";
7
+ import { encode, decode } from "../protocol/codec";
8
+ import type { Envelope, Logger } from "../protocol/types";
9
+ import { TransportError, TransportErrorCode } from "../errors";
10
+ import { TypedEmitter } from "../utils";
11
+
12
+ // ─── Transport events ─────────────────────────────────────────────────────────
13
+
14
+ interface TransportEvents extends Record<string, unknown> {
15
+ /** Fired once the WebSocket handshake completes (HTTP 101). */
16
+ open: void;
17
+ /** Fired for every successfully decoded inbound frame. */
18
+ message: Envelope;
19
+ /** Fired on any raw decode / transport error (non-fatal — logged only). */
20
+ error: Error;
21
+ /** Fired when the socket closes, for any reason. */
22
+ close: { code: number; reason: string; wasClean: boolean };
23
+ }
24
+
25
+ // ─── Transport ────────────────────────────────────────────────────────────────
26
+
27
+ export class Transport extends TypedEmitter<TransportEvents> {
28
+ private ws: WebSocket | null = null;
29
+ private _url: string;
30
+ private log: Logger;
31
+
32
+ constructor(url: string, logger: Logger) {
33
+ super();
34
+ this._url = this.validateUrl(url);
35
+ this.log = logger;
36
+ }
37
+
38
+ // ─── Public ─────────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Upgrades the HTTP connection to WebSocket.
42
+ *
43
+ * The browser/Node.js runtime performs the actual HTTP→WS handshake
44
+ * (sends `Upgrade: websocket`, `Connection: Upgrade`, `Sec-WebSocket-Key`,
45
+ * etc. and awaits the `101 Switching Protocols` response).
46
+ *
47
+ * We request the `wsframe.v1` sub-protocol so the server can reject
48
+ * unknown clients early, before any application logic runs.
49
+ *
50
+ * @param connectTimeoutMs Maximum ms to wait for the WS open event.
51
+ */
52
+ open(connectTimeoutMs = 10_000): Promise<void> {
53
+ if (this.ws && this.ws.readyState < WebSocket.CLOSING) {
54
+ return Promise.resolve(); // already open
55
+ }
56
+
57
+ return new Promise<void>((resolve, reject) => {
58
+ let settled = false;
59
+
60
+ const settle = (fn: () => void) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ fn();
65
+ };
66
+
67
+ const timer = setTimeout(() => {
68
+ settle(() => {
69
+ this.ws?.close();
70
+ reject(
71
+ new TransportError(
72
+ TransportErrorCode.CONNECT_TIMEOUT,
73
+ `WebSocket did not open within ${connectTimeoutMs}ms`,
74
+ ),
75
+ );
76
+ });
77
+ }, connectTimeoutMs);
78
+
79
+ let ws: WebSocket;
80
+ try {
81
+ ws = new WebSocket(this._url, [WS_SUBPROTOCOL]);
82
+ } catch (err) {
83
+ settle(() =>
84
+ reject(
85
+ new TransportError(
86
+ TransportErrorCode.UPGRADE_FAILED,
87
+ `WebSocket constructor threw: ${(err as Error).message}`,
88
+ ),
89
+ ),
90
+ );
91
+ return;
92
+ }
93
+
94
+ ws.binaryType = "arraybuffer"; // we don't use binary, but be explicit
95
+
96
+ ws.onopen = () => {
97
+ this.ws = ws;
98
+ this.log.debug("[transport] WebSocket open", { protocol: ws.protocol });
99
+ this.emit("open", undefined as unknown as void);
100
+ settle(resolve);
101
+ };
102
+
103
+ ws.onmessage = (ev: MessageEvent<string>) => {
104
+ this.handleIncoming(ev.data);
105
+ };
106
+
107
+ ws.onerror = () => {
108
+ // The browser intentionally gives no detail here (security).
109
+ // We log and let onclose carry the code.
110
+ const err = new TransportError(
111
+ TransportErrorCode.UPGRADE_FAILED,
112
+ "WebSocket error event fired",
113
+ );
114
+ this.log.warn("[transport] WebSocket error event");
115
+ this.emit("error", err);
116
+ settle(() => reject(err)); // only matters if we haven't opened yet
117
+ };
118
+
119
+ ws.onclose = (ev: CloseEvent) => {
120
+ this.ws = null;
121
+ this.log.info("[transport] closed", {
122
+ code: ev.code,
123
+ reason: ev.reason,
124
+ });
125
+ this.emit("close", {
126
+ code: ev.code,
127
+ reason: ev.reason,
128
+ wasClean: ev.wasClean,
129
+ });
130
+ };
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Serialises and sends an envelope.
136
+ * Throws TransportError if the socket is not open.
137
+ */
138
+ send<P>(env: Envelope<P>): void {
139
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
140
+ throw new TransportError(
141
+ TransportErrorCode.SOCKET_CLOSED,
142
+ `Cannot send event "${env.event}": socket is not open`,
143
+ );
144
+ }
145
+ const frame = encode(env); // throws FrameError if > 64 KB
146
+ this.ws.send(frame);
147
+ }
148
+
149
+ /**
150
+ * Closes the WebSocket with the given code and reason.
151
+ * Safe to call when already closed.
152
+ */
153
+ close(code: number = CloseCodes.NORMAL, reason: string = ""): void {
154
+ if (this.ws && this.ws.readyState < WebSocket.CLOSING) {
155
+ this.ws.close(code, reason);
156
+ }
157
+ }
158
+
159
+ get isOpen(): boolean {
160
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
161
+ }
162
+
163
+ get bufferedAmount(): number {
164
+ return this.ws?.bufferedAmount ?? 0;
165
+ }
166
+
167
+ // ─── Private ────────────────────────────────────────────────────────────────
168
+
169
+ private handleIncoming(raw: string): void {
170
+ try {
171
+ const env = decode(raw);
172
+ this.emit("message", env);
173
+ } catch (err) {
174
+ this.log.warn("[transport] malformed frame — dropped", { err });
175
+ this.emit("error", err as Error);
176
+ }
177
+ }
178
+
179
+ private validateUrl(url: string): string {
180
+ let parsed: URL;
181
+ try {
182
+ parsed = new URL(url);
183
+ } catch {
184
+ throw new TransportError(
185
+ TransportErrorCode.UPGRADE_FAILED,
186
+ `Invalid WebSocket URL: "${url}"`,
187
+ );
188
+ }
189
+
190
+ if (parsed.protocol === "http:") {
191
+ // Rewrite http: → ws: so consumers can pass HTTP URLs conveniently.
192
+ parsed.protocol = "ws:";
193
+ } else if (parsed.protocol === "https:") {
194
+ parsed.protocol = "wss:";
195
+ }
196
+
197
+ const secure = parsed.protocol === "wss:";
198
+
199
+ // Block plaintext connections when the page itself is HTTPS.
200
+ const pageIsSecure =
201
+ typeof location !== "undefined" && location.protocol === "https:";
202
+
203
+ if (!secure && pageIsSecure) {
204
+ throw new TransportError(
205
+ TransportErrorCode.INSECURE_URL,
206
+ `Insecure ws:// connection blocked on an HTTPS origin. Use wss://.`,
207
+ );
208
+ }
209
+
210
+ return parsed.toString();
211
+ }
212
+ }
@@ -0,0 +1,157 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Zero-dependency utilities used throughout the library.
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+
5
+ // ─── UUID v4 ──────────────────────────────────────────────────────────────────
6
+
7
+ export function uuid(): string {
8
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
9
+ return crypto.randomUUID()
10
+ }
11
+ // Fallback: Math.random-based (non-cryptographic, acceptable for frame IDs)
12
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
13
+ const r = (Math.random() * 16) | 0
14
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
15
+ })
16
+ }
17
+
18
+ // ─── Exponential backoff with full jitter ─────────────────────────────────────
19
+
20
+ /**
21
+ * Returns the next reconnect delay using full-jitter exponential backoff.
22
+ * delay = rand(0, min(cap, base × 2^attempt))
23
+ */
24
+ export function backoffMs(attempt: number, baseMs: number, capMs: number): number {
25
+ const ceiling = Math.min(capMs, baseMs * Math.pow(2, attempt))
26
+ return Math.floor(Math.random() * ceiling)
27
+ }
28
+
29
+ // ─── Deferred promise ─────────────────────────────────────────────────────────
30
+
31
+ export interface Deferred<T> {
32
+ promise: Promise<T>
33
+ resolve(value: T | PromiseLike<T>): void
34
+ reject(reason?: unknown): void
35
+ }
36
+
37
+ export function deferred<T>(): Deferred<T> {
38
+ let resolve!: Deferred<T>['resolve']
39
+ let reject!: Deferred<T>['reject']
40
+ const promise = new Promise<T>((res, rej) => {
41
+ resolve = res
42
+ reject = rej
43
+ })
44
+ return { promise, resolve, reject }
45
+ }
46
+
47
+ // ─── Typed event emitter ──────────────────────────────────────────────────────
48
+
49
+ type Listener<T> = (data: T) => void
50
+ type AnyListener = Listener<unknown>
51
+
52
+ export class TypedEmitter<Events extends Record<string, unknown>> {
53
+ private readonly _listeners = new Map<string, Set<AnyListener>>()
54
+
55
+ /** Subscribe. Returns an unsubscribe function. */
56
+ on<K extends keyof Events & string>(event: K, fn: Listener<Events[K]>): () => void {
57
+ let set = this._listeners.get(event)
58
+ if (!set) {
59
+ set = new Set()
60
+ this._listeners.set(event, set)
61
+ }
62
+ set.add(fn as AnyListener)
63
+ return () => this.off(event, fn)
64
+ }
65
+
66
+ /** Subscribe once, then auto-unsubscribe. */
67
+ once<K extends keyof Events & string>(event: K, fn: Listener<Events[K]>): () => void {
68
+ const wrapped = (data: Events[K]) => {
69
+ fn(data)
70
+ this.off(event, wrapped)
71
+ }
72
+ return this.on(event, wrapped)
73
+ }
74
+
75
+ off<K extends keyof Events & string>(event: K, fn: Listener<Events[K]>): void {
76
+ this._listeners.get(event)?.delete(fn as AnyListener)
77
+ }
78
+
79
+ emit<K extends keyof Events & string>(event: K, data: Events[K]): void {
80
+ this._listeners.get(event)?.forEach((fn) => {
81
+ try {
82
+ fn(data)
83
+ } catch {
84
+ /* isolate listener errors */
85
+ }
86
+ })
87
+ }
88
+
89
+ /** Remove all listeners, optionally scoped to one event. */
90
+ removeAllListeners(event?: keyof Events & string): void {
91
+ if (event) {
92
+ this._listeners.delete(event)
93
+ } else {
94
+ this._listeners.clear()
95
+ }
96
+ }
97
+ }
98
+
99
+ // ─── Token bucket (client-side rate limiting) ─────────────────────────────────
100
+
101
+ export class TokenBucket {
102
+ private tokens: number
103
+ private windowAt: number
104
+
105
+ constructor(
106
+ private readonly capacity: number,
107
+ private readonly windowMs: number,
108
+ ) {
109
+ this.tokens = capacity
110
+ this.windowAt = Date.now()
111
+ }
112
+
113
+ /** Returns true when a token was consumed; false when empty. */
114
+ tryConsume(): boolean {
115
+ this.refill()
116
+ if (this.tokens <= 0) return false
117
+ this.tokens -= 1
118
+ return true
119
+ }
120
+
121
+ get remaining(): number {
122
+ this.refill()
123
+ return this.tokens
124
+ }
125
+
126
+ get nextRefillMs(): number {
127
+ const elapsed = Date.now() - this.windowAt
128
+ return Math.max(0, this.windowMs - elapsed)
129
+ }
130
+
131
+ private refill(): void {
132
+ if (Date.now() - this.windowAt >= this.windowMs) {
133
+ this.tokens = this.capacity
134
+ this.windowAt = Date.now()
135
+ }
136
+ }
137
+ }
138
+
139
+ // ─── Abort-aware sleep ────────────────────────────────────────────────────────
140
+
141
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
142
+ return new Promise<void>((resolve, reject) => {
143
+ if (signal?.aborted) {
144
+ reject(new DOMException('Aborted', 'AbortError'))
145
+ return
146
+ }
147
+ const t = setTimeout(resolve, ms)
148
+ signal?.addEventListener(
149
+ 'abort',
150
+ () => {
151
+ clearTimeout(t)
152
+ reject(new DOMException('Aborted', 'AbortError'))
153
+ },
154
+ { once: true },
155
+ )
156
+ })
157
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "allowJs": true,
9
+
10
+ // Bundler mode
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "noEmit": true,
15
+
16
+ // Best practices
17
+ "strict": true,
18
+ "skipLibCheck": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "noUncheckedIndexedAccess": true,
21
+ "noImplicitOverride": true,
22
+
23
+ // Some stricter flags (disabled by default)
24
+ "noUnusedLocals": false,
25
+ "noUnusedParameters": false,
26
+ "noPropertyAccessFromIndexSignature": false
27
+ }
28
+ }