@ailura/nestjs-hono-adapter 1.0.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/LICENSE +21 -0
- package/README.md +411 -0
- package/dist/body.d.ts +35 -0
- package/dist/body.d.ts.map +1 -0
- package/dist/body.js +180 -0
- package/dist/body.js.map +1 -0
- package/dist/bridge.d.ts +64 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +168 -0
- package/dist/bridge.js.map +1 -0
- package/dist/closing.d.ts +13 -0
- package/dist/closing.d.ts.map +1 -0
- package/dist/closing.js +30 -0
- package/dist/closing.js.map +1 -0
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/cors-middleware.d.ts +64 -0
- package/dist/cors-middleware.d.ts.map +1 -0
- package/dist/cors-middleware.js +211 -0
- package/dist/cors-middleware.js.map +1 -0
- package/dist/handler-bridge.d.ts +51 -0
- package/dist/handler-bridge.d.ts.map +1 -0
- package/dist/handler-bridge.js +122 -0
- package/dist/handler-bridge.js.map +1 -0
- package/dist/hono-lifecycle.d.ts +90 -0
- package/dist/hono-lifecycle.d.ts.map +1 -0
- package/dist/hono-lifecycle.js +169 -0
- package/dist/hono-lifecycle.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/path.d.ts +3 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +144 -0
- package/dist/path.js.map +1 -0
- package/dist/query.d.ts +19 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +238 -0
- package/dist/query.js.map +1 -0
- package/dist/response-helpers.d.ts +16 -0
- package/dist/response-helpers.d.ts.map +1 -0
- package/dist/response-helpers.js +45 -0
- package/dist/response-helpers.js.map +1 -0
- package/dist/response-writer.d.ts +28 -0
- package/dist/response-writer.d.ts.map +1 -0
- package/dist/response-writer.js +52 -0
- package/dist/response-writer.js.map +1 -0
- package/dist/route-adapter.d.ts +53 -0
- package/dist/route-adapter.d.ts.map +1 -0
- package/dist/route-adapter.js +141 -0
- package/dist/route-adapter.js.map +1 -0
- package/dist/server-adapter.d.ts +106 -0
- package/dist/server-adapter.d.ts.map +1 -0
- package/dist/server-adapter.js +153 -0
- package/dist/server-adapter.js.map +1 -0
- package/dist/sse.d.ts +67 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +211 -0
- package/dist/sse.js.map +1 -0
- package/dist/static-assets.d.ts +39 -0
- package/dist/static-assets.d.ts.map +1 -0
- package/dist/static-assets.js +155 -0
- package/dist/static-assets.js.map +1 -0
- package/dist/version-filter.d.ts +24 -0
- package/dist/version-filter.d.ts.map +1 -0
- package/dist/version-filter.js +107 -0
- package/dist/version-filter.js.map +1 -0
- package/dist/versioned-route.d.ts +21 -0
- package/dist/versioned-route.d.ts.map +1 -0
- package/dist/versioned-route.js +15 -0
- package/dist/versioned-route.js.map +1 -0
- package/dist/views.d.ts +42 -0
- package/dist/views.d.ts.map +1 -0
- package/dist/views.js +110 -0
- package/dist/views.js.map +1 -0
- package/dist/ws-adapter.d.ts +81 -0
- package/dist/ws-adapter.d.ts.map +1 -0
- package/dist/ws-adapter.js +214 -0
- package/dist/ws-adapter.js.map +1 -0
- package/dist/ws-client.d.ts +68 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +135 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-server.d.ts +23 -0
- package/dist/ws-server.d.ts.map +1 -0
- package/dist/ws-server.js +37 -0
- package/dist/ws-server.js.map +1 -0
- package/dist/ws.d.ts +14 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +12 -0
- package/dist/ws.js.map +1 -0
- package/package.json +99 -0
- package/src/body.ts +251 -0
- package/src/bridge.ts +308 -0
- package/src/closing.ts +38 -0
- package/src/context.ts +25 -0
- package/src/cors-middleware.ts +347 -0
- package/src/handler-bridge.ts +226 -0
- package/src/hono-lifecycle.ts +259 -0
- package/src/index.ts +27 -0
- package/src/path.ts +169 -0
- package/src/query.ts +304 -0
- package/src/response-helpers.ts +60 -0
- package/src/response-writer.ts +100 -0
- package/src/route-adapter.ts +261 -0
- package/src/server-adapter.ts +294 -0
- package/src/sse.ts +274 -0
- package/src/static-assets.ts +247 -0
- package/src/version-filter.ts +170 -0
- package/src/versioned-route.ts +30 -0
- package/src/views.ts +188 -0
- package/src/ws-adapter.ts +329 -0
- package/src/ws-client.ts +190 -0
- package/src/ws-server.ts +40 -0
- package/src/ws.ts +13 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { ServerType } from '@hono/node-server';
|
|
2
|
+
import { createNodeWebSocket } from '@hono/node-ws';
|
|
3
|
+
import type { NodeWebSocket } from '@hono/node-ws';
|
|
4
|
+
import type { WsMessageHandler } from '@nestjs/common';
|
|
5
|
+
import { Logger } from '@nestjs/common';
|
|
6
|
+
import { AbstractWsAdapter } from '@nestjs/websockets';
|
|
7
|
+
import {
|
|
8
|
+
EMPTY,
|
|
9
|
+
catchError,
|
|
10
|
+
filter,
|
|
11
|
+
first,
|
|
12
|
+
fromEvent,
|
|
13
|
+
map,
|
|
14
|
+
mergeMap,
|
|
15
|
+
share,
|
|
16
|
+
takeUntil,
|
|
17
|
+
} from 'rxjs';
|
|
18
|
+
import type { Observable } from 'rxjs';
|
|
19
|
+
|
|
20
|
+
import type { ServerAdapter } from './server-adapter.ts';
|
|
21
|
+
import {
|
|
22
|
+
CLOSE_EVENT,
|
|
23
|
+
HonoSocket,
|
|
24
|
+
MESSAGE_EVENT,
|
|
25
|
+
isReply,
|
|
26
|
+
parseFrame,
|
|
27
|
+
toReply,
|
|
28
|
+
} from './ws-client.ts';
|
|
29
|
+
import type { WsFrame } from './ws-client.ts';
|
|
30
|
+
import { GatewayServer } from './ws-server.ts';
|
|
31
|
+
|
|
32
|
+
/** The port a gateway asks for when it keeps the HTTP one. */
|
|
33
|
+
const UNDERLYING_PORT = 0;
|
|
34
|
+
|
|
35
|
+
/** The path a gateway listens on when it named none. */
|
|
36
|
+
const ROOT_PATH = '/';
|
|
37
|
+
|
|
38
|
+
/** The event Node reports a protocol upgrade on. */
|
|
39
|
+
const UPGRADE_EVENT = 'upgrade';
|
|
40
|
+
|
|
41
|
+
/** What Nest passes to `create()` for one gateway. */
|
|
42
|
+
interface HonoGatewayOptions {
|
|
43
|
+
/** The path the gateway listens on. */
|
|
44
|
+
readonly path?: string;
|
|
45
|
+
/** The namespace the gateway asked for. */
|
|
46
|
+
readonly namespace?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The part of a message event this adapter reads. It is named
|
|
51
|
+
* here rather than taken from the DOM library, which this
|
|
52
|
+
* package does not build against.
|
|
53
|
+
*/
|
|
54
|
+
interface WsMessageEvent {
|
|
55
|
+
/** The frame the socket delivered. */
|
|
56
|
+
readonly data: unknown;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Gives a gateway path the leading slash a route needs. */
|
|
60
|
+
function normalizePath(path?: string): string {
|
|
61
|
+
if (path === undefined || path === '') {
|
|
62
|
+
return ROOT_PATH;
|
|
63
|
+
}
|
|
64
|
+
if (path.startsWith(ROOT_PATH)) {
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
67
|
+
return `/${path}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The handlers of one gateway, by the event each answers. */
|
|
71
|
+
function indexHandlers(
|
|
72
|
+
handlers: WsMessageHandler[],
|
|
73
|
+
): Map<string, WsMessageHandler> {
|
|
74
|
+
const byEvent = new Map<string, WsMessageHandler>();
|
|
75
|
+
for (const handler of handlers) {
|
|
76
|
+
byEvent.set(handler.message, handler);
|
|
77
|
+
}
|
|
78
|
+
return byEvent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Removes the upgrade listeners `@hono/node-ws` installed on
|
|
83
|
+
* the server. Node types a listener as `Function`, which `off`
|
|
84
|
+
* does not accept, so the call goes through `Reflect.apply`,
|
|
85
|
+
* whose argument list carries no type.
|
|
86
|
+
*/
|
|
87
|
+
function detachUpgradeListeners(
|
|
88
|
+
server: ServerType,
|
|
89
|
+
kept: readonly unknown[],
|
|
90
|
+
): void {
|
|
91
|
+
const off = server.off.bind(server);
|
|
92
|
+
for (const listener of server.listeners(UPGRADE_EVENT)) {
|
|
93
|
+
if (!kept.includes(listener)) {
|
|
94
|
+
Reflect.apply(off, undefined, [UPGRADE_EVENT, listener]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* WebSocket adapter that runs Nest's gateways on the Hono
|
|
101
|
+
* application the HTTP adapter already serves.
|
|
102
|
+
*
|
|
103
|
+
* `@hono/node-ws` upgrades a request by asking the Hono
|
|
104
|
+
* application for the gateway path, so the upgrade happens on
|
|
105
|
+
* one server rather than on a second one opened beside it. One
|
|
106
|
+
* route is registered per path a gateway named, and each route
|
|
107
|
+
* hands Nest a {@link HonoSocket} to answer on.
|
|
108
|
+
*
|
|
109
|
+
* A gateway that asked for its own port or for a namespace
|
|
110
|
+
* cannot be served this way; both are refused rather than
|
|
111
|
+
* quietly served on the HTTP server.
|
|
112
|
+
*/
|
|
113
|
+
class HonoWsAdapter extends AbstractWsAdapter {
|
|
114
|
+
private readonly adapter: ServerAdapter;
|
|
115
|
+
private readonly node: NodeWebSocket;
|
|
116
|
+
private readonly servers = new Map<string, GatewayServer>();
|
|
117
|
+
private readonly logger = new Logger(HonoWsAdapter.name);
|
|
118
|
+
private detach: (() => void) | undefined;
|
|
119
|
+
private injected = false;
|
|
120
|
+
|
|
121
|
+
public constructor(httpAdapter: ServerAdapter) {
|
|
122
|
+
super(httpAdapter);
|
|
123
|
+
this.adapter = httpAdapter;
|
|
124
|
+
this.node = createNodeWebSocket({
|
|
125
|
+
app: httpAdapter.getHono(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public override create(
|
|
130
|
+
port: number,
|
|
131
|
+
options: HonoGatewayOptions = {},
|
|
132
|
+
): GatewayServer {
|
|
133
|
+
this.ensureSupported(port, options);
|
|
134
|
+
return this.open(normalizePath(options.path));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Bridges the handlers Nest explored for one client onto the
|
|
139
|
+
* frames that client sends. Every subscription is tied to the
|
|
140
|
+
* socket's own close event, so a connection that goes away
|
|
141
|
+
* takes its subscriptions with it.
|
|
142
|
+
*/
|
|
143
|
+
public override bindMessageHandlers(
|
|
144
|
+
client: HonoSocket,
|
|
145
|
+
handlers: WsMessageHandler[],
|
|
146
|
+
transform: (data: unknown) => Observable<unknown>,
|
|
147
|
+
): void {
|
|
148
|
+
const byEvent = indexHandlers(handlers);
|
|
149
|
+
const closed = fromEvent(client, CLOSE_EVENT).pipe(
|
|
150
|
+
share(),
|
|
151
|
+
first(),
|
|
152
|
+
);
|
|
153
|
+
const answers = fromEvent(client, MESSAGE_EVENT).pipe(
|
|
154
|
+
mergeMap((raw: unknown) =>
|
|
155
|
+
this.answer(raw, byEvent, transform),
|
|
156
|
+
),
|
|
157
|
+
takeUntil(closed),
|
|
158
|
+
);
|
|
159
|
+
answers.subscribe((reply) => {
|
|
160
|
+
client.sendJson(reply);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public override bindClientDisconnect(
|
|
165
|
+
client: HonoSocket,
|
|
166
|
+
callback: () => void,
|
|
167
|
+
): void {
|
|
168
|
+
client.once(CLOSE_EVENT, callback);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Closes every socket a gateway path is serving. */
|
|
172
|
+
public override close(server: GatewayServer): Promise<void> {
|
|
173
|
+
server.close();
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Releases the paths, the sockets and the WebSocket server
|
|
179
|
+
* this adapter created, and takes the upgrade listeners back
|
|
180
|
+
* off the Node server.
|
|
181
|
+
*/
|
|
182
|
+
public override dispose(): Promise<void> {
|
|
183
|
+
this.release();
|
|
184
|
+
return Promise.resolve();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Releases the paths, the sockets, the WebSocket server and
|
|
189
|
+
* the upgrade listeners this adapter registered.
|
|
190
|
+
*/
|
|
191
|
+
private release(): void {
|
|
192
|
+
const servers = [...this.servers.values()];
|
|
193
|
+
this.servers.clear();
|
|
194
|
+
for (const server of servers) {
|
|
195
|
+
server.close();
|
|
196
|
+
}
|
|
197
|
+
this.node.wss.close();
|
|
198
|
+
const { detach } = this;
|
|
199
|
+
if (detach !== undefined) {
|
|
200
|
+
detach();
|
|
201
|
+
}
|
|
202
|
+
this.detach = undefined;
|
|
203
|
+
this.injected = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Serves one path, reusing the server already serving it:
|
|
208
|
+
* Nest asks once per path, and a repeated ask must not
|
|
209
|
+
* register the route twice.
|
|
210
|
+
*/
|
|
211
|
+
private open(path: string): GatewayServer {
|
|
212
|
+
const known = this.servers.get(path);
|
|
213
|
+
if (known !== undefined) {
|
|
214
|
+
return known;
|
|
215
|
+
}
|
|
216
|
+
const server = new GatewayServer();
|
|
217
|
+
this.servers.set(path, server);
|
|
218
|
+
this.serve(path, server);
|
|
219
|
+
this.inject();
|
|
220
|
+
return server;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Registers the upgrade route one gateway path needs. */
|
|
224
|
+
private serve(path: string, server: GatewayServer): void {
|
|
225
|
+
const upgrade = this.node.upgradeWebSocket(() => {
|
|
226
|
+
const client = new HonoSocket();
|
|
227
|
+
return {
|
|
228
|
+
onClose: (): void => {
|
|
229
|
+
client.disconnect();
|
|
230
|
+
},
|
|
231
|
+
onMessage: (event: WsMessageEvent): void => {
|
|
232
|
+
client.deliver(event.data);
|
|
233
|
+
},
|
|
234
|
+
onOpen: (_event, socket): void => {
|
|
235
|
+
client.use(socket);
|
|
236
|
+
server.accept(client);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
this.adapter.getHono().get(path, upgrade);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Lets the Node server hand upgrades to the Hono app. */
|
|
244
|
+
private inject(): void {
|
|
245
|
+
if (this.injected) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const server = this.adapter.getHttpServer();
|
|
249
|
+
const before = server.listeners(UPGRADE_EVENT);
|
|
250
|
+
this.node.injectWebSocket(server);
|
|
251
|
+
this.detach = (): void => {
|
|
252
|
+
detachUpgradeListeners(server, before);
|
|
253
|
+
};
|
|
254
|
+
this.injected = true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Answers one frame, when a handler claims its event. */
|
|
258
|
+
private answer(
|
|
259
|
+
raw: unknown,
|
|
260
|
+
byEvent: ReadonlyMap<string, WsMessageHandler>,
|
|
261
|
+
transform: (data: unknown) => Observable<unknown>,
|
|
262
|
+
): Observable<unknown> {
|
|
263
|
+
const frame = parseFrame(raw);
|
|
264
|
+
if (frame === undefined) {
|
|
265
|
+
return EMPTY;
|
|
266
|
+
}
|
|
267
|
+
const handler = byEvent.get(frame.event);
|
|
268
|
+
if (handler === undefined) {
|
|
269
|
+
return EMPTY;
|
|
270
|
+
}
|
|
271
|
+
return this.invoke(handler, frame, transform);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Runs one handler and turns its answer into frames. */
|
|
275
|
+
private invoke(
|
|
276
|
+
handler: WsMessageHandler,
|
|
277
|
+
frame: WsFrame,
|
|
278
|
+
transform: (data: unknown) => Observable<unknown>,
|
|
279
|
+
): Observable<unknown> {
|
|
280
|
+
try {
|
|
281
|
+
return this.replies(
|
|
282
|
+
transform(handler.callback(frame.data)),
|
|
283
|
+
frame,
|
|
284
|
+
);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return this.report(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The frames one handler's stream answers with. */
|
|
291
|
+
private replies(
|
|
292
|
+
stream: Observable<unknown>,
|
|
293
|
+
frame: WsFrame,
|
|
294
|
+
): Observable<unknown> {
|
|
295
|
+
return stream.pipe(
|
|
296
|
+
map((value) => toReply(frame, value)),
|
|
297
|
+
filter(isReply),
|
|
298
|
+
catchError((error: unknown) => this.report(error)),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Reports a handler that failed and answers nothing. */
|
|
303
|
+
private report(error: unknown): Observable<never> {
|
|
304
|
+
this.logger.error(error);
|
|
305
|
+
return EMPTY;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Refuses the gateways this adapter cannot serve. */
|
|
309
|
+
private ensureSupported(
|
|
310
|
+
port: number,
|
|
311
|
+
options: HonoGatewayOptions,
|
|
312
|
+
): void {
|
|
313
|
+
if (port !== UNDERLYING_PORT) {
|
|
314
|
+
throw new TypeError(
|
|
315
|
+
'HonoWsAdapter serves every gateway on the HTTP ' +
|
|
316
|
+
`adapter's server, so the gateway port ${port} ` +
|
|
317
|
+
'cannot be honoured.',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (options.namespace !== undefined) {
|
|
321
|
+
throw new TypeError(
|
|
322
|
+
'HonoWsAdapter does not support WebSocket namespaces.',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { HonoWsAdapter };
|
|
329
|
+
export type { HonoGatewayOptions };
|
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import type { WSContext } from 'hono/ws';
|
|
4
|
+
|
|
5
|
+
/** The event a frame from the client arrives on. */
|
|
6
|
+
const MESSAGE_EVENT = 'message';
|
|
7
|
+
|
|
8
|
+
/** The event that says the socket has gone away. */
|
|
9
|
+
const CLOSE_EVENT = 'close';
|
|
10
|
+
|
|
11
|
+
/** The state a socket is in once it accepts a send. */
|
|
12
|
+
const OPEN_STATE = 1;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One frame from a client: the event it names, the payload it
|
|
16
|
+
* carries, and the correlation it asked to have repeated.
|
|
17
|
+
*/
|
|
18
|
+
interface WsFrame {
|
|
19
|
+
/** The event the gateway answers. */
|
|
20
|
+
readonly event: string;
|
|
21
|
+
/** What the handler is given. */
|
|
22
|
+
readonly data?: unknown;
|
|
23
|
+
/** The correlation the answer repeats, when there is one. */
|
|
24
|
+
readonly id?: string | number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a gateway answers with. */
|
|
28
|
+
interface WsReply {
|
|
29
|
+
/** The event being answered. */
|
|
30
|
+
readonly event: string;
|
|
31
|
+
/** What the handler returned, when it returned anything. */
|
|
32
|
+
readonly data?: unknown;
|
|
33
|
+
/** The correlation the client asked to have repeated. */
|
|
34
|
+
readonly id?: string | number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Says whether a value is a frame, by the event it names. */
|
|
38
|
+
function isFrame(value: unknown): value is WsFrame {
|
|
39
|
+
if (typeof value !== 'object' || value === null) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return 'event' in value && typeof value.event === 'string';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads the frame a parsed payload carries. A payload that is
|
|
47
|
+
* only a string names the event and carries no data, which is
|
|
48
|
+
* how a gateway is called without arguments.
|
|
49
|
+
*/
|
|
50
|
+
function frameFrom(value: unknown): WsFrame | undefined {
|
|
51
|
+
if (isFrame(value)) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === 'string') {
|
|
55
|
+
return { event: value };
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads a frame from text. Text that is not JSON is taken as
|
|
62
|
+
* the event name itself, so a client may send `ping` as well as
|
|
63
|
+
* `{"event":"ping"}`.
|
|
64
|
+
*/
|
|
65
|
+
function parseText(text: string): WsFrame | undefined {
|
|
66
|
+
try {
|
|
67
|
+
return frameFrom(JSON.parse(text) as unknown);
|
|
68
|
+
} catch {
|
|
69
|
+
return { event: text };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads a frame from what the socket delivered. Text frames
|
|
75
|
+
* arrive as text, binary frames as bytes, and a socket may hand
|
|
76
|
+
* over an already parsed frame when one is replayed.
|
|
77
|
+
*/
|
|
78
|
+
function parseFrame(raw: unknown): WsFrame | undefined {
|
|
79
|
+
if (typeof raw === 'string') {
|
|
80
|
+
return parseText(raw);
|
|
81
|
+
}
|
|
82
|
+
if (raw instanceof Uint8Array) {
|
|
83
|
+
return parseText(new TextDecoder().decode(raw));
|
|
84
|
+
}
|
|
85
|
+
return frameFrom(raw);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Repeats the correlation the client asked for, when it did. */
|
|
89
|
+
function withCorrelation(
|
|
90
|
+
answer: WsReply,
|
|
91
|
+
frame: WsFrame,
|
|
92
|
+
): WsReply {
|
|
93
|
+
if (frame.id === undefined) {
|
|
94
|
+
return answer;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
data: answer.data,
|
|
98
|
+
event: answer.event,
|
|
99
|
+
id: frame.id,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The frame that answers one, or nothing when the handler
|
|
105
|
+
* answered with nothing and the client asked for no
|
|
106
|
+
* acknowledgement.
|
|
107
|
+
*
|
|
108
|
+
* A handler that answered with its own `{ event, data }` keeps
|
|
109
|
+
* that shape, which is what the `ws` platform sends; anything
|
|
110
|
+
* else is wrapped in the event being answered.
|
|
111
|
+
*/
|
|
112
|
+
function toReply(
|
|
113
|
+
frame: WsFrame,
|
|
114
|
+
value: unknown,
|
|
115
|
+
): WsReply | undefined {
|
|
116
|
+
if (value === undefined || value === null) {
|
|
117
|
+
if (frame.id === undefined) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return { event: frame.event, id: frame.id };
|
|
121
|
+
}
|
|
122
|
+
if (isFrame(value)) {
|
|
123
|
+
return withCorrelation(value, frame);
|
|
124
|
+
}
|
|
125
|
+
const answer: WsReply = { data: value, event: frame.event };
|
|
126
|
+
return withCorrelation(answer, frame);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Says whether an answer is worth sending. */
|
|
130
|
+
function isReply(value: WsReply | undefined): value is WsReply {
|
|
131
|
+
return value !== undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One client connection, in the shape Nest's WebSocket layer
|
|
136
|
+
* reads: a `message` event for every frame the client sends, a
|
|
137
|
+
* `close` event once, and `send()` for the answers. Nest
|
|
138
|
+
* subscribes with `on` and `once`, so this follows the Node
|
|
139
|
+
* emitter its own adapters use rather than an `EventTarget`.
|
|
140
|
+
*/
|
|
141
|
+
class HonoSocket extends EventEmitter {
|
|
142
|
+
private socket: WSContext | undefined;
|
|
143
|
+
|
|
144
|
+
/** Binds this client to the socket the upgrade produced. */
|
|
145
|
+
public use(socket: WSContext): void {
|
|
146
|
+
this.socket = socket;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Emits the frame the client sent. */
|
|
150
|
+
public deliver(data: unknown): void {
|
|
151
|
+
this.emit(MESSAGE_EVENT, data);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Says the socket is gone, and only ever once. */
|
|
155
|
+
public disconnect(): void {
|
|
156
|
+
this.socket = undefined;
|
|
157
|
+
this.emit(CLOSE_EVENT);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Sends one answer, unless the socket is no longer open. */
|
|
161
|
+
public sendJson(reply: unknown): void {
|
|
162
|
+
const { socket } = this;
|
|
163
|
+
if (socket === undefined) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (socket.readyState !== OPEN_STATE) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
socket.send(JSON.stringify(reply));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Closes the socket from this side. */
|
|
173
|
+
public close(): void {
|
|
174
|
+
const { socket } = this;
|
|
175
|
+
if (socket === undefined) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
socket.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export {
|
|
183
|
+
CLOSE_EVENT,
|
|
184
|
+
MESSAGE_EVENT,
|
|
185
|
+
HonoSocket,
|
|
186
|
+
isReply,
|
|
187
|
+
parseFrame,
|
|
188
|
+
toReply,
|
|
189
|
+
};
|
|
190
|
+
export type { WsFrame, WsReply };
|
package/src/ws-server.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import { CLOSE_EVENT } from './ws-client.ts';
|
|
4
|
+
import type { HonoSocket } from './ws-client.ts';
|
|
5
|
+
|
|
6
|
+
/** The event Nest listens on for every new socket. */
|
|
7
|
+
const CONNECTION_EVENT = 'connection';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The sockets of one gateway path.
|
|
11
|
+
*
|
|
12
|
+
* Nest binds a connection handler to the server `create()`
|
|
13
|
+
* returns and then, per socket, its message handlers and its
|
|
14
|
+
* disconnect hook. This is the emitter those bindings need: it
|
|
15
|
+
* emits `connection` with each socket and holds that socket
|
|
16
|
+
* until the path is closed, so no listener outlives the
|
|
17
|
+
* connection it was bound to.
|
|
18
|
+
*/
|
|
19
|
+
class GatewayServer extends EventEmitter {
|
|
20
|
+
private readonly sockets = new Set<HonoSocket>();
|
|
21
|
+
|
|
22
|
+
/** Tracks a socket and hands it to Nest. */
|
|
23
|
+
public accept(socket: HonoSocket): void {
|
|
24
|
+
this.sockets.add(socket);
|
|
25
|
+
socket.once(CLOSE_EVENT, () => {
|
|
26
|
+
this.sockets.delete(socket);
|
|
27
|
+
});
|
|
28
|
+
this.emit(CONNECTION_EVENT, socket);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Closes every socket on the path. */
|
|
32
|
+
public close(): void {
|
|
33
|
+
for (const socket of this.sockets) {
|
|
34
|
+
socket.close();
|
|
35
|
+
}
|
|
36
|
+
this.sockets.clear();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { CONNECTION_EVENT, GatewayServer };
|
package/src/ws.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebSocket entry point, kept out of the root module so a
|
|
3
|
+
* deployment that only serves HTTP never resolves
|
|
4
|
+
* `@nestjs/websockets` or `@hono/node-ws`: both are optional
|
|
5
|
+
* peers, and neither is needed to run the HTTP adapter.
|
|
6
|
+
*
|
|
7
|
+
* It is published as the `@ailura/nestjs-hono-adapter/ws`
|
|
8
|
+
* subpath rather than re-exported from `index.ts`, because an
|
|
9
|
+
* ESM re-export resolves eagerly and would load them anyway.
|
|
10
|
+
*/
|
|
11
|
+
export { HonoWsAdapter } from './ws-adapter.ts';
|
|
12
|
+
export type { HonoGatewayOptions } from './ws-adapter.ts';
|
|
13
|
+
export type { HonoSocket } from './ws-client.ts';
|