@velajs/vela 1.16.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +45 -0
- package/dist/live/live.invalidation.js +99 -0
- package/dist/live/live.module.d.ts +28 -0
- package/dist/live/live.module.js +87 -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
|
@@ -24,7 +24,7 @@ import { MetadataRegistry } from "../registry/metadata.registry.js";
|
|
|
24
24
|
import { resolveWsArgs } from "./ws-argument-resolver.js";
|
|
25
25
|
import { buildWsExecutionContext } from "./ws-execution-context.js";
|
|
26
26
|
import { toErrorFrame, WsException } from "./ws-exception.js";
|
|
27
|
-
import { WS_GATEWAY_METADATA, WS_SERVER, WS_SUBSCRIBE_METADATA } from "./websocket.tokens.js";
|
|
27
|
+
import { RESERVED_WS_EVENT_PREFIX, WS_GATEWAY_METADATA, WS_RESERVED_METADATA, WS_SERVER, WS_SUBSCRIBE_METADATA } from "./websocket.tokens.js";
|
|
28
28
|
function hasAfterInit(x) {
|
|
29
29
|
return typeof x?.afterInit === 'function';
|
|
30
30
|
}
|
|
@@ -43,6 +43,7 @@ export class WsDispatcher {
|
|
|
43
43
|
server;
|
|
44
44
|
routeManager;
|
|
45
45
|
gateways = new Map();
|
|
46
|
+
reserved = new Map();
|
|
46
47
|
constructor(container, discovery, server, routeManager){
|
|
47
48
|
this.container = container;
|
|
48
49
|
this.discovery = discovery;
|
|
@@ -55,6 +56,19 @@ export class WsDispatcher {
|
|
|
55
56
|
];
|
|
56
57
|
}
|
|
57
58
|
async onApplicationBootstrap() {
|
|
59
|
+
// Reserved-event handlers first: modules claiming a `$…` event
|
|
60
|
+
// (@ReservedWsEvent) receive those frames across every gateway path.
|
|
61
|
+
for (const found of this.discovery.providersWithMeta(WS_RESERVED_METADATA)){
|
|
62
|
+
if (!found.instance) continue;
|
|
63
|
+
const event = found.meta.event;
|
|
64
|
+
if (this.reserved.has(event)) {
|
|
65
|
+
const msg = `[vela] duplicate @ReservedWsEvent('${event}') ` + `(${found.metatype.name}); keeping the first.`;
|
|
66
|
+
if (this.container.getDiagnostics() === 'throw') throw new Error(msg);
|
|
67
|
+
console.warn(msg);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
this.reserved.set(event, found.instance);
|
|
71
|
+
}
|
|
58
72
|
for (const found of this.discovery.providersWithMeta(WS_GATEWAY_METADATA)){
|
|
59
73
|
if (!found.instance) continue;
|
|
60
74
|
const gatewayClass = found.metatype;
|
|
@@ -98,6 +112,15 @@ export class WsDispatcher {
|
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
async handleClose(path, client, _code, _reason) {
|
|
115
|
+
// Reserved handlers drop per-connection state first (isolated per handler)
|
|
116
|
+
// so a throwing gateway handleDisconnect can't leak live subscriptions.
|
|
117
|
+
for (const handler of this.reserved.values()){
|
|
118
|
+
try {
|
|
119
|
+
await handler.handleSocketClose?.(path, client);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
await this.handleError(path, client, err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
101
124
|
const entry = this.gateways.get(path);
|
|
102
125
|
if (entry && hasHandleDisconnect(entry.instance)) {
|
|
103
126
|
await entry.instance.handleDisconnect(client);
|
|
@@ -120,6 +143,13 @@ export class WsDispatcher {
|
|
|
120
143
|
});
|
|
121
144
|
return;
|
|
122
145
|
}
|
|
146
|
+
// Reserved namespace: `$…` frames route to @ReservedWsEvent handlers
|
|
147
|
+
// (after app-wide guards) and NEVER reach gateway handlers; an unclaimed
|
|
148
|
+
// reserved event is dropped like any unknown event.
|
|
149
|
+
if (message.event.startsWith(RESERVED_WS_EVENT_PREFIX)) {
|
|
150
|
+
await this.dispatchReserved(path, client, message);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
123
153
|
const handler = entry.handlers.get(message.event);
|
|
124
154
|
if (!handler) return; // unknown event — ignored (NestJS parity)
|
|
125
155
|
const ctx = buildWsExecutionContext(client, message.data, entry.gatewayClass, handler.methodName, message.event);
|
|
@@ -162,6 +192,31 @@ export class WsDispatcher {
|
|
|
162
192
|
await this.runFilters(client, message, error, filters, ctx);
|
|
163
193
|
}
|
|
164
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Route a reserved (`$…`) frame to its claiming handler. App-wide (`APP_*` /
|
|
197
|
+
* `useGlobalGuards`) guards protect reserved frames exactly like gateway
|
|
198
|
+
* messages — guards-first, WS convention; handler/class-tier components are
|
|
199
|
+
* the claiming module's own concern (e.g. live resolvers run their scoped
|
|
200
|
+
* guards at subscribe).
|
|
201
|
+
*/ async dispatchReserved(path, client, message) {
|
|
202
|
+
const handler = this.reserved.get(message.event);
|
|
203
|
+
if (!handler) return;
|
|
204
|
+
const ctx = buildWsExecutionContext(client, message.data, handler.constructor, 'handleReservedEvent', message.event);
|
|
205
|
+
const globals = this.routeManager?.getGlobalComponents();
|
|
206
|
+
const guards = instantiateMany(globals?.guards ?? [], this.container);
|
|
207
|
+
try {
|
|
208
|
+
for (const guard of guards){
|
|
209
|
+
if (!await guard.canActivate(ctx)) {
|
|
210
|
+
const frame = toErrorFrame(new WsException('Forbidden'));
|
|
211
|
+
this.trySend(client, frame.event, frame.data, message.id);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
await handler.handleReservedEvent(path, client, message);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
await this.handleError(path, client, err);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
165
220
|
reply(client, message, result) {
|
|
166
221
|
if (result === undefined || result === null) return;
|
|
167
222
|
if (isWsResponse(result)) {
|
|
@@ -216,6 +271,14 @@ export class WsDispatcher {
|
|
|
216
271
|
const allParams = MetadataRegistry.getParameters(ctor);
|
|
217
272
|
const handlers = new Map();
|
|
218
273
|
for (const { event, methodName } of subs){
|
|
274
|
+
// The `$` namespace belongs to framework modules (@ReservedWsEvent) — an
|
|
275
|
+
// app gateway subscribing to it would never receive the frames anyway.
|
|
276
|
+
if (event.startsWith(RESERVED_WS_EVENT_PREFIX)) {
|
|
277
|
+
const msg = `[vela] @SubscribeMessage('${event}') on ${gatewayClass.name}: the ` + `'${RESERVED_WS_EVENT_PREFIX}' event prefix is reserved for framework modules — skipped.`;
|
|
278
|
+
if (this.container.getDiagnostics() === 'throw') throw new Error(msg);
|
|
279
|
+
console.warn(msg);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
219
282
|
const paramMeta = [
|
|
220
283
|
...allParams.get(methodName) ?? []
|
|
221
284
|
].sort((a, b)=>a.index - b.index);
|
|
@@ -2,3 +2,5 @@ export { NodeWsClient } from './node-ws-client';
|
|
|
2
2
|
export { registerWebSocketGateways } from './register-gateways';
|
|
3
3
|
export { redis } from './redis-sync';
|
|
4
4
|
export type { RedisPubSubClient, RedisSyncOptions } from './redis-sync';
|
|
5
|
+
export { redisLive } from './redis-live';
|
|
6
|
+
export type { RedisLiveOptions } from './redis-live';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LiveDriver } from '../live/index';
|
|
2
|
+
import type { RedisPubSubClient } from './redis-sync';
|
|
3
|
+
export interface RedisLiveOptions {
|
|
4
|
+
/** Connection used to publish invalidations. */
|
|
5
|
+
pub: RedisPubSubClient;
|
|
6
|
+
/** Separate connection kept in subscriber mode. */
|
|
7
|
+
sub: RedisPubSubClient;
|
|
8
|
+
/** Channel prefix. Default `vela:live`. */
|
|
9
|
+
prefix?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Cross-instance live-invalidation driver for node/bun via Redis pub/sub —
|
|
13
|
+
* the live sibling of the WebSocket `redis()` sync driver, carrying TAGS
|
|
14
|
+
* instead of frames. Local-first: `dispatch` applies to this instance's
|
|
15
|
+
* engine immediately (its log stamps the returned commit cursor), then fans
|
|
16
|
+
* the command out so every peer re-runs ITS local subscriptions.
|
|
17
|
+
*
|
|
18
|
+
* Guarantees (same honesty as `redis()`): at-most-once, no cross-publisher
|
|
19
|
+
* ordering, no replay. Each instance keeps its own per-process epoch, so a
|
|
20
|
+
* client reconnecting onto a different instance always receives a full
|
|
21
|
+
* snapshot — exactly the protocol's multi-instance-node rule. Real cursor
|
|
22
|
+
* resume needs a shared ordered log (the Cloudflare DO transport).
|
|
23
|
+
*/
|
|
24
|
+
export declare function redisLive(options: RedisLiveOptions): LiveDriver;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const swallow = (op)=>{
|
|
2
|
+
void Promise.resolve(op).catch(()=>{});
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* Cross-instance live-invalidation driver for node/bun via Redis pub/sub —
|
|
6
|
+
* the live sibling of the WebSocket `redis()` sync driver, carrying TAGS
|
|
7
|
+
* instead of frames. Local-first: `dispatch` applies to this instance's
|
|
8
|
+
* engine immediately (its log stamps the returned commit cursor), then fans
|
|
9
|
+
* the command out so every peer re-runs ITS local subscriptions.
|
|
10
|
+
*
|
|
11
|
+
* Guarantees (same honesty as `redis()`): at-most-once, no cross-publisher
|
|
12
|
+
* ordering, no replay. Each instance keeps its own per-process epoch, so a
|
|
13
|
+
* client reconnecting onto a different instance always receives a full
|
|
14
|
+
* snapshot — exactly the protocol's multi-instance-node rule. Real cursor
|
|
15
|
+
* resume needs a shared ordered log (the Cloudflare DO transport).
|
|
16
|
+
*/ export function redisLive(options) {
|
|
17
|
+
const prefix = options.prefix ?? 'vela:live';
|
|
18
|
+
const channel = `${prefix}:invalidate`;
|
|
19
|
+
const origin = crypto.randomUUID();
|
|
20
|
+
let sink;
|
|
21
|
+
let bound = false;
|
|
22
|
+
const onMessage = (ch, message)=>{
|
|
23
|
+
if (ch !== channel) return;
|
|
24
|
+
let cmd;
|
|
25
|
+
try {
|
|
26
|
+
cmd = JSON.parse(message);
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (cmd.origin === origin) return; // our own publish — already applied locally
|
|
31
|
+
if (sink) swallow(sink.applyInvalidation(cmd));
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
kind: 'redis',
|
|
35
|
+
bind (boundSink) {
|
|
36
|
+
sink = boundSink;
|
|
37
|
+
if (bound) return; // idempotent — never stack duplicate 'message' listeners
|
|
38
|
+
bound = true;
|
|
39
|
+
swallow(options.sub.subscribe(channel));
|
|
40
|
+
options.sub.on('message', onMessage);
|
|
41
|
+
},
|
|
42
|
+
async dispatch (cmd) {
|
|
43
|
+
swallow(options.pub.publish(channel, JSON.stringify({
|
|
44
|
+
...cmd,
|
|
45
|
+
origin
|
|
46
|
+
})));
|
|
47
|
+
return sink?.applyInvalidation(cmd);
|
|
48
|
+
},
|
|
49
|
+
stop () {
|
|
50
|
+
if (!bound) return;
|
|
51
|
+
bound = false;
|
|
52
|
+
options.sub.off?.('message', onMessage);
|
|
53
|
+
options.sub.removeListener?.('message', onMessage);
|
|
54
|
+
swallow(options.sub.unsubscribe?.(channel));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velajs/vela",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.1",
|
|
4
4
|
"description": "NestJS-compatible framework for edge runtimes, powered by Hono",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
"types": "./dist/queue/index.d.ts",
|
|
39
39
|
"import": "./dist/queue/index.js"
|
|
40
40
|
},
|
|
41
|
+
"./live": {
|
|
42
|
+
"types": "./dist/live/index.d.ts",
|
|
43
|
+
"import": "./dist/live/index.js"
|
|
44
|
+
},
|
|
41
45
|
"./storage": {
|
|
42
46
|
"types": "./dist/storage/index.d.ts",
|
|
43
47
|
"import": "./dist/storage/index.js"
|
|
@@ -84,6 +88,7 @@
|
|
|
84
88
|
"node": ">=20"
|
|
85
89
|
},
|
|
86
90
|
"dependencies": {
|
|
91
|
+
"@velajs/live-protocol": "^1.0.0",
|
|
87
92
|
"hono": "^4.12.26"
|
|
88
93
|
},
|
|
89
94
|
"peerDependencies": {
|