@velajs/vela 1.16.0 → 1.17.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/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/live/index.d.ts +12 -0
- package/dist/live/index.js +16 -0
- package/dist/live/live.cursor.d.ts +22 -0
- package/dist/live/live.cursor.js +56 -0
- package/dist/live/live.decorators.d.ts +32 -0
- package/dist/live/live.decorators.js +46 -0
- package/dist/live/live.delta.d.ts +15 -0
- package/dist/live/live.delta.js +51 -0
- package/dist/live/live.engine.d.ts +81 -0
- package/dist/live/live.engine.js +448 -0
- package/dist/live/live.invalidation.d.ts +27 -0
- package/dist/live/live.invalidation.js +56 -0
- package/dist/live/live.module.d.ts +28 -0
- package/dist/live/live.module.js +83 -0
- package/dist/live/live.tokens.d.ts +9 -0
- package/dist/live/live.tokens.js +8 -0
- package/dist/live/live.types.d.ts +138 -0
- package/dist/live/live.types.js +1 -0
- package/dist/live/presence.d.ts +44 -0
- package/dist/live/presence.js +130 -0
- package/dist/websocket/index.d.ts +3 -3
- package/dist/websocket/index.js +2 -2
- package/dist/websocket/websocket.decorators.d.ts +14 -0
- package/dist/websocket/websocket.decorators.js +23 -1
- package/dist/websocket/websocket.tokens.d.ts +7 -0
- package/dist/websocket/websocket.tokens.js +6 -0
- package/dist/websocket/websocket.types.d.ts +16 -0
- package/dist/websocket/ws-dispatcher.d.ts +9 -0
- package/dist/websocket/ws-dispatcher.js +64 -1
- package/dist/websocket-node/index.d.ts +2 -0
- package/dist/websocket-node/index.js +1 -0
- package/dist/websocket-node/redis-live.d.ts +24 -0
- package/dist/websocket-node/redis-live.js +57 -0
- package/package.json +6 -1
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
function _ts_decorate(decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
}
|
|
7
|
+
function _ts_metadata(k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
}
|
|
10
|
+
function _ts_param(paramIndex, decorator) {
|
|
11
|
+
return function(target, key) {
|
|
12
|
+
decorator(target, key, paramIndex);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
import { LIVE_ERROR_CODES, LIVE_PROTOCOL, encodeLiveEnvelope, isClientLiveFrame } from "@velajs/live-protocol";
|
|
16
|
+
import { Container, DiscoveryService, Inject, Injectable, Optional, PipelineRunner, ReservedWsEvent, buildEntrypointExecutionContext, resolveScopedComponents, runInEntrypointScope } from "../index.js";
|
|
17
|
+
import { getLiveQueries } from "./live.decorators.js";
|
|
18
|
+
import { encodeSubscriptionUpdate } from "./live.delta.js";
|
|
19
|
+
import { LIVE_CURSOR_LOG, LIVE_DRIVER, LIVE_MODULE_OPTIONS, LIVE_RESOLVER_METADATA } from "./live.tokens.js";
|
|
20
|
+
import { PresenceService } from "./presence.js";
|
|
21
|
+
/** How many subscriptions refresh concurrently per flush (lunora's socket-pool default). */ const REFRESH_POOL_SIZE = 8;
|
|
22
|
+
/**
|
|
23
|
+
* Where the engine persists a connection's subscription records inside
|
|
24
|
+
* `client.data` (committed through `WsClient.commit()`). On Cloudflare that
|
|
25
|
+
* lands in the hibernation attachment, so subscriptions survive DO eviction;
|
|
26
|
+
* the transport replays them via `restoreSubscription` on wake. On node,
|
|
27
|
+
* `commit()` is a no-op and this is just in-memory bookkeeping.
|
|
28
|
+
*/ export const LIVE_SUBS_DATA_KEY = '__velaLiveSubs';
|
|
29
|
+
/** Read the subscription records a transport persisted for a connection (wake/restore path). */ export function readPersistedLiveSubscriptions(client) {
|
|
30
|
+
const raw = client.data?.[LIVE_SUBS_DATA_KEY];
|
|
31
|
+
if (!Array.isArray(raw)) return [];
|
|
32
|
+
return raw.filter((record)=>typeof record === 'object' && record !== null && typeof record.sub === 'string' && typeof record.query === 'string' && Array.isArray(record.tags));
|
|
33
|
+
}
|
|
34
|
+
const defaultIdentity = (client)=>{
|
|
35
|
+
const data = client.data;
|
|
36
|
+
if (!data || Object.keys(data).length === 0) return undefined;
|
|
37
|
+
return {
|
|
38
|
+
...data
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
async function runPool(items, size, run) {
|
|
42
|
+
const queue = [
|
|
43
|
+
...items
|
|
44
|
+
];
|
|
45
|
+
const workers = Array.from({
|
|
46
|
+
length: Math.min(size, queue.length)
|
|
47
|
+
}, async ()=>{
|
|
48
|
+
for(let item = queue.shift(); item !== undefined; item = queue.shift()){
|
|
49
|
+
await run(item);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
await Promise.all(workers);
|
|
53
|
+
}
|
|
54
|
+
export class LiveEngine {
|
|
55
|
+
container;
|
|
56
|
+
discovery;
|
|
57
|
+
log;
|
|
58
|
+
options;
|
|
59
|
+
presence;
|
|
60
|
+
queries = new Map();
|
|
61
|
+
connections = new Map();
|
|
62
|
+
pendingTags = new Set();
|
|
63
|
+
drainChain = Promise.resolve();
|
|
64
|
+
draining = false;
|
|
65
|
+
constructor(container, discovery, log, driver, options, presence){
|
|
66
|
+
this.container = container;
|
|
67
|
+
this.discovery = discovery;
|
|
68
|
+
this.log = log;
|
|
69
|
+
this.options = options;
|
|
70
|
+
this.presence = presence;
|
|
71
|
+
driver.bind(this);
|
|
72
|
+
this.presence?.bindInvalidator((tags)=>{
|
|
73
|
+
void driver.dispatch({
|
|
74
|
+
tags
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async onApplicationBootstrap() {
|
|
79
|
+
for (const found of this.discovery.providersWithMeta(LIVE_RESOLVER_METADATA)){
|
|
80
|
+
if (!found.instance) continue;
|
|
81
|
+
for (const declared of getLiveQueries(found.metatype)){
|
|
82
|
+
if (this.queries.has(declared.name)) {
|
|
83
|
+
const msg = `[vela] duplicate @LiveQuery('${declared.name}') ` + `(${found.metatype.name}); keeping the first.`;
|
|
84
|
+
if (this.container.getDiagnostics() === 'throw') throw new Error(msg);
|
|
85
|
+
console.warn(msg);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
this.queries.set(declared.name, {
|
|
89
|
+
token: found.metatype,
|
|
90
|
+
methodName: declared.methodName,
|
|
91
|
+
options: declared.options
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** The `'live'` entrypoint — how transports (node registrar, CF DO bootstrap) find the engine. */ collectEntrypoints() {
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
kind: 'live',
|
|
100
|
+
token: LiveEngine,
|
|
101
|
+
instance: this,
|
|
102
|
+
meta: {
|
|
103
|
+
engine: this
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
// ---- ReservedWsEventHandler ----
|
|
109
|
+
async handleReservedEvent(path, client, message) {
|
|
110
|
+
const frame = message.data;
|
|
111
|
+
// Forward-compat: unknown frame shapes are ignored, not errors.
|
|
112
|
+
if (!isClientLiveFrame(frame)) return;
|
|
113
|
+
switch(frame.t){
|
|
114
|
+
case 'sub':
|
|
115
|
+
await this.onSubscribe(path, client, frame);
|
|
116
|
+
return;
|
|
117
|
+
case 'unsub':
|
|
118
|
+
{
|
|
119
|
+
const conn = this.connections.get(client.id);
|
|
120
|
+
if (!conn) return;
|
|
121
|
+
conn.subs.delete(frame.sub);
|
|
122
|
+
if (conn.subs.size === 0) this.connections.delete(client.id);
|
|
123
|
+
await this.persistSubscriptions(conn);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case 'presence':
|
|
127
|
+
if (this.presence?.enabled) {
|
|
128
|
+
this.presence.beat(frame.room, client.id, frame.meta);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async handleSocketClose(_path, client) {
|
|
134
|
+
this.connections.delete(client.id);
|
|
135
|
+
this.presence?.reap(client.id);
|
|
136
|
+
}
|
|
137
|
+
// ---- LiveInvalidationSink ----
|
|
138
|
+
async applyInvalidation(cmd) {
|
|
139
|
+
const stamp = await this.log.append(cmd.tags);
|
|
140
|
+
for (const tag of cmd.tags)this.pendingTags.add(tag);
|
|
141
|
+
this.scheduleDrain();
|
|
142
|
+
return stamp;
|
|
143
|
+
}
|
|
144
|
+
/** Settles when every pending invalidation has been flushed (tests, graceful transports). */ async whenIdle() {
|
|
145
|
+
do {
|
|
146
|
+
await this.drainChain;
|
|
147
|
+
}while (this.draining || this.pendingTags.size > 0)
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Re-attach a persisted subscription without the subscribe handshake — the
|
|
151
|
+
* Cloudflare transport replays hibernation-attachment records through this
|
|
152
|
+
* on wake. No frames are sent; the next relevant invalidation pushes.
|
|
153
|
+
*/ restoreSubscription(path, client, record) {
|
|
154
|
+
this.ensureConnection(path, client).subs.set(record.sub, record);
|
|
155
|
+
}
|
|
156
|
+
// ---- subscribe path ----
|
|
157
|
+
async onSubscribe(path, client, frame) {
|
|
158
|
+
if (frame.v !== undefined && frame.v > LIVE_PROTOCOL) {
|
|
159
|
+
this.sendFrame(client, {
|
|
160
|
+
t: 'error',
|
|
161
|
+
sub: frame.sub,
|
|
162
|
+
code: LIVE_ERROR_CODES.UNSUPPORTED_PROTOCOL,
|
|
163
|
+
message: `server speaks live protocol v${LIVE_PROTOCOL}, client asked for v${frame.v}`,
|
|
164
|
+
fatal: true
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const conn = this.ensureConnection(path, client);
|
|
169
|
+
if (conn.subs.has(frame.sub)) {
|
|
170
|
+
this.sendFrame(client, {
|
|
171
|
+
t: 'error',
|
|
172
|
+
sub: frame.sub,
|
|
173
|
+
code: LIVE_ERROR_CODES.DUPLICATE_SUB,
|
|
174
|
+
message: `subscription id '${frame.sub}' is already active on this socket`,
|
|
175
|
+
fatal: false
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const registered = this.queries.get(frame.query);
|
|
180
|
+
if (!registered) {
|
|
181
|
+
this.sendFrame(client, {
|
|
182
|
+
t: 'error',
|
|
183
|
+
sub: frame.sub,
|
|
184
|
+
code: LIVE_ERROR_CODES.UNKNOWN_QUERY,
|
|
185
|
+
message: `no @LiveQuery('${frame.query}') is registered`,
|
|
186
|
+
fatal: true
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
let args = frame.args;
|
|
191
|
+
if (registered.options.parse) {
|
|
192
|
+
try {
|
|
193
|
+
args = registered.options.parse(frame.args);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
this.sendFrame(client, {
|
|
196
|
+
t: 'error',
|
|
197
|
+
sub: frame.sub,
|
|
198
|
+
code: LIVE_ERROR_CODES.BAD_ARGS,
|
|
199
|
+
message: err instanceof Error ? err.message : 'invalid subscription args',
|
|
200
|
+
fatal: true
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Resolver-tier guards run ONCE, here (app-wide guards already ran in the
|
|
206
|
+
// dispatcher's reserved-event path). Re-runs are server-initiated and rely
|
|
207
|
+
// on the identity captured below instead of re-authorizing.
|
|
208
|
+
if (!await this.runSubscribeGuards(registered, client, frame.query, args)) {
|
|
209
|
+
this.sendFrame(client, {
|
|
210
|
+
t: 'error',
|
|
211
|
+
sub: frame.sub,
|
|
212
|
+
code: LIVE_ERROR_CODES.FORBIDDEN,
|
|
213
|
+
message: `subscription to '${frame.query}' was rejected`,
|
|
214
|
+
fatal: true
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const identity = (this.options.identity ?? defaultIdentity)(client);
|
|
219
|
+
const tags = typeof registered.options.tags === 'function' ? registered.options.tags(args) : registered.options.tags;
|
|
220
|
+
const record = {
|
|
221
|
+
sub: frame.sub,
|
|
222
|
+
query: frame.query,
|
|
223
|
+
args,
|
|
224
|
+
tags,
|
|
225
|
+
key: frame.key ?? registered.options.key,
|
|
226
|
+
identity
|
|
227
|
+
};
|
|
228
|
+
conn.subs.set(frame.sub, record);
|
|
229
|
+
await this.persistSubscriptions(conn);
|
|
230
|
+
this.sendFrame(client, {
|
|
231
|
+
t: 'ack',
|
|
232
|
+
sub: frame.sub
|
|
233
|
+
});
|
|
234
|
+
// Resume: when the client's cursor is still explainable and none of the
|
|
235
|
+
// subscription's tags were invalidated in the gap, a tiny `resume` frame
|
|
236
|
+
// keeps its cached value — no re-run at all. The diff baseline stays
|
|
237
|
+
// undefined, so the next relevant change sends a full snapshot.
|
|
238
|
+
if (frame.sinceCursor !== undefined && frame.sinceEpoch !== undefined) {
|
|
239
|
+
const verdict = await this.log.evaluateResume(frame.sinceCursor, frame.sinceEpoch, tags);
|
|
240
|
+
if (verdict === 'resume') {
|
|
241
|
+
const stamp = await this.log.current();
|
|
242
|
+
if (this.sendFrame(client, {
|
|
243
|
+
t: 'resume',
|
|
244
|
+
sub: frame.sub,
|
|
245
|
+
cursor: stamp.cursor,
|
|
246
|
+
epoch: stamp.epoch
|
|
247
|
+
})) {
|
|
248
|
+
record.lastCursor = stamp.cursor;
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
await this.push(conn, record, await this.log.current(), {
|
|
254
|
+
initial: true
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async runSubscribeGuards(registered, client, query, args) {
|
|
258
|
+
const guards = resolveScopedComponents('guard', registered.token, registered.methodName, this.container);
|
|
259
|
+
if (guards.length === 0) return true;
|
|
260
|
+
const context = buildEntrypointExecutionContext('live', registered.token, registered.methodName, {
|
|
261
|
+
query,
|
|
262
|
+
args,
|
|
263
|
+
client
|
|
264
|
+
});
|
|
265
|
+
try {
|
|
266
|
+
for (const guard of guards){
|
|
267
|
+
if (!await guard.canActivate(context)) return false;
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// ---- refresh path ----
|
|
275
|
+
scheduleDrain() {
|
|
276
|
+
if (this.draining) return;
|
|
277
|
+
this.draining = true;
|
|
278
|
+
this.drainChain = this.drainChain.then(()=>this.drainLoop()).catch((err)=>{
|
|
279
|
+
if (this.container.getDiagnostics() !== 'silent') {
|
|
280
|
+
console.warn('[vela] live refresh flush failed:', err);
|
|
281
|
+
}
|
|
282
|
+
}).finally(()=>{
|
|
283
|
+
this.draining = false;
|
|
284
|
+
if (this.pendingTags.size > 0) this.scheduleDrain();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async drainLoop() {
|
|
288
|
+
while(this.pendingTags.size > 0){
|
|
289
|
+
const changed = new Set(this.pendingTags);
|
|
290
|
+
this.pendingTags.clear();
|
|
291
|
+
// The stamp is resolved once per pass, BEFORE the re-runs (lunora
|
|
292
|
+
// resolves its CDC cut the same way): results can only be newer than
|
|
293
|
+
// the cursor they carry.
|
|
294
|
+
const stamp = await this.log.current();
|
|
295
|
+
const work = [];
|
|
296
|
+
for (const conn of this.connections.values()){
|
|
297
|
+
for (const record of conn.subs.values()){
|
|
298
|
+
if (record.tags.some((tag)=>changed.has(tag))) work.push({
|
|
299
|
+
conn,
|
|
300
|
+
record
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await runPool(work, REFRESH_POOL_SIZE, async ({ conn, record })=>{
|
|
305
|
+
await this.push(conn, record, stamp, {
|
|
306
|
+
initial: false
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async push(conn, record, stamp, { initial }) {
|
|
312
|
+
// Outbound expiry enforcement — the only place expiry CAN be enforced for
|
|
313
|
+
// a passive subscriber.
|
|
314
|
+
const expiresAt = record.identity?.expiresAt;
|
|
315
|
+
if (typeof expiresAt === 'number' && expiresAt <= Date.now()) {
|
|
316
|
+
this.connections.delete(conn.client.id);
|
|
317
|
+
try {
|
|
318
|
+
conn.client.close(1008, 'identity expired');
|
|
319
|
+
} catch {
|
|
320
|
+
// already gone
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
let json;
|
|
325
|
+
let result;
|
|
326
|
+
try {
|
|
327
|
+
result = await this.runQuery(record, conn.client);
|
|
328
|
+
// `undefined` has no JSON form — normalize so the frame always carries
|
|
329
|
+
// an explicit `snapshot` and the baseline stays a string.
|
|
330
|
+
if (result === undefined) result = null;
|
|
331
|
+
json = JSON.stringify(result);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (initial) {
|
|
334
|
+
conn.subs.delete(record.sub);
|
|
335
|
+
this.sendFrame(conn.client, {
|
|
336
|
+
t: 'error',
|
|
337
|
+
sub: record.sub,
|
|
338
|
+
code: LIVE_ERROR_CODES.INTERNAL,
|
|
339
|
+
message: err instanceof Error ? err.message : 'live query failed',
|
|
340
|
+
fatal: true
|
|
341
|
+
});
|
|
342
|
+
} else if (this.container.getDiagnostics() !== 'silent') {
|
|
343
|
+
// Transient: baseline untouched, the subscription retries next flush.
|
|
344
|
+
console.warn(`[vela] live query '${record.query}' re-run failed (will retry):`, err);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const frame = encodeSubscriptionUpdate(record, json, result, stamp, initial);
|
|
349
|
+
if (this.sendFrame(conn.client, frame)) {
|
|
350
|
+
record.lastJson = json;
|
|
351
|
+
record.lastCursor = stamp.cursor;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async runQuery(record, client) {
|
|
355
|
+
const registered = this.queries.get(record.query);
|
|
356
|
+
if (!registered) throw new Error(`live query '${record.query}' disappeared from the registry`);
|
|
357
|
+
return runInEntrypointScope(this.container, async (scope)=>{
|
|
358
|
+
// Async seam: lazy resolver modules materialize, request-scoped
|
|
359
|
+
// resolvers rebuild per run (mirrors queue dispatch).
|
|
360
|
+
const instance = await scope.resolveAsync(registered.token);
|
|
361
|
+
const liveContext = {
|
|
362
|
+
identity: record.identity,
|
|
363
|
+
clientId: client.id,
|
|
364
|
+
rooms: [
|
|
365
|
+
...client.rooms
|
|
366
|
+
]
|
|
367
|
+
};
|
|
368
|
+
const context = buildEntrypointExecutionContext('live', registered.token, registered.methodName, {
|
|
369
|
+
query: record.query,
|
|
370
|
+
args: record.args,
|
|
371
|
+
client
|
|
372
|
+
});
|
|
373
|
+
const interceptors = resolveScopedComponents('interceptor', registered.token, registered.methodName, scope);
|
|
374
|
+
return PipelineRunner.run({
|
|
375
|
+
context,
|
|
376
|
+
guards: [],
|
|
377
|
+
interceptors,
|
|
378
|
+
resolveArgs: async ()=>[
|
|
379
|
+
record.args,
|
|
380
|
+
liveContext
|
|
381
|
+
],
|
|
382
|
+
invoke: async (args)=>{
|
|
383
|
+
const method = instance[registered.methodName];
|
|
384
|
+
return method.apply(instance, args);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Mirror the connection's records into `client.data` + `commit()` so
|
|
391
|
+
* hibernating transports can replay them on wake. The volatile diff
|
|
392
|
+
* baseline (`lastJson`/`lastCursor`) is deliberately stripped: it can be
|
|
393
|
+
* large (the whole last result vs the 16 KiB attachment cap) and a lost
|
|
394
|
+
* baseline just means the next relevant change sends a full snapshot.
|
|
395
|
+
*/ async persistSubscriptions(conn) {
|
|
396
|
+
const records = [
|
|
397
|
+
...conn.subs.values()
|
|
398
|
+
].map(({ lastJson: _lastJson, lastCursor: _lastCursor, ...persisted })=>persisted);
|
|
399
|
+
try {
|
|
400
|
+
conn.client.data[LIVE_SUBS_DATA_KEY] = records;
|
|
401
|
+
await conn.client.commit();
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (this.container.getDiagnostics() !== 'silent') {
|
|
404
|
+
console.warn('[vela] live subscription persistence failed (resume across eviction disabled):', err);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
ensureConnection(path, client) {
|
|
409
|
+
let conn = this.connections.get(client.id);
|
|
410
|
+
if (!conn) {
|
|
411
|
+
conn = {
|
|
412
|
+
client,
|
|
413
|
+
path,
|
|
414
|
+
subs: new Map()
|
|
415
|
+
};
|
|
416
|
+
this.connections.set(client.id, conn);
|
|
417
|
+
}
|
|
418
|
+
return conn;
|
|
419
|
+
}
|
|
420
|
+
sendFrame(client, frame) {
|
|
421
|
+
try {
|
|
422
|
+
client.sendRaw(encodeLiveEnvelope(frame));
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
LiveEngine = _ts_decorate([
|
|
430
|
+
ReservedWsEvent('$live'),
|
|
431
|
+
Injectable(),
|
|
432
|
+
_ts_param(0, Inject(Container)),
|
|
433
|
+
_ts_param(1, Inject(DiscoveryService)),
|
|
434
|
+
_ts_param(2, Inject(LIVE_CURSOR_LOG)),
|
|
435
|
+
_ts_param(3, Inject(LIVE_DRIVER)),
|
|
436
|
+
_ts_param(4, Inject(LIVE_MODULE_OPTIONS)),
|
|
437
|
+
_ts_param(5, Optional()),
|
|
438
|
+
_ts_param(5, Inject(PresenceService)),
|
|
439
|
+
_ts_metadata("design:type", Function),
|
|
440
|
+
_ts_metadata("design:paramtypes", [
|
|
441
|
+
typeof Container === "undefined" ? Object : Container,
|
|
442
|
+
typeof DiscoveryService === "undefined" ? Object : DiscoveryService,
|
|
443
|
+
typeof CursorLog === "undefined" ? Object : CursorLog,
|
|
444
|
+
typeof LiveDriver === "undefined" ? Object : LiveDriver,
|
|
445
|
+
typeof LiveModuleOptions === "undefined" ? Object : LiveModuleOptions,
|
|
446
|
+
typeof PresenceService === "undefined" ? Object : PresenceService
|
|
447
|
+
])
|
|
448
|
+
], LiveEngine);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CommitStamp, InvalidationCommand, LiveDriver } from './live.types';
|
|
2
|
+
/** Single-scope default driver: deliver straight to this process's engine. */
|
|
3
|
+
export declare function localLive(): LiveDriver;
|
|
4
|
+
/**
|
|
5
|
+
* The injectable invalidation surface for custom (non-CRUD) write paths:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* await this.live.invalidate({ tags: ['todos:' + listId] });
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Every affected live query re-runs and pushes. Returns the commit stamp of
|
|
12
|
+
* the targeted log scope so mutation endpoints can expose it to the client
|
|
13
|
+
* (`Vela-Commit-Cursor` / `Vela-Commit-Epoch`) — the cursor optimistic
|
|
14
|
+
* updates gate on. When ambient request access is enabled
|
|
15
|
+
* (`VelaFactory.create(m, { ambientContainer: true })`) the headers are
|
|
16
|
+
* stamped onto the current HTTP response automatically; otherwise stamp them
|
|
17
|
+
* yourself via {@link stampCommitHeaders}.
|
|
18
|
+
*/
|
|
19
|
+
export declare class LiveInvalidation {
|
|
20
|
+
private readonly driver;
|
|
21
|
+
constructor(driver: LiveDriver);
|
|
22
|
+
invalidate(cmd: InvalidationCommand): Promise<CommitStamp | undefined>;
|
|
23
|
+
}
|
|
24
|
+
/** Write the commit headers onto a Hono context (a mutation route's response). */
|
|
25
|
+
export declare function stampCommitHeaders(c: {
|
|
26
|
+
header: (name: string, value: string) => unknown;
|
|
27
|
+
}, stamp: CommitStamp): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { COMMIT_CURSOR_HEADER, COMMIT_EPOCH_HEADER } from "@velajs/live-protocol";
|
|
2
|
+
import { getContext } from "hono/context-storage";
|
|
3
|
+
/** Single-scope default driver: deliver straight to this process's engine. */ export function localLive() {
|
|
4
|
+
let sink;
|
|
5
|
+
return {
|
|
6
|
+
kind: 'local',
|
|
7
|
+
bind (s) {
|
|
8
|
+
sink = s;
|
|
9
|
+
},
|
|
10
|
+
dispatch (cmd) {
|
|
11
|
+
return sink?.applyInvalidation(cmd);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The injectable invalidation surface for custom (non-CRUD) write paths:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* await this.live.invalidate({ tags: ['todos:' + listId] });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Every affected live query re-runs and pushes. Returns the commit stamp of
|
|
23
|
+
* the targeted log scope so mutation endpoints can expose it to the client
|
|
24
|
+
* (`Vela-Commit-Cursor` / `Vela-Commit-Epoch`) — the cursor optimistic
|
|
25
|
+
* updates gate on. When ambient request access is enabled
|
|
26
|
+
* (`VelaFactory.create(m, { ambientContainer: true })`) the headers are
|
|
27
|
+
* stamped onto the current HTTP response automatically; otherwise stamp them
|
|
28
|
+
* yourself via {@link stampCommitHeaders}.
|
|
29
|
+
*/ export class LiveInvalidation {
|
|
30
|
+
driver;
|
|
31
|
+
constructor(driver){
|
|
32
|
+
this.driver = driver;
|
|
33
|
+
}
|
|
34
|
+
async invalidate(cmd) {
|
|
35
|
+
const stamp = await this.driver.dispatch(cmd);
|
|
36
|
+
if (stamp) stampAmbientCommitHeaders(stamp);
|
|
37
|
+
return stamp;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Write the commit headers onto a Hono context (a mutation route's response). */ export function stampCommitHeaders(c, stamp) {
|
|
41
|
+
c.header(COMMIT_CURSOR_HEADER, String(stamp.cursor));
|
|
42
|
+
c.header(COMMIT_EPOCH_HEADER, stamp.epoch);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Best-effort automatic header stamping through Hono's ambient context
|
|
46
|
+
* storage. A no-op outside a request or when `contextStorage()` isn't
|
|
47
|
+
* registered — ALS keeps this per-request-correct under concurrency (a
|
|
48
|
+
* mutable singleton slot would race interleaved awaits).
|
|
49
|
+
*/ function stampAmbientCommitHeaders(stamp) {
|
|
50
|
+
try {
|
|
51
|
+
stampCommitHeaders(getContext(), stamp);
|
|
52
|
+
} catch {
|
|
53
|
+
// Ambient access not enabled, or invalidate() ran outside a request
|
|
54
|
+
// (queue job, cron tick) — there is no HTTP response to stamp.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LiveModuleOptions } from './live.types';
|
|
2
|
+
/**
|
|
3
|
+
* First-party live-query module (tag-based realtime reactivity) — authored,
|
|
4
|
+
* like the queue module, entirely on vela's public API (`live-openness.test.ts`
|
|
5
|
+
* machine-verifies it; the only extra dependency is the shared wire package
|
|
6
|
+
* `@velajs/live-protocol`).
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* imports: [WebSocketModule.forRoot({}), LiveModule.forRoot({})]
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* App code declares `@LiveResolver` classes with `@LiveQuery(name, { tags })`
|
|
13
|
+
* methods; clients subscribe over the `$live` reserved WebSocket event; writes
|
|
14
|
+
* invalidate tags via `LiveInvalidation` (the `@velajs/crud` bridge does it
|
|
15
|
+
* automatically per table). See `LIVE.md` for the wire protocol, delivery
|
|
16
|
+
* guarantees, and the resume story.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately EAGER (like WebSocketModule): the engine self-drives — it
|
|
19
|
+
* claims the `$live` event at bootstrap and receives invalidations from
|
|
20
|
+
* writes that never touch a live token — so the lazy-module contract
|
|
21
|
+
* ("nothing self-drives") rules laziness out.
|
|
22
|
+
*/
|
|
23
|
+
declare const ConfigurableModuleClass: import("../module").ConfigurableModuleClassType<LiveModuleOptions, "forRoot", "create", {
|
|
24
|
+
isGlobal?: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
export declare class LiveModule extends ConfigurableModuleClass {
|
|
27
|
+
}
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineModule } from "../index.js";
|
|
2
|
+
import { InMemoryCursorLog } from "./live.cursor.js";
|
|
3
|
+
import { LiveEngine } from "./live.engine.js";
|
|
4
|
+
import { LiveInvalidation, localLive } from "./live.invalidation.js";
|
|
5
|
+
import { LIVE_CURSOR_LOG, LIVE_DRIVER, LIVE_MODULE_OPTIONS } from "./live.tokens.js";
|
|
6
|
+
import { PresenceResolver, PresenceService } from "./presence.js";
|
|
7
|
+
/**
|
|
8
|
+
* First-party live-query module (tag-based realtime reactivity) — authored,
|
|
9
|
+
* like the queue module, entirely on vela's public API (`live-openness.test.ts`
|
|
10
|
+
* machine-verifies it; the only extra dependency is the shared wire package
|
|
11
|
+
* `@velajs/live-protocol`).
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* imports: [WebSocketModule.forRoot({}), LiveModule.forRoot({})]
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* App code declares `@LiveResolver` classes with `@LiveQuery(name, { tags })`
|
|
18
|
+
* methods; clients subscribe over the `$live` reserved WebSocket event; writes
|
|
19
|
+
* invalidate tags via `LiveInvalidation` (the `@velajs/crud` bridge does it
|
|
20
|
+
* automatically per table). See `LIVE.md` for the wire protocol, delivery
|
|
21
|
+
* guarantees, and the resume story.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately EAGER (like WebSocketModule): the engine self-drives — it
|
|
24
|
+
* claims the `$live` event at bootstrap and receives invalidations from
|
|
25
|
+
* writes that never touch a live token — so the lazy-module contract
|
|
26
|
+
* ("nothing self-drives") rules laziness out.
|
|
27
|
+
*/ const { ConfigurableModuleClass } = defineModule({
|
|
28
|
+
name: 'Live',
|
|
29
|
+
optionsToken: LIVE_MODULE_OPTIONS,
|
|
30
|
+
key: (options)=>`live#${options?.driver?.kind ?? 'local'}`,
|
|
31
|
+
setup: ({ OPTIONS, options })=>({
|
|
32
|
+
providers: [
|
|
33
|
+
{
|
|
34
|
+
provide: LIVE_CURSOR_LOG,
|
|
35
|
+
useFactory: (o)=>o.log ?? new InMemoryCursorLog(),
|
|
36
|
+
inject: [
|
|
37
|
+
OPTIONS
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
provide: LIVE_DRIVER,
|
|
42
|
+
useFactory: (o)=>o.driver ?? localLive(),
|
|
43
|
+
inject: [
|
|
44
|
+
OPTIONS
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
provide: PresenceService,
|
|
49
|
+
useFactory: (o)=>{
|
|
50
|
+
const presence = o.presence;
|
|
51
|
+
if (presence === false) return new PresenceService(undefined, false);
|
|
52
|
+
return new PresenceService(presence?.ttlMs, true);
|
|
53
|
+
},
|
|
54
|
+
inject: [
|
|
55
|
+
OPTIONS
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
provide: LiveInvalidation,
|
|
60
|
+
useFactory: (driver)=>new LiveInvalidation(driver),
|
|
61
|
+
inject: [
|
|
62
|
+
LIVE_DRIVER
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
LiveEngine,
|
|
66
|
+
// The built-in `$presence.roster` resolver. Skipping it is STRUCTURAL
|
|
67
|
+
// (`presence: false` must be visible at forRoot/forRootAsync call time,
|
|
68
|
+
// like queue's `queues`); a disabled-at-runtime service still no-ops.
|
|
69
|
+
...options?.presence === false ? [] : [
|
|
70
|
+
PresenceResolver
|
|
71
|
+
]
|
|
72
|
+
],
|
|
73
|
+
exports: [
|
|
74
|
+
LiveEngine,
|
|
75
|
+
LiveInvalidation,
|
|
76
|
+
LIVE_DRIVER,
|
|
77
|
+
LIVE_CURSOR_LOG,
|
|
78
|
+
PresenceService
|
|
79
|
+
]
|
|
80
|
+
})
|
|
81
|
+
});
|
|
82
|
+
export class LiveModule extends ConfigurableModuleClass {
|
|
83
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { InjectionToken } from '../index';
|
|
2
|
+
import type { CursorLog, LiveDriver, LiveModuleOptions } from './live.types';
|
|
3
|
+
export declare const LIVE_RESOLVER_METADATA = "vela:live:resolver";
|
|
4
|
+
export declare const LIVE_QUERY_METADATA = "vela:live:query";
|
|
5
|
+
/** The invalidation driver in effect for a `LiveModule` instance (`localLive()` by default). */
|
|
6
|
+
export declare const LIVE_DRIVER: InjectionToken<LiveDriver>;
|
|
7
|
+
/** The ordered tag-invalidation log backing cursors/epochs for this log scope. */
|
|
8
|
+
export declare const LIVE_CURSOR_LOG: InjectionToken<CursorLog>;
|
|
9
|
+
export declare const LIVE_MODULE_OPTIONS: InjectionToken<LiveModuleOptions>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { InjectionToken } from "../index.js";
|
|
2
|
+
// Free-form metadata keys — same string-token convention as the queue module.
|
|
3
|
+
export const LIVE_RESOLVER_METADATA = 'vela:live:resolver';
|
|
4
|
+
export const LIVE_QUERY_METADATA = 'vela:live:query';
|
|
5
|
+
/** The invalidation driver in effect for a `LiveModule` instance (`localLive()` by default). */ export const LIVE_DRIVER = new InjectionToken('vela:live:driver');
|
|
6
|
+
/** The ordered tag-invalidation log backing cursors/epochs for this log scope. */ export const LIVE_CURSOR_LOG = new InjectionToken('vela:live:cursor-log');
|
|
7
|
+
// forRoot() options carrier.
|
|
8
|
+
export const LIVE_MODULE_OPTIONS = new InjectionToken('LIVE_MODULE_OPTIONS');
|