@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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +411 -0
  3. package/dist/body.d.ts +35 -0
  4. package/dist/body.d.ts.map +1 -0
  5. package/dist/body.js +180 -0
  6. package/dist/body.js.map +1 -0
  7. package/dist/bridge.d.ts +64 -0
  8. package/dist/bridge.d.ts.map +1 -0
  9. package/dist/bridge.js +168 -0
  10. package/dist/bridge.js.map +1 -0
  11. package/dist/closing.d.ts +13 -0
  12. package/dist/closing.d.ts.map +1 -0
  13. package/dist/closing.js +30 -0
  14. package/dist/closing.js.map +1 -0
  15. package/dist/context.d.ts +22 -0
  16. package/dist/context.d.ts.map +1 -0
  17. package/dist/context.js +2 -0
  18. package/dist/context.js.map +1 -0
  19. package/dist/cors-middleware.d.ts +64 -0
  20. package/dist/cors-middleware.d.ts.map +1 -0
  21. package/dist/cors-middleware.js +211 -0
  22. package/dist/cors-middleware.js.map +1 -0
  23. package/dist/handler-bridge.d.ts +51 -0
  24. package/dist/handler-bridge.d.ts.map +1 -0
  25. package/dist/handler-bridge.js +122 -0
  26. package/dist/handler-bridge.js.map +1 -0
  27. package/dist/hono-lifecycle.d.ts +90 -0
  28. package/dist/hono-lifecycle.d.ts.map +1 -0
  29. package/dist/hono-lifecycle.js +169 -0
  30. package/dist/hono-lifecycle.js.map +1 -0
  31. package/dist/index.d.ts +16 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +8 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/path.d.ts +3 -0
  36. package/dist/path.d.ts.map +1 -0
  37. package/dist/path.js +144 -0
  38. package/dist/path.js.map +1 -0
  39. package/dist/query.d.ts +19 -0
  40. package/dist/query.d.ts.map +1 -0
  41. package/dist/query.js +238 -0
  42. package/dist/query.js.map +1 -0
  43. package/dist/response-helpers.d.ts +16 -0
  44. package/dist/response-helpers.d.ts.map +1 -0
  45. package/dist/response-helpers.js +45 -0
  46. package/dist/response-helpers.js.map +1 -0
  47. package/dist/response-writer.d.ts +28 -0
  48. package/dist/response-writer.d.ts.map +1 -0
  49. package/dist/response-writer.js +52 -0
  50. package/dist/response-writer.js.map +1 -0
  51. package/dist/route-adapter.d.ts +53 -0
  52. package/dist/route-adapter.d.ts.map +1 -0
  53. package/dist/route-adapter.js +141 -0
  54. package/dist/route-adapter.js.map +1 -0
  55. package/dist/server-adapter.d.ts +106 -0
  56. package/dist/server-adapter.d.ts.map +1 -0
  57. package/dist/server-adapter.js +153 -0
  58. package/dist/server-adapter.js.map +1 -0
  59. package/dist/sse.d.ts +67 -0
  60. package/dist/sse.d.ts.map +1 -0
  61. package/dist/sse.js +211 -0
  62. package/dist/sse.js.map +1 -0
  63. package/dist/static-assets.d.ts +39 -0
  64. package/dist/static-assets.d.ts.map +1 -0
  65. package/dist/static-assets.js +155 -0
  66. package/dist/static-assets.js.map +1 -0
  67. package/dist/version-filter.d.ts +24 -0
  68. package/dist/version-filter.d.ts.map +1 -0
  69. package/dist/version-filter.js +107 -0
  70. package/dist/version-filter.js.map +1 -0
  71. package/dist/versioned-route.d.ts +21 -0
  72. package/dist/versioned-route.d.ts.map +1 -0
  73. package/dist/versioned-route.js +15 -0
  74. package/dist/versioned-route.js.map +1 -0
  75. package/dist/views.d.ts +42 -0
  76. package/dist/views.d.ts.map +1 -0
  77. package/dist/views.js +110 -0
  78. package/dist/views.js.map +1 -0
  79. package/dist/ws-adapter.d.ts +81 -0
  80. package/dist/ws-adapter.d.ts.map +1 -0
  81. package/dist/ws-adapter.js +214 -0
  82. package/dist/ws-adapter.js.map +1 -0
  83. package/dist/ws-client.d.ts +68 -0
  84. package/dist/ws-client.d.ts.map +1 -0
  85. package/dist/ws-client.js +135 -0
  86. package/dist/ws-client.js.map +1 -0
  87. package/dist/ws-server.d.ts +23 -0
  88. package/dist/ws-server.d.ts.map +1 -0
  89. package/dist/ws-server.js +37 -0
  90. package/dist/ws-server.js.map +1 -0
  91. package/dist/ws.d.ts +14 -0
  92. package/dist/ws.d.ts.map +1 -0
  93. package/dist/ws.js +12 -0
  94. package/dist/ws.js.map +1 -0
  95. package/package.json +99 -0
  96. package/src/body.ts +251 -0
  97. package/src/bridge.ts +308 -0
  98. package/src/closing.ts +38 -0
  99. package/src/context.ts +25 -0
  100. package/src/cors-middleware.ts +347 -0
  101. package/src/handler-bridge.ts +226 -0
  102. package/src/hono-lifecycle.ts +259 -0
  103. package/src/index.ts +27 -0
  104. package/src/path.ts +169 -0
  105. package/src/query.ts +304 -0
  106. package/src/response-helpers.ts +60 -0
  107. package/src/response-writer.ts +100 -0
  108. package/src/route-adapter.ts +261 -0
  109. package/src/server-adapter.ts +294 -0
  110. package/src/sse.ts +274 -0
  111. package/src/static-assets.ts +247 -0
  112. package/src/version-filter.ts +170 -0
  113. package/src/versioned-route.ts +30 -0
  114. package/src/views.ts +188 -0
  115. package/src/ws-adapter.ts +329 -0
  116. package/src/ws-client.ts +190 -0
  117. package/src/ws-server.ts +40 -0
  118. package/src/ws.ts +13 -0
