@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,138 @@
|
|
|
1
|
+
import type { WsClient } from '../index';
|
|
2
|
+
/**
|
|
3
|
+
* Identity captured at subscribe time and replayed into every re-run of the
|
|
4
|
+
* subscription's query — never re-read from the client afterwards. Free-form;
|
|
5
|
+
* `expiresAt` (epoch ms) is the one recognized field: a lapsed identity drops
|
|
6
|
+
* the socket before it receives another scoped push (outbound enforcement —
|
|
7
|
+
* a passive subscriber never trips inbound checks).
|
|
8
|
+
*/
|
|
9
|
+
export interface LiveIdentity {
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
expiresAt?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Second positional argument every `@LiveQuery` handler receives. */
|
|
14
|
+
export interface LiveQueryContext {
|
|
15
|
+
identity?: LiveIdentity;
|
|
16
|
+
/** The subscribed connection's id. */
|
|
17
|
+
clientId: string;
|
|
18
|
+
/** Rooms the connection was in when it subscribed. */
|
|
19
|
+
rooms: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface LiveQueryOptions<A = unknown> {
|
|
22
|
+
/**
|
|
23
|
+
* Dependency tags this query's result is built from — the invalidation
|
|
24
|
+
* contract. Static for the common case; the function form derives
|
|
25
|
+
* per-entity tags from the (parsed) subscribe args. Writes invalidate tags
|
|
26
|
+
* via `LiveInvalidation.invalidate()` (the CRUD bridge does it
|
|
27
|
+
* automatically with `crud:<table>` tags).
|
|
28
|
+
*/
|
|
29
|
+
tags: string[] | ((args: A) => string[]);
|
|
30
|
+
/** Key field for incremental list deltas (default `'id'`). */
|
|
31
|
+
key?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Args validator, run ONCE at subscribe (a zod schema's `.parse` slots in
|
|
34
|
+
* directly). A throw rejects the subscription with a `bad_args` error frame.
|
|
35
|
+
*/
|
|
36
|
+
parse?: (args: unknown) => A;
|
|
37
|
+
}
|
|
38
|
+
/** One `@LiveQuery` declaration on a `@LiveResolver` class. */
|
|
39
|
+
export interface LiveQueryMetadata {
|
|
40
|
+
name: string;
|
|
41
|
+
methodName: string | symbol;
|
|
42
|
+
options: LiveQueryOptions;
|
|
43
|
+
}
|
|
44
|
+
/** Class-level marker meta written by `@LiveResolver()`. */
|
|
45
|
+
export interface LiveResolverMetadata {
|
|
46
|
+
}
|
|
47
|
+
/** A position in this log scope's ordered invalidation log. */
|
|
48
|
+
export interface CommitStamp {
|
|
49
|
+
cursor: number;
|
|
50
|
+
epoch: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resume verdict for a reconnecting subscription:
|
|
54
|
+
* - `'resume'` — nothing the subscription depends on changed while away; keep
|
|
55
|
+
* the client's cached value and just advance its cursor.
|
|
56
|
+
* - `'rerun'` — a relevant tag was invalidated in the gap; re-run and snapshot.
|
|
57
|
+
* - `'snapshot'` — the gap cannot be reasoned about (epoch fork, trimmed log,
|
|
58
|
+
* rollback); re-run and snapshot.
|
|
59
|
+
*/
|
|
60
|
+
export type ResumeVerdict = 'resume' | 'rerun' | 'snapshot';
|
|
61
|
+
/**
|
|
62
|
+
* The ordered tag-invalidation log for ONE log scope (this process on
|
|
63
|
+
* node/bun/deno; one Durable Object on Cloudflare). Cursor = monotonic
|
|
64
|
+
* sequence; epoch = timeline token rolled whenever monotonicity can no longer
|
|
65
|
+
* be guaranteed (process restart, DO reset).
|
|
66
|
+
*/
|
|
67
|
+
export interface CursorLog {
|
|
68
|
+
append(tags: string[]): CommitStamp | Promise<CommitStamp>;
|
|
69
|
+
current(): CommitStamp | Promise<CommitStamp>;
|
|
70
|
+
evaluateResume(sinceCursor: number, sinceEpoch: string, subscriptionTags: string[]): ResumeVerdict | Promise<ResumeVerdict>;
|
|
71
|
+
}
|
|
72
|
+
/** A tag invalidation crossing the driver seam. `room` addresses the log scope holding the subscribers (advisory for local delivery). */
|
|
73
|
+
export interface InvalidationCommand {
|
|
74
|
+
room?: string;
|
|
75
|
+
tags: string[];
|
|
76
|
+
/** Origin instance id — lets pub/sub drivers drop their own echo. */
|
|
77
|
+
origin?: string;
|
|
78
|
+
}
|
|
79
|
+
/** What a driver delivers invalidations INTO — the live engine of one log scope. */
|
|
80
|
+
export interface LiveInvalidationSink {
|
|
81
|
+
applyInvalidation(cmd: InvalidationCommand): CommitStamp | Promise<CommitStamp>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Cross-boundary invalidation transport — the live sibling of the WebSocket
|
|
85
|
+
* `SyncDriver`, carrying TAGS (re-run instructions) instead of pre-serialized
|
|
86
|
+
* frames. `localLive()` is the in-core single-scope default; platform packages
|
|
87
|
+
* (`durableObjectLive()` in @velajs/cloudflare, `redisLive()` in
|
|
88
|
+
* websocket-node) implement it out-of-core. `dispatch` returns the commit
|
|
89
|
+
* stamp of the targeted log scope when the driver can observe it.
|
|
90
|
+
*/
|
|
91
|
+
export interface LiveDriver {
|
|
92
|
+
readonly kind: string;
|
|
93
|
+
bind(sink: LiveInvalidationSink): void;
|
|
94
|
+
dispatch(cmd: InvalidationCommand): CommitStamp | undefined | Promise<CommitStamp | undefined>;
|
|
95
|
+
start?(): void | Promise<void>;
|
|
96
|
+
stop?(): void | Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
/** One live subscription as tracked by the engine (and persisted by transports that survive eviction). */
|
|
99
|
+
export interface SubscriptionRecord {
|
|
100
|
+
sub: string;
|
|
101
|
+
query: string;
|
|
102
|
+
args: unknown;
|
|
103
|
+
/** Frozen at subscribe from the (parsed) args. */
|
|
104
|
+
tags: string[];
|
|
105
|
+
/** Delta key field (subscribe-frame override wins over the handler option). */
|
|
106
|
+
key?: string;
|
|
107
|
+
identity?: LiveIdentity;
|
|
108
|
+
/**
|
|
109
|
+
* The last result this subscription confirmed on the wire (the delta diff
|
|
110
|
+
* baseline). `undefined` after a cursor-resume: the server never re-ran the
|
|
111
|
+
* query, so the next relevant change sends a full snapshot.
|
|
112
|
+
*/
|
|
113
|
+
lastJson?: string;
|
|
114
|
+
lastCursor?: number;
|
|
115
|
+
}
|
|
116
|
+
export interface LivePresenceOptions {
|
|
117
|
+
/** Roster entries older than this are filtered at read time (default 30_000 ms). */
|
|
118
|
+
ttlMs?: number;
|
|
119
|
+
}
|
|
120
|
+
export interface LiveModuleOptions {
|
|
121
|
+
/** Cross-boundary invalidation driver. Defaults to `localLive()`. */
|
|
122
|
+
driver?: LiveDriver;
|
|
123
|
+
/** Ordered invalidation log. Defaults to the in-memory per-process log. */
|
|
124
|
+
log?: CursorLog;
|
|
125
|
+
/**
|
|
126
|
+
* Identity capture at subscribe. Default: a shallow copy of `client.data`
|
|
127
|
+
* (whatever the app's upgrade/`handleConnection` auth stamped there), or
|
|
128
|
+
* `undefined` when empty.
|
|
129
|
+
*/
|
|
130
|
+
identity?: (client: WsClient) => LiveIdentity | undefined;
|
|
131
|
+
/** Presence preset configuration; `false` disables the built-in resolver. */
|
|
132
|
+
presence?: LivePresenceOptions | false;
|
|
133
|
+
}
|
|
134
|
+
/** Meta carried by the `'live'` entrypoint (transports reach the engine through it). */
|
|
135
|
+
export interface LiveEntrypointMeta {
|
|
136
|
+
/** The engine — typed loosely to avoid a circular type edge; cast to `LiveEngine`. */
|
|
137
|
+
engine: unknown;
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** Meta carried by the `'live'` entrypoint (transports reach the engine through it). */ export { };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** The invalidation tag for one room's roster. `$`-prefixed: never collides with app tags. */
|
|
2
|
+
export declare const presenceTag: (room: string) => string;
|
|
3
|
+
/** The built-in roster query name (`useLiveQuery`-able like any app query). */
|
|
4
|
+
export declare const PRESENCE_ROSTER_QUERY = "$presence.roster";
|
|
5
|
+
/** One present connection as returned by the roster query. Keyed by `id` so list deltas apply. */
|
|
6
|
+
export interface PresenceMember {
|
|
7
|
+
/** The connection id that heartbeats this membership. */
|
|
8
|
+
id: string;
|
|
9
|
+
meta?: unknown;
|
|
10
|
+
lastSeen: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The presence preset's server half. Heartbeats arrive as `{ t: 'presence' }`
|
|
14
|
+
* frames on the live socket (routed here by the engine); the roster is a
|
|
15
|
+
* built-in live query invalidated per room. Departure is immediate: the WS
|
|
16
|
+
* close hook reaps the member without waiting out the TTL. Staleness (an
|
|
17
|
+
* ungraceful drop the transport never noticed) is filtered AT READ TIME —
|
|
18
|
+
* no timers, per the edge rules (`setInterval` is forbidden).
|
|
19
|
+
*
|
|
20
|
+
* Scope note: the roster lives in this log scope (one process on node, one
|
|
21
|
+
* room-DO on Cloudflare — where it is exactly the room's roster).
|
|
22
|
+
*/
|
|
23
|
+
export declare class PresenceService {
|
|
24
|
+
private readonly ttlMs;
|
|
25
|
+
readonly enabled: boolean;
|
|
26
|
+
private readonly rooms;
|
|
27
|
+
private readonly roomsByClient;
|
|
28
|
+
private invalidator?;
|
|
29
|
+
constructor(ttlMs?: number, enabled?: boolean);
|
|
30
|
+
/** Wired by the engine so beats/reaps invalidate through the live driver. */
|
|
31
|
+
bindInvalidator(invalidator: (tags: string[]) => void): void;
|
|
32
|
+
beat(room: string, clientId: string, meta?: unknown): void;
|
|
33
|
+
/** Immediate departure on socket close — peers see it without a TTL wait. */
|
|
34
|
+
reap(clientId: string): void;
|
|
35
|
+
roster(room: string): PresenceMember[];
|
|
36
|
+
}
|
|
37
|
+
/** The built-in resolver backing `$presence.roster`. Registered by `LiveModule` unless presence is disabled. */
|
|
38
|
+
export declare class PresenceResolver {
|
|
39
|
+
private readonly presence;
|
|
40
|
+
constructor(presence: PresenceService);
|
|
41
|
+
roster(args: {
|
|
42
|
+
room: string;
|
|
43
|
+
}): PresenceMember[];
|
|
44
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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 { Inject, Injectable } from "../index.js";
|
|
16
|
+
import { LiveQuery, LiveResolver } from "./live.decorators.js";
|
|
17
|
+
/** The invalidation tag for one room's roster. `$`-prefixed: never collides with app tags. */ export const presenceTag = (room)=>`$presence:${room}`;
|
|
18
|
+
/** The built-in roster query name (`useLiveQuery`-able like any app query). */ export const PRESENCE_ROSTER_QUERY = '$presence.roster';
|
|
19
|
+
/**
|
|
20
|
+
* The presence preset's server half. Heartbeats arrive as `{ t: 'presence' }`
|
|
21
|
+
* frames on the live socket (routed here by the engine); the roster is a
|
|
22
|
+
* built-in live query invalidated per room. Departure is immediate: the WS
|
|
23
|
+
* close hook reaps the member without waiting out the TTL. Staleness (an
|
|
24
|
+
* ungraceful drop the transport never noticed) is filtered AT READ TIME —
|
|
25
|
+
* no timers, per the edge rules (`setInterval` is forbidden).
|
|
26
|
+
*
|
|
27
|
+
* Scope note: the roster lives in this log scope (one process on node, one
|
|
28
|
+
* room-DO on Cloudflare — where it is exactly the room's roster).
|
|
29
|
+
*/ export class PresenceService {
|
|
30
|
+
ttlMs;
|
|
31
|
+
enabled;
|
|
32
|
+
rooms = new Map();
|
|
33
|
+
roomsByClient = new Map();
|
|
34
|
+
invalidator;
|
|
35
|
+
constructor(ttlMs = 30_000, enabled = true){
|
|
36
|
+
this.ttlMs = ttlMs;
|
|
37
|
+
this.enabled = enabled;
|
|
38
|
+
}
|
|
39
|
+
/** Wired by the engine so beats/reaps invalidate through the live driver. */ bindInvalidator(invalidator) {
|
|
40
|
+
this.invalidator = invalidator;
|
|
41
|
+
}
|
|
42
|
+
beat(room, clientId, meta) {
|
|
43
|
+
if (!this.enabled) return;
|
|
44
|
+
let state = this.rooms.get(room);
|
|
45
|
+
if (!state) {
|
|
46
|
+
state = {
|
|
47
|
+
members: new Map()
|
|
48
|
+
};
|
|
49
|
+
this.rooms.set(room, state);
|
|
50
|
+
}
|
|
51
|
+
state.members.set(clientId, {
|
|
52
|
+
meta,
|
|
53
|
+
lastSeen: Date.now()
|
|
54
|
+
});
|
|
55
|
+
let joined = this.roomsByClient.get(clientId);
|
|
56
|
+
if (!joined) {
|
|
57
|
+
joined = new Set();
|
|
58
|
+
this.roomsByClient.set(clientId, joined);
|
|
59
|
+
}
|
|
60
|
+
joined.add(room);
|
|
61
|
+
this.invalidator?.([
|
|
62
|
+
presenceTag(room)
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
/** Immediate departure on socket close — peers see it without a TTL wait. */ reap(clientId) {
|
|
66
|
+
const joined = this.roomsByClient.get(clientId);
|
|
67
|
+
if (!joined) return;
|
|
68
|
+
this.roomsByClient.delete(clientId);
|
|
69
|
+
for (const room of joined){
|
|
70
|
+
const state = this.rooms.get(room);
|
|
71
|
+
if (!state) continue;
|
|
72
|
+
state.members.delete(clientId);
|
|
73
|
+
if (state.members.size === 0) this.rooms.delete(room);
|
|
74
|
+
this.invalidator?.([
|
|
75
|
+
presenceTag(room)
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
roster(room) {
|
|
80
|
+
const state = this.rooms.get(room);
|
|
81
|
+
if (!state) return [];
|
|
82
|
+
const oldestAlive = Date.now() - this.ttlMs;
|
|
83
|
+
return [
|
|
84
|
+
...state.members.entries()
|
|
85
|
+
].filter(([, member])=>member.lastSeen >= oldestAlive).map(([id, member])=>({
|
|
86
|
+
id,
|
|
87
|
+
meta: member.meta,
|
|
88
|
+
lastSeen: member.lastSeen
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export class PresenceResolver {
|
|
93
|
+
presence;
|
|
94
|
+
constructor(presence){
|
|
95
|
+
this.presence = presence;
|
|
96
|
+
}
|
|
97
|
+
roster(args) {
|
|
98
|
+
return this.presence.roster(args.room);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
_ts_decorate([
|
|
102
|
+
LiveQuery(PRESENCE_ROSTER_QUERY, {
|
|
103
|
+
tags: (args)=>[
|
|
104
|
+
presenceTag(args.room)
|
|
105
|
+
],
|
|
106
|
+
parse: (args)=>{
|
|
107
|
+
const room = args?.room;
|
|
108
|
+
if (typeof room !== 'string' || room.length === 0) {
|
|
109
|
+
throw new Error("presence roster args require a non-empty 'room' string");
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
room
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
_ts_metadata("design:type", Function),
|
|
117
|
+
_ts_metadata("design:paramtypes", [
|
|
118
|
+
Object
|
|
119
|
+
]),
|
|
120
|
+
_ts_metadata("design:returntype", Array)
|
|
121
|
+
], PresenceResolver.prototype, "roster", null);
|
|
122
|
+
PresenceResolver = _ts_decorate([
|
|
123
|
+
LiveResolver(),
|
|
124
|
+
Injectable(),
|
|
125
|
+
_ts_param(0, Inject(PresenceService)),
|
|
126
|
+
_ts_metadata("design:type", Function),
|
|
127
|
+
_ts_metadata("design:paramtypes", [
|
|
128
|
+
typeof PresenceService === "undefined" ? Object : PresenceService
|
|
129
|
+
])
|
|
130
|
+
], PresenceResolver);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket, WebSocketServer, } from './websocket.decorators';
|
|
1
|
+
export { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket, WebSocketServer, ReservedWsEvent, } from './websocket.decorators';
|
|
2
2
|
export { WebSocketModule } from './websocket.module';
|
|
3
3
|
export type { WebSocketModuleOptions } from './websocket.module';
|
|
4
4
|
export { WsDispatcher, type WsEntrypointMeta } from './ws-dispatcher';
|
|
@@ -8,5 +8,5 @@ export type { SyncDriver, RoomRegistry } from './ws-sync';
|
|
|
8
8
|
export { buildWsExecutionContext } from './ws-execution-context';
|
|
9
9
|
export { WsException, toErrorFrame } from './ws-exception';
|
|
10
10
|
export { resolveWsArgs } from './ws-argument-resolver';
|
|
11
|
-
export { WS_SERVER, WS_SYNC_DRIVER, WS_ROOM_REGISTRY, WS_MODULE_OPTIONS, WS_GATEWAY_METADATA, WS_SUBSCRIBE_METADATA, WsParamType, } from './websocket.tokens';
|
|
12
|
-
export type { WsClient, WsServer, WsMessage, WsResponse, BroadcastCommand, BroadcastOperator, WebSocketGatewayOptions, SubscribeMessageMetadata, WsExecutionContext, WsArgumentsHost, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, } from './websocket.types';
|
|
11
|
+
export { WS_SERVER, WS_SYNC_DRIVER, WS_ROOM_REGISTRY, WS_MODULE_OPTIONS, WS_GATEWAY_METADATA, WS_SUBSCRIBE_METADATA, WS_RESERVED_METADATA, RESERVED_WS_EVENT_PREFIX, WsParamType, } from './websocket.tokens';
|
|
12
|
+
export type { WsClient, WsServer, WsMessage, WsResponse, BroadcastCommand, BroadcastOperator, WebSocketGatewayOptions, SubscribeMessageMetadata, ReservedWsEventMetadata, ReservedWsEventHandler, WsExecutionContext, WsArgumentsHost, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, } from './websocket.types';
|
package/dist/websocket/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Decorators
|
|
2
|
-
export { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket, WebSocketServer } from "./websocket.decorators.js";
|
|
2
|
+
export { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket, WebSocketServer, ReservedWsEvent } from "./websocket.decorators.js";
|
|
3
3
|
// Module
|
|
4
4
|
export { WebSocketModule } from "./websocket.module.js";
|
|
5
5
|
// Dispatcher (injected/called by transports) + the 'websocket' entrypoint meta
|
|
@@ -13,4 +13,4 @@ export { WsException, toErrorFrame } from "./ws-exception.js";
|
|
|
13
13
|
// Argument resolution (used by transports building custom dispatch)
|
|
14
14
|
export { resolveWsArgs } from "./ws-argument-resolver.js";
|
|
15
15
|
// Tokens
|
|
16
|
-
export { WS_SERVER, WS_SYNC_DRIVER, WS_ROOM_REGISTRY, WS_MODULE_OPTIONS, WS_GATEWAY_METADATA, WS_SUBSCRIBE_METADATA, WsParamType } from "./websocket.tokens.js";
|
|
16
|
+
export { WS_SERVER, WS_SYNC_DRIVER, WS_ROOM_REGISTRY, WS_MODULE_OPTIONS, WS_GATEWAY_METADATA, WS_SUBSCRIBE_METADATA, WS_RESERVED_METADATA, RESERVED_WS_EVENT_PREFIX, WsParamType } from "./websocket.tokens.js";
|
|
@@ -16,6 +16,20 @@ export declare function WebSocketGateway(options?: WebSocketGatewayOptions): Cla
|
|
|
16
16
|
* exactly like `@OnEvent` (`event-emitter.decorators.ts`).
|
|
17
17
|
*/
|
|
18
18
|
export declare function SubscribeMessage(event: string): MethodDecorator;
|
|
19
|
+
/**
|
|
20
|
+
* Claims a reserved (`$`-prefixed) envelope event for a framework-module
|
|
21
|
+
* provider (see `ReservedWsEventHandler`). Reserved frames route to the
|
|
22
|
+
* decorated provider across every gateway path — gateways themselves may not
|
|
23
|
+
* subscribe to `$…` events. Stack with `@Injectable()` like `@Processor`.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* @ReservedWsEvent('$live')
|
|
28
|
+
* @Injectable()
|
|
29
|
+
* class LiveEngine implements ReservedWsEventHandler { … }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function ReservedWsEvent(event: string): ClassDecorator;
|
|
19
33
|
/** Injects the inbound message payload (the envelope's `data`) into a handler parameter. */
|
|
20
34
|
export declare const MessageBody: () => ParameterDecorator;
|
|
21
35
|
/** Injects the connected socket (`WsClient`) into a handler parameter. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Scope } from "../constants.js";
|
|
2
2
|
import { Inject } from "../container/decorators.js";
|
|
3
|
+
import { createDiscoverableDecorator } from "../discovery/index.js";
|
|
3
4
|
import { MetadataRegistry } from "../registry/metadata.registry.js";
|
|
4
|
-
import { WS_GATEWAY_METADATA, WS_SERVER, WS_SUBSCRIBE_METADATA, WsParamType } from "./websocket.tokens.js";
|
|
5
|
+
import { RESERVED_WS_EVENT_PREFIX, WS_GATEWAY_METADATA, WS_RESERVED_METADATA, WS_SERVER, WS_SUBSCRIBE_METADATA, WsParamType } from "./websocket.tokens.js";
|
|
5
6
|
/**
|
|
6
7
|
* Marks a class as a WebSocket gateway. Mirrors `@Controller` for the socket
|
|
7
8
|
* transport: registers the class as an injectable singleton and stores its
|
|
@@ -31,6 +32,27 @@ import { WS_GATEWAY_METADATA, WS_SERVER, WS_SUBSCRIBE_METADATA, WsParamType } fr
|
|
|
31
32
|
});
|
|
32
33
|
};
|
|
33
34
|
}
|
|
35
|
+
const ReservedWsEventMeta = createDiscoverableDecorator(WS_RESERVED_METADATA);
|
|
36
|
+
/**
|
|
37
|
+
* Claims a reserved (`$`-prefixed) envelope event for a framework-module
|
|
38
|
+
* provider (see `ReservedWsEventHandler`). Reserved frames route to the
|
|
39
|
+
* decorated provider across every gateway path — gateways themselves may not
|
|
40
|
+
* subscribe to `$…` events. Stack with `@Injectable()` like `@Processor`.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* @ReservedWsEvent('$live')
|
|
45
|
+
* @Injectable()
|
|
46
|
+
* class LiveEngine implements ReservedWsEventHandler { … }
|
|
47
|
+
* ```
|
|
48
|
+
*/ export function ReservedWsEvent(event) {
|
|
49
|
+
if (!event.startsWith(RESERVED_WS_EVENT_PREFIX)) {
|
|
50
|
+
throw new Error(`@ReservedWsEvent('${event}') must claim a '${RESERVED_WS_EVENT_PREFIX}'-prefixed event — ` + 'unprefixed events belong to app gateways (@SubscribeMessage).');
|
|
51
|
+
}
|
|
52
|
+
return ReservedWsEventMeta({
|
|
53
|
+
event
|
|
54
|
+
});
|
|
55
|
+
}
|
|
34
56
|
function wsParamDecorator(type) {
|
|
35
57
|
return ()=>(target, propertyKey, parameterIndex)=>{
|
|
36
58
|
if (propertyKey === undefined) {
|
|
@@ -4,6 +4,13 @@ import type { RoomRegistry, SyncDriver } from './ws-sync';
|
|
|
4
4
|
import type { WsServer } from './websocket.types';
|
|
5
5
|
export declare const WS_GATEWAY_METADATA = "vela:ws-gateway";
|
|
6
6
|
export declare const WS_SUBSCRIBE_METADATA = "vela:ws-subscribe";
|
|
7
|
+
export declare const WS_RESERVED_METADATA = "vela:ws-reserved";
|
|
8
|
+
/**
|
|
9
|
+
* Event names starting with this prefix are RESERVED for framework modules
|
|
10
|
+
* (`$live`, …): app gateways may not subscribe to them, and inbound frames
|
|
11
|
+
* carrying them route to `@ReservedWsEvent` handlers instead of gateways.
|
|
12
|
+
*/
|
|
13
|
+
export declare const RESERVED_WS_EVENT_PREFIX = "$";
|
|
7
14
|
export declare const WsParamType: {
|
|
8
15
|
readonly SOCKET: "ws_socket";
|
|
9
16
|
readonly BODY: "ws_body";
|
|
@@ -2,6 +2,12 @@ import { InjectionToken } from "../container/types.js";
|
|
|
2
2
|
// Free-form metadata keys — same string-token convention as ON_EVENT_METADATA / CRON_METADATA.
|
|
3
3
|
export const WS_GATEWAY_METADATA = 'vela:ws-gateway';
|
|
4
4
|
export const WS_SUBSCRIBE_METADATA = 'vela:ws-subscribe';
|
|
5
|
+
export const WS_RESERVED_METADATA = 'vela:ws-reserved';
|
|
6
|
+
/**
|
|
7
|
+
* Event names starting with this prefix are RESERVED for framework modules
|
|
8
|
+
* (`$live`, …): app gateways may not subscribe to them, and inbound frames
|
|
9
|
+
* carrying them route to `@ReservedWsEvent` handlers instead of gateways.
|
|
10
|
+
*/ export const RESERVED_WS_EVENT_PREFIX = '$';
|
|
5
11
|
// WebSocket parameter-decorator kinds — parallel to `ParamType` for HTTP, keyed
|
|
6
12
|
// by the WS argument resolver rather than the Hono-bound HTTP one.
|
|
7
13
|
export const WsParamType = {
|
|
@@ -70,6 +70,22 @@ export interface SubscribeMessageMetadata {
|
|
|
70
70
|
event: string;
|
|
71
71
|
methodName: string;
|
|
72
72
|
}
|
|
73
|
+
/** Class-level meta written by `@ReservedWsEvent(event)`. */
|
|
74
|
+
export interface ReservedWsEventMetadata {
|
|
75
|
+
/** The reserved (`$`-prefixed) envelope event this provider handles. */
|
|
76
|
+
event: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A provider claiming a reserved (`$`-prefixed) envelope event across EVERY
|
|
80
|
+
* gateway path. Discovered by `WsDispatcher` at bootstrap via the
|
|
81
|
+
* `@ReservedWsEvent` decorator; inbound frames for the event route here
|
|
82
|
+
* (after app-wide guards), never to gateways. `handleSocketClose` fires for
|
|
83
|
+
* every closing socket so the handler can drop per-connection state.
|
|
84
|
+
*/
|
|
85
|
+
export interface ReservedWsEventHandler {
|
|
86
|
+
handleReservedEvent(path: string, client: WsClient, message: WsMessage): void | Promise<void>;
|
|
87
|
+
handleSocketClose?(path: string, client: WsClient): void | Promise<void>;
|
|
88
|
+
}
|
|
73
89
|
/**
|
|
74
90
|
* A fully serializable broadcast instruction. Crosses isolate / Durable-Object /
|
|
75
91
|
* Redis boundaries as JSON, so every sync driver speaks the same command.
|
|
@@ -26,6 +26,7 @@ export declare class WsDispatcher implements OnApplicationBootstrap, Contributes
|
|
|
26
26
|
private readonly server?;
|
|
27
27
|
private readonly routeManager?;
|
|
28
28
|
private readonly gateways;
|
|
29
|
+
private readonly reserved;
|
|
29
30
|
constructor(container: Container, discovery: DiscoveryService, server?: WsServer | undefined, routeManager?: RouteManager | undefined);
|
|
30
31
|
/** Paths of every discovered `@WebSocketGateway` — used by transports to register routes. */
|
|
31
32
|
get gatewayPaths(): string[];
|
|
@@ -36,6 +37,14 @@ export declare class WsDispatcher implements OnApplicationBootstrap, Contributes
|
|
|
36
37
|
handleClose(path: string, client: WsClient, _code: number, _reason: string): Promise<void>;
|
|
37
38
|
handleError(path: string, _client: WsClient, err: unknown): Promise<void>;
|
|
38
39
|
dispatchMessage(path: string, client: WsClient, raw: string | ArrayBuffer): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Route a reserved (`$…`) frame to its claiming handler. App-wide (`APP_*` /
|
|
42
|
+
* `useGlobalGuards`) guards protect reserved frames exactly like gateway
|
|
43
|
+
* messages — guards-first, WS convention; handler/class-tier components are
|
|
44
|
+
* the claiming module's own concern (e.g. live resolvers run their scoped
|
|
45
|
+
* guards at subscribe).
|
|
46
|
+
*/
|
|
47
|
+
private dispatchReserved;
|
|
39
48
|
private reply;
|
|
40
49
|
private runFilters;
|
|
41
50
|
private trySend;
|
|
@@ -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;
|