@velajs/vela 1.9.0 → 1.12.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/CHANGELOG.md +122 -0
- package/dist/__tests__/workers-als/entry-als.d.ts +9 -0
- package/dist/application.d.ts +18 -0
- package/dist/application.js +45 -0
- package/dist/cache/cache.module.d.ts +5 -7
- package/dist/cache/cache.module.js +44 -66
- package/dist/cache/cache.types.d.ts +26 -0
- package/dist/cache/cache.types.js +1 -1
- package/dist/cache/index.d.ts +2 -1
- package/dist/cache/index.js +1 -0
- package/dist/cache/tiered-cache.store.d.ts +19 -0
- package/dist/cache/tiered-cache.store.js +40 -0
- package/dist/config/config.module.d.ts +6 -5
- package/dist/config/config.module.js +46 -49
- package/dist/container/container.d.ts +58 -0
- package/dist/container/container.js +223 -18
- package/dist/container/disposable.d.ts +5 -0
- package/dist/container/disposable.js +33 -0
- package/dist/container/types.d.ts +8 -0
- package/dist/cors/cors.module.d.ts +5 -5
- package/dist/cors/cors.module.js +26 -25
- package/dist/discovery/discoverable.decorator.d.ts +38 -0
- package/dist/discovery/discoverable.decorator.js +42 -0
- package/dist/discovery/discovery.service.d.ts +94 -0
- package/dist/discovery/discovery.service.js +166 -0
- package/dist/discovery/index.d.ts +2 -0
- package/dist/discovery/index.js +2 -0
- package/dist/entrypoint/entrypoint.registry.d.ts +40 -0
- package/dist/entrypoint/entrypoint.registry.js +106 -0
- package/dist/entrypoint/entrypoint.types.d.ts +41 -0
- package/dist/entrypoint/entrypoint.types.js +3 -0
- package/dist/entrypoint/execution-context.d.ts +15 -0
- package/dist/entrypoint/execution-context.js +21 -0
- package/dist/entrypoint/execution-scope.d.ts +21 -0
- package/dist/entrypoint/execution-scope.js +28 -0
- package/dist/entrypoint/index.d.ts +4 -0
- package/dist/entrypoint/index.js +4 -0
- package/dist/event-emitter/event-emitter.subscriber.d.ts +3 -3
- package/dist/event-emitter/event-emitter.subscriber.js +12 -29
- package/dist/factory/adapter.d.ts +36 -0
- package/dist/factory/adapter.js +18 -0
- package/dist/factory/bootstrap.js +20 -2
- package/dist/factory.d.ts +11 -1
- package/dist/factory.js +23 -2
- package/dist/fetch/fetch.module.d.ts +5 -6
- package/dist/fetch/fetch.module.js +9 -42
- package/dist/http/ambient.d.ts +17 -0
- package/dist/http/ambient.js +44 -0
- package/dist/http/execution-context.js +4 -1
- package/dist/http/handler-executor.js +22 -19
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.js +1 -0
- package/dist/http/route-contributor.d.ts +68 -0
- package/dist/http/route-contributor.js +22 -0
- package/dist/http/route.manager.d.ts +19 -0
- package/dist/http/route.manager.js +112 -19
- package/dist/i18n/deep-merge.d.ts +2 -0
- package/dist/i18n/deep-merge.js +15 -0
- package/dist/i18n/i18n.middleware.d.ts +15 -0
- package/dist/i18n/i18n.middleware.js +94 -0
- package/dist/i18n/i18n.module.d.ts +13 -0
- package/dist/i18n/i18n.module.js +64 -0
- package/dist/i18n/i18n.options.d.ts +30 -0
- package/dist/i18n/i18n.options.js +16 -0
- package/dist/i18n/i18n.service.d.ts +20 -0
- package/dist/i18n/i18n.service.js +49 -0
- package/dist/i18n/i18n.tokens.d.ts +6 -0
- package/dist/i18n/i18n.tokens.js +3 -0
- package/dist/i18n/i18n.types.d.ts +6 -0
- package/dist/i18n/i18n.types.js +1 -0
- package/dist/i18n/index.d.ts +10 -0
- package/dist/i18n/index.js +8 -0
- package/dist/i18n/message-loader.service.d.ts +21 -0
- package/dist/i18n/message-loader.service.js +90 -0
- package/dist/i18n/message-registry.d.ts +16 -0
- package/dist/i18n/message-registry.js +49 -0
- package/dist/index.d.ts +17 -5
- package/dist/index.js +14 -3
- package/dist/internal.d.ts +0 -2
- package/dist/internal.js +0 -7
- package/dist/module/configurable-module.builder.d.ts +56 -0
- package/dist/module/configurable-module.builder.js +84 -0
- package/dist/module/configurable-module.types.d.ts +93 -0
- package/dist/module/configurable-module.types.js +5 -0
- package/dist/module/define-module.d.ts +105 -0
- package/dist/module/define-module.js +227 -0
- package/dist/module/index.d.ts +4 -0
- package/dist/module/index.js +3 -0
- package/dist/module/lazy-provider.d.ts +65 -0
- package/dist/module/lazy-provider.js +111 -0
- package/dist/openapi/document.js +16 -15
- package/dist/pipeline/component.manager.d.ts +15 -9
- package/dist/pipeline/component.manager.js +32 -46
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pipeline-runner.d.ts +31 -0
- package/dist/pipeline/pipeline-runner.js +40 -0
- package/dist/pipeline/types.d.ts +21 -4
- package/dist/registry/metadata.registry.d.ts +24 -18
- package/dist/registry/metadata.registry.js +137 -49
- package/dist/schedule/schedule.module.d.ts +5 -0
- package/dist/schedule/schedule.module.js +18 -8
- package/dist/schedule/schedule.registry.d.ts +3 -3
- package/dist/schedule/schedule.registry.js +23 -44
- package/dist/schedule-node/schedule-node.module.d.ts +5 -0
- package/dist/schedule-node/schedule-node.module.js +20 -10
- package/dist/seeder/index.d.ts +5 -0
- package/dist/seeder/index.js +4 -0
- package/dist/seeder/seeder.decorator.d.ts +7 -0
- package/dist/seeder/seeder.decorator.js +15 -0
- package/dist/seeder/seeder.module.d.ts +16 -0
- package/dist/seeder/seeder.module.js +41 -0
- package/dist/seeder/seeder.registry.d.ts +23 -0
- package/dist/seeder/seeder.registry.js +83 -0
- package/dist/seeder/seeder.tokens.d.ts +2 -0
- package/dist/seeder/seeder.tokens.js +1 -0
- package/dist/seeder/seeder.types.d.ts +20 -0
- package/dist/seeder/seeder.types.js +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/path-template.d.ts +9 -0
- package/dist/storage/path-template.js +20 -0
- package/dist/storage/signed-url.d.ts +12 -0
- package/dist/storage/signed-url.js +55 -0
- package/dist/storage/storage.types.d.ts +41 -0
- package/dist/storage/storage.types.js +5 -0
- package/dist/throttler/throttler.module.d.ts +5 -8
- package/dist/throttler/throttler.module.js +27 -55
- package/dist/websocket/index.d.ts +12 -0
- package/dist/websocket/index.js +16 -0
- package/dist/websocket/websocket.decorators.d.ts +33 -0
- package/dist/websocket/websocket.decorators.js +58 -0
- package/dist/websocket/websocket.module.d.ts +35 -0
- package/dist/websocket/websocket.module.js +63 -0
- package/dist/websocket/websocket.tokens.d.ts +23 -0
- package/dist/websocket/websocket.tokens.js +21 -0
- package/dist/websocket/websocket.types.d.ts +86 -0
- package/dist/websocket/websocket.types.js +4 -0
- package/dist/websocket/ws-argument-resolver.d.ts +13 -0
- package/dist/websocket/ws-argument-resolver.js +40 -0
- package/dist/websocket/ws-dispatcher.d.ts +45 -0
- package/dist/websocket/ws-dispatcher.js +259 -0
- package/dist/websocket/ws-exception.d.ts +16 -0
- package/dist/websocket/ws-exception.js +32 -0
- package/dist/websocket/ws-execution-context.d.ts +9 -0
- package/dist/websocket/ws-execution-context.js +24 -0
- package/dist/websocket/ws-server.d.ts +31 -0
- package/dist/websocket/ws-server.js +74 -0
- package/dist/websocket/ws-sync.d.ts +47 -0
- package/dist/websocket/ws-sync.js +74 -0
- package/dist/websocket-node/index.d.ts +4 -0
- package/dist/websocket-node/index.js +5 -0
- package/dist/websocket-node/node-ws-client.d.ts +24 -0
- package/dist/websocket-node/node-ws-client.js +49 -0
- package/dist/websocket-node/redis-sync.d.ts +33 -0
- package/dist/websocket-node/redis-sync.js +56 -0
- package/dist/websocket-node/register-gateways.d.ts +20 -0
- package/dist/websocket-node/register-gateways.js +61 -0
- package/package.json +31 -1
- package/dist/http/crud-bridge.d.ts +0 -90
- package/dist/http/crud-bridge.js +0 -25
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SyncDriver } from './ws-sync';
|
|
2
|
+
import type { BroadcastOperator, WsServer } from './websocket.types';
|
|
3
|
+
/**
|
|
4
|
+
* Accumulates `{ rooms, exceptRooms, exceptIds }` across a fluent chain and, on
|
|
5
|
+
* `.emit()`, produces a serializable `BroadcastCommand` handed to the active
|
|
6
|
+
* `SyncDriver`. The driver decides local-vs-remote delivery.
|
|
7
|
+
*/
|
|
8
|
+
export declare class BroadcastOperatorImpl implements BroadcastOperator {
|
|
9
|
+
private readonly driver;
|
|
10
|
+
private readonly rooms;
|
|
11
|
+
private readonly exceptRooms;
|
|
12
|
+
private readonly exceptIds;
|
|
13
|
+
constructor(driver: SyncDriver, rooms?: Set<string>, exceptRooms?: Set<string>, exceptIds?: Set<string>);
|
|
14
|
+
to(room: string): this;
|
|
15
|
+
in(room: string): this;
|
|
16
|
+
except(room: string): this;
|
|
17
|
+
emit(event: string, data?: unknown): void | Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The `@WebSocketServer()`-injected handle. Server-origin broadcasts never
|
|
21
|
+
* pre-exclude anyone (a client-origin `client.to()` seeds `exceptIds` with the
|
|
22
|
+
* sender — that lives in the transport's client, not here).
|
|
23
|
+
*/
|
|
24
|
+
export declare class WsServerImpl implements WsServer {
|
|
25
|
+
private readonly driver;
|
|
26
|
+
constructor(driver: SyncDriver);
|
|
27
|
+
emit(event: string, data?: unknown): void | Promise<void>;
|
|
28
|
+
to(room: string): BroadcastOperator;
|
|
29
|
+
in(room: string): BroadcastOperator;
|
|
30
|
+
except(room: string): BroadcastOperator;
|
|
31
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accumulates `{ rooms, exceptRooms, exceptIds }` across a fluent chain and, on
|
|
3
|
+
* `.emit()`, produces a serializable `BroadcastCommand` handed to the active
|
|
4
|
+
* `SyncDriver`. The driver decides local-vs-remote delivery.
|
|
5
|
+
*/ export class BroadcastOperatorImpl {
|
|
6
|
+
driver;
|
|
7
|
+
rooms;
|
|
8
|
+
exceptRooms;
|
|
9
|
+
exceptIds;
|
|
10
|
+
constructor(driver, rooms = new Set(), exceptRooms = new Set(), exceptIds = new Set()){
|
|
11
|
+
this.driver = driver;
|
|
12
|
+
this.rooms = rooms;
|
|
13
|
+
this.exceptRooms = exceptRooms;
|
|
14
|
+
this.exceptIds = exceptIds;
|
|
15
|
+
}
|
|
16
|
+
to(room) {
|
|
17
|
+
this.rooms.add(room);
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
in(room) {
|
|
21
|
+
return this.to(room);
|
|
22
|
+
}
|
|
23
|
+
except(room) {
|
|
24
|
+
this.exceptRooms.add(room);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
emit(event, data) {
|
|
28
|
+
const cmd = {
|
|
29
|
+
rooms: [
|
|
30
|
+
...this.rooms
|
|
31
|
+
],
|
|
32
|
+
exceptRooms: this.exceptRooms.size ? [
|
|
33
|
+
...this.exceptRooms
|
|
34
|
+
] : undefined,
|
|
35
|
+
exceptIds: this.exceptIds.size ? [
|
|
36
|
+
...this.exceptIds
|
|
37
|
+
] : undefined,
|
|
38
|
+
frame: JSON.stringify({
|
|
39
|
+
event,
|
|
40
|
+
data
|
|
41
|
+
})
|
|
42
|
+
};
|
|
43
|
+
return this.driver.dispatch(cmd);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The `@WebSocketServer()`-injected handle. Server-origin broadcasts never
|
|
48
|
+
* pre-exclude anyone (a client-origin `client.to()` seeds `exceptIds` with the
|
|
49
|
+
* sender — that lives in the transport's client, not here).
|
|
50
|
+
*/ export class WsServerImpl {
|
|
51
|
+
driver;
|
|
52
|
+
constructor(driver){
|
|
53
|
+
this.driver = driver;
|
|
54
|
+
}
|
|
55
|
+
emit(event, data) {
|
|
56
|
+
// Empty `rooms` means GLOBAL.
|
|
57
|
+
return this.driver.dispatch({
|
|
58
|
+
rooms: [],
|
|
59
|
+
frame: JSON.stringify({
|
|
60
|
+
event,
|
|
61
|
+
data
|
|
62
|
+
})
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
to(room) {
|
|
66
|
+
return new BroadcastOperatorImpl(this.driver).to(room);
|
|
67
|
+
}
|
|
68
|
+
in(room) {
|
|
69
|
+
return this.to(room);
|
|
70
|
+
}
|
|
71
|
+
except(room) {
|
|
72
|
+
return new BroadcastOperatorImpl(this.driver).except(room);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { BroadcastCommand, WsClient } from './websocket.types';
|
|
2
|
+
/**
|
|
3
|
+
* Owns LOCAL room membership and local fan-out for the connections a single
|
|
4
|
+
* node/isolate holds. The Cloudflare transport backs this with hibernation
|
|
5
|
+
* tags + attachment; node/bun/deno back it with the in-memory implementation
|
|
6
|
+
* below. Always edge-safe (no `node:*`).
|
|
7
|
+
*/
|
|
8
|
+
export interface RoomRegistry {
|
|
9
|
+
/** Register a connection (called on connect) so global broadcasts can reach it. */
|
|
10
|
+
register(client: WsClient): void;
|
|
11
|
+
join(client: WsClient, room: string): void | Promise<void>;
|
|
12
|
+
leave(client: WsClient, room: string): void | Promise<void>;
|
|
13
|
+
/** Remove a connection and all its room memberships (called on disconnect). */
|
|
14
|
+
leaveAll(client: WsClient): void | Promise<void>;
|
|
15
|
+
/** Deliver a command to the sockets THIS node holds, applying every exclusion. */
|
|
16
|
+
deliverLocal(cmd: BroadcastCommand): void | Promise<void>;
|
|
17
|
+
localIdsInRoom(room: string): string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Pluggable cross-instance transport for broadcast commands. `local()` is the
|
|
21
|
+
* zero-config single-instance default; `durableObject()` (Cloudflare) and
|
|
22
|
+
* `redis()` (node) are additive and chosen at the composition root.
|
|
23
|
+
*/
|
|
24
|
+
export interface SyncDriver {
|
|
25
|
+
readonly kind: string;
|
|
26
|
+
/** Wire the local registry so the driver can deliver to locally-held sockets. */
|
|
27
|
+
bind(registry: RoomRegistry): void;
|
|
28
|
+
/** Route a command so every node that may hold a matching socket delivers it. */
|
|
29
|
+
dispatch(cmd: BroadcastCommand): void | Promise<void>;
|
|
30
|
+
countRoom?(room: string): Promise<number>;
|
|
31
|
+
start?(): void | Promise<void>;
|
|
32
|
+
stop?(): void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** Single-instance default: deliver straight to the local registry, no cross-node hop. */
|
|
35
|
+
export declare function local(): SyncDriver;
|
|
36
|
+
/** In-memory `RoomRegistry` for node/bun/deno and tests. Edge-safe (plain Maps). */
|
|
37
|
+
export declare class InMemoryRoomRegistry implements RoomRegistry {
|
|
38
|
+
private readonly rooms;
|
|
39
|
+
private readonly clientRooms;
|
|
40
|
+
private readonly clients;
|
|
41
|
+
register(client: WsClient): void;
|
|
42
|
+
join(client: WsClient, room: string): void;
|
|
43
|
+
leave(client: WsClient, room: string): void;
|
|
44
|
+
leaveAll(client: WsClient): void;
|
|
45
|
+
localIdsInRoom(room: string): string[];
|
|
46
|
+
deliverLocal(cmd: BroadcastCommand): void;
|
|
47
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Single-instance default: deliver straight to the local registry, no cross-node hop. */ export function local() {
|
|
2
|
+
let registry;
|
|
3
|
+
return {
|
|
4
|
+
kind: 'local',
|
|
5
|
+
bind (r) {
|
|
6
|
+
registry = r;
|
|
7
|
+
},
|
|
8
|
+
dispatch (cmd) {
|
|
9
|
+
return registry?.deliverLocal(cmd);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** In-memory `RoomRegistry` for node/bun/deno and tests. Edge-safe (plain Maps). */ export class InMemoryRoomRegistry {
|
|
14
|
+
rooms = new Map();
|
|
15
|
+
clientRooms = new Map();
|
|
16
|
+
clients = new Map();
|
|
17
|
+
register(client) {
|
|
18
|
+
this.clients.set(client.id, client);
|
|
19
|
+
}
|
|
20
|
+
join(client, room) {
|
|
21
|
+
this.register(client);
|
|
22
|
+
let members = this.rooms.get(room);
|
|
23
|
+
if (!members) {
|
|
24
|
+
members = new Set();
|
|
25
|
+
this.rooms.set(room, members);
|
|
26
|
+
}
|
|
27
|
+
members.add(client);
|
|
28
|
+
let joined = this.clientRooms.get(client.id);
|
|
29
|
+
if (!joined) {
|
|
30
|
+
joined = new Set();
|
|
31
|
+
this.clientRooms.set(client.id, joined);
|
|
32
|
+
}
|
|
33
|
+
joined.add(room);
|
|
34
|
+
}
|
|
35
|
+
leave(client, room) {
|
|
36
|
+
const members = this.rooms.get(room);
|
|
37
|
+
members?.delete(client);
|
|
38
|
+
if (members && members.size === 0) this.rooms.delete(room);
|
|
39
|
+
this.clientRooms.get(client.id)?.delete(room);
|
|
40
|
+
}
|
|
41
|
+
leaveAll(client) {
|
|
42
|
+
for (const room of this.clientRooms.get(client.id) ?? []){
|
|
43
|
+
const members = this.rooms.get(room);
|
|
44
|
+
members?.delete(client);
|
|
45
|
+
if (members && members.size === 0) this.rooms.delete(room);
|
|
46
|
+
}
|
|
47
|
+
this.clientRooms.delete(client.id);
|
|
48
|
+
this.clients.delete(client.id);
|
|
49
|
+
}
|
|
50
|
+
localIdsInRoom(room) {
|
|
51
|
+
return [
|
|
52
|
+
...this.rooms.get(room) ?? []
|
|
53
|
+
].map((c)=>c.id);
|
|
54
|
+
}
|
|
55
|
+
deliverLocal(cmd) {
|
|
56
|
+
const excludeIds = new Set(cmd.exceptIds ?? []);
|
|
57
|
+
const excludeRooms = cmd.exceptRooms ?? [];
|
|
58
|
+
// Empty `rooms` => GLOBAL: every registered connection. Otherwise the union
|
|
59
|
+
// of the targeted rooms' members (deduped per connection).
|
|
60
|
+
const candidates = cmd.rooms.length === 0 ? new Set(this.clients.values()) : (()=>{
|
|
61
|
+
const set = new Set();
|
|
62
|
+
for (const room of cmd.rooms){
|
|
63
|
+
for (const client of this.rooms.get(room) ?? [])set.add(client);
|
|
64
|
+
}
|
|
65
|
+
return set;
|
|
66
|
+
})();
|
|
67
|
+
for (const client of candidates){
|
|
68
|
+
if (excludeIds.has(client.id)) continue;
|
|
69
|
+
const joined = this.clientRooms.get(client.id);
|
|
70
|
+
if (excludeRooms.some((room)=>joined?.has(room))) continue;
|
|
71
|
+
client.sendRaw(cmd.frame);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Node / Bun / Deno WebSocket adapter for the Vela WebSocketModule.
|
|
2
|
+
// Transport uses Hono's own `upgradeWebSocket` (no Durable Object hibernation).
|
|
3
|
+
export { NodeWsClient } from "./node-ws-client.js";
|
|
4
|
+
export { registerWebSocketGateways } from "./register-gateways.js";
|
|
5
|
+
export { redis } from "./redis-sync.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { WSContext } from 'hono/ws';
|
|
2
|
+
import type { RoomRegistry, WsClient } from '../websocket/index';
|
|
3
|
+
/**
|
|
4
|
+
* Core `WsClient` over Hono's `WSContext` (node/bun/deno — no hibernation).
|
|
5
|
+
* Room membership is mirrored into the shared `RoomRegistry` so broadcasts from
|
|
6
|
+
* the `WsServer` reach this connection.
|
|
7
|
+
*/
|
|
8
|
+
export declare class NodeWsClient<TData extends Record<string, unknown> = Record<string, unknown>> implements WsClient<TData> {
|
|
9
|
+
private readonly ws;
|
|
10
|
+
private readonly registry;
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly id: string;
|
|
13
|
+
data: TData;
|
|
14
|
+
private readonly _rooms;
|
|
15
|
+
constructor(ws: WSContext, registry: RoomRegistry, path: string);
|
|
16
|
+
get rooms(): ReadonlySet<string>;
|
|
17
|
+
get raw(): unknown;
|
|
18
|
+
send(event: string, data?: unknown, id?: string): void;
|
|
19
|
+
sendRaw(payload: string): void;
|
|
20
|
+
join(room: string): void | Promise<void>;
|
|
21
|
+
leave(room: string): void | Promise<void>;
|
|
22
|
+
commit(): void;
|
|
23
|
+
close(code?: number, reason?: string): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core `WsClient` over Hono's `WSContext` (node/bun/deno — no hibernation).
|
|
3
|
+
* Room membership is mirrored into the shared `RoomRegistry` so broadcasts from
|
|
4
|
+
* the `WsServer` reach this connection.
|
|
5
|
+
*/ export class NodeWsClient {
|
|
6
|
+
ws;
|
|
7
|
+
registry;
|
|
8
|
+
path;
|
|
9
|
+
id = crypto.randomUUID();
|
|
10
|
+
data = {};
|
|
11
|
+
_rooms = new Set();
|
|
12
|
+
constructor(ws, registry, path){
|
|
13
|
+
this.ws = ws;
|
|
14
|
+
this.registry = registry;
|
|
15
|
+
this.path = path;
|
|
16
|
+
}
|
|
17
|
+
get rooms() {
|
|
18
|
+
return this._rooms;
|
|
19
|
+
}
|
|
20
|
+
get raw() {
|
|
21
|
+
return this.ws;
|
|
22
|
+
}
|
|
23
|
+
send(event, data, id) {
|
|
24
|
+
this.ws.send(JSON.stringify(id !== undefined ? {
|
|
25
|
+
id,
|
|
26
|
+
event,
|
|
27
|
+
data
|
|
28
|
+
} : {
|
|
29
|
+
event,
|
|
30
|
+
data
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
sendRaw(payload) {
|
|
34
|
+
this.ws.send(payload);
|
|
35
|
+
}
|
|
36
|
+
join(room) {
|
|
37
|
+
this._rooms.add(room);
|
|
38
|
+
return this.registry.join(this, room);
|
|
39
|
+
}
|
|
40
|
+
leave(room) {
|
|
41
|
+
this._rooms.delete(room);
|
|
42
|
+
return this.registry.leave(this, room);
|
|
43
|
+
}
|
|
44
|
+
// In-memory registry mutates eagerly; nothing deferred to persist.
|
|
45
|
+
commit() {}
|
|
46
|
+
close(code, reason) {
|
|
47
|
+
this.ws.close(code, reason);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SyncDriver } from '../websocket/index';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal Redis pub/sub surface (satisfied by `ioredis` / `node-redis`). Pub and
|
|
4
|
+
* sub MUST be separate connections — a subscribed Redis connection cannot publish.
|
|
5
|
+
* `off`/`removeListener`/`unsubscribe` are optional and used only by `stop()`.
|
|
6
|
+
*/
|
|
7
|
+
export interface RedisPubSubClient {
|
|
8
|
+
publish(channel: string, message: string): unknown;
|
|
9
|
+
subscribe(channel: string): unknown;
|
|
10
|
+
on(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
11
|
+
off?(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
12
|
+
removeListener?(event: 'message', listener: (channel: string, message: string) => void): unknown;
|
|
13
|
+
unsubscribe?(channel: string): unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface RedisSyncOptions {
|
|
16
|
+
/** Connection used to publish broadcast commands. */
|
|
17
|
+
pub: RedisPubSubClient;
|
|
18
|
+
/** Separate connection used to subscribe (kept in subscriber mode). */
|
|
19
|
+
sub: RedisPubSubClient;
|
|
20
|
+
/** Channel prefix. Default `vela:ws`. */
|
|
21
|
+
prefix?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Cross-instance sync driver for node/bun via Redis pub/sub. Local-first: every
|
|
25
|
+
* `dispatch` delivers to this instance's sockets immediately, then fans the
|
|
26
|
+
* command out to peers on a single broadcast channel; each peer's registry
|
|
27
|
+
* filters delivery to its own local room members. The publishing instance drops
|
|
28
|
+
* its own echo via an `origin` stamp.
|
|
29
|
+
*
|
|
30
|
+
* Guarantees: at-most-once, no ordering across publishers, no replay — a fan-out
|
|
31
|
+
* bus, not a log. Pass it to `WebSocketModule.forRoot({ sync: redis({ pub, sub }) })`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function redis(options: RedisSyncOptions): SyncDriver;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const swallow = (op)=>{
|
|
2
|
+
// Redis client calls return promises that reject on transport failure; a
|
|
3
|
+
// fan-out miss must never become an unhandled rejection that crashes the app.
|
|
4
|
+
void Promise.resolve(op).catch(()=>{});
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Cross-instance sync driver for node/bun via Redis pub/sub. Local-first: every
|
|
8
|
+
* `dispatch` delivers to this instance's sockets immediately, then fans the
|
|
9
|
+
* command out to peers on a single broadcast channel; each peer's registry
|
|
10
|
+
* filters delivery to its own local room members. The publishing instance drops
|
|
11
|
+
* its own echo via an `origin` stamp.
|
|
12
|
+
*
|
|
13
|
+
* Guarantees: at-most-once, no ordering across publishers, no replay — a fan-out
|
|
14
|
+
* bus, not a log. Pass it to `WebSocketModule.forRoot({ sync: redis({ pub, sub }) })`.
|
|
15
|
+
*/ export function redis(options) {
|
|
16
|
+
const prefix = options.prefix ?? 'vela:ws';
|
|
17
|
+
const channel = `${prefix}:broadcast`;
|
|
18
|
+
const origin = crypto.randomUUID();
|
|
19
|
+
let registry;
|
|
20
|
+
let bound = false;
|
|
21
|
+
const onMessage = (ch, message)=>{
|
|
22
|
+
if (ch !== channel) return;
|
|
23
|
+
let cmd;
|
|
24
|
+
try {
|
|
25
|
+
cmd = JSON.parse(message);
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (cmd.origin === origin) return; // our own publish — already delivered locally
|
|
30
|
+
swallow(registry?.deliverLocal(cmd));
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
kind: 'redis',
|
|
34
|
+
bind (reg) {
|
|
35
|
+
registry = reg;
|
|
36
|
+
if (bound) return; // idempotent — never stack duplicate 'message' listeners on re-bind
|
|
37
|
+
bound = true;
|
|
38
|
+
swallow(options.sub.subscribe(channel));
|
|
39
|
+
options.sub.on('message', onMessage);
|
|
40
|
+
},
|
|
41
|
+
dispatch (cmd) {
|
|
42
|
+
swallow(registry?.deliverLocal(cmd));
|
|
43
|
+
swallow(options.pub.publish(channel, JSON.stringify({
|
|
44
|
+
...cmd,
|
|
45
|
+
origin
|
|
46
|
+
})));
|
|
47
|
+
},
|
|
48
|
+
stop () {
|
|
49
|
+
if (!bound) return;
|
|
50
|
+
bound = false;
|
|
51
|
+
options.sub.off?.('message', onMessage);
|
|
52
|
+
options.sub.removeListener?.('message', onMessage);
|
|
53
|
+
swallow(options.sub.unsubscribe?.(channel));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { UpgradeWebSocket } from 'hono/ws';
|
|
2
|
+
import type { VelaApplication } from '../application';
|
|
3
|
+
/**
|
|
4
|
+
* Registers every `@WebSocketGateway` on the app's Hono instance using the
|
|
5
|
+
* runtime's Hono `upgradeWebSocket` factory — the same call works on node
|
|
6
|
+
* (`@hono/node-ws` / `@hono/node-server` v2), Bun (`hono/bun`), and Deno
|
|
7
|
+
* (`hono/deno`). Each connection auto-joins the room from a `:id` route param
|
|
8
|
+
* (or the route path), mirroring the Cloudflare DO-per-room model.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* // Node
|
|
13
|
+
* const app = await VelaFactory.create(AppModule);
|
|
14
|
+
* const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app: app.getHonoApp() });
|
|
15
|
+
* registerWebSocketGateways(app, upgradeWebSocket);
|
|
16
|
+
* const server = serve({ fetch: app.fetch, port: 3000 });
|
|
17
|
+
* injectWebSocket(server);
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function registerWebSocketGateways(app: VelaApplication, upgradeWebSocket: UpgradeWebSocket): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { WS_ROOM_REGISTRY } from "../websocket/index.js";
|
|
2
|
+
import { NodeWsClient } from "./node-ws-client.js";
|
|
3
|
+
function toText(data) {
|
|
4
|
+
if (typeof data === 'string') return data;
|
|
5
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
6
|
+
// Blob / SharedArrayBuffer — binary is out of scope for the JSON envelope protocol.
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Registers every `@WebSocketGateway` on the app's Hono instance using the
|
|
11
|
+
* runtime's Hono `upgradeWebSocket` factory — the same call works on node
|
|
12
|
+
* (`@hono/node-ws` / `@hono/node-server` v2), Bun (`hono/bun`), and Deno
|
|
13
|
+
* (`hono/deno`). Each connection auto-joins the room from a `:id` route param
|
|
14
|
+
* (or the route path), mirroring the Cloudflare DO-per-room model.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* // Node
|
|
19
|
+
* const app = await VelaFactory.create(AppModule);
|
|
20
|
+
* const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app: app.getHonoApp() });
|
|
21
|
+
* registerWebSocketGateways(app, upgradeWebSocket);
|
|
22
|
+
* const server = serve({ fetch: app.fetch, port: 3000 });
|
|
23
|
+
* injectWebSocket(server);
|
|
24
|
+
* ```
|
|
25
|
+
*/ export function registerWebSocketGateways(app, upgradeWebSocket) {
|
|
26
|
+
const hono = app.getHonoApp();
|
|
27
|
+
const registry = app.get(WS_ROOM_REGISTRY);
|
|
28
|
+
for (const { meta } of app.entrypoints.ofKind('websocket')){
|
|
29
|
+
const { path, dispatcher } = meta;
|
|
30
|
+
hono.get(path, upgradeWebSocket((c)=>{
|
|
31
|
+
const roomId = c.req.param('id') ?? path;
|
|
32
|
+
let client;
|
|
33
|
+
// Connection-setup barrier: messages queue behind join + handleConnection
|
|
34
|
+
// so an auth check in handleConnection runs before any message dispatches.
|
|
35
|
+
let ready = Promise.resolve();
|
|
36
|
+
const onSetupError = (err)=>{
|
|
37
|
+
void dispatcher.handleError(path, client, err);
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
onOpen: (_evt, ws)=>{
|
|
41
|
+
client = new NodeWsClient(ws, registry, path);
|
|
42
|
+
registry.register(client);
|
|
43
|
+
ready = Promise.resolve(client.join(roomId)).then(()=>dispatcher.handleOpen(path, client)).catch(onSetupError);
|
|
44
|
+
},
|
|
45
|
+
onMessage: (evt)=>{
|
|
46
|
+
const text = toText(evt.data);
|
|
47
|
+
if (text === undefined) return;
|
|
48
|
+
void ready.then(()=>dispatcher.dispatchMessage(path, client, text)).catch(onSetupError);
|
|
49
|
+
},
|
|
50
|
+
onClose: (evt)=>{
|
|
51
|
+
const code = evt.code || 1000;
|
|
52
|
+
const reason = evt.reason || '';
|
|
53
|
+
void Promise.resolve(dispatcher.handleClose(path, client, code, reason)).catch(onSetupError).finally(()=>registry.leaveAll(client));
|
|
54
|
+
},
|
|
55
|
+
onError: (evt)=>{
|
|
56
|
+
void dispatcher.handleError(path, client, evt);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velajs/vela",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "NestJS-compatible framework for edge runtimes, powered by Hono",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,6 +21,26 @@
|
|
|
21
21
|
"./schedule-node": {
|
|
22
22
|
"types": "./dist/schedule-node/index.d.ts",
|
|
23
23
|
"import": "./dist/schedule-node/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./websocket": {
|
|
26
|
+
"types": "./dist/websocket/index.d.ts",
|
|
27
|
+
"import": "./dist/websocket/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./websocket-node": {
|
|
30
|
+
"types": "./dist/websocket-node/index.d.ts",
|
|
31
|
+
"import": "./dist/websocket-node/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./i18n": {
|
|
34
|
+
"types": "./dist/i18n/index.d.ts",
|
|
35
|
+
"import": "./dist/i18n/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./storage": {
|
|
38
|
+
"types": "./dist/storage/index.d.ts",
|
|
39
|
+
"import": "./dist/storage/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./seeder": {
|
|
42
|
+
"types": "./dist/seeder/index.d.ts",
|
|
43
|
+
"import": "./dist/seeder/index.js"
|
|
24
44
|
}
|
|
25
45
|
},
|
|
26
46
|
"files": [
|
|
@@ -62,6 +82,14 @@
|
|
|
62
82
|
"dependencies": {
|
|
63
83
|
"hono": "^4.12.26"
|
|
64
84
|
},
|
|
85
|
+
"peerDependencies": {
|
|
86
|
+
"intl-messageformat": ">=10"
|
|
87
|
+
},
|
|
88
|
+
"peerDependenciesMeta": {
|
|
89
|
+
"intl-messageformat": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
},
|
|
65
93
|
"devDependencies": {
|
|
66
94
|
"@cloudflare/vitest-pool-workers": "^0.16.18",
|
|
67
95
|
"@swc/cli": "^0.8.1",
|
|
@@ -69,6 +97,7 @@
|
|
|
69
97
|
"@types/bun": "latest",
|
|
70
98
|
"@vitest/runner": "^4.1.9",
|
|
71
99
|
"@vitest/snapshot": "^4.1.9",
|
|
100
|
+
"intl-messageformat": "^11.2.9",
|
|
72
101
|
"typescript": "^6.0.3",
|
|
73
102
|
"unplugin-swc": "^1.5.9",
|
|
74
103
|
"vitest": "^4.1.9",
|
|
@@ -78,6 +107,7 @@
|
|
|
78
107
|
"build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
|
|
79
108
|
"test": "vitest run",
|
|
80
109
|
"test:workers": "vitest run --config vitest.config.workers.ts",
|
|
110
|
+
"test:workers:als": "vitest run --config vitest.config.workers-als.ts",
|
|
81
111
|
"typecheck": "tsc --noEmit"
|
|
82
112
|
}
|
|
83
113
|
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import type { Hono } from 'hono';
|
|
2
|
-
import type { Type } from '../container/types';
|
|
3
|
-
import type { CanActivate } from '../pipeline/types';
|
|
4
|
-
import type { OpenApiPathItem } from '../openapi/types';
|
|
5
|
-
/**
|
|
6
|
-
* Context passed to {@link CrudBridge.buildRoutes} when mounting CRUD
|
|
7
|
-
* sub-applications for a `@Crud()`-decorated controller.
|
|
8
|
-
*
|
|
9
|
-
* The bridge receives the framework's already-resolved view of the world so
|
|
10
|
-
* it can produce identical routing behavior to a hand-written controller:
|
|
11
|
-
* the same global prefix is prepended to every path, the same global guard
|
|
12
|
-
* instances run before any generated handler, and the same path-joining
|
|
13
|
-
* helper composes URL segments without double slashes.
|
|
14
|
-
*/
|
|
15
|
-
export interface CrudBridgeRouteContext {
|
|
16
|
-
/** Application-wide path prefix (e.g. `/api`), already normalized. */
|
|
17
|
-
globalPrefix: string;
|
|
18
|
-
/** Global guard instances, pre-resolved from the root container. */
|
|
19
|
-
globalGuards: CanActivate[];
|
|
20
|
-
/** Path joiner used by the framework — handles slash normalization. */
|
|
21
|
-
joinPaths: (...parts: string[]) => string;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Context passed to {@link CrudBridge.buildOpenApiPaths} when contributing
|
|
25
|
-
* OpenAPI path items for a `@Crud()`-decorated controller.
|
|
26
|
-
*
|
|
27
|
-
* The bridge is responsible for producing fully-qualified path keys that
|
|
28
|
-
* match the routes it actually mounts at request time. The framework supplies
|
|
29
|
-
* the prefixes so the bridge does not have to re-derive them.
|
|
30
|
-
*/
|
|
31
|
-
export interface CrudBridgeOpenApiContext {
|
|
32
|
-
/** Application-wide path prefix (e.g. `/api`), already normalized. */
|
|
33
|
-
globalPrefix: string;
|
|
34
|
-
/** Controller-level prefix as registered with `@Controller(...)`. */
|
|
35
|
-
controllerPrefix: string;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Contract between `@velajs/vela` and a CRUD route generator (typically
|
|
39
|
-
* `@velajs/crud`). The bridge inverts the previous dynamic-import pattern,
|
|
40
|
-
* which was incompatible with Cloudflare Workers because esbuild leaves
|
|
41
|
-
* `await import(variable)` calls as runtime imports the Workers loader
|
|
42
|
-
* cannot resolve.
|
|
43
|
-
*
|
|
44
|
-
* A consumer registers an implementation once via {@link registerCrudBridge}
|
|
45
|
-
* — usually as a side effect of importing `@velajs/crud` — and the framework
|
|
46
|
-
* delegates both route building (at bootstrap) and OpenAPI generation
|
|
47
|
-
* (at document creation) to it.
|
|
48
|
-
*/
|
|
49
|
-
export interface CrudBridge {
|
|
50
|
-
/**
|
|
51
|
-
* Mount the CRUD sub-app for a controller with `vela:crud` metadata.
|
|
52
|
-
*
|
|
53
|
-
* Called during {@link RouteManager.build} after all `@Get`/`@Post`/etc.
|
|
54
|
-
* routes have been registered. The bridge is expected to attach generated
|
|
55
|
-
* routes (list, read, create, update, delete, …) onto the supplied Hono
|
|
56
|
-
* app under `ctx.globalPrefix + prefix`.
|
|
57
|
-
*/
|
|
58
|
-
buildRoutes(app: Hono, controller: Type, prefix: string, crudConfig: unknown, ctx: CrudBridgeRouteContext): Promise<void>;
|
|
59
|
-
/**
|
|
60
|
-
* Contribute OpenAPI path items for a `@Crud()`-decorated controller.
|
|
61
|
-
*
|
|
62
|
-
* Called once per CRUD controller from `createOpenApiDocument` after the
|
|
63
|
-
* standard `@Get`/`@Post`/etc. paths have been collected. The returned
|
|
64
|
-
* record maps full OpenAPI path strings (e.g. `/api/users/{id}`) to path
|
|
65
|
-
* items, and is merged into the document — entries already present from
|
|
66
|
-
* hand-written handlers are preserved verb-by-verb.
|
|
67
|
-
*/
|
|
68
|
-
buildOpenApiPaths(controller: Type, crudConfig: unknown, ctx: CrudBridgeOpenApiContext): Record<string, OpenApiPathItem>;
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Register a {@link CrudBridge} implementation. Intended to be called once at
|
|
72
|
-
* import time by the CRUD package; subsequent calls overwrite the previous
|
|
73
|
-
* registration (last-writer-wins). This is deliberate so test harnesses can
|
|
74
|
-
* swap in mocks without a teardown step on the global state.
|
|
75
|
-
*/
|
|
76
|
-
export declare function registerCrudBridge(bridge: CrudBridge): void;
|
|
77
|
-
/**
|
|
78
|
-
* Return the currently registered {@link CrudBridge}, or `undefined` if no
|
|
79
|
-
* bridge has been registered. Consumers using `@Crud()` without a registered
|
|
80
|
-
* bridge will receive a clear install-it error from the framework at build
|
|
81
|
-
* time — see `RouteManager.build`.
|
|
82
|
-
*/
|
|
83
|
-
export declare function getCrudBridge(): CrudBridge | undefined;
|
|
84
|
-
/**
|
|
85
|
-
* Reset the global bridge registration. Test-only — not exported from
|
|
86
|
-
* `@velajs/vela/internal`. Use this in `beforeEach` to keep cases isolated.
|
|
87
|
-
*
|
|
88
|
-
* @internal
|
|
89
|
-
*/
|
|
90
|
-
export declare function _resetCrudBridge(): void;
|
package/dist/http/crud-bridge.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
let registered;
|
|
2
|
-
/**
|
|
3
|
-
* Register a {@link CrudBridge} implementation. Intended to be called once at
|
|
4
|
-
* import time by the CRUD package; subsequent calls overwrite the previous
|
|
5
|
-
* registration (last-writer-wins). This is deliberate so test harnesses can
|
|
6
|
-
* swap in mocks without a teardown step on the global state.
|
|
7
|
-
*/ export function registerCrudBridge(bridge) {
|
|
8
|
-
registered = bridge;
|
|
9
|
-
}
|
|
10
|
-
/**
|
|
11
|
-
* Return the currently registered {@link CrudBridge}, or `undefined` if no
|
|
12
|
-
* bridge has been registered. Consumers using `@Crud()` without a registered
|
|
13
|
-
* bridge will receive a clear install-it error from the framework at build
|
|
14
|
-
* time — see `RouteManager.build`.
|
|
15
|
-
*/ export function getCrudBridge() {
|
|
16
|
-
return registered;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Reset the global bridge registration. Test-only — not exported from
|
|
20
|
-
* `@velajs/vela/internal`. Use this in `beforeEach` to keep cases isolated.
|
|
21
|
-
*
|
|
22
|
-
* @internal
|
|
23
|
-
*/ export function _resetCrudBridge() {
|
|
24
|
-
registered = undefined;
|
|
25
|
-
}
|