@@ -0,0 +1,259 @@
1
+ import { createServer as createHttpsServer } from 'node:https';
2
+ import { promisify } from 'node:util';
3
+
4
+ import { createAdaptorServer } from '@hono/node-server';
5
+ import type { ServerType } from '@hono/node-server';
6
+ import type { NestApplicationOptions } from '@nestjs/common';
7
+ import { Hono } from 'hono';
8
+ import { secureHeaders } from 'hono/secure-headers';
9
+
10
+ import { closingBridge } from './closing.ts';
11
+ import type { NestHono, NodeEnv } from './context.ts';
12
+ import { corsBridge } from './cors-middleware.ts';
13
+ import type { CorsOptions } from './cors-middleware.ts';
14
+ import { RouteAdapter } from './route-adapter.ts';
15
+
16
+ /**
17
+ * The shutdown options, which Nest 11 does not declare as part
18
+ * of the application options: `return503OnClosing` was added in
19
+ * Nest 12, so it is read through a type that names it and stays
20
+ * optional for both.
21
+ */
22
+ type ClosingOptions = NestApplicationOptions & {
23
+ readonly return503OnClosing?: boolean;
24
+ };
25
+
26
+ /** The size a request body may reach before it is refused. */
27
+ const DEFAULT_BODY_LIMIT = 1_048_576;
28
+
29
+ /** The security headers Hono is configured with. */
30
+ type SecureHeadersOptions = NonNullable<
31
+ Parameters<typeof secureHeaders>[0]
32
+ >;
33
+
34
+ /** What `@hono/node-server` accepts to build the Node server. */
35
+ type AdaptorOptions = Parameters<typeof createAdaptorServer>[0];
36
+
37
+ /** The transport options the lifecycle reads. */
38
+ interface TransportOptions {
39
+ /** Largest body accepted, in bytes; `0` accepts any size. */
40
+ readonly bodyLimit?: number;
41
+ /** Whether `req.rawBody` keeps the bytes of every body. */
42
+ readonly rawBody?: boolean;
43
+ /** The security headers to send, or `false` to send none. */
44
+ readonly secureHeaders?: boolean | SecureHeadersOptions;
45
+ /** Whether `X-Forwarded-*` headers come from a proxy. */
46
+ readonly trustProxy?: boolean;
47
+ }
48
+
49
+ /** Destroys the connections a server is still holding open. */
50
+ function closeConnections(server: ServerType): void {
51
+ if ('closeAllConnections' in server) {
52
+ server.closeAllConnections();
53
+ }
54
+ }
55
+
56
+ /** Says whether a close failure only means the server never ran. */
57
+ function isNotRunning(error: unknown): boolean {
58
+ return (
59
+ typeof error === 'object' &&
60
+ error !== null &&
61
+ 'code' in error &&
62
+ error.code === 'ERR_SERVER_NOT_RUNNING'
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Runs Nest on Hono: this owns the Hono application, the
68
+ * middleware every request passes through, and the Node server
69
+ * the fetch handler is served on. The routes Nest registers and
70
+ * the answers it writes are the business of the class below.
71
+ *
72
+ * The transport options Nest passes in are honoured: requests
73
+ * are served over TLS when `httpsOptions` is set, connections
74
+ * are dropped on shutdown when `forceCloseConnections` is set,
75
+ * and a request that arrives while the application is closing
76
+ * is answered with `503` when `return503OnClosing` is set.
77
+ */
78
+ abstract class HonoLifecycle extends RouteAdapter {
79
+ protected readonly hono: NestHono;
80
+ protected readonly trustProxy: boolean;
81
+ protected bodyLimit: number;
82
+ protected bodyParsingEnabled = false;
83
+ protected rawBodyEnabled: boolean;
84
+ protected corsOptions: CorsOptions | undefined;
85
+ private closing = false;
86
+ private forceCloseConnections = false;
87
+ private return503OnClosing = false;
88
+
89
+ protected constructor(options: TransportOptions = {}) {
90
+ const hono = new Hono<NodeEnv>();
91
+ super(hono);
92
+ this.hono = hono;
93
+ this.bodyLimit = options.bodyLimit ?? DEFAULT_BODY_LIMIT;
94
+ this.rawBodyEnabled = options.rawBody ?? false;
95
+ this.trustProxy = options.trustProxy ?? false;
96
+ this.installSecurityHeaders(options.secureHeaders ?? true);
97
+ // CORS runs first, so a 503 sent while closing is readable.
98
+ hono.use(
99
+ '*',
100
+ corsBridge(() => this.corsOptions),
101
+ );
102
+ hono.use(
103
+ '*',
104
+ closingBridge(
105
+ () => this.return503OnClosing && this.closing,
106
+ ),
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Identifier ecosystem packages branch on. It is the only
112
+ * value Nest itself does not consume.
113
+ */
114
+ public override getType(): string {
115
+ return 'hono';
116
+ }
117
+
118
+ /**
119
+ * The Hono application behind the adapter, for the middleware
120
+ * and routes only Hono knows how to express. Registering on
121
+ * it before the application listens puts them ahead of Nest's
122
+ * routes, after the headers and CORS this adapter installs.
123
+ */
124
+ public getHono(): NestHono {
125
+ return this.hono;
126
+ }
127
+
128
+ /**
129
+ * Says that a Hono router scores its routes rather than
130
+ * matching them in the order they were added, so two routes
131
+ * cannot shadow each other and Nest does not have to sort
132
+ * them.
133
+ */
134
+ public isRouteOrderSensitive(): boolean {
135
+ return false;
136
+ }
137
+
138
+ /** Reads the shutdown options Nest hands the adapter. */
139
+ private readShutdownOptions(
140
+ options: NestApplicationOptions,
141
+ ): void {
142
+ const closing: ClosingOptions = options;
143
+ this.forceCloseConnections =
144
+ options.forceCloseConnections ?? false;
145
+ this.return503OnClosing =
146
+ closing.return503OnClosing ?? false;
147
+ }
148
+
149
+ /**
150
+ * Creates the Node server Hono's fetch handler is served
151
+ * through. Hono has no listener of its own, so `listen()` and
152
+ * `close()` operate on the value built here.
153
+ */
154
+ public override initHttpServer(
155
+ options: NestApplicationOptions,
156
+ ): void {
157
+ this.readShutdownOptions(options);
158
+ if (options.rawBody === true) {
159
+ this.rawBodyEnabled = true;
160
+ }
161
+ const certificate = options.httpsOptions;
162
+ if (certificate === undefined) {
163
+ this.setHttpServer(
164
+ createAdaptorServer({ fetch: this.hono.fetch }),
165
+ );
166
+ return;
167
+ }
168
+ const adaptorOptions: AdaptorOptions = {
169
+ createServer: createHttpsServer,
170
+ fetch: this.hono.fetch,
171
+ serverOptions: certificate,
172
+ };
173
+ this.setHttpServer(createAdaptorServer(adaptorOptions));
174
+ }
175
+
176
+ public override listen(
177
+ port: string | number,
178
+ callback?: () => void,
179
+ ): void;
180
+ public override listen(
181
+ port: string | number,
182
+ hostname: string,
183
+ callback?: () => void,
184
+ ): void;
185
+ public override listen(
186
+ port: string | number,
187
+ hostnameOrCallback?: string | (() => void),
188
+ callback?: () => void,
189
+ ): void {
190
+ if (typeof port === 'string') {
191
+ this.httpServer.listen(port, callback);
192
+ return;
193
+ }
194
+ if (typeof hostnameOrCallback === 'function') {
195
+ this.httpServer.listen(port, hostnameOrCallback);
196
+ return;
197
+ }
198
+ if (typeof hostnameOrCallback === 'string') {
199
+ this.httpServer.listen(
200
+ port,
201
+ hostnameOrCallback,
202
+ callback,
203
+ );
204
+ return;
205
+ }
206
+ this.httpServer.listen(port, callback);
207
+ }
208
+
209
+ /**
210
+ * Stops the server and, when the application asked for it,
211
+ * the connections it is still holding open. A server that
212
+ * never started listening has nothing to close, so the error
213
+ * it reports then is not a failure worth propagating.
214
+ */
215
+ public override async close(): Promise<void> {
216
+ this.closing = true;
217
+ const { httpServer } = this;
218
+ if (this.forceCloseConnections) {
219
+ closeConnections(httpServer);
220
+ }
221
+ try {
222
+ await promisify((done: () => void) => {
223
+ httpServer.close(done);
224
+ })();
225
+ } catch (error) {
226
+ if (!isNotRunning(error)) {
227
+ throw error;
228
+ }
229
+ }
230
+ }
231
+
232
+ /** Records that the payload's bytes have to be kept. */
233
+ protected keepRawBody(rawBody?: boolean): void {
234
+ if (rawBody === true) {
235
+ this.rawBodyEnabled = true;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Installs the security headers Hono sends with every answer,
241
+ * unless the deployment turned them off. They come from Hono
242
+ * rather than from Helmet, which is Express middleware: the
243
+ * families of headers are the same.
244
+ */
245
+ private installSecurityHeaders(
246
+ headers: boolean | SecureHeadersOptions,
247
+ ): void {
248
+ if (headers === true) {
249
+ this.hono.use('*', secureHeaders());
250
+ return;
251
+ }
252
+ if (headers !== false) {
253
+ this.hono.use('*', secureHeaders(headers));
254
+ }
255
+ }
256
+ }
257
+
258
+ export { HonoLifecycle };
259
+ export type { TransportOptions };
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The public surface of the adapter: the class a bootstrap
3
+ * hands to `NestFactory.create`, the options it and
4
+ * `enableCors` accept, and the types a handler or a middleware
5
+ * needs to describe what it touches.
6
+ */
7
+ export { ServerAdapter } from './server-adapter.ts';
8
+ export type { ParsedBody } from './body.ts';
9
+ export type {
10
+ NestHandler,
11
+ NestRequest,
12
+ NextHandler,
13
+ } from './bridge.ts';
14
+ export type {
15
+ NestContext,
16
+ NestHono,
17
+ NodeEnv,
18
+ } from './context.ts';
19
+ export type { CorsOptions } from './cors-middleware.ts';
20
+ export type { ParsedQuery } from './query.ts';
21
+ export type { StaticAssetsOptions } from './static-assets.ts';
22
+ export type {
23
+ ViewData,
24
+ ViewEngine,
25
+ ViewOptions,
26
+ } from './views.ts';
27
+ export type { ServerAdapterOptions } from './server-adapter.ts';
package/src/path.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * A parameter name, which both dialects write the same way. The
3
+ * whole match is the name, so the expression captures nothing.
4
+ */
5
+ const PARAMETER = /^[A-Za-z0-9_]+/u;
6
+
7
+ /** The name of a wildcard, which the v8 syntax spells `*rest`. */
8
+ const WILDCARD = /^[A-Za-z0-9_]+/u;
9
+
10
+ /** An optional segment, which the v8 syntax spells `{/:id}`. */
11
+ const OPTIONAL_SEGMENT =
12
+ /^:(?<name>[A-Za-z0-9_]+)(?:\((?<constraint>[^()]*)\))?\??$/u;
13
+
14
+ /** The characters this translator has no meaning for. */
15
+ const REFUSED = new Set(['}', '(', ')']);
16
+
17
+ const MISSING = -1;
18
+
19
+ function unsupported(path: string): TypeError {
20
+ return new TypeError(
21
+ `The route path "${path}" is not one the Hono adapter ` +
22
+ 'can translate. Write it the way Hono reads it: ' +
23
+ '`:name`, `:name?`, `:name{regex}`, `{/:name}` or `*`.',
24
+ );
25
+ }
26
+
27
+ function withoutLeadingSlash(content: string): string {
28
+ if (content.startsWith('/')) {
29
+ return content.slice(1);
30
+ }
31
+ return content;
32
+ }
33
+
34
+ /**
35
+ * Translates one optional segment. Hono can only make a single
36
+ * parameter optional, so a group that holds a literal or more
37
+ * than one segment is refused instead of quietly ignored.
38
+ */
39
+ function translateGroup(content: string, path: string): string {
40
+ const match = OPTIONAL_SEGMENT.exec(
41
+ withoutLeadingSlash(content),
42
+ );
43
+ if (match === null) {
44
+ throw unsupported(path);
45
+ }
46
+ const { constraint, name } = match.groups ?? {
47
+ constraint: undefined,
48
+ name: '',
49
+ };
50
+ if (constraint === undefined) {
51
+ return `/:${name}?`;
52
+ }
53
+ return `/:${name}{${constraint}}?`;
54
+ }
55
+
56
+ /**
57
+ * Rewrites one route path into the dialect Hono's router reads.
58
+ *
59
+ * The two differ in three places: a constraint is written
60
+ * `:id(\\d+)` rather than `:id{\\d+}`, an optional segment is
61
+ * written `{/:id}` rather than `/:id?`, and a named wildcard is
62
+ * written `*rest` rather than `*`. Anything else is refused
63
+ * with the path that caused it, because a route that is
64
+ * registered but never matches is worse than one that fails at
65
+ * startup.
66
+ */
67
+ class PathTranslator {
68
+ private index = 0;
69
+ private translated = '';
70
+ private readonly path: string;
71
+
72
+ public constructor(path: string) {
73
+ this.path = path;
74
+ }
75
+
76
+ public translate(): string {
77
+ while (this.index < this.path.length) {
78
+ this.step();
79
+ }
80
+ return this.translated;
81
+ }
82
+
83
+ private step(): void {
84
+ const character = this.path.charAt(this.index);
85
+ if (REFUSED.has(character)) {
86
+ throw unsupported(this.path);
87
+ }
88
+ if (this.readSpecial(character)) {
89
+ return;
90
+ }
91
+ this.emit(character);
92
+ this.index += 1;
93
+ }
94
+
95
+ /** Copies a character, or handles the ones that mean more. */
96
+ private readSpecial(character: string): boolean {
97
+ if (character === '{') {
98
+ this.readGroup();
99
+ } else if (character === ':') {
100
+ this.readParameter();
101
+ } else if (character === '*') {
102
+ this.readWildcard();
103
+ } else {
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+
109
+ private emit(text: string): void {
110
+ this.translated += text;
111
+ }
112
+
113
+ private readGroup(): void {
114
+ const end = this.path.indexOf('}', this.index);
115
+ if (end === MISSING) {
116
+ throw unsupported(this.path);
117
+ }
118
+ const content = this.path.slice(this.index + 1, end);
119
+ this.emit(translateGroup(content, this.path));
120
+ this.index = end + 1;
121
+ }
122
+
123
+ private readParameter(): void {
124
+ const match = PARAMETER.exec(
125
+ this.path.slice(this.index + 1),
126
+ );
127
+ if (match === null) {
128
+ this.emit(':');
129
+ this.index += 1;
130
+ return;
131
+ }
132
+ const [name] = match;
133
+ this.index += name.length + 1;
134
+ this.closeParameter(name);
135
+ }
136
+
137
+ private closeParameter(name: string): void {
138
+ if (this.path.charAt(this.index) !== '(') {
139
+ this.emit(`:${name}`);
140
+ return;
141
+ }
142
+ const end = this.path.indexOf(')', this.index);
143
+ if (end === MISSING) {
144
+ throw unsupported(this.path);
145
+ }
146
+ const constraint = this.path.slice(this.index + 1, end);
147
+ this.emit(`:${name}{${constraint}}`);
148
+ this.index = end + 1;
149
+ }
150
+
151
+ private readWildcard(): void {
152
+ const match = WILDCARD.exec(
153
+ this.path.slice(this.index + 1),
154
+ );
155
+ this.emit('*');
156
+ if (match === null) {
157
+ this.index += 1;
158
+ return;
159
+ }
160
+ const [name] = match;
161
+ this.index += name.length + 1;
162
+ }
163
+ }
164
+
165
+ function toHonoPath(path: string): string {
166
+ return new PathTranslator(path).translate();
167
+ }
168
+
169
+ export { toHonoPath };