@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 +21 -0
- package/dist/connection.d.ts +43 -0
- package/dist/connection.js +215 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +36 -0
- package/dist/frame-reducer.d.ts +17 -0
- package/dist/frame-reducer.js +66 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +10 -0
- package/dist/live-client.d.ts +44 -0
- package/dist/live-client.js +210 -0
- package/dist/optimistic.d.ts +54 -0
- package/dist/optimistic.js +67 -0
- package/dist/presence.d.ts +34 -0
- package/dist/presence.js +45 -0
- package/dist/reconnect.d.ts +13 -0
- package/dist/reconnect.js +16 -0
- package/dist/stable-key.d.ts +7 -0
- package/dist/stable-key.js +20 -0
- package/dist/subscription.d.ts +42 -0
- package/dist/subscription.js +0 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.js +1 -0
- package/package.json +63 -0
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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/presence.js
ADDED
|
@@ -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
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|