@lumencast/runtime 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 +201 -0
- package/README.md +79 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/animate/crossfade.d.ts +13 -0
- package/dist/animate/crossfade.d.ts.map +1 -0
- package/dist/animate/crossfade.js +10 -0
- package/dist/animate/crossfade.js.map +1 -0
- package/dist/animate/keyframes.d.ts +42 -0
- package/dist/animate/keyframes.d.ts.map +1 -0
- package/dist/animate/keyframes.js +94 -0
- package/dist/animate/keyframes.js.map +1 -0
- package/dist/animate/transitions.d.ts +38 -0
- package/dist/animate/transitions.d.ts.map +1 -0
- package/dist/animate/transitions.js +81 -0
- package/dist/animate/transitions.js.map +1 -0
- package/dist/app.d.ts +16 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/broadcast-BqOhSNsY.js +11 -0
- package/dist/broadcast-BqOhSNsY.js.map +1 -0
- package/dist/control-CRFn328D.js +16 -0
- package/dist/control-CRFn328D.js.map +1 -0
- package/dist/dev-entry.d.ts +2 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +31 -0
- package/dist/dev-entry.js.map +1 -0
- package/dist/index-DUhPPRvw.js +583 -0
- package/dist/index-DUhPPRvw.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.html +46 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/validate-options.d.ts +5 -0
- package/dist/internal/validate-options.d.ts.map +1 -0
- package/dist/internal/validate-options.js +19 -0
- package/dist/internal/validate-options.js.map +1 -0
- package/dist/lumencast.js +5 -0
- package/dist/lumencast.js.map +1 -0
- package/dist/modes/broadcast.d.ts +3 -0
- package/dist/modes/broadcast.d.ts.map +1 -0
- package/dist/modes/broadcast.js +9 -0
- package/dist/modes/broadcast.js.map +1 -0
- package/dist/modes/control.d.ts +4 -0
- package/dist/modes/control.d.ts.map +1 -0
- package/dist/modes/control.js +12 -0
- package/dist/modes/control.js.map +1 -0
- package/dist/modes/test.d.ts +4 -0
- package/dist/modes/test.d.ts.map +1 -0
- package/dist/modes/test.js +13 -0
- package/dist/modes/test.js.map +1 -0
- package/dist/mount.d.ts +3 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +144 -0
- package/dist/mount.js.map +1 -0
- package/dist/overlay/control.d.ts +2 -0
- package/dist/overlay/control.d.ts.map +1 -0
- package/dist/overlay/control.js +127 -0
- package/dist/overlay/control.js.map +1 -0
- package/dist/overlay/runtime-context.d.ts +20 -0
- package/dist/overlay/runtime-context.d.ts.map +1 -0
- package/dist/overlay/runtime-context.js +14 -0
- package/dist/overlay/runtime-context.js.map +1 -0
- package/dist/overlay/status-pill.d.ts +2 -0
- package/dist/overlay/status-pill.d.ts.map +1 -0
- package/dist/overlay/status-pill.js +29 -0
- package/dist/overlay/status-pill.js.map +1 -0
- package/dist/overlay/test.d.ts +5 -0
- package/dist/overlay/test.d.ts.map +1 -0
- package/dist/overlay/test.js +116 -0
- package/dist/overlay/test.js.map +1 -0
- package/dist/render/bundle.d.ts +102 -0
- package/dist/render/bundle.d.ts.map +1 -0
- package/dist/render/bundle.js +86 -0
- package/dist/render/bundle.js.map +1 -0
- package/dist/render/fill.d.ts +41 -0
- package/dist/render/fill.d.ts.map +1 -0
- package/dist/render/fill.js +95 -0
- package/dist/render/fill.js.map +1 -0
- package/dist/render/keyframe-player.d.ts +10 -0
- package/dist/render/keyframe-player.d.ts.map +1 -0
- package/dist/render/keyframe-player.js +65 -0
- package/dist/render/keyframe-player.js.map +1 -0
- package/dist/render/primitives/frame.d.ts +12 -0
- package/dist/render/primitives/frame.d.ts.map +1 -0
- package/dist/render/primitives/frame.js +65 -0
- package/dist/render/primitives/frame.js.map +1 -0
- package/dist/render/primitives/grid.d.ts +4 -0
- package/dist/render/primitives/grid.d.ts.map +1 -0
- package/dist/render/primitives/grid.js +14 -0
- package/dist/render/primitives/grid.js.map +1 -0
- package/dist/render/primitives/image.d.ts +5 -0
- package/dist/render/primitives/image.d.ts.map +1 -0
- package/dist/render/primitives/image.js +25 -0
- package/dist/render/primitives/image.js.map +1 -0
- package/dist/render/primitives/index.d.ts +10 -0
- package/dist/render/primitives/index.d.ts.map +1 -0
- package/dist/render/primitives/index.js +22 -0
- package/dist/render/primitives/index.js.map +1 -0
- package/dist/render/primitives/instance.d.ts +4 -0
- package/dist/render/primitives/instance.d.ts.map +1 -0
- package/dist/render/primitives/instance.js +35 -0
- package/dist/render/primitives/instance.js.map +1 -0
- package/dist/render/primitives/media.d.ts +6 -0
- package/dist/render/primitives/media.d.ts.map +1 -0
- package/dist/render/primitives/media.js +19 -0
- package/dist/render/primitives/media.js.map +1 -0
- package/dist/render/primitives/shape.d.ts +12 -0
- package/dist/render/primitives/shape.d.ts.map +1 -0
- package/dist/render/primitives/shape.js +66 -0
- package/dist/render/primitives/shape.js.map +1 -0
- package/dist/render/primitives/stack.d.ts +13 -0
- package/dist/render/primitives/stack.d.ts.map +1 -0
- package/dist/render/primitives/stack.js +45 -0
- package/dist/render/primitives/stack.js.map +1 -0
- package/dist/render/primitives/text.d.ts +6 -0
- package/dist/render/primitives/text.d.ts.map +1 -0
- package/dist/render/primitives/text.js +27 -0
- package/dist/render/primitives/text.js.map +1 -0
- package/dist/render/scope.d.ts +10 -0
- package/dist/render/scope.d.ts.map +1 -0
- package/dist/render/scope.js +27 -0
- package/dist/render/scope.js.map +1 -0
- package/dist/render/stagger-context.d.ts +9 -0
- package/dist/render/stagger-context.d.ts.map +1 -0
- package/dist/render/stagger-context.js +22 -0
- package/dist/render/stagger-context.js.map +1 -0
- package/dist/render/tree.d.ts +9 -0
- package/dist/render/tree.d.ts.map +1 -0
- package/dist/render/tree.js +139 -0
- package/dist/render/tree.js.map +1 -0
- package/dist/render/universal-wrapper.d.ts +16 -0
- package/dist/render/universal-wrapper.d.ts.map +1 -0
- package/dist/render/universal-wrapper.js +58 -0
- package/dist/render/universal-wrapper.js.map +1 -0
- package/dist/state/apply-delta.d.ts +11 -0
- package/dist/state/apply-delta.d.ts.map +1 -0
- package/dist/state/apply-delta.js +23 -0
- package/dist/state/apply-delta.js.map +1 -0
- package/dist/state/apply-snapshot.d.ts +6 -0
- package/dist/state/apply-snapshot.d.ts.map +1 -0
- package/dist/state/apply-snapshot.js +6 -0
- package/dist/state/apply-snapshot.js.map +1 -0
- package/dist/state/store.d.ts +28 -0
- package/dist/state/store.d.ts.map +1 -0
- package/dist/state/store.js +119 -0
- package/dist/state/store.js.map +1 -0
- package/dist/status-pill-DCHvrd_y.js +241 -0
- package/dist/status-pill-DCHvrd_y.js.map +1 -0
- package/dist/test-DBCtwx_I.js +210 -0
- package/dist/test-DBCtwx_I.js.map +1 -0
- package/dist/transport/reconnect.d.ts +22 -0
- package/dist/transport/reconnect.d.ts.map +1 -0
- package/dist/transport/reconnect.js +60 -0
- package/dist/transport/reconnect.js.map +1 -0
- package/dist/transport/ws.d.ts +66 -0
- package/dist/transport/ws.d.ts.map +1 -0
- package/dist/transport/ws.js +270 -0
- package/dist/transport/ws.js.map +1 -0
- package/dist/tree-CnhX02kd.js +494 -0
- package/dist/tree-CnhX02kd.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
- package/src/animate/crossfade.tsx +31 -0
- package/src/animate/keyframes.ts +142 -0
- package/src/animate/transitions.ts +116 -0
- package/src/app.tsx +84 -0
- package/src/dev-entry.tsx +38 -0
- package/src/index.ts +24 -0
- package/src/internal/validate-options.ts +20 -0
- package/src/modes/broadcast.tsx +8 -0
- package/src/modes/control.tsx +17 -0
- package/src/modes/test.tsx +19 -0
- package/src/mount.ts +169 -0
- package/src/overlay/control.tsx +239 -0
- package/src/overlay/runtime-context.tsx +37 -0
- package/src/overlay/status-pill.tsx +37 -0
- package/src/overlay/test.tsx +213 -0
- package/src/render/bundle.ts +208 -0
- package/src/render/fill.tsx +163 -0
- package/src/render/keyframe-player.tsx +89 -0
- package/src/render/primitives/frame.tsx +78 -0
- package/src/render/primitives/grid.tsx +20 -0
- package/src/render/primitives/image.tsx +35 -0
- package/src/render/primitives/index.ts +35 -0
- package/src/render/primitives/instance.tsx +70 -0
- package/src/render/primitives/media.tsx +28 -0
- package/src/render/primitives/shape.tsx +135 -0
- package/src/render/primitives/stack.tsx +48 -0
- package/src/render/primitives/text.tsx +38 -0
- package/src/render/scope.tsx +27 -0
- package/src/render/stagger-context.tsx +24 -0
- package/src/render/tree.tsx +182 -0
- package/src/render/universal-wrapper.tsx +95 -0
- package/src/state/apply-delta.ts +24 -0
- package/src/state/apply-snapshot.ts +8 -0
- package/src/state/store.ts +141 -0
- package/src/transport/reconnect.ts +83 -0
- package/src/transport/ws.ts +359 -0
- package/src/types.ts +54 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SnapshotFrame } from "@lumencast/protocol";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
|
|
4
|
+
/** Apply an LSDP/1 snapshot to the store. Replaces the entire state — paths
|
|
5
|
+
* not present in the snapshot are reset to `undefined`. */
|
|
6
|
+
export function applySnapshot(store: Store, frame: SnapshotFrame): void {
|
|
7
|
+
store.reset(frame.state);
|
|
8
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// State store — one signal per leaf path.
|
|
2
|
+
//
|
|
3
|
+
// Integration point between the WS layer (snapshot + delta) and the render
|
|
4
|
+
// layer. Each path Lumencast has ever seen owns a `Signal<unknown>`;
|
|
5
|
+
// readers subscribe via @preact/signals-react `useSignals()` and re-render
|
|
6
|
+
// only when their path's value changes.
|
|
7
|
+
//
|
|
8
|
+
// LSDP/1.1 §3.2.2 — incoming deltas may carry a per-leaf `transition`
|
|
9
|
+
// directive. The store keeps the most-recent directive per path so the
|
|
10
|
+
// renderer can pick it up on the next animation cycle. Snapshots clear
|
|
11
|
+
// any pending transitions for the affected paths (snapshots are not
|
|
12
|
+
// animated transitions).
|
|
13
|
+
|
|
14
|
+
import { signal, type Signal, batch } from "@preact/signals-react";
|
|
15
|
+
import type { Transition } from "../animate/transitions";
|
|
16
|
+
|
|
17
|
+
export interface Store {
|
|
18
|
+
/** Get-or-create the signal for a path. New paths start as `undefined`. */
|
|
19
|
+
signal(path: string): Signal<unknown>;
|
|
20
|
+
/** Apply a single leaf write. */
|
|
21
|
+
set(path: string, value: unknown): void;
|
|
22
|
+
/** Apply a single leaf write with an LSDP/1.1 §3.2.2 transition directive.
|
|
23
|
+
* The directive lives in a separate signal so the renderer can subscribe
|
|
24
|
+
* to it independently. Passing `undefined` clears any pending directive. */
|
|
25
|
+
setWithTransition(path: string, value: unknown, transition: Transition | undefined): void;
|
|
26
|
+
/** Read the most-recent transition directive for a path (or undefined
|
|
27
|
+
* when no directive has been applied since the last snapshot). The
|
|
28
|
+
* returned signal is reactive — components reading via `useSignals()`
|
|
29
|
+
* re-render when the directive changes. */
|
|
30
|
+
transitionSignal(path: string): Signal<Transition | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* Replace the whole state — used by `apply-snapshot`. Existing signals are
|
|
33
|
+
* reused (subscribers stay attached); paths missing from the snapshot reset
|
|
34
|
+
* to `undefined`. Pending per-path transitions are cleared (a snapshot is
|
|
35
|
+
* a state restore, not an animated change).
|
|
36
|
+
*/
|
|
37
|
+
reset(state: Record<string, unknown>): void;
|
|
38
|
+
/** Snapshot of every known path → current value. For debug / state inspector. */
|
|
39
|
+
toRecord(): Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class StoreImpl implements Store {
|
|
43
|
+
private readonly signals = new Map<string, Signal<unknown>>();
|
|
44
|
+
private readonly transitions = new Map<string, Signal<Transition | undefined>>();
|
|
45
|
+
|
|
46
|
+
signal(path: string): Signal<unknown> {
|
|
47
|
+
let s = this.signals.get(path);
|
|
48
|
+
if (!s) {
|
|
49
|
+
s = signal<unknown>(undefined);
|
|
50
|
+
this.signals.set(path, s);
|
|
51
|
+
}
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
transitionSignal(path: string): Signal<Transition | undefined> {
|
|
56
|
+
let s = this.transitions.get(path);
|
|
57
|
+
if (!s) {
|
|
58
|
+
s = signal<Transition | undefined>(undefined);
|
|
59
|
+
this.transitions.set(path, s);
|
|
60
|
+
}
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
set(path: string, value: unknown): void {
|
|
65
|
+
const s = this.signal(path);
|
|
66
|
+
if (!shallowEqual(s.peek(), value)) {
|
|
67
|
+
s.value = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setWithTransition(path: string, value: unknown, transition: Transition | undefined): void {
|
|
72
|
+
batch(() => {
|
|
73
|
+
const ts = this.transitionSignal(path);
|
|
74
|
+
// Update transition before value so the render that observes the
|
|
75
|
+
// new value sees the correct transition.
|
|
76
|
+
if (ts.peek() !== transition) ts.value = transition;
|
|
77
|
+
const s = this.signal(path);
|
|
78
|
+
if (!shallowEqual(s.peek(), value)) s.value = value;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
reset(state: Record<string, unknown>): void {
|
|
83
|
+
batch(() => {
|
|
84
|
+
const seen = new Set<string>();
|
|
85
|
+
for (const [path, value] of Object.entries(state)) {
|
|
86
|
+
seen.add(path);
|
|
87
|
+
const s = this.signal(path);
|
|
88
|
+
if (!shallowEqual(s.peek(), value)) {
|
|
89
|
+
s.value = value;
|
|
90
|
+
}
|
|
91
|
+
// Snapshots are not animated transitions — clear any pending
|
|
92
|
+
// per-path directive (LSDP/1.1 §3.2.2 — directives apply to
|
|
93
|
+
// the NEXT delta only, snapshots reseed state authoritatively).
|
|
94
|
+
const ts = this.transitions.get(path);
|
|
95
|
+
if (ts && ts.peek() !== undefined) ts.value = undefined;
|
|
96
|
+
}
|
|
97
|
+
for (const path of this.signals.keys()) {
|
|
98
|
+
if (!seen.has(path)) {
|
|
99
|
+
const s = this.signals.get(path);
|
|
100
|
+
if (s && s.peek() !== undefined) s.value = undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
toRecord(): Record<string, unknown> {
|
|
107
|
+
const out: Record<string, unknown> = {};
|
|
108
|
+
for (const [path, s] of this.signals.entries()) {
|
|
109
|
+
out[path] = s.peek();
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createStore(): Store {
|
|
116
|
+
return new StoreImpl();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
120
|
+
if (a === b) return true;
|
|
121
|
+
if (a === null || b === null) return false;
|
|
122
|
+
if (typeof a !== typeof b) return false;
|
|
123
|
+
if (typeof a !== "object") return false;
|
|
124
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
125
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
126
|
+
if (a.length !== b.length) return false;
|
|
127
|
+
for (let i = 0; i < a.length; i++) {
|
|
128
|
+
if (a[i] !== b[i]) return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
const ao = a as Record<string, unknown>;
|
|
133
|
+
const bo = b as Record<string, unknown>;
|
|
134
|
+
const ak = Object.keys(ao);
|
|
135
|
+
const bk = Object.keys(bo);
|
|
136
|
+
if (ak.length !== bk.length) return false;
|
|
137
|
+
for (const k of ak) {
|
|
138
|
+
if (ao[k] !== bo[k]) return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Exponential backoff schedule for the WS reconnect loop.
|
|
2
|
+
//
|
|
3
|
+
// Aggressive at first (200 ms, 400 ms, 800 ms…) then capped at 5 s so a
|
|
4
|
+
// sustained outage doesn't hammer the gateway. Jittered to avoid thundering
|
|
5
|
+
// herds when several Lumencast instances reconnect together (e.g. a CEF
|
|
6
|
+
// host + a webview waking from suspend at the same time).
|
|
7
|
+
//
|
|
8
|
+
// The reference schedule in LSDP/1 §7 is "0 ms, 500 ms, 1 s, 2 s, 4 s, 8 s,
|
|
9
|
+
// 15 s, 30 s, 60 s cap". This implementation defaults are tighter; both are
|
|
10
|
+
// MAY-bounded by the spec.
|
|
11
|
+
|
|
12
|
+
export interface ReconnectScheduleOptions {
|
|
13
|
+
/** First delay in milliseconds. */
|
|
14
|
+
initial?: number;
|
|
15
|
+
/** Maximum delay in milliseconds. */
|
|
16
|
+
max?: number;
|
|
17
|
+
/** Multiplicative factor between attempts (>= 1). */
|
|
18
|
+
factor?: number;
|
|
19
|
+
/** Jitter as a fraction of the delay (0 disables, 0.2 = ±20 %). */
|
|
20
|
+
jitter?: number;
|
|
21
|
+
/** Random source — only injected for tests. */
|
|
22
|
+
random?: () => number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULTS: Required<Omit<ReconnectScheduleOptions, "random">> = {
|
|
26
|
+
initial: 200,
|
|
27
|
+
max: 5_000,
|
|
28
|
+
factor: 2,
|
|
29
|
+
jitter: 0.2,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface ReconnectSchedule {
|
|
33
|
+
/** Returns the delay to wait before the n-th attempt (1-indexed). */
|
|
34
|
+
delayFor(attempt: number): number;
|
|
35
|
+
/** Reset to attempt 1 (called on a successful connection). */
|
|
36
|
+
reset(): void;
|
|
37
|
+
/** Current attempt counter. */
|
|
38
|
+
readonly attempt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class ScheduleImpl implements ReconnectSchedule {
|
|
42
|
+
private _attempt = 0;
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly opts: Required<Omit<ReconnectScheduleOptions, "random">>,
|
|
45
|
+
private readonly random: () => number,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
get attempt(): number {
|
|
49
|
+
return this._attempt;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
delayFor(attempt: number): number {
|
|
53
|
+
if (!Number.isInteger(attempt) || attempt < 1) {
|
|
54
|
+
throw new RangeError(`attempt must be a positive integer, got ${attempt}`);
|
|
55
|
+
}
|
|
56
|
+
this._attempt = attempt;
|
|
57
|
+
const base = Math.min(
|
|
58
|
+
this.opts.initial * Math.pow(this.opts.factor, attempt - 1),
|
|
59
|
+
this.opts.max,
|
|
60
|
+
);
|
|
61
|
+
if (this.opts.jitter <= 0) return base;
|
|
62
|
+
const offset = (this.random() * 2 - 1) * this.opts.jitter * base;
|
|
63
|
+
return Math.max(0, base + offset);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
reset(): void {
|
|
67
|
+
this._attempt = 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createReconnectSchedule(opts: ReconnectScheduleOptions = {}): ReconnectSchedule {
|
|
72
|
+
const merged = {
|
|
73
|
+
initial: opts.initial ?? DEFAULTS.initial,
|
|
74
|
+
max: opts.max ?? DEFAULTS.max,
|
|
75
|
+
factor: opts.factor ?? DEFAULTS.factor,
|
|
76
|
+
jitter: opts.jitter ?? DEFAULTS.jitter,
|
|
77
|
+
};
|
|
78
|
+
if (merged.initial <= 0) throw new RangeError("initial must be > 0");
|
|
79
|
+
if (merged.max < merged.initial) throw new RangeError("max must be >= initial");
|
|
80
|
+
if (merged.factor < 1) throw new RangeError("factor must be >= 1");
|
|
81
|
+
if (merged.jitter < 0 || merged.jitter > 1) throw new RangeError("jitter must be within [0, 1]");
|
|
82
|
+
return new ScheduleImpl(merged, opts.random ?? Math.random);
|
|
83
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// LSDP/1 WebSocket client.
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle (LSDP/1 §6):
|
|
4
|
+
// 1. open() — opens the WS with subprotocol `lsdp.v1`
|
|
5
|
+
// 2. on open: send `subscribe` with the resolved token (and scene + session
|
|
6
|
+
// for test mode)
|
|
7
|
+
// 3. server replies `snapshot` (seq=1) → emit onSnapshot
|
|
8
|
+
// 4. subsequent `delta` / `scene_changed` / `error` / `pong` are dispatched
|
|
9
|
+
// 5. on a sequence gap → close + reconnect (fresh snapshot)
|
|
10
|
+
// 6. on close → reconnect with backoff, unless close was triggered by close()
|
|
11
|
+
// 7. setToken() opens a parallel WS with the new token; once its snapshot
|
|
12
|
+
// lands, atomically swap and close the old socket — no rendering gap
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
decodeServerFrame,
|
|
16
|
+
encodeFrame,
|
|
17
|
+
LumencastError,
|
|
18
|
+
SequenceTracker,
|
|
19
|
+
WS_SUBPROTOCOL_V1_1,
|
|
20
|
+
WS_SUBPROTOCOLS,
|
|
21
|
+
input as inputFrame,
|
|
22
|
+
subscribe as subscribeFrame,
|
|
23
|
+
type DeltaFrame,
|
|
24
|
+
type ErrorCode,
|
|
25
|
+
type ErrorFrame,
|
|
26
|
+
type Patch,
|
|
27
|
+
type SceneChangedFrame,
|
|
28
|
+
type SnapshotFrame,
|
|
29
|
+
} from "@lumencast/protocol";
|
|
30
|
+
import type { LumencastToken } from "../types.js";
|
|
31
|
+
import {
|
|
32
|
+
createReconnectSchedule,
|
|
33
|
+
type ReconnectSchedule,
|
|
34
|
+
type ReconnectScheduleOptions,
|
|
35
|
+
} from "./reconnect.js";
|
|
36
|
+
|
|
37
|
+
export type ConnectionStatus = "disconnected" | "connecting" | "live";
|
|
38
|
+
|
|
39
|
+
export interface WsClientOptions {
|
|
40
|
+
url: string;
|
|
41
|
+
token: LumencastToken;
|
|
42
|
+
/** Optional scene identifier (test mode). */
|
|
43
|
+
scene?: string;
|
|
44
|
+
/** Optional session identifier (test mode). */
|
|
45
|
+
session?: string;
|
|
46
|
+
/** Override the WebSocket constructor (for tests / non-browser hosts). */
|
|
47
|
+
webSocketImpl?: typeof WebSocket;
|
|
48
|
+
/** Reconnect tuning. */
|
|
49
|
+
reconnect?: ReconnectScheduleOptions;
|
|
50
|
+
/** Inject scheduler for tests. */
|
|
51
|
+
scheduler?: {
|
|
52
|
+
setTimeout: typeof globalThis.setTimeout;
|
|
53
|
+
clearTimeout: typeof globalThis.clearTimeout;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
onStatus?: (status: ConnectionStatus) => void;
|
|
57
|
+
onSnapshot?: (frame: SnapshotFrame) => void;
|
|
58
|
+
onDelta?: (frame: DeltaFrame) => void;
|
|
59
|
+
onSceneChanged?: (frame: SceneChangedFrame) => void;
|
|
60
|
+
onServerError?: (frame: ErrorFrame) => void;
|
|
61
|
+
/** Wire-level / codec / unrecoverable errors. */
|
|
62
|
+
onTransportError?: (err: TransportError) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class TransportError extends Error {
|
|
66
|
+
public readonly recoverable: boolean;
|
|
67
|
+
public readonly code: ErrorCode;
|
|
68
|
+
public override readonly cause?: unknown;
|
|
69
|
+
constructor(
|
|
70
|
+
message: string,
|
|
71
|
+
recoverable: boolean,
|
|
72
|
+
code: ErrorCode = "INTERNAL",
|
|
73
|
+
cause?: unknown,
|
|
74
|
+
) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = "TransportError";
|
|
77
|
+
this.recoverable = recoverable;
|
|
78
|
+
this.code = code;
|
|
79
|
+
this.cause = cause;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Timer = ReturnType<typeof setTimeout>;
|
|
84
|
+
|
|
85
|
+
interface InternalScheduler {
|
|
86
|
+
setTimeout: typeof globalThis.setTimeout;
|
|
87
|
+
clearTimeout: typeof globalThis.clearTimeout;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class WsClient {
|
|
91
|
+
private status: ConnectionStatus = "disconnected";
|
|
92
|
+
private socket: WebSocket | null = null;
|
|
93
|
+
private token: LumencastToken;
|
|
94
|
+
private readonly url: string;
|
|
95
|
+
private readonly WebSocketCtor: typeof WebSocket;
|
|
96
|
+
private readonly schedule: ReconnectSchedule;
|
|
97
|
+
private readonly seq = new SequenceTracker();
|
|
98
|
+
private readonly opts: WsClientOptions;
|
|
99
|
+
private readonly scheduler: InternalScheduler;
|
|
100
|
+
|
|
101
|
+
private reconnectTimer: Timer | null = null;
|
|
102
|
+
private active = true;
|
|
103
|
+
|
|
104
|
+
constructor(opts: WsClientOptions) {
|
|
105
|
+
this.opts = opts;
|
|
106
|
+
this.url = opts.url;
|
|
107
|
+
this.token = opts.token;
|
|
108
|
+
const ctor = opts.webSocketImpl ?? globalThis.WebSocket;
|
|
109
|
+
if (!ctor) {
|
|
110
|
+
throw new TypeError(
|
|
111
|
+
"Lumencast WsClient: no WebSocket implementation found in this environment",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
this.WebSocketCtor = ctor;
|
|
115
|
+
this.schedule = createReconnectSchedule(opts.reconnect);
|
|
116
|
+
this.scheduler = opts.scheduler ?? {
|
|
117
|
+
setTimeout: globalThis.setTimeout.bind(globalThis),
|
|
118
|
+
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Open and start the connection lifecycle. Idempotent. */
|
|
123
|
+
start(): void {
|
|
124
|
+
if (!this.active) return;
|
|
125
|
+
if (this.socket || this.status === "connecting") return;
|
|
126
|
+
void this.openSocket();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Send `input` patches to the server. No-op if not connected. */
|
|
130
|
+
sendInput(patches: Patch[]): void {
|
|
131
|
+
if (!this.socket || this.socket.readyState !== this.WebSocketCtor.OPEN) return;
|
|
132
|
+
if (patches.length === 0) return;
|
|
133
|
+
this.socket.send(encodeFrame(inputFrame(patches)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Replace the auth token. Closes and reopens with the new token. */
|
|
137
|
+
setToken(token: LumencastToken): void {
|
|
138
|
+
this.token = token;
|
|
139
|
+
if (!this.active) return;
|
|
140
|
+
if (this.socket) {
|
|
141
|
+
this.closeSocket();
|
|
142
|
+
this.scheduleReconnect(true);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Tear down for good. No more reconnect attempts. */
|
|
147
|
+
close(): void {
|
|
148
|
+
if (!this.active) return;
|
|
149
|
+
this.active = false;
|
|
150
|
+
this.cancelReconnect();
|
|
151
|
+
this.closeSocket();
|
|
152
|
+
this.setStatus("disconnected");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- internals --------------------------------------------------
|
|
156
|
+
|
|
157
|
+
private async openSocket(): Promise<void> {
|
|
158
|
+
if (!this.active) return;
|
|
159
|
+
this.setStatus("connecting");
|
|
160
|
+
|
|
161
|
+
let resolvedToken: string;
|
|
162
|
+
try {
|
|
163
|
+
resolvedToken = await resolveToken(this.token);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
this.opts.onTransportError?.(
|
|
166
|
+
new TransportError(
|
|
167
|
+
`failed to resolve token: ${(err as Error).message}`,
|
|
168
|
+
true,
|
|
169
|
+
"AUTH_DENIED",
|
|
170
|
+
err,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
this.scheduleReconnect();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!this.active) return;
|
|
177
|
+
|
|
178
|
+
let socket: WebSocket;
|
|
179
|
+
try {
|
|
180
|
+
// Advertise both 1.1 (preferred) and 1.0 (fallback) ; the server
|
|
181
|
+
// picks one. Spread to a mutable array — the WebSocket constructor
|
|
182
|
+
// type expects string[].
|
|
183
|
+
socket = new this.WebSocketCtor(this.url, [...WS_SUBPROTOCOLS]);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.opts.onTransportError?.(
|
|
186
|
+
new TransportError(
|
|
187
|
+
`failed to open WebSocket: ${(err as Error).message}`,
|
|
188
|
+
true,
|
|
189
|
+
"INTERNAL",
|
|
190
|
+
err,
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
this.scheduleReconnect();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.socket = socket;
|
|
198
|
+
socket.onopen = () => this.handleOpen(resolvedToken);
|
|
199
|
+
socket.onmessage = (event) => this.handleMessage(event);
|
|
200
|
+
socket.onerror = (event) => this.handleError(event);
|
|
201
|
+
socket.onclose = (event) => this.handleClose(event);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private handleOpen(token: string): void {
|
|
205
|
+
if (!this.socket) return;
|
|
206
|
+
// LSDP/1.1 §4.1, §18 — if we have a previously-observed seq AND the
|
|
207
|
+
// negotiated subprotocol is 1.1, request an incremental resume.
|
|
208
|
+
// The server will EITHER ship buffered deltas (cache stays valid)
|
|
209
|
+
// OR a fresh snapshot which we rebase via observeSnapshot.
|
|
210
|
+
const subprotocol = this.socket.protocol;
|
|
211
|
+
const canResume = subprotocol === WS_SUBPROTOCOL_V1_1 && this.seq.last > 0;
|
|
212
|
+
const sinceSequence = canResume ? this.seq.last : undefined;
|
|
213
|
+
if (!canResume) {
|
|
214
|
+
// Fresh subscription (no resume) — reset the tracker baseline.
|
|
215
|
+
this.seq.reset();
|
|
216
|
+
}
|
|
217
|
+
const frame = subscribeFrame({
|
|
218
|
+
token,
|
|
219
|
+
...(this.opts.scene !== undefined ? { scene: this.opts.scene } : {}),
|
|
220
|
+
...(this.opts.session !== undefined ? { session: this.opts.session } : {}),
|
|
221
|
+
...(sinceSequence !== undefined ? { since_sequence: sinceSequence } : {}),
|
|
222
|
+
});
|
|
223
|
+
this.socket.send(encodeFrame(frame));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private handleMessage(event: MessageEvent): void {
|
|
227
|
+
const data = typeof event.data === "string" ? event.data : "";
|
|
228
|
+
if (!data) return;
|
|
229
|
+
let frame;
|
|
230
|
+
try {
|
|
231
|
+
frame = decodeServerFrame(data);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const message = err instanceof LumencastError ? err.message : (err as Error).message;
|
|
234
|
+
const code: ErrorCode = err instanceof LumencastError ? err.code : "INTERNAL";
|
|
235
|
+
this.opts.onTransportError?.(new TransportError(`codec: ${message}`, true, code, err));
|
|
236
|
+
this.closeSocket();
|
|
237
|
+
this.scheduleReconnect();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (frame === null) return; // unknown frame type — forward-compat ignore
|
|
241
|
+
|
|
242
|
+
switch (frame.type) {
|
|
243
|
+
case "snapshot": {
|
|
244
|
+
// LSDP/1.1 §18.1.1 — snapshot rebases the tracker to its seq
|
|
245
|
+
// value. This is the ONLY valid way to set or change the
|
|
246
|
+
// tracker's baseline (handles fresh sub, scene_changed, and
|
|
247
|
+
// back-pressure recovery uniformly).
|
|
248
|
+
if (frame.seq < 1) {
|
|
249
|
+
this.opts.onTransportError?.(
|
|
250
|
+
new TransportError(`snapshot seq must be >= 1, got ${frame.seq}`, true, "VERSION_GAP"),
|
|
251
|
+
);
|
|
252
|
+
this.closeSocket();
|
|
253
|
+
this.scheduleReconnect();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this.seq.observeSnapshot(frame.seq);
|
|
257
|
+
this.schedule.reset();
|
|
258
|
+
this.setStatus("live");
|
|
259
|
+
this.opts.onSnapshot?.(frame);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case "delta": {
|
|
263
|
+
const obs = this.seq.observe(frame.seq);
|
|
264
|
+
if (obs.kind === "gap") {
|
|
265
|
+
this.opts.onTransportError?.(
|
|
266
|
+
new TransportError(
|
|
267
|
+
`sequence gap: expected ${this.seq.last + 1}, got ${frame.seq}`,
|
|
268
|
+
true,
|
|
269
|
+
"VERSION_GAP",
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
this.closeSocket();
|
|
273
|
+
this.scheduleReconnect();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (obs.kind === "duplicate") return; // silent drop per LSDP/1 §5
|
|
277
|
+
this.opts.onDelta?.(frame);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
case "scene_changed": {
|
|
281
|
+
// The next snapshot will rebase the tracker via observeSnapshot.
|
|
282
|
+
// Reset here so the tracker doesn't fault on the SceneChanged's
|
|
283
|
+
// own seq (which advances prev's counter one final step).
|
|
284
|
+
this.seq.reset();
|
|
285
|
+
this.opts.onSceneChanged?.(frame);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
case "error": {
|
|
289
|
+
this.opts.onServerError?.(frame);
|
|
290
|
+
if (!frame.recoverable) this.close();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
case "pong":
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private handleError(_event: Event): void {
|
|
299
|
+
// The browser does not give us a real reason — `close` will follow.
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private handleClose(event: CloseEvent): void {
|
|
303
|
+
this.socket = null;
|
|
304
|
+
if (!this.active) {
|
|
305
|
+
this.setStatus("disconnected");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (event.code === 4401 || event.code === 4403 || event.code === 1008) {
|
|
309
|
+
// Auth-related close codes: not recoverable without operator intervention.
|
|
310
|
+
this.opts.onTransportError?.(
|
|
311
|
+
new TransportError(`server closed: ${event.code} ${event.reason}`, false, "AUTH_DENIED"),
|
|
312
|
+
);
|
|
313
|
+
this.close();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.scheduleReconnect();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private scheduleReconnect(immediate = false): void {
|
|
320
|
+
if (!this.active) return;
|
|
321
|
+
this.cancelReconnect();
|
|
322
|
+
const attempt = (this.schedule.attempt || 0) + 1;
|
|
323
|
+
const delay = immediate ? 0 : this.schedule.delayFor(attempt);
|
|
324
|
+
this.setStatus("disconnected");
|
|
325
|
+
this.reconnectTimer = this.scheduler.setTimeout(() => {
|
|
326
|
+
this.reconnectTimer = null;
|
|
327
|
+
void this.openSocket();
|
|
328
|
+
}, delay);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private cancelReconnect(): void {
|
|
332
|
+
if (this.reconnectTimer) {
|
|
333
|
+
this.scheduler.clearTimeout(this.reconnectTimer);
|
|
334
|
+
this.reconnectTimer = null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private closeSocket(): void {
|
|
339
|
+
if (this.socket) {
|
|
340
|
+
try {
|
|
341
|
+
this.socket.close(1000, "client closing");
|
|
342
|
+
} catch {
|
|
343
|
+
// ignore
|
|
344
|
+
}
|
|
345
|
+
this.socket = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private setStatus(next: ConnectionStatus): void {
|
|
350
|
+
if (this.status === next) return;
|
|
351
|
+
this.status = next;
|
|
352
|
+
this.opts.onStatus?.(next);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function resolveToken(token: LumencastToken): Promise<string> {
|
|
357
|
+
if (typeof token === "string") return token;
|
|
358
|
+
return await token.fetch();
|
|
359
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Public types of @lumencast/runtime — must align with RUNTIME-API.md.
|
|
2
|
+
|
|
3
|
+
import type { ErrorCode } from "@lumencast/protocol";
|
|
4
|
+
|
|
5
|
+
export type LumencastMode = "broadcast" | "control" | "test";
|
|
6
|
+
|
|
7
|
+
export type LumencastStatus = "disconnected" | "connecting" | "live";
|
|
8
|
+
|
|
9
|
+
export interface LumencastTokenProvider {
|
|
10
|
+
fetch: () => Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type LumencastToken = string | LumencastTokenProvider;
|
|
14
|
+
|
|
15
|
+
export interface LumencastError {
|
|
16
|
+
code: ErrorCode;
|
|
17
|
+
message: string;
|
|
18
|
+
recoverable: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LumencastMetric {
|
|
22
|
+
name:
|
|
23
|
+
| "delta_received"
|
|
24
|
+
| "delta_applied"
|
|
25
|
+
| "frame_dropped"
|
|
26
|
+
| "reconnect"
|
|
27
|
+
| "snapshot_received"
|
|
28
|
+
| "scene_changed";
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MountOptions {
|
|
33
|
+
target: HTMLElement;
|
|
34
|
+
/** WebSocket URL of the LSDP/1 server (wss://... in production). */
|
|
35
|
+
serverUrl: string;
|
|
36
|
+
token: LumencastToken;
|
|
37
|
+
mode: LumencastMode;
|
|
38
|
+
/** Required when mode === "test". */
|
|
39
|
+
testSession?: string;
|
|
40
|
+
/** Required when mode === "test". */
|
|
41
|
+
scene?: string;
|
|
42
|
+
onStatus?: (status: LumencastStatus) => void;
|
|
43
|
+
onError?: (err: LumencastError) => void;
|
|
44
|
+
onMetric?: (metric: LumencastMetric) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LumencastHandle {
|
|
48
|
+
/** Tear down the WS, unmount the React tree, release timers. Idempotent. */
|
|
49
|
+
disconnect: () => void;
|
|
50
|
+
/** Swap the auth token without unmounting the React tree. */
|
|
51
|
+
setToken: (token: LumencastToken) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type { ErrorCode };
|