@velajs/client 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kauan Guesser
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,43 @@
1
+ import type { SubscriptionState } from './subscription';
2
+ import type { ConnectionStatus, ReconnectOptions, WebSocketFactory } from './types';
3
+ export interface ConnectionDeps {
4
+ makeSocket: WebSocketFactory;
5
+ authToken?: () => string | undefined | Promise<string | undefined>;
6
+ heartbeatIntervalMs: number;
7
+ reconnect?: ReconnectOptions;
8
+ onStatusChange: () => void;
9
+ }
10
+ /**
11
+ * One socket per room (vela's node transport auto-joins the `:id` route
12
+ * param's room; Cloudflare is one-DO≈one-room), multiplexing every live
13
+ * subscription for that room. Owns: connect/reconnect (decorrelated jitter),
14
+ * resubscribe-with-cursor on open, app-level ping keepalive (self-rescheduling
15
+ * setTimeout — never setInterval), and inbound `$live` frame routing into the
16
+ * pure frame reducer.
17
+ */
18
+ export declare class RoomConnection {
19
+ private readonly url;
20
+ private readonly deps;
21
+ status: ConnectionStatus;
22
+ private socket?;
23
+ private readonly bySub;
24
+ private readonly reconnectState;
25
+ private reconnectTimer?;
26
+ private heartbeatTimer?;
27
+ private closedByUser;
28
+ private generation;
29
+ constructor(url: string, deps: ConnectionDeps);
30
+ register(state: SubscriptionState): void;
31
+ unregister(state: SubscriptionState): void;
32
+ sendPresence(room: string, meta?: unknown): void;
33
+ close(): void;
34
+ private ensureConnected;
35
+ private connect;
36
+ private handleMessage;
37
+ private sendSub;
38
+ private sendFrame;
39
+ private scheduleReconnect;
40
+ private scheduleHeartbeat;
41
+ private clearTimers;
42
+ private setStatus;
43
+ }
@@ -0,0 +1,215 @@
1
+ import { LIVE_PROTOCOL, encodeLiveEnvelope, isServerLiveFrame, readLiveEnvelope } from "@velajs/live-protocol";
2
+ import { applyServerFrame } from "./frame-reducer.js";
3
+ import { nextReconnectDelay, resetReconnect } from "./reconnect.js";
4
+ import { notify } from "./subscription.js";
5
+ const OPEN = 1;
6
+ /**
7
+ * One socket per room (vela's node transport auto-joins the `:id` route
8
+ * param's room; Cloudflare is one-DO≈one-room), multiplexing every live
9
+ * subscription for that room. Owns: connect/reconnect (decorrelated jitter),
10
+ * resubscribe-with-cursor on open, app-level ping keepalive (self-rescheduling
11
+ * setTimeout — never setInterval), and inbound `$live` frame routing into the
12
+ * pure frame reducer.
13
+ */ export class RoomConnection {
14
+ url;
15
+ deps;
16
+ status = 'idle';
17
+ socket;
18
+ bySub = new Map();
19
+ reconnectState = {};
20
+ reconnectTimer;
21
+ heartbeatTimer;
22
+ closedByUser = false;
23
+ generation = 0;
24
+ constructor(url, deps){
25
+ this.url = url;
26
+ this.deps = deps;
27
+ }
28
+ register(state) {
29
+ this.bySub.set(state.sub, state);
30
+ if (this.socket?.readyState === OPEN) {
31
+ this.sendSub(state);
32
+ } else {
33
+ this.ensureConnected();
34
+ }
35
+ }
36
+ unregister(state) {
37
+ this.bySub.delete(state.sub);
38
+ this.sendFrame({
39
+ t: 'unsub',
40
+ sub: state.sub
41
+ });
42
+ }
43
+ sendPresence(room, meta) {
44
+ this.ensureConnected();
45
+ this.sendFrame({
46
+ t: 'presence',
47
+ room,
48
+ meta
49
+ });
50
+ }
51
+ close() {
52
+ this.closedByUser = true;
53
+ this.clearTimers();
54
+ this.socket?.close(1000, 'client closed');
55
+ this.socket = undefined;
56
+ this.setStatus('closed');
57
+ }
58
+ ensureConnected() {
59
+ if (this.closedByUser) return;
60
+ if (this.socket && this.socket.readyState <= OPEN) return; // CONNECTING or OPEN
61
+ void this.connect();
62
+ }
63
+ async connect() {
64
+ this.setStatus('connecting');
65
+ const generation = ++this.generation;
66
+ let token;
67
+ try {
68
+ token = await this.deps.authToken?.();
69
+ } catch {
70
+ // A failing token provider is treated as a connection failure: back off
71
+ // and retry — the next attempt re-invokes it (rotation-friendly).
72
+ if (generation === this.generation) this.scheduleReconnect();
73
+ return;
74
+ }
75
+ if (generation !== this.generation || this.closedByUser) return;
76
+ const url = token === undefined ? this.url : `${this.url}${this.url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
77
+ let socket;
78
+ try {
79
+ socket = this.deps.makeSocket(url);
80
+ } catch {
81
+ this.scheduleReconnect();
82
+ return;
83
+ }
84
+ this.socket = socket;
85
+ socket.onopen = ()=>{
86
+ if (generation !== this.generation) return;
87
+ resetReconnect(this.reconnectState);
88
+ this.setStatus('connected');
89
+ // Resubscribe everything with resume watermarks — the server answers
90
+ // each with data (re-run), resume (untouched), or a cold snapshot.
91
+ for (const state of this.bySub.values())this.sendSub(state);
92
+ this.scheduleHeartbeat();
93
+ };
94
+ socket.onmessage = (event)=>{
95
+ if (typeof event.data !== 'string') return;
96
+ this.handleMessage(event.data);
97
+ };
98
+ socket.onclose = ()=>{
99
+ if (generation !== this.generation) return;
100
+ this.clearTimers();
101
+ this.socket = undefined;
102
+ if (this.closedByUser) return;
103
+ this.setStatus('offline');
104
+ this.scheduleReconnect();
105
+ };
106
+ socket.onerror = ()=>{
107
+ // onclose follows; nothing to do here.
108
+ };
109
+ }
110
+ handleMessage(raw) {
111
+ let envelope;
112
+ try {
113
+ envelope = JSON.parse(raw);
114
+ } catch {
115
+ return;
116
+ }
117
+ const frame = readLiveEnvelope(envelope);
118
+ if (frame === undefined || !isServerLiveFrame(frame)) return; // classic gateway events / pong / unknown frames
119
+ const sub = 'sub' in frame ? frame.sub : undefined;
120
+ const state = sub === undefined ? undefined : this.bySub.get(sub);
121
+ if (!state) return;
122
+ const effect = applyServerFrame(state, frame);
123
+ switch(effect){
124
+ case 'notify':
125
+ notify(state);
126
+ return;
127
+ case 'error':
128
+ if (frame.t === 'error') {
129
+ for (const callback of state.errorCallbacks){
130
+ callback({
131
+ code: frame.code,
132
+ message: frame.message,
133
+ fatal: frame.fatal
134
+ });
135
+ }
136
+ }
137
+ return;
138
+ case 'resubscribe':
139
+ // Unusable cache (epoch fork mid-delta / unmergeable delta): start
140
+ // cold. Same wire id ⇒ unsub first so the server replaces the record.
141
+ state.hasBase = false;
142
+ state.serverBase = undefined;
143
+ state.serverCursor = undefined;
144
+ state.serverEpoch = undefined;
145
+ this.sendFrame({
146
+ t: 'unsub',
147
+ sub: state.sub
148
+ });
149
+ this.sendSub(state);
150
+ return;
151
+ case 'none':
152
+ return;
153
+ }
154
+ }
155
+ sendSub(state) {
156
+ const frame = {
157
+ t: 'sub',
158
+ sub: state.sub,
159
+ query: state.query,
160
+ ...state.args === undefined ? {} : {
161
+ args: state.args
162
+ },
163
+ ...state.serverCursor !== undefined && state.serverEpoch !== undefined ? {
164
+ sinceCursor: state.serverCursor,
165
+ sinceEpoch: state.serverEpoch
166
+ } : {},
167
+ ...state.key === undefined ? {} : {
168
+ key: state.key
169
+ },
170
+ v: LIVE_PROTOCOL
171
+ };
172
+ this.sendFrame(frame);
173
+ }
174
+ sendFrame(frame) {
175
+ if (this.socket?.readyState !== OPEN) return; // onopen resends subscriptions
176
+ try {
177
+ this.socket.send(encodeLiveEnvelope(frame));
178
+ } catch {
179
+ // socket died between the readyState check and send — onclose recovers
180
+ }
181
+ }
182
+ scheduleReconnect() {
183
+ if (this.closedByUser || this.reconnectTimer) return;
184
+ const delay = nextReconnectDelay(this.reconnectState, this.deps.reconnect?.baseMs, this.deps.reconnect?.capMs);
185
+ this.reconnectTimer = setTimeout(()=>{
186
+ this.reconnectTimer = undefined;
187
+ void this.connect();
188
+ }, delay);
189
+ }
190
+ scheduleHeartbeat() {
191
+ if (this.heartbeatTimer) return;
192
+ const tick = ()=>{
193
+ this.heartbeatTimer = undefined;
194
+ if (this.socket?.readyState !== OPEN) return;
195
+ try {
196
+ this.socket.send('{"event":"ping"}');
197
+ } catch {
198
+ return;
199
+ }
200
+ this.heartbeatTimer = setTimeout(tick, this.deps.heartbeatIntervalMs);
201
+ };
202
+ this.heartbeatTimer = setTimeout(tick, this.deps.heartbeatIntervalMs);
203
+ }
204
+ clearTimers() {
205
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
206
+ if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
207
+ this.reconnectTimer = undefined;
208
+ this.heartbeatTimer = undefined;
209
+ }
210
+ setStatus(status) {
211
+ if (this.status === status) return;
212
+ this.status = status;
213
+ this.deps.onStatusChange();
214
+ }
215
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The client's error surface — three tiers:
3
+ * - connection-level: surfaced through `onConnectionStatus('offline')`;
4
+ * - subscription-level: `error` frames → `SubscribeOptions.onError`;
5
+ * - mutation-level: `mutate()` rejects with a {@link VelaLiveError}.
6
+ *
7
+ * Follows lunora's zero-dep catalog pattern (one error class, a small code
8
+ * union, structural guards) inlined rather than published separately.
9
+ */
10
+ export declare class VelaLiveError extends Error {
11
+ readonly code: string;
12
+ readonly status?: number | undefined;
13
+ constructor(code: string, message: string, status?: number | undefined);
14
+ }
15
+ export declare const isVelaLiveError: (value: unknown) => value is VelaLiveError;
16
+ export declare const getErrorCode: (value: unknown) => string | undefined;
17
+ /** Decode an HTTP error response into a VelaLiveError (vela's `{ error | message }` shapes tolerated). */
18
+ export declare const toMutationError: (response: Response) => Promise<VelaLiveError>;
package/dist/errors.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The client's error surface — three tiers:
3
+ * - connection-level: surfaced through `onConnectionStatus('offline')`;
4
+ * - subscription-level: `error` frames → `SubscribeOptions.onError`;
5
+ * - mutation-level: `mutate()` rejects with a {@link VelaLiveError}.
6
+ *
7
+ * Follows lunora's zero-dep catalog pattern (one error class, a small code
8
+ * union, structural guards) inlined rather than published separately.
9
+ */ export class VelaLiveError extends Error {
10
+ code;
11
+ status;
12
+ constructor(code, message, status){
13
+ super(message), this.code = code, this.status = status;
14
+ this.name = 'VelaLiveError';
15
+ }
16
+ }
17
+ export const isVelaLiveError = (value)=>value instanceof VelaLiveError || typeof value === 'object' && value !== null && value.name === 'VelaLiveError' && typeof value.code === 'string';
18
+ export const getErrorCode = (value)=>isVelaLiveError(value) ? value.code : undefined;
19
+ /** Decode an HTTP error response into a VelaLiveError (vela's `{ error | message }` shapes tolerated). */ export const toMutationError = async (response)=>{
20
+ let code = `http_${response.status}`;
21
+ let message = response.statusText || `mutation failed with status ${response.status}`;
22
+ try {
23
+ const body = await response.json();
24
+ if (typeof body.error === 'object' && body.error !== null) {
25
+ code = body.error.code ?? code;
26
+ message = body.error.message ?? message;
27
+ } else if (typeof body.error === 'string') {
28
+ message = body.error;
29
+ } else if (typeof body.message === 'string') {
30
+ message = body.message;
31
+ }
32
+ } catch {
33
+ // non-JSON body — keep the status-derived error
34
+ }
35
+ return new VelaLiveError(code, message, response.status);
36
+ };
@@ -0,0 +1,17 @@
1
+ import type { ServerLiveFrame } from '@velajs/live-protocol';
2
+ import type { SubscriptionState } from './subscription';
3
+ /**
4
+ * What the connection layer must do after a frame was applied:
5
+ * - `notify` — the displayed value changed, fire callbacks;
6
+ * - `resubscribe` — the cache is unusable (epoch fork mid-delta, unmergeable
7
+ * delta); send a fresh cold `sub` for this state;
8
+ * - `error` — an error frame; route to error callbacks;
9
+ * - `none` — bookkeeping only.
10
+ */
11
+ export type FrameEffect = 'none' | 'notify' | 'resubscribe' | 'error';
12
+ /**
13
+ * The pure per-subscription frame state machine (adapts lunora's
14
+ * `handleDataMessage`/`handleResumeMessage`/`handleSettledMessage`).
15
+ * Deliberately socket-free so the protocol semantics are unit-testable.
16
+ */
17
+ export declare function applyServerFrame(state: SubscriptionState, frame: ServerLiveFrame): FrameEffect;
@@ -0,0 +1,66 @@
1
+ import { DEFAULT_KEY_FIELD, applyListDelta } from "@velajs/live-protocol";
2
+ import { dropConfirmedLayers } from "./optimistic.js";
3
+ import { refold } from "./subscription.js";
4
+ /**
5
+ * The pure per-subscription frame state machine (adapts lunora's
6
+ * `handleDataMessage`/`handleResumeMessage`/`handleSettledMessage`).
7
+ * Deliberately socket-free so the protocol semantics are unit-testable.
8
+ */ export function applyServerFrame(state, frame) {
9
+ switch(frame.t){
10
+ case 'ack':
11
+ state.acked = true;
12
+ return 'none';
13
+ case 'error':
14
+ return 'error';
15
+ case 'resume':
16
+ {
17
+ // Nothing relevant changed while away: keep the cached value, advance
18
+ // the watermark. Confirmed layers the new cursor covers still drop.
19
+ state.serverCursor = frame.cursor;
20
+ state.serverEpoch = frame.epoch;
21
+ const dropped = dropConfirmedLayers(state, frame.cursor, frame.epoch);
22
+ return dropped && refold(state) ? 'notify' : 'none';
23
+ }
24
+ case 'settled':
25
+ {
26
+ // Byte-identical re-run: no payload, but the cursor advance is what
27
+ // drops optimistic layers for writes that didn't change this query.
28
+ advanceWatermark(state, frame.cursor, frame.epoch);
29
+ const dropped = dropConfirmedLayers(state, frame.cursor, frame.epoch);
30
+ return dropped && refold(state) ? 'notify' : 'none';
31
+ }
32
+ case 'data':
33
+ {
34
+ if (epochForked(state, frame.epoch)) {
35
+ // New timeline: every optimistic gate is void. The snapshot itself is
36
+ // authoritative, so apply it as a cold first frame.
37
+ state.layers = [];
38
+ }
39
+ state.serverBase = frame.snapshot;
40
+ state.hasBase = true;
41
+ advanceWatermark(state, frame.cursor, frame.epoch);
42
+ dropConfirmedLayers(state, frame.cursor, frame.epoch);
43
+ refold(state);
44
+ return 'notify';
45
+ }
46
+ case 'delta':
47
+ {
48
+ // A delta diffs against the baseline the server believes we confirmed —
49
+ // across an epoch fork or without a base that belief is wrong by
50
+ // construction: start over.
51
+ if (epochForked(state, frame.epoch) || !state.hasBase) return 'resubscribe';
52
+ const merged = applyListDelta(state.serverBase, frame.ops, state.key ?? DEFAULT_KEY_FIELD);
53
+ if (merged === undefined) return 'resubscribe';
54
+ state.serverBase = merged;
55
+ advanceWatermark(state, frame.cursor, frame.epoch);
56
+ dropConfirmedLayers(state, frame.cursor, frame.epoch);
57
+ refold(state);
58
+ return 'notify';
59
+ }
60
+ }
61
+ }
62
+ const epochForked = (state, epoch)=>epoch !== undefined && state.serverEpoch !== undefined && epoch !== state.serverEpoch;
63
+ function advanceWatermark(state, cursor, epoch) {
64
+ if (cursor !== undefined) state.serverCursor = cursor;
65
+ if (epoch !== undefined) state.serverEpoch = epoch;
66
+ }
@@ -0,0 +1,14 @@
1
+ export { LiveClient } from './live-client';
2
+ export { RoomConnection } from './connection';
3
+ export { applyServerFrame } from './frame-reducer';
4
+ export type { FrameEffect } from './frame-reducer';
5
+ export { applyOptimisticLayer, dropConfirmedLayers, foldOptimistic, } from './optimistic';
6
+ export type { CommitStamp, LayerHandle, OptimisticHost, OptimisticLayer } from './optimistic';
7
+ export { argsKeyOf, createSubscriptionState, refold, subscriptionKey, } from './subscription';
8
+ export type { SubscriptionState } from './subscription';
9
+ export { VelaLiveError, getErrorCode, isVelaLiveError } from './errors';
10
+ export { DEFAULT_RECONNECT_BASE_MS, DEFAULT_RECONNECT_CAP_MS, nextReconnectDelay, resetReconnect, } from './reconnect';
11
+ export { stableStringify } from './stable-key';
12
+ export type { ArgsOf, ConnectionStatus, HydrationEntry, LiveClientOptions, LiveContract, LiveStore, MutateOptions, OptimisticTarget, OutboxSink, ReadCacheAdapter, ReconnectOptions, ResultOf, SubscribeOptions, Unsubscribe, WebSocketFactory, WebSocketLike, } from './types';
13
+ export { COMMIT_CURSOR_HEADER, COMMIT_EPOCH_HEADER, LIVE_EVENT, LIVE_PROTOCOL, applyListDelta, encodeListDelta, } from '@velajs/live-protocol';
14
+ export type { ClientLiveFrame, RowOp, ServerLiveFrame } from '@velajs/live-protocol';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { LiveClient } from "./live-client.js";
2
+ export { RoomConnection } from "./connection.js";
3
+ export { applyServerFrame } from "./frame-reducer.js";
4
+ export { applyOptimisticLayer, dropConfirmedLayers, foldOptimistic } from "./optimistic.js";
5
+ export { argsKeyOf, createSubscriptionState, refold, subscriptionKey } from "./subscription.js";
6
+ export { VelaLiveError, getErrorCode, isVelaLiveError } from "./errors.js";
7
+ export { DEFAULT_RECONNECT_BASE_MS, DEFAULT_RECONNECT_CAP_MS, nextReconnectDelay, resetReconnect } from "./reconnect.js";
8
+ export { stableStringify } from "./stable-key.js";
9
+ // The wire contract, re-exported for tooling/tests.
10
+ export { COMMIT_CURSOR_HEADER, COMMIT_EPOCH_HEADER, LIVE_EVENT, LIVE_PROTOCOL, applyListDelta, encodeListDelta } from "@velajs/live-protocol";
@@ -0,0 +1,44 @@
1
+ import type { ArgsOf, ConnectionStatus, HydrationEntry, LiveClientOptions, LiveContract, MutateOptions, ResultOf, SubscribeOptions, Unsubscribe } from './types';
2
+ /**
3
+ * The framework-neutral Vela live client.
4
+ *
5
+ * ```ts
6
+ * const client = new LiveClient<AppLive>({ url: 'https://api.example.com' });
7
+ * const stop = client.subscribe('todos.list', { listId: 'l1' }, (todos) => render(todos));
8
+ * await client.mutate('/todos', { text: 'hi' }, {
9
+ * optimistic: { query: 'todos.list', args: { listId: 'l1' }, apply: (t = []) => [...t, temp] },
10
+ * });
11
+ * ```
12
+ *
13
+ * Identical `(query, args, room)` subscriptions share one wire registration;
14
+ * reconnects resubscribe with the last cursor+epoch so untouched
15
+ * subscriptions resume without a re-run; optimistic updates are rebaseable
16
+ * layers gated on the mutation's `Vela-Commit-Cursor` response header.
17
+ */
18
+ export declare class LiveClient<C extends LiveContract = LiveContract> {
19
+ private readonly options;
20
+ private readonly registry;
21
+ private readonly connections;
22
+ private readonly statusListeners;
23
+ private lastStatus;
24
+ constructor(options: LiveClientOptions);
25
+ subscribe<Q extends keyof C & string>(query: Q, args: ArgsOf<C, Q>, callback: (value: ResultOf<C, Q> | undefined) => void, subscribeOptions?: SubscribeOptions): Unsubscribe;
26
+ /** The current cached (folded) value — referentially stable between notifications. */
27
+ peek<Q extends keyof C & string>(query: Q, args: ArgsOf<C, Q>, room?: string): ResultOf<C, Q> | undefined;
28
+ /**
29
+ * Fire an HTTP mutation. Optimistic targets paint immediately; the layer
30
+ * drops when a subscription frame's cursor passes the response's
31
+ * `Vela-Commit-Cursor` (missing header ⇒ one-shot optimism); a rejection
32
+ * rolls back. Resolves with the parsed JSON body (undefined when empty).
33
+ */
34
+ mutate<R = unknown>(path: string, body?: unknown, mutateOptions?: MutateOptions): Promise<R>;
35
+ /** Presence heartbeat for a room (see `@velajs/client/presence` for the managed preset). */
36
+ presenceBeat(room: string, meta?: unknown): void;
37
+ /** Seed subscription state before connecting (SSR hydration). Subscribes then resume from the seeded cursor. */
38
+ hydrate(entries: HydrationEntry[]): void;
39
+ connectionStatus(): ConnectionStatus;
40
+ onConnectionStatus(listener: (status: ConnectionStatus) => void): Unsubscribe;
41
+ close(): void;
42
+ private makeStore;
43
+ private connectionFor;
44
+ }
@@ -0,0 +1,210 @@
1
+ import { COMMIT_CURSOR_HEADER, COMMIT_EPOCH_HEADER } from "@velajs/live-protocol";
2
+ import { RoomConnection } from "./connection.js";
3
+ import { toMutationError } from "./errors.js";
4
+ import { applyOptimisticLayer } from "./optimistic.js";
5
+ import { argsKeyOf, createSubscriptionState, notify, refold, subscriptionKey } from "./subscription.js";
6
+ const DEFAULT_WS_PATH = '/rooms/:room/ws';
7
+ const DEFAULT_ROOM = 'default';
8
+ const DEFAULT_HEARTBEAT_MS = 30_000;
9
+ /**
10
+ * The framework-neutral Vela live client.
11
+ *
12
+ * ```ts
13
+ * const client = new LiveClient<AppLive>({ url: 'https://api.example.com' });
14
+ * const stop = client.subscribe('todos.list', { listId: 'l1' }, (todos) => render(todos));
15
+ * await client.mutate('/todos', { text: 'hi' }, {
16
+ * optimistic: { query: 'todos.list', args: { listId: 'l1' }, apply: (t = []) => [...t, temp] },
17
+ * });
18
+ * ```
19
+ *
20
+ * Identical `(query, args, room)` subscriptions share one wire registration;
21
+ * reconnects resubscribe with the last cursor+epoch so untouched
22
+ * subscriptions resume without a re-run; optimistic updates are rebaseable
23
+ * layers gated on the mutation's `Vela-Commit-Cursor` response header.
24
+ */ export class LiveClient {
25
+ options;
26
+ registry = new Map();
27
+ connections = new Map();
28
+ statusListeners = new Set();
29
+ lastStatus = 'idle';
30
+ constructor(options){
31
+ this.options = options;
32
+ }
33
+ subscribe(query, args, callback, subscribeOptions) {
34
+ const room = subscribeOptions?.room ?? this.options.defaultRoom ?? DEFAULT_ROOM;
35
+ const key = subscriptionKey(query, argsKeyOf(args), room);
36
+ let state = this.registry.get(key);
37
+ let fresh = false;
38
+ if (!state) {
39
+ state = createSubscriptionState(query, args, room, subscribeOptions?.key);
40
+ this.registry.set(key, state);
41
+ fresh = true;
42
+ }
43
+ const cb = callback;
44
+ state.callbacks.add(cb);
45
+ if (subscribeOptions?.onError) state.errorCallbacks.add(subscribeOptions.onError);
46
+ // Late joiners get the cached value synchronously (lunora parity).
47
+ if (state.hasBase || state.layers.length > 0) cb(state.lastValue);
48
+ if (fresh) this.connectionFor(room).register(state);
49
+ return ()=>{
50
+ state.callbacks.delete(cb);
51
+ if (subscribeOptions?.onError) state.errorCallbacks.delete(subscribeOptions.onError);
52
+ if (state.callbacks.size === 0) {
53
+ this.registry.delete(key);
54
+ this.connectionFor(room).unregister(state);
55
+ }
56
+ };
57
+ }
58
+ /** The current cached (folded) value — referentially stable between notifications. */ peek(query, args, room) {
59
+ const state = this.registry.get(subscriptionKey(query, argsKeyOf(args), room ?? this.options.defaultRoom ?? DEFAULT_ROOM));
60
+ return state?.lastValue;
61
+ }
62
+ /**
63
+ * Fire an HTTP mutation. Optimistic targets paint immediately; the layer
64
+ * drops when a subscription frame's cursor passes the response's
65
+ * `Vela-Commit-Cursor` (missing header ⇒ one-shot optimism); a rejection
66
+ * rolls back. Resolves with the parsed JSON body (undefined when empty).
67
+ */ async mutate(path, body, mutateOptions) {
68
+ const handles = [];
69
+ const paint = (state, transform)=>{
70
+ const handle = applyOptimisticLayer(state, transform);
71
+ handles.push({
72
+ state,
73
+ handle
74
+ });
75
+ if (refold(state)) notify(state);
76
+ };
77
+ if (mutateOptions?.optimistic) {
78
+ const target = mutateOptions.optimistic;
79
+ const state = this.registry.get(subscriptionKey(target.query, argsKeyOf(target.args), target.room ?? mutateOptions.room ?? this.options.defaultRoom ?? DEFAULT_ROOM));
80
+ // No live subscription for the target ⇒ nothing displays it: no-op.
81
+ if (state) paint(state, target.apply);
82
+ }
83
+ if (mutateOptions?.optimisticUpdate) {
84
+ mutateOptions.optimisticUpdate(this.makeStore(mutateOptions, paint));
85
+ }
86
+ try {
87
+ const doFetch = this.options.fetch ?? fetch;
88
+ const token = await this.options.authToken?.();
89
+ const headers = {
90
+ ...body === undefined ? {} : {
91
+ 'content-type': 'application/json'
92
+ },
93
+ ...token === undefined ? {} : {
94
+ authorization: `Bearer ${token}`
95
+ },
96
+ ...mutateOptions?.headers
97
+ };
98
+ const response = await doFetch(new URL(path, this.options.url).toString(), {
99
+ method: mutateOptions?.method ?? 'POST',
100
+ headers,
101
+ ...body === undefined ? {} : {
102
+ body: JSON.stringify(body)
103
+ }
104
+ });
105
+ if (!response.ok) throw await toMutationError(response);
106
+ const stamp = readCommitStamp(response);
107
+ for (const { state, handle } of handles){
108
+ // confirm() reports true when the covering frame already arrived —
109
+ // drop is immediate, re-fold now.
110
+ if (handle.confirm(stamp) && refold(state)) notify(state);
111
+ }
112
+ if (response.status === 204 || response.status === 205) return undefined;
113
+ try {
114
+ return await response.json();
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ } catch (error) {
119
+ for (const { state, handle } of handles){
120
+ if (handle.rollback() && refold(state)) notify(state);
121
+ }
122
+ throw error;
123
+ }
124
+ }
125
+ /** Presence heartbeat for a room (see `@velajs/client/presence` for the managed preset). */ presenceBeat(room, meta) {
126
+ this.connectionFor(room).sendPresence(room, meta);
127
+ }
128
+ /** Seed subscription state before connecting (SSR hydration). Subscribes then resume from the seeded cursor. */ hydrate(entries) {
129
+ for (const entry of entries){
130
+ const room = entry.room ?? this.options.defaultRoom ?? DEFAULT_ROOM;
131
+ const key = subscriptionKey(entry.query, argsKeyOf(entry.args), room);
132
+ if (this.registry.has(key)) continue;
133
+ const state = createSubscriptionState(entry.query, entry.args, room);
134
+ state.serverBase = entry.value;
135
+ state.hasBase = true;
136
+ state.lastValue = entry.value;
137
+ state.serverCursor = entry.cursor;
138
+ state.serverEpoch = entry.epoch;
139
+ this.registry.set(key, state);
140
+ }
141
+ }
142
+ connectionStatus() {
143
+ const statuses = [
144
+ ...this.connections.values()
145
+ ].map((connection)=>connection.status);
146
+ if (statuses.includes('connected')) return 'connected';
147
+ if (statuses.includes('connecting')) return 'connecting';
148
+ if (statuses.includes('offline')) return 'offline';
149
+ if (statuses.length > 0 && statuses.every((status)=>status === 'closed')) return 'closed';
150
+ return 'idle';
151
+ }
152
+ onConnectionStatus(listener) {
153
+ this.statusListeners.add(listener);
154
+ return ()=>this.statusListeners.delete(listener);
155
+ }
156
+ close() {
157
+ for (const connection of this.connections.values())connection.close();
158
+ }
159
+ makeStore(mutateOptions, paint) {
160
+ const resolve = (query, args, room)=>this.registry.get(subscriptionKey(query, argsKeyOf(args), room ?? mutateOptions.room ?? this.options.defaultRoom ?? DEFAULT_ROOM));
161
+ return {
162
+ get: (query, args, room)=>resolve(query, args, room)?.lastValue,
163
+ set: (query, args, next, room)=>{
164
+ const state = resolve(query, args, room);
165
+ if (!state) return;
166
+ const transform = typeof next === 'function' ? next : ()=>next;
167
+ paint(state, transform);
168
+ }
169
+ };
170
+ }
171
+ connectionFor(room) {
172
+ const wsBase = (this.options.wsUrl ?? this.options.url).replace(/^http/, 'ws');
173
+ const template = this.options.wsPath ?? DEFAULT_WS_PATH;
174
+ const path = template.includes(':room') ? template.replace(':room', encodeURIComponent(room)) : template;
175
+ const url = new URL(path, wsBase).toString();
176
+ let connection = this.connections.get(url);
177
+ if (!connection) {
178
+ connection = new RoomConnection(url, {
179
+ makeSocket: this.options.WebSocket ?? ((socketUrl)=>{
180
+ const WS = globalThis.WebSocket;
181
+ if (!WS) {
182
+ throw new Error('No WebSocket implementation available — pass one via LiveClientOptions.WebSocket');
183
+ }
184
+ return new WS(socketUrl);
185
+ }),
186
+ authToken: this.options.authToken,
187
+ heartbeatIntervalMs: this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS,
188
+ reconnect: this.options.reconnect,
189
+ onStatusChange: ()=>{
190
+ const status = this.connectionStatus();
191
+ if (status === this.lastStatus) return;
192
+ this.lastStatus = status;
193
+ for (const listener of this.statusListeners)listener(status);
194
+ }
195
+ });
196
+ this.connections.set(url, connection);
197
+ }
198
+ return connection;
199
+ }
200
+ }
201
+ function readCommitStamp(response) {
202
+ const cursor = response.headers.get(COMMIT_CURSOR_HEADER);
203
+ const epoch = response.headers.get(COMMIT_EPOCH_HEADER);
204
+ if (cursor === null || epoch === null) return undefined;
205
+ const parsed = Number(cursor);
206
+ return Number.isFinite(parsed) ? {
207
+ cursor: parsed,
208
+ epoch
209
+ } : undefined;
210
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Rebaseable, cursor-gated optimistic layers (lunora `optimistic-layers.ts`
3
+ * port). The displayed value is ALWAYS the authoritative server base folded
4
+ * through the pending layers, so:
5
+ *
6
+ * - an unrelated server frame re-folds the layers onto the new base instead of
7
+ * clobbering the optimistic state ("rebasing");
8
+ * - a layer drops only when a subscription frame arrives whose cursor passes
9
+ * the mutation's commit cursor (same epoch) — NEVER on HTTP-response
10
+ * timing, which races the broadcast;
11
+ * - confirm without a stamp (a non-live-aware endpoint) degrades to one-shot
12
+ * optimism: the layer drops silently and the next authoritative frame
13
+ * reconciles;
14
+ * - rollback removes the layer and re-folds so the failed write's effect
15
+ * disappears immediately.
16
+ */
17
+ export interface CommitStamp {
18
+ cursor: number;
19
+ epoch: string;
20
+ }
21
+ export interface OptimisticLayer {
22
+ readonly id: symbol;
23
+ readonly transform: (current: unknown) => unknown;
24
+ /** Set by confirm(); the layer is dropped by the first frame whose cursor passes it. */
25
+ commitCursor?: number;
26
+ commitEpoch?: string;
27
+ }
28
+ /** The slice of subscription state the optimistic engine operates on. */
29
+ export interface OptimisticHost {
30
+ layers: OptimisticLayer[];
31
+ serverBase: unknown;
32
+ hasBase: boolean;
33
+ serverCursor?: number;
34
+ serverEpoch?: string;
35
+ }
36
+ export declare const foldOptimistic: (host: OptimisticHost) => unknown;
37
+ export interface LayerHandle {
38
+ /**
39
+ * Record the mutation's commit stamp. With a stamp, the layer survives
40
+ * until a frame's cursor passes it (dropping immediately when the current
41
+ * frame already has). Without one, the layer is removed silently — no
42
+ * re-fold, the incoming authoritative frame reconciles.
43
+ */
44
+ confirm(stamp?: CommitStamp): boolean;
45
+ /** Remove the layer and report whether the host must re-fold + notify. */
46
+ rollback(): boolean;
47
+ }
48
+ export declare function applyOptimisticLayer(host: OptimisticHost, transform: (current: unknown) => unknown): LayerHandle;
49
+ /**
50
+ * Drop every confirmed layer the frame at (cursor, epoch) covers. A layer
51
+ * confirmed under a DIFFERENT epoch is also dropped — its gate can never fire
52
+ * on this timeline. Returns whether any layer was removed (host re-folds).
53
+ */
54
+ export declare function dropConfirmedLayers(host: OptimisticHost, cursor?: number, epoch?: string): boolean;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Rebaseable, cursor-gated optimistic layers (lunora `optimistic-layers.ts`
3
+ * port). The displayed value is ALWAYS the authoritative server base folded
4
+ * through the pending layers, so:
5
+ *
6
+ * - an unrelated server frame re-folds the layers onto the new base instead of
7
+ * clobbering the optimistic state ("rebasing");
8
+ * - a layer drops only when a subscription frame arrives whose cursor passes
9
+ * the mutation's commit cursor (same epoch) — NEVER on HTTP-response
10
+ * timing, which races the broadcast;
11
+ * - confirm without a stamp (a non-live-aware endpoint) degrades to one-shot
12
+ * optimism: the layer drops silently and the next authoritative frame
13
+ * reconciles;
14
+ * - rollback removes the layer and re-folds so the failed write's effect
15
+ * disappears immediately.
16
+ */ export const foldOptimistic = (host)=>host.layers.reduce((value, layer)=>layer.transform(value), host.hasBase ? host.serverBase : undefined);
17
+ export function applyOptimisticLayer(host, transform) {
18
+ const layer = {
19
+ id: Symbol('optimistic'),
20
+ transform
21
+ };
22
+ host.layers.push(layer);
23
+ const remove = ()=>{
24
+ const index = host.layers.indexOf(layer);
25
+ if (index === -1) return false;
26
+ host.layers.splice(index, 1);
27
+ return true;
28
+ };
29
+ return {
30
+ confirm (stamp) {
31
+ if (!stamp) {
32
+ remove();
33
+ return false;
34
+ }
35
+ // A stamp from a foreign epoch can never be gated — treat as unstamped.
36
+ if (host.serverEpoch !== undefined && stamp.epoch !== host.serverEpoch) {
37
+ remove();
38
+ return false;
39
+ }
40
+ layer.commitCursor = stamp.cursor;
41
+ layer.commitEpoch = stamp.epoch;
42
+ // The confirming frame may already have passed while the HTTP response
43
+ // was in flight — drop now and tell the caller to re-fold.
44
+ if (host.serverCursor !== undefined && host.serverCursor >= stamp.cursor) {
45
+ return remove();
46
+ }
47
+ return false;
48
+ },
49
+ rollback () {
50
+ return remove();
51
+ }
52
+ };
53
+ }
54
+ /**
55
+ * Drop every confirmed layer the frame at (cursor, epoch) covers. A layer
56
+ * confirmed under a DIFFERENT epoch is also dropped — its gate can never fire
57
+ * on this timeline. Returns whether any layer was removed (host re-folds).
58
+ */ export function dropConfirmedLayers(host, cursor, epoch) {
59
+ if (cursor === undefined) return false;
60
+ const before = host.layers.length;
61
+ host.layers = host.layers.filter((layer)=>{
62
+ if (layer.commitCursor === undefined) return true; // still pending
63
+ if (epoch !== undefined && layer.commitEpoch !== epoch) return false;
64
+ return cursor < layer.commitCursor;
65
+ });
66
+ return host.layers.length !== before;
67
+ }
@@ -0,0 +1,34 @@
1
+ import type { LiveClient } from './live-client';
2
+ import type { LiveContract } from './types';
3
+ /**
4
+ * The built-in roster query name served by `@velajs/vela/live`'s presence
5
+ * preset (`PRESENCE_ROSTER_QUERY` on the server side).
6
+ */
7
+ export declare const PRESENCE_ROSTER_QUERY = "$presence.roster";
8
+ /** One present connection, as the server's roster query returns it. */
9
+ export interface PresenceMember {
10
+ id: string;
11
+ meta?: unknown;
12
+ lastSeen: number;
13
+ }
14
+ export interface PresenceOptions {
15
+ room: string;
16
+ /** Payload attached to this connection's roster entry (function form re-evaluated per beat). */
17
+ meta?: unknown | (() => unknown);
18
+ /** Keep WELL under the server's TTL (default beat 10 000 ms vs 30 000 ms TTL). */
19
+ heartbeatIntervalMs?: number;
20
+ onRoster?: (members: PresenceMember[]) => void;
21
+ }
22
+ export interface PresenceHandle {
23
+ /** The last received roster (undefined until the first push). */
24
+ roster(): PresenceMember[] | undefined;
25
+ /** Send an immediate heartbeat (e.g. on visibility regain — the React binding wires this). */
26
+ beat(): void;
27
+ stop(): void;
28
+ }
29
+ /**
30
+ * Framework-neutral presence: joins a room's roster (heartbeats over the
31
+ * room's live socket) and subscribes to it. Departure is immediate on socket
32
+ * close — the heartbeat/TTL pair only covers ungraceful drops.
33
+ */
34
+ export declare function createPresence(client: LiveClient<LiveContract>, options: PresenceOptions): PresenceHandle;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The built-in roster query name served by `@velajs/vela/live`'s presence
3
+ * preset (`PRESENCE_ROSTER_QUERY` on the server side).
4
+ */ export const PRESENCE_ROSTER_QUERY = '$presence.roster';
5
+ /**
6
+ * Framework-neutral presence: joins a room's roster (heartbeats over the
7
+ * room's live socket) and subscribes to it. Departure is immediate on socket
8
+ * close — the heartbeat/TTL pair only covers ungraceful drops.
9
+ */ export function createPresence(client, options) {
10
+ const intervalMs = options.heartbeatIntervalMs ?? 10_000;
11
+ let members;
12
+ let timer;
13
+ let stopped = false;
14
+ const beat = ()=>{
15
+ if (stopped) return;
16
+ const meta = typeof options.meta === 'function' ? options.meta() : options.meta;
17
+ client.presenceBeat(options.room, meta);
18
+ };
19
+ const tick = ()=>{
20
+ timer = undefined;
21
+ if (stopped) return;
22
+ beat();
23
+ timer = setTimeout(tick, intervalMs);
24
+ };
25
+ const unsubscribe = client.subscribe(PRESENCE_ROSTER_QUERY, {
26
+ room: options.room
27
+ }, (value)=>{
28
+ members = value ?? [];
29
+ options.onRoster?.(members);
30
+ }, {
31
+ room: options.room
32
+ });
33
+ // First beat rides after the subscription so the socket is being opened.
34
+ tick();
35
+ return {
36
+ roster: ()=>members,
37
+ beat,
38
+ stop () {
39
+ stopped = true;
40
+ if (timer) clearTimeout(timer);
41
+ timer = undefined;
42
+ unsubscribe();
43
+ }
44
+ };
45
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Decorrelated exponential jitter (lunora `reconnect.ts` port): each retry
3
+ * waits a random duration between the base and 3× the previous wait, capped.
4
+ * Decorrelation avoids the reconnect stampede a fixed exponential schedule
5
+ * produces when a fleet of clients drops at once.
6
+ */
7
+ export interface ReconnectState {
8
+ previousMs?: number;
9
+ }
10
+ export declare const DEFAULT_RECONNECT_BASE_MS = 250;
11
+ export declare const DEFAULT_RECONNECT_CAP_MS = 30000;
12
+ export declare function nextReconnectDelay(state: ReconnectState, baseMs?: number, capMs?: number, random?: () => number): number;
13
+ export declare function resetReconnect(state: ReconnectState): void;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Decorrelated exponential jitter (lunora `reconnect.ts` port): each retry
3
+ * waits a random duration between the base and 3× the previous wait, capped.
4
+ * Decorrelation avoids the reconnect stampede a fixed exponential schedule
5
+ * produces when a fleet of clients drops at once.
6
+ */ export const DEFAULT_RECONNECT_BASE_MS = 250;
7
+ export const DEFAULT_RECONNECT_CAP_MS = 30_000;
8
+ export function nextReconnectDelay(state, baseMs = DEFAULT_RECONNECT_BASE_MS, capMs = DEFAULT_RECONNECT_CAP_MS, random = Math.random) {
9
+ const high = Math.max(baseMs, (state.previousMs ?? baseMs) * 3);
10
+ const delay = Math.min(capMs, baseMs + random() * (high - baseMs));
11
+ state.previousMs = delay;
12
+ return delay;
13
+ }
14
+ export function resetReconnect(state) {
15
+ state.previousMs = undefined;
16
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Deterministic JSON encoding for cache/dedup keys: object keys are sorted at
3
+ * every depth so `{a,b}` and `{b,a}` produce the same key (lunora
4
+ * `shared/stable-key.ts` pattern). NOT a wire format — wire frames use plain
5
+ * `JSON.stringify` via @velajs/live-protocol's canonical encoders.
6
+ */
7
+ export declare function stableStringify(value: unknown): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Deterministic JSON encoding for cache/dedup keys: object keys are sorted at
3
+ * every depth so `{a,b}` and `{b,a}` produce the same key (lunora
4
+ * `shared/stable-key.ts` pattern). NOT a wire format — wire frames use plain
5
+ * `JSON.stringify` via @velajs/live-protocol's canonical encoders.
6
+ */ export function stableStringify(value) {
7
+ return JSON.stringify(sortDeep(value));
8
+ }
9
+ function sortDeep(value) {
10
+ if (Array.isArray(value)) return value.map(sortDeep);
11
+ if (typeof value === 'object' && value !== null) {
12
+ const source = value;
13
+ const out = {};
14
+ for (const key of Object.keys(source).sort()){
15
+ out[key] = sortDeep(source[key]);
16
+ }
17
+ return out;
18
+ }
19
+ return value;
20
+ }
@@ -0,0 +1,42 @@
1
+ import type { OptimisticLayer } from './optimistic';
2
+ /**
3
+ * One deduped live subscription: every `subscribe()` for the same
4
+ * (query, args, room) joins a single state (one wire registration, N
5
+ * callbacks), lunora `subscription.ts` style.
6
+ */
7
+ export interface SubscriptionState {
8
+ /** Wire subscription id — client-chosen, unique per client (thus per socket). */
9
+ sub: string;
10
+ query: string;
11
+ args: unknown;
12
+ argsKey: string;
13
+ room: string;
14
+ key?: string;
15
+ /** Server acknowledged the registration. */
16
+ acked: boolean;
17
+ callbacks: Set<(value: unknown) => void>;
18
+ errorCallbacks: Set<(error: {
19
+ code: string;
20
+ message: string;
21
+ fatal: boolean;
22
+ }) => void>;
23
+ layers: OptimisticLayer[];
24
+ /** Authoritative value with NO optimistic overlay. */
25
+ serverBase: unknown;
26
+ /** Distinguishes "no value yet" from an authoritative `undefined`-shaped value. */
27
+ hasBase: boolean;
28
+ serverCursor?: number;
29
+ serverEpoch?: string;
30
+ /**
31
+ * Displayed value = serverBase folded through layers. Reassigned only when
32
+ * something actually changed, so snapshot consumers (React's
33
+ * useSyncExternalStore) get referentially stable reads between notifies.
34
+ */
35
+ lastValue: unknown;
36
+ }
37
+ export declare const subscriptionKey: (query: string, argsKey: string, room: string) => string;
38
+ export declare const argsKeyOf: (args: unknown) => string;
39
+ export declare function createSubscriptionState(query: string, args: unknown, room: string, key?: string): SubscriptionState;
40
+ /** Re-fold the displayed value; true when it changed (notify). */
41
+ export declare function refold(state: SubscriptionState): boolean;
42
+ export declare function notify(state: SubscriptionState): void;
Binary file
@@ -0,0 +1,126 @@
1
+ import type { CommitStamp } from './optimistic';
2
+ /**
3
+ * The app-supplied typing contract: one entry per live query, keyed by the
4
+ * server's `@LiveQuery(name)`. Hand-written in v1; shaped so a future
5
+ * `vela codegen` emission (from OpenAPI operationIds + live metadata) is a
6
+ * drop-in replacement.
7
+ *
8
+ * ```ts
9
+ * interface AppLive {
10
+ * 'todos.list': { args: { listId: string }; result: Todo[] };
11
+ * }
12
+ * const client = new LiveClient<AppLive>({ url });
13
+ * ```
14
+ */
15
+ export interface LiveContract {
16
+ [query: string]: {
17
+ args: unknown;
18
+ result: unknown;
19
+ };
20
+ }
21
+ export type ArgsOf<C, Q extends keyof C> = C[Q] extends {
22
+ args: infer A;
23
+ } ? A : never;
24
+ export type ResultOf<C, Q extends keyof C> = C[Q] extends {
25
+ result: infer R;
26
+ } ? R : never;
27
+ export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'offline' | 'closed';
28
+ /** The WebSocket surface the client uses — injectable for SSR/tests/non-browser runtimes. */
29
+ export interface WebSocketLike {
30
+ onopen: ((event?: unknown) => void) | null;
31
+ onmessage: ((event: {
32
+ data: unknown;
33
+ }) => void) | null;
34
+ onclose: ((event?: unknown) => void) | null;
35
+ onerror: ((event?: unknown) => void) | null;
36
+ send(data: string): void;
37
+ close(code?: number, reason?: string): void;
38
+ readyState: number;
39
+ }
40
+ export type WebSocketFactory = (url: string) => WebSocketLike;
41
+ export interface ReconnectOptions {
42
+ /** First-retry floor (default 250 ms). */
43
+ baseMs?: number;
44
+ /** Backoff ceiling (default 30 000 ms). */
45
+ capMs?: number;
46
+ }
47
+ export interface LiveClientOptions {
48
+ /** HTTP(S) base of the Vela app, e.g. `https://api.example.com`. */
49
+ url: string;
50
+ /** WS(S) base override; derived from `url` (http→ws) when omitted. */
51
+ wsUrl?: string;
52
+ /**
53
+ * Gateway path template the live socket connects to. A `:room` placeholder
54
+ * is substituted per subscription room (matching vela's node transport,
55
+ * which auto-joins the `:id` route param, and Cloudflare's one-DO≈one-room
56
+ * model). Default `/rooms/:room/ws`.
57
+ */
58
+ wsPath?: string;
59
+ /** Room used when a subscribe/mutate call names none. Default `'default'`. */
60
+ defaultRoom?: string;
61
+ /** Injectables — the core never touches globals directly (SSR/edge safety). */
62
+ WebSocket?: WebSocketFactory;
63
+ fetch?: typeof fetch;
64
+ /**
65
+ * Async token provider, re-invoked per socket connect and per mutation.
66
+ * Sockets carry it as a `?token=` query param (browsers cannot set WS
67
+ * headers — use short-lived rotating tokens); mutations as a Bearer header.
68
+ */
69
+ authToken?: () => string | undefined | Promise<string | undefined>;
70
+ /** App-level `{event:'ping'}` keepalive cadence (default 30 000 ms; CF answers without waking the DO). */
71
+ heartbeatIntervalMs?: number;
72
+ reconnect?: ReconnectOptions;
73
+ }
74
+ export interface SubscribeOptions {
75
+ room?: string;
76
+ /** Delta key-field override forwarded to the server (default `'id'`). */
77
+ key?: string;
78
+ onError?: (error: {
79
+ code: string;
80
+ message: string;
81
+ fatal: boolean;
82
+ }) => void;
83
+ }
84
+ /** Single-subscription optimistic target for `mutate()`. */
85
+ export interface OptimisticTarget<T = unknown> {
86
+ query: string;
87
+ args?: unknown;
88
+ room?: string;
89
+ apply: (current: T | undefined) => T;
90
+ }
91
+ /** Multi-subscription optimistic store (the `optimisticUpdate` callback's argument). */
92
+ export interface LiveStore {
93
+ get(query: string, args?: unknown, room?: string): unknown;
94
+ set(query: string, args: unknown, next: unknown | ((current: unknown) => unknown), room?: string): void;
95
+ }
96
+ export interface MutateOptions {
97
+ method?: string;
98
+ headers?: Record<string, string>;
99
+ room?: string;
100
+ optimistic?: OptimisticTarget;
101
+ optimisticUpdate?: (store: LiveStore) => void;
102
+ }
103
+ export type Unsubscribe = () => void;
104
+ /** Seed states before the socket connects (SSR hydration). */
105
+ export interface HydrationEntry {
106
+ query: string;
107
+ args?: unknown;
108
+ room?: string;
109
+ value: unknown;
110
+ cursor?: number;
111
+ epoch?: string;
112
+ }
113
+ /** Durable offline write outbox seam — not implemented in v1 (mutations are plain HTTP). */
114
+ export interface OutboxSink {
115
+ enqueue(path: string, body: unknown, options?: MutateOptions): Promise<void>;
116
+ onCommit?(stamp: CommitStamp): void;
117
+ }
118
+ /** Durable read-cache seam (IndexedDB, …) — in-memory only in v1. */
119
+ export interface ReadCacheAdapter {
120
+ load(key: string): Promise<{
121
+ value: unknown;
122
+ cursor?: number;
123
+ epoch?: string;
124
+ } | undefined>;
125
+ persist(key: string, value: unknown, cursor?: number, epoch?: string): Promise<void>;
126
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ /** Durable read-cache seam (IndexedDB, …) — in-memory only in v1. */ export { };
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@velajs/client",
3
+ "version": "0.1.0",
4
+ "description": "Framework-neutral client for Vela live queries: live subscriptions over WebSocket, keyed deltas, cursor-gated optimistic updates, reconnect with resume",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./presence": {
14
+ "types": "./dist/presence.d.ts",
15
+ "import": "./dist/presence.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE",
22
+ "CHANGELOG.md"
23
+ ],
24
+ "sideEffects": false,
25
+ "keywords": [
26
+ "vela",
27
+ "live-queries",
28
+ "realtime",
29
+ "websocket",
30
+ "optimistic-updates",
31
+ "client"
32
+ ],
33
+ "author": "ksh",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/velajs/client.git",
38
+ "directory": "packages/client"
39
+ },
40
+ "homepage": "https://github.com/velajs/client#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/velajs/client/issues"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "dependencies": {
48
+ "@velajs/live-protocol": "^1.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@swc/cli": "^0.8.1",
52
+ "@swc/core": "^1.15.43",
53
+ "typescript": "^6.0.3",
54
+ "unplugin-swc": "^1.5.9",
55
+ "vitest": "^4.1.9",
56
+ "@velajs/vela": "^1.17.0"
57
+ },
58
+ "scripts": {
59
+ "build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
60
+ "test": "vitest run",
61
+ "typecheck": "tsc --noEmit"
62
+ }
63
+ }