@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.
Files changed (36) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +1 -1
  3. package/dist/live/index.d.ts +12 -0
  4. package/dist/live/index.js +16 -0
  5. package/dist/live/live.cursor.d.ts +22 -0
  6. package/dist/live/live.cursor.js +56 -0
  7. package/dist/live/live.decorators.d.ts +32 -0
  8. package/dist/live/live.decorators.js +46 -0
  9. package/dist/live/live.delta.d.ts +15 -0
  10. package/dist/live/live.delta.js +51 -0
  11. package/dist/live/live.engine.d.ts +81 -0
  12. package/dist/live/live.engine.js +448 -0
  13. package/dist/live/live.invalidation.d.ts +45 -0
  14. package/dist/live/live.invalidation.js +99 -0
  15. package/dist/live/live.module.d.ts +28 -0
  16. package/dist/live/live.module.js +87 -0
  17. package/dist/live/live.tokens.d.ts +9 -0
  18. package/dist/live/live.tokens.js +8 -0
  19. package/dist/live/live.types.d.ts +138 -0
  20. package/dist/live/live.types.js +1 -0
  21. package/dist/live/presence.d.ts +44 -0
  22. package/dist/live/presence.js +130 -0
  23. package/dist/websocket/index.d.ts +3 -3
  24. package/dist/websocket/index.js +2 -2
  25. package/dist/websocket/websocket.decorators.d.ts +14 -0
  26. package/dist/websocket/websocket.decorators.js +23 -1
  27. package/dist/websocket/websocket.tokens.d.ts +7 -0
  28. package/dist/websocket/websocket.tokens.js +6 -0
  29. package/dist/websocket/websocket.types.d.ts +16 -0
  30. package/dist/websocket/ws-dispatcher.d.ts +9 -0
  31. package/dist/websocket/ws-dispatcher.js +64 -1
  32. package/dist/websocket-node/index.d.ts +2 -0
  33. package/dist/websocket-node/index.js +1 -0
  34. package/dist/websocket-node/redis-live.d.ts +24 -0
  35. package/dist/websocket-node/redis-live.js +57 -0
  36. 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';
@@ -3,3 +3,4 @@
3
3
  export { NodeWsClient } from "./node-ws-client.js";
4
4
  export { registerWebSocketGateways } from "./register-gateways.js";
5
5
  export { redis } from "./redis-sync.js";
6
+ export { redisLive } from "./redis-live.js";
@@ -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.16.0",
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": {