@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.
- package/README.md +2 -0
- package/bun.lock +78 -0
- package/package.json +23 -0
- package/src/client/index.ts +17 -0
- package/src/errors/index.ts +152 -0
- package/src/fabric/index.ts +172 -0
- package/src/fabric/systembus.ts +93 -0
- package/src/index.ts +1 -0
- package/src/protocol/codec.ts +177 -0
- package/src/protocol/constants.ts +125 -0
- package/src/protocol/index.ts +3 -0
- package/src/protocol/types.ts +255 -0
- package/src/transport/index.ts +212 -0
- package/src/utils/index.ts +157 -0
- package/tsconfig.json +28 -0
|
@@ -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
|
+
}
|