@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,63 @@
|
|
|
1
|
+
import { defineModule } from "../module/define-module.js";
|
|
2
|
+
import { WsDispatcher } from "./ws-dispatcher.js";
|
|
3
|
+
import { WsServerImpl } from "./ws-server.js";
|
|
4
|
+
import { InMemoryRoomRegistry, local } from "./ws-sync.js";
|
|
5
|
+
import { WS_MODULE_OPTIONS, WS_ROOM_REGISTRY, WS_SERVER, WS_SYNC_DRIVER } from "./websocket.tokens.js";
|
|
6
|
+
/**
|
|
7
|
+
* Registers the WebSocket gateway machinery: the message dispatcher (discovers
|
|
8
|
+
* `@WebSocketGateway` classes via DiscoveryService at bootstrap), the room
|
|
9
|
+
* registry, the sync driver, and the `@WebSocketServer()`-injected server
|
|
10
|
+
* handle.
|
|
11
|
+
*
|
|
12
|
+
* Construction lives in chained provider factories — registry → driver
|
|
13
|
+
* (bound to THAT registry) → server — so the single shared registry instance
|
|
14
|
+
* is preserved and everything materializes at bootstrap's eager
|
|
15
|
+
* instantiation, before any lifecycle hook or message dispatch.
|
|
16
|
+
*
|
|
17
|
+
* The instance key derives from the sync driver kind: two `forRoot()` calls
|
|
18
|
+
* with the same driver kind dedup (HMR-idempotent); pass an explicit `key`
|
|
19
|
+
* to run multiple same-kind instances side by side.
|
|
20
|
+
*/ const { ConfigurableModuleClass } = defineModule({
|
|
21
|
+
name: 'WebSocket',
|
|
22
|
+
optionsToken: WS_MODULE_OPTIONS,
|
|
23
|
+
key: (options)=>`ws#${options.sync?.kind ?? 'local'}`,
|
|
24
|
+
setup: ({ OPTIONS })=>({
|
|
25
|
+
providers: [
|
|
26
|
+
{
|
|
27
|
+
provide: WS_ROOM_REGISTRY,
|
|
28
|
+
useFactory: (o)=>o.registry ?? new InMemoryRoomRegistry(),
|
|
29
|
+
inject: [
|
|
30
|
+
OPTIONS
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
provide: WS_SYNC_DRIVER,
|
|
35
|
+
useFactory: (o, registry)=>{
|
|
36
|
+
const driver = o.sync ?? local();
|
|
37
|
+
driver.bind(registry);
|
|
38
|
+
return driver;
|
|
39
|
+
},
|
|
40
|
+
inject: [
|
|
41
|
+
OPTIONS,
|
|
42
|
+
WS_ROOM_REGISTRY
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
provide: WS_SERVER,
|
|
47
|
+
useFactory: (driver)=>new WsServerImpl(driver),
|
|
48
|
+
inject: [
|
|
49
|
+
WS_SYNC_DRIVER
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
WsDispatcher
|
|
53
|
+
],
|
|
54
|
+
exports: [
|
|
55
|
+
WS_SERVER,
|
|
56
|
+
WS_SYNC_DRIVER,
|
|
57
|
+
WS_ROOM_REGISTRY,
|
|
58
|
+
WsDispatcher
|
|
59
|
+
]
|
|
60
|
+
})
|
|
61
|
+
});
|
|
62
|
+
export class WebSocketModule extends ConfigurableModuleClass {
|
|
63
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { InjectionToken } from '../container/types';
|
|
2
|
+
import type { WebSocketModuleOptions } from './websocket.module';
|
|
3
|
+
import type { RoomRegistry, SyncDriver } from './ws-sync';
|
|
4
|
+
import type { WsServer } from './websocket.types';
|
|
5
|
+
export declare const WS_GATEWAY_METADATA = "vela:ws-gateway";
|
|
6
|
+
export declare const WS_SUBSCRIBE_METADATA = "vela:ws-subscribe";
|
|
7
|
+
export declare const WsParamType: {
|
|
8
|
+
readonly SOCKET: "ws_socket";
|
|
9
|
+
readonly BODY: "ws_body";
|
|
10
|
+
readonly SERVER: "ws_server";
|
|
11
|
+
};
|
|
12
|
+
export type WsParamType = (typeof WsParamType)[keyof typeof WsParamType];
|
|
13
|
+
/**
|
|
14
|
+
* The connected gateway server handle. Injected into gateways/controllers via
|
|
15
|
+
* `@WebSocketServer()` (constructor injection only — the container has no
|
|
16
|
+
* property-injection pass) or `@Inject(WS_SERVER)`.
|
|
17
|
+
*/
|
|
18
|
+
export declare const WS_SERVER: InjectionToken<WsServer>;
|
|
19
|
+
/** The active cross-instance sync driver (`local()` by default). */
|
|
20
|
+
export declare const WS_SYNC_DRIVER: InjectionToken<SyncDriver>;
|
|
21
|
+
/** The local room-membership registry a transport reads/writes. */
|
|
22
|
+
export declare const WS_ROOM_REGISTRY: InjectionToken<RoomRegistry>;
|
|
23
|
+
export declare const WS_MODULE_OPTIONS: InjectionToken<WebSocketModuleOptions>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { InjectionToken } from "../container/types.js";
|
|
2
|
+
// Free-form metadata keys — same string-token convention as ON_EVENT_METADATA / CRON_METADATA.
|
|
3
|
+
export const WS_GATEWAY_METADATA = 'vela:ws-gateway';
|
|
4
|
+
export const WS_SUBSCRIBE_METADATA = 'vela:ws-subscribe';
|
|
5
|
+
// WebSocket parameter-decorator kinds — parallel to `ParamType` for HTTP, keyed
|
|
6
|
+
// by the WS argument resolver rather than the Hono-bound HTTP one.
|
|
7
|
+
export const WsParamType = {
|
|
8
|
+
SOCKET: 'ws_socket',
|
|
9
|
+
BODY: 'ws_body',
|
|
10
|
+
SERVER: 'ws_server'
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The connected gateway server handle. Injected into gateways/controllers via
|
|
14
|
+
* `@WebSocketServer()` (constructor injection only — the container has no
|
|
15
|
+
* property-injection pass) or `@Inject(WS_SERVER)`.
|
|
16
|
+
*/ export const WS_SERVER = new InjectionToken('WS_SERVER');
|
|
17
|
+
/** The active cross-instance sync driver (`local()` by default). */ export const WS_SYNC_DRIVER = new InjectionToken('WS_SYNC_DRIVER');
|
|
18
|
+
/** The local room-membership registry a transport reads/writes. */ export const WS_ROOM_REGISTRY = new InjectionToken('WS_ROOM_REGISTRY');
|
|
19
|
+
// forRoot() options carrier — a typed InjectionToken like every other module
|
|
20
|
+
// options token (the raw-string form was the odd one out).
|
|
21
|
+
export const WS_MODULE_OPTIONS = new InjectionToken('WS_MODULE_OPTIONS');
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExecutionContext, WsArgumentsHost } from '../pipeline/types';
|
|
2
|
+
export type { WsArgumentsHost };
|
|
3
|
+
/** The JSON envelope carried over the raw WebSocket. `id` is an optional client correlation id echoed on the reply. */
|
|
4
|
+
export interface WsMessage<T = unknown> {
|
|
5
|
+
id?: string;
|
|
6
|
+
event: string;
|
|
7
|
+
data?: T;
|
|
8
|
+
}
|
|
9
|
+
/** A handler's structured reply. Returning one frames `{ event, data }` back to the sender. */
|
|
10
|
+
export interface WsResponse<T = unknown> {
|
|
11
|
+
event: string;
|
|
12
|
+
data: T;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A single connected socket, normalized across runtimes. Per-connection state
|
|
16
|
+
* lives in `data`; mutations to `data`/room membership are persisted by
|
|
17
|
+
* `commit()` (on Cloudflare this writes the hibernation attachment).
|
|
18
|
+
*/
|
|
19
|
+
export interface WsClient<TData = Record<string, unknown>> {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly rooms: ReadonlySet<string>;
|
|
22
|
+
data: TData;
|
|
23
|
+
/** Frame and send `{ event, data }` (optionally correlated by `id`) to this socket. */
|
|
24
|
+
send(event: string, data?: unknown, id?: string): void;
|
|
25
|
+
/** Send an already-serialized string. Escape hatch for custom framing. */
|
|
26
|
+
sendRaw(payload: string): void;
|
|
27
|
+
join(room: string): void | Promise<void>;
|
|
28
|
+
leave(room: string): void | Promise<void>;
|
|
29
|
+
/** Persist `data`/room mutations made during a handler. */
|
|
30
|
+
commit(): void | Promise<void>;
|
|
31
|
+
close(code?: number, reason?: string): void;
|
|
32
|
+
/** The native socket (`WSContext` on node/bun/deno, `WebSocket` in a Cloudflare DO). */
|
|
33
|
+
readonly raw: unknown;
|
|
34
|
+
}
|
|
35
|
+
/** Fluent broadcast builder. Terminal `.emit()` produces a `BroadcastCommand`. */
|
|
36
|
+
export interface BroadcastOperator {
|
|
37
|
+
to(room: string): BroadcastOperator;
|
|
38
|
+
in(room: string): BroadcastOperator;
|
|
39
|
+
except(room: string): BroadcastOperator;
|
|
40
|
+
emit(event: string, data?: unknown): void | Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/** The server handle injected via `@WebSocketServer()`. Server-origin broadcasts never exclude anyone. */
|
|
43
|
+
export interface WsServer {
|
|
44
|
+
emit(event: string, data?: unknown): void | Promise<void>;
|
|
45
|
+
to(room: string): BroadcastOperator;
|
|
46
|
+
in(room: string): BroadcastOperator;
|
|
47
|
+
except(room: string): BroadcastOperator;
|
|
48
|
+
}
|
|
49
|
+
/** An `ExecutionContext` whose transport is a WebSocket gateway. `switchToWs()` is guaranteed present. */
|
|
50
|
+
export interface WsExecutionContext extends ExecutionContext {
|
|
51
|
+
switchToWs(): WsArgumentsHost;
|
|
52
|
+
}
|
|
53
|
+
export interface OnGatewayInit {
|
|
54
|
+
afterInit(server: WsServer): void | Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export interface OnGatewayConnection {
|
|
57
|
+
handleConnection(client: WsClient): void | Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
export interface OnGatewayDisconnect {
|
|
60
|
+
handleDisconnect(client: WsClient): void | Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
export interface WebSocketGatewayOptions {
|
|
63
|
+
/** Route path the upgrade is served on (e.g. `/rooms/:id/ws`). */
|
|
64
|
+
path?: string;
|
|
65
|
+
/** Cloudflare Durable Object binding name that hosts this gateway's sockets. */
|
|
66
|
+
binding?: string;
|
|
67
|
+
}
|
|
68
|
+
/** Stored per `@SubscribeMessage` — a flat class-level list, mirroring `@OnEvent`. */
|
|
69
|
+
export interface SubscribeMessageMetadata {
|
|
70
|
+
event: string;
|
|
71
|
+
methodName: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A fully serializable broadcast instruction. Crosses isolate / Durable-Object /
|
|
75
|
+
* Redis boundaries as JSON, so every sync driver speaks the same command.
|
|
76
|
+
*/
|
|
77
|
+
export interface BroadcastCommand {
|
|
78
|
+
/** Target rooms. An empty array means GLOBAL (every connection). */
|
|
79
|
+
rooms: string[];
|
|
80
|
+
exceptRooms?: string[];
|
|
81
|
+
exceptIds?: string[];
|
|
82
|
+
/** The exact bytes written to each socket: `JSON.stringify({ event, data })`. */
|
|
83
|
+
frame: string;
|
|
84
|
+
/** Origin instance/DO id — lets pub/sub drivers drop their own echo. */
|
|
85
|
+
origin?: string;
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PipeTransform } from '../pipeline/types';
|
|
2
|
+
import type { ParameterMetadata } from '../registry/types';
|
|
3
|
+
import type { WsClient } from './websocket.types';
|
|
4
|
+
/**
|
|
5
|
+
* Builds handler arguments for a gateway message. WS sibling of the HTTP
|
|
6
|
+
* `ArgumentResolver`, but sourced from the envelope (`data`) and the socket
|
|
7
|
+
* (`client`) instead of the Hono `Context`.
|
|
8
|
+
*
|
|
9
|
+
* With no parameter decorators the handler receives `(client, data)` — the
|
|
10
|
+
* NestJS gateway convention. Pipes run only over `@MessageBody()` values, where
|
|
11
|
+
* validation/transformation is meaningful.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveWsArgs(paramMeta: ParameterMetadata[], client: WsClient, data: unknown, pipes: PipeTransform[], paramTypes?: unknown[]): Promise<unknown[]>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { WsParamType } from "./websocket.tokens.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds handler arguments for a gateway message. WS sibling of the HTTP
|
|
4
|
+
* `ArgumentResolver`, but sourced from the envelope (`data`) and the socket
|
|
5
|
+
* (`client`) instead of the Hono `Context`.
|
|
6
|
+
*
|
|
7
|
+
* With no parameter decorators the handler receives `(client, data)` — the
|
|
8
|
+
* NestJS gateway convention. Pipes run only over `@MessageBody()` values, where
|
|
9
|
+
* validation/transformation is meaningful.
|
|
10
|
+
*/ export async function resolveWsArgs(paramMeta, client, data, pipes, paramTypes) {
|
|
11
|
+
if (paramMeta.length === 0) {
|
|
12
|
+
return [
|
|
13
|
+
client,
|
|
14
|
+
data
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
const maxIndex = paramMeta[paramMeta.length - 1].index;
|
|
18
|
+
const args = new Array(maxIndex + 1).fill(undefined);
|
|
19
|
+
for (const param of paramMeta){
|
|
20
|
+
if (param.type === WsParamType.SOCKET) {
|
|
21
|
+
args[param.index] = client;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (param.type === WsParamType.BODY) {
|
|
25
|
+
let value = data;
|
|
26
|
+
const metadata = {
|
|
27
|
+
type: param.type,
|
|
28
|
+
data: param.name,
|
|
29
|
+
metatype: paramTypes?.[param.index]
|
|
30
|
+
};
|
|
31
|
+
for (const pipe of pipes){
|
|
32
|
+
value = await pipe.transform(value, metadata);
|
|
33
|
+
}
|
|
34
|
+
args[param.index] = value;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
args[param.index] = undefined;
|
|
38
|
+
}
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Container } from '../container/container';
|
|
2
|
+
import { DiscoveryService } from '../discovery/discovery.service';
|
|
3
|
+
import type { ContributesEntrypoints, Entrypoint } from '../entrypoint/entrypoint.types';
|
|
4
|
+
import { RouteManager } from '../http/route.manager';
|
|
5
|
+
import type { OnApplicationBootstrap } from '../lifecycle/index';
|
|
6
|
+
import type { WsClient, WsServer } from './websocket.types';
|
|
7
|
+
/** Metadata carried by each `'websocket'` entrypoint a transport consumes. */
|
|
8
|
+
export interface WsEntrypointMeta {
|
|
9
|
+
/** The gateway's route path (from `@WebSocketGateway({ path })`). */
|
|
10
|
+
path: string;
|
|
11
|
+
/** The dispatcher that routes frames for this gateway. */
|
|
12
|
+
dispatcher: WsDispatcher;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Discovers `@WebSocketGateway` classes at bootstrap (via `DiscoveryService`)
|
|
16
|
+
* and routes inbound messages to `@SubscribeMessage` handlers. Transports read
|
|
17
|
+
* the gateways from `app.entrypoints.ofKind('websocket')` (this dispatcher
|
|
18
|
+
* contributes them) and call `handleOpen`, `dispatchMessage`, `handleClose`,
|
|
19
|
+
* and `handleError`; the guard → pipe → interceptor → filter pipeline
|
|
20
|
+
* (including `APP_*` global components) runs through the shared
|
|
21
|
+
* `PipelineRunner`.
|
|
22
|
+
*/
|
|
23
|
+
export declare class WsDispatcher implements OnApplicationBootstrap, ContributesEntrypoints {
|
|
24
|
+
private readonly container;
|
|
25
|
+
private readonly discovery;
|
|
26
|
+
private readonly server?;
|
|
27
|
+
private readonly routeManager?;
|
|
28
|
+
private readonly gateways;
|
|
29
|
+
constructor(container: Container, discovery: DiscoveryService, server?: WsServer | undefined, routeManager?: RouteManager | undefined);
|
|
30
|
+
/** Paths of every discovered `@WebSocketGateway` — used by transports to register routes. */
|
|
31
|
+
get gatewayPaths(): string[];
|
|
32
|
+
onApplicationBootstrap(): Promise<void>;
|
|
33
|
+
/** One `'websocket'` entrypoint per discovered gateway (authoritative). */
|
|
34
|
+
collectEntrypoints(): Entrypoint<WsEntrypointMeta>[];
|
|
35
|
+
handleOpen(path: string, client: WsClient): Promise<void>;
|
|
36
|
+
handleClose(path: string, client: WsClient, _code: number, _reason: string): Promise<void>;
|
|
37
|
+
handleError(path: string, _client: WsClient, err: unknown): Promise<void>;
|
|
38
|
+
dispatchMessage(path: string, client: WsClient, raw: string | ArrayBuffer): Promise<void>;
|
|
39
|
+
private reply;
|
|
40
|
+
private runFilters;
|
|
41
|
+
private trySend;
|
|
42
|
+
private reportBootstrapError;
|
|
43
|
+
private parse;
|
|
44
|
+
private buildGatewayEntry;
|
|
45
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
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 { Container } from "../container/container.js";
|
|
16
|
+
import { Inject, Injectable, Optional } from "../container/decorators.js";
|
|
17
|
+
import { DiscoveryService } from "../discovery/discovery.service.js";
|
|
18
|
+
import { instantiateMany } from "../http/instantiate.js";
|
|
19
|
+
import { RouteManager } from "../http/route.manager.js";
|
|
20
|
+
import { ComponentManager } from "../pipeline/component.manager.js";
|
|
21
|
+
import { shouldFilterCatch } from "../pipeline/decorators.js";
|
|
22
|
+
import { PipelineRunner } from "../pipeline/pipeline-runner.js";
|
|
23
|
+
import { MetadataRegistry } from "../registry/metadata.registry.js";
|
|
24
|
+
import { resolveWsArgs } from "./ws-argument-resolver.js";
|
|
25
|
+
import { buildWsExecutionContext } from "./ws-execution-context.js";
|
|
26
|
+
import { toErrorFrame, WsException } from "./ws-exception.js";
|
|
27
|
+
import { WS_GATEWAY_METADATA, WS_SERVER, WS_SUBSCRIBE_METADATA } from "./websocket.tokens.js";
|
|
28
|
+
function hasAfterInit(x) {
|
|
29
|
+
return typeof x?.afterInit === 'function';
|
|
30
|
+
}
|
|
31
|
+
function hasHandleConnection(x) {
|
|
32
|
+
return typeof x?.handleConnection === 'function';
|
|
33
|
+
}
|
|
34
|
+
function hasHandleDisconnect(x) {
|
|
35
|
+
return typeof x?.handleDisconnect === 'function';
|
|
36
|
+
}
|
|
37
|
+
function isWsResponse(x) {
|
|
38
|
+
return typeof x === 'object' && x !== null && typeof x.event === 'string';
|
|
39
|
+
}
|
|
40
|
+
export class WsDispatcher {
|
|
41
|
+
container;
|
|
42
|
+
discovery;
|
|
43
|
+
server;
|
|
44
|
+
routeManager;
|
|
45
|
+
gateways = new Map();
|
|
46
|
+
constructor(container, discovery, server, routeManager){
|
|
47
|
+
this.container = container;
|
|
48
|
+
this.discovery = discovery;
|
|
49
|
+
this.server = server;
|
|
50
|
+
this.routeManager = routeManager;
|
|
51
|
+
}
|
|
52
|
+
/** Paths of every discovered `@WebSocketGateway` — used by transports to register routes. */ get gatewayPaths() {
|
|
53
|
+
return [
|
|
54
|
+
...this.gateways.keys()
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
async onApplicationBootstrap() {
|
|
58
|
+
for (const found of this.discovery.providersWithMeta(WS_GATEWAY_METADATA)){
|
|
59
|
+
if (!found.instance) continue;
|
|
60
|
+
const gatewayClass = found.metatype;
|
|
61
|
+
const instance = found.instance;
|
|
62
|
+
const entry = this.buildGatewayEntry(gatewayClass, found.meta, instance);
|
|
63
|
+
// Two gateways on the same path (or both defaulting to '') would silently
|
|
64
|
+
// overwrite each other in the routing Map — surface it instead.
|
|
65
|
+
if (this.gateways.has(entry.path)) {
|
|
66
|
+
const msg = `[vela] duplicate @WebSocketGateway path '${entry.path}' ` + `(${gatewayClass.name}); keeping the first. Give each gateway a distinct path.`;
|
|
67
|
+
if (this.container.getDiagnostics() === 'throw') throw new Error(msg);
|
|
68
|
+
console.warn(msg);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
this.gateways.set(entry.path, entry);
|
|
72
|
+
if (this.server && hasAfterInit(instance)) {
|
|
73
|
+
try {
|
|
74
|
+
await instance.afterInit(this.server);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
this.reportBootstrapError(gatewayClass.name, err);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** One `'websocket'` entrypoint per discovered gateway (authoritative). */ collectEntrypoints() {
|
|
82
|
+
return [
|
|
83
|
+
...this.gateways.values()
|
|
84
|
+
].map((entry)=>({
|
|
85
|
+
kind: 'websocket',
|
|
86
|
+
token: entry.gatewayClass,
|
|
87
|
+
instance: entry.instance,
|
|
88
|
+
meta: {
|
|
89
|
+
path: entry.path,
|
|
90
|
+
dispatcher: this
|
|
91
|
+
}
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
async handleOpen(path, client) {
|
|
95
|
+
const entry = this.gateways.get(path);
|
|
96
|
+
if (entry && hasHandleConnection(entry.instance)) {
|
|
97
|
+
await entry.instance.handleConnection(client);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async handleClose(path, client, _code, _reason) {
|
|
101
|
+
const entry = this.gateways.get(path);
|
|
102
|
+
if (entry && hasHandleDisconnect(entry.instance)) {
|
|
103
|
+
await entry.instance.handleDisconnect(client);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async handleError(path, _client, err) {
|
|
107
|
+
if (this.container.getDiagnostics() !== 'silent') {
|
|
108
|
+
console.warn(`[vela] websocket error on gateway ${path}:`, err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async dispatchMessage(path, client, raw) {
|
|
112
|
+
const entry = this.gateways.get(path);
|
|
113
|
+
if (!entry) return;
|
|
114
|
+
let message;
|
|
115
|
+
try {
|
|
116
|
+
message = this.parse(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
this.trySend(client, 'exception', {
|
|
119
|
+
message: 'Invalid message'
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const handler = entry.handlers.get(message.event);
|
|
124
|
+
if (!handler) return; // unknown event — ignored (NestJS parity)
|
|
125
|
+
const ctx = buildWsExecutionContext(client, message.data, entry.gatewayClass, handler.methodName, message.event);
|
|
126
|
+
// App-wide global components (APP_* provider tokens + imperative
|
|
127
|
+
// app.useGlobalX()) run first, then controller/handler-tier — mirroring the
|
|
128
|
+
// HTTP HandlerExecutor so global guards/pipes/interceptors/filters protect
|
|
129
|
+
// gateway messages too. Read live so late registrations propagate.
|
|
130
|
+
const globals = this.routeManager?.getGlobalComponents();
|
|
131
|
+
const guards = [
|
|
132
|
+
...instantiateMany(globals?.guards ?? [], this.container),
|
|
133
|
+
...instantiateMany(handler.guards, this.container)
|
|
134
|
+
];
|
|
135
|
+
const pipes = [
|
|
136
|
+
...instantiateMany(globals?.pipes ?? [], this.container),
|
|
137
|
+
...instantiateMany(handler.pipes, this.container)
|
|
138
|
+
];
|
|
139
|
+
const interceptors = [
|
|
140
|
+
...instantiateMany(globals?.interceptors ?? [], this.container),
|
|
141
|
+
...instantiateMany(handler.interceptors, this.container)
|
|
142
|
+
];
|
|
143
|
+
// Handler/controller filters first (closest), then global filters last.
|
|
144
|
+
const filters = [
|
|
145
|
+
...instantiateMany(handler.filters, this.container),
|
|
146
|
+
...instantiateMany(globals?.filters ?? [], this.container)
|
|
147
|
+
];
|
|
148
|
+
try {
|
|
149
|
+
const method = entry.instance[handler.methodName];
|
|
150
|
+
// Guards → args + pipes → interceptor chain → handler, via the shared
|
|
151
|
+
// runner (WS keeps guards-first, unlike HTTP's args-before-guards).
|
|
152
|
+
const result = await PipelineRunner.run({
|
|
153
|
+
context: ctx,
|
|
154
|
+
guards,
|
|
155
|
+
interceptors,
|
|
156
|
+
resolveArgs: ()=>resolveWsArgs(handler.paramMeta, client, message.data, pipes, handler.paramTypes),
|
|
157
|
+
invoke: async (args)=>method.apply(entry.instance, args),
|
|
158
|
+
onGuardReject: ()=>new WsException('Forbidden')
|
|
159
|
+
});
|
|
160
|
+
this.reply(client, message, result);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await this.runFilters(client, message, error, filters, ctx);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
reply(client, message, result) {
|
|
166
|
+
if (result === undefined || result === null) return;
|
|
167
|
+
if (isWsResponse(result)) {
|
|
168
|
+
this.trySend(client, result.event, result.data, message.id);
|
|
169
|
+
} else {
|
|
170
|
+
this.trySend(client, message.event, result, message.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async runFilters(client, message, error, filters, ctx) {
|
|
174
|
+
for (const filter of filters){
|
|
175
|
+
if (shouldFilterCatch(filter, error)) {
|
|
176
|
+
try {
|
|
177
|
+
const handled = await filter.catch(error, ctx);
|
|
178
|
+
if (isWsResponse(handled)) {
|
|
179
|
+
this.trySend(client, handled.event, handled.data, message.id);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// A throwing filter falls back to the default error frame (mirrors HandlerExecutor).
|
|
183
|
+
const frame = toErrorFrame(error);
|
|
184
|
+
this.trySend(client, frame.event, frame.data, message.id);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const errorFrame = toErrorFrame(error);
|
|
190
|
+
this.trySend(client, errorFrame.event, errorFrame.data, message.id);
|
|
191
|
+
}
|
|
192
|
+
// Outbound sends are best-effort: the socket may have closed mid-dispatch.
|
|
193
|
+
trySend(client, event, data, id) {
|
|
194
|
+
try {
|
|
195
|
+
client.send(event, data, id);
|
|
196
|
+
} catch {
|
|
197
|
+
// socket closed — nothing to do
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
reportBootstrapError(name, err) {
|
|
201
|
+
const mode = this.container.getDiagnostics();
|
|
202
|
+
if (mode === 'throw') throw err;
|
|
203
|
+
if (mode === 'log') console.warn(`[vela] websocket gateway ${name} afterInit failed:`, err);
|
|
204
|
+
}
|
|
205
|
+
parse(raw) {
|
|
206
|
+
const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
|
|
207
|
+
const parsed = JSON.parse(text);
|
|
208
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.event !== 'string') {
|
|
209
|
+
throw new Error('Invalid WebSocket message envelope');
|
|
210
|
+
}
|
|
211
|
+
return parsed;
|
|
212
|
+
}
|
|
213
|
+
buildGatewayEntry(gatewayClass, options, instance) {
|
|
214
|
+
const ctor = gatewayClass;
|
|
215
|
+
const subs = MetadataRegistry.getCustomClassMeta(ctor, WS_SUBSCRIBE_METADATA) ?? [];
|
|
216
|
+
const allParams = MetadataRegistry.getParameters(ctor);
|
|
217
|
+
const handlers = new Map();
|
|
218
|
+
for (const { event, methodName } of subs){
|
|
219
|
+
const paramMeta = [
|
|
220
|
+
...allParams.get(methodName) ?? []
|
|
221
|
+
].sort((a, b)=>a.index - b.index);
|
|
222
|
+
const paramTypes = Reflect.getMetadata('design:paramtypes', gatewayClass.prototype, methodName);
|
|
223
|
+
handlers.set(event, {
|
|
224
|
+
methodName,
|
|
225
|
+
paramMeta,
|
|
226
|
+
paramTypes,
|
|
227
|
+
guards: ComponentManager.getScopedComponents('guard', ctor, methodName),
|
|
228
|
+
pipes: ComponentManager.getScopedComponents('pipe', ctor, methodName),
|
|
229
|
+
interceptors: ComponentManager.getScopedComponents('interceptor', ctor, methodName),
|
|
230
|
+
// Handler → controller → global, so the closest filter runs first (mirrors HandlerExecutor).
|
|
231
|
+
filters: [
|
|
232
|
+
...ComponentManager.getScopedComponents('filter', ctor, methodName)
|
|
233
|
+
].reverse()
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
path: options.path ?? '',
|
|
238
|
+
instance,
|
|
239
|
+
gatewayClass,
|
|
240
|
+
handlers
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
WsDispatcher = _ts_decorate([
|
|
245
|
+
Injectable(),
|
|
246
|
+
_ts_param(0, Inject(Container)),
|
|
247
|
+
_ts_param(1, Inject(DiscoveryService)),
|
|
248
|
+
_ts_param(2, Optional()),
|
|
249
|
+
_ts_param(2, Inject(WS_SERVER)),
|
|
250
|
+
_ts_param(3, Optional()),
|
|
251
|
+
_ts_param(3, Inject(RouteManager)),
|
|
252
|
+
_ts_metadata("design:type", Function),
|
|
253
|
+
_ts_metadata("design:paramtypes", [
|
|
254
|
+
typeof Container === "undefined" ? Object : Container,
|
|
255
|
+
typeof DiscoveryService === "undefined" ? Object : DiscoveryService,
|
|
256
|
+
typeof WsServer === "undefined" ? Object : WsServer,
|
|
257
|
+
typeof RouteManager === "undefined" ? Object : RouteManager
|
|
258
|
+
])
|
|
259
|
+
], WsDispatcher);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket analogue of `HttpException`. Thrown from guards/pipes/handlers to
|
|
3
|
+
* signal a client-facing error; the dispatcher serializes it to an
|
|
4
|
+
* `{ event: 'exception', data }` frame unless a matching exception filter
|
|
5
|
+
* handles it first.
|
|
6
|
+
*/
|
|
7
|
+
export declare class WsException extends Error {
|
|
8
|
+
private readonly err;
|
|
9
|
+
constructor(err: string | Record<string, unknown>);
|
|
10
|
+
getError(): string | Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
/** Default serialization of an uncaught error into the outbound exception frame. */
|
|
13
|
+
export declare function toErrorFrame(error: unknown): {
|
|
14
|
+
event: 'exception';
|
|
15
|
+
data: unknown;
|
|
16
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket analogue of `HttpException`. Thrown from guards/pipes/handlers to
|
|
3
|
+
* signal a client-facing error; the dispatcher serializes it to an
|
|
4
|
+
* `{ event: 'exception', data }` frame unless a matching exception filter
|
|
5
|
+
* handles it first.
|
|
6
|
+
*/ export class WsException extends Error {
|
|
7
|
+
err;
|
|
8
|
+
constructor(err){
|
|
9
|
+
super(typeof err === 'string' ? err : String(err.message ?? 'WsException')), this.err = err;
|
|
10
|
+
this.name = 'WsException';
|
|
11
|
+
}
|
|
12
|
+
getError() {
|
|
13
|
+
return this.err;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Default serialization of an uncaught error into the outbound exception frame. */ export function toErrorFrame(error) {
|
|
17
|
+
if (error instanceof WsException) {
|
|
18
|
+
const e = error.getError();
|
|
19
|
+
return {
|
|
20
|
+
event: 'exception',
|
|
21
|
+
data: typeof e === 'string' ? {
|
|
22
|
+
message: e
|
|
23
|
+
} : e
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
event: 'exception',
|
|
28
|
+
data: {
|
|
29
|
+
message: 'Internal server error'
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Type } from '../container/types';
|
|
2
|
+
import type { WsClient, WsExecutionContext } from './websocket.types';
|
|
3
|
+
/**
|
|
4
|
+
* WebSocket sibling of `buildExecutionContext` (`http/execution-context.ts`).
|
|
5
|
+
* Guards/pipes/interceptors/filters that read `getClass()`/`getHandler()` or
|
|
6
|
+
* call `switchToWs()` reuse unchanged; the HTTP-only accessors throw rather
|
|
7
|
+
* than fabricate a fake `Request`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildWsExecutionContext(client: WsClient, data: unknown, controller: Type, handlerName: string | symbol, pattern: string): WsExecutionContext;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const HTTP_ON_WS = (accessor)=>()=>{
|
|
2
|
+
throw new Error(`${accessor} called on a WebSocket ExecutionContext. This handler runs over a gateway message, not HTTP — use switchToWs().`);
|
|
3
|
+
};
|
|
4
|
+
/**
|
|
5
|
+
* WebSocket sibling of `buildExecutionContext` (`http/execution-context.ts`).
|
|
6
|
+
* Guards/pipes/interceptors/filters that read `getClass()`/`getHandler()` or
|
|
7
|
+
* call `switchToWs()` reuse unchanged; the HTTP-only accessors throw rather
|
|
8
|
+
* than fabricate a fake `Request`.
|
|
9
|
+
*/ export function buildWsExecutionContext(client, data, controller, handlerName, pattern) {
|
|
10
|
+
const host = {
|
|
11
|
+
getClient: ()=>client,
|
|
12
|
+
getData: ()=>data,
|
|
13
|
+
getPattern: ()=>pattern
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
getType: ()=>'ws',
|
|
17
|
+
getClass: ()=>controller,
|
|
18
|
+
getHandler: ()=>handlerName,
|
|
19
|
+
getContext: HTTP_ON_WS('getContext()'),
|
|
20
|
+
getRequest: HTTP_ON_WS('getRequest()'),
|
|
21
|
+
switchToHttp: HTTP_ON_WS('switchToHttp()'),
|
|
22
|
+
switchToWs: ()=>host
|
|
23
|
+
};
|
|
24
|
+
}
|