@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,247 @@
|
|
|
1
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
2
|
+
import type { MiddlewareHandler } from 'hono';
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
NestContext,
|
|
6
|
+
NestHono,
|
|
7
|
+
NodeEnv,
|
|
8
|
+
} from './context.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The options Nest accepts for static assets. They are declared
|
|
12
|
+
* here because Nest types the parameter as `any` on both the
|
|
13
|
+
* application and the adapter, so nothing can be taken from a
|
|
14
|
+
* signature.
|
|
15
|
+
*
|
|
16
|
+
* The options Hono's handler decides for itself are refused
|
|
17
|
+
* rather than ignored, so a deployment finds out at startup
|
|
18
|
+
* instead of from a response that quietly differs from the one
|
|
19
|
+
* that was asked for.
|
|
20
|
+
*/
|
|
21
|
+
interface StaticAssetsOptions {
|
|
22
|
+
readonly dotfiles?: string;
|
|
23
|
+
readonly etag?: boolean;
|
|
24
|
+
readonly extensions?: readonly string[];
|
|
25
|
+
readonly fallthrough?: boolean;
|
|
26
|
+
readonly immutable?: boolean;
|
|
27
|
+
readonly index?: string | false;
|
|
28
|
+
readonly maxAge?: number | string;
|
|
29
|
+
readonly prefix?: string;
|
|
30
|
+
readonly redirect?: boolean;
|
|
31
|
+
readonly setHeaders?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The index file a directory request is answered with. */
|
|
35
|
+
const DEFAULT_INDEX = 'index.html';
|
|
36
|
+
|
|
37
|
+
/** The header a cache lifetime is written with. */
|
|
38
|
+
const CACHE_CONTROL = 'cache-control';
|
|
39
|
+
|
|
40
|
+
/** How long each named unit lasts, in milliseconds. */
|
|
41
|
+
const MILLISECOND = 1;
|
|
42
|
+
const SECOND = 1000;
|
|
43
|
+
const MINUTE = 60_000;
|
|
44
|
+
const HOUR = 3_600_000;
|
|
45
|
+
const DAY = 86_400_000;
|
|
46
|
+
const WEEK = 604_800_000;
|
|
47
|
+
const YEAR = 31_536_000_000;
|
|
48
|
+
|
|
49
|
+
/** The milliseconds one unit of a duration lasts. */
|
|
50
|
+
const UNIT_MS = new Map<string, number>([
|
|
51
|
+
['d', DAY],
|
|
52
|
+
['h', HOUR],
|
|
53
|
+
['m', MINUTE],
|
|
54
|
+
['ms', MILLISECOND],
|
|
55
|
+
['s', SECOND],
|
|
56
|
+
['w', WEEK],
|
|
57
|
+
['y', YEAR],
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** A cache lifetime, spelled the way the `ms` package spells it. */
|
|
61
|
+
const DURATION =
|
|
62
|
+
/^(?<amount>\d+(?:\.\d+)?)\s*(?<unit>ms|s|m|h|d|w|y)?$/u;
|
|
63
|
+
|
|
64
|
+
/** Names the option the Hono handler cannot honour. */
|
|
65
|
+
function unsupported(name: string): TypeError {
|
|
66
|
+
return new TypeError(
|
|
67
|
+
'The Hono adapter cannot honour the static asset ' +
|
|
68
|
+
`option ${name}.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The directories a call named, as a list. */
|
|
73
|
+
function toDirectories(
|
|
74
|
+
path: string | readonly string[],
|
|
75
|
+
): readonly string[] {
|
|
76
|
+
if (typeof path === 'string') {
|
|
77
|
+
return [path];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Refuses the options that hook into another server. */
|
|
84
|
+
function assertNoHooks(options: StaticAssetsOptions): void {
|
|
85
|
+
if (options.setHeaders !== undefined) {
|
|
86
|
+
throw unsupported('setHeaders');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.extensions !== undefined) {
|
|
90
|
+
throw unsupported('extensions');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.etag === false) {
|
|
94
|
+
throw unsupported('etag: false');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Refuses the options that ask for another serving mode. */
|
|
99
|
+
function assertNoOtherModes(
|
|
100
|
+
options: StaticAssetsOptions,
|
|
101
|
+
): void {
|
|
102
|
+
if (options.fallthrough === false) {
|
|
103
|
+
throw unsupported('fallthrough: false');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (options.dotfiles !== undefined) {
|
|
107
|
+
throw unsupported('dotfiles');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (options.index === false) {
|
|
111
|
+
throw unsupported('index: false');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
options.immutable === true &&
|
|
116
|
+
options.maxAge === undefined
|
|
117
|
+
) {
|
|
118
|
+
throw new TypeError(
|
|
119
|
+
'The Hono adapter writes immutability with a maximum ' +
|
|
120
|
+
'age, so maxAge has to be named with it.',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assertSupported(options: StaticAssetsOptions): void {
|
|
126
|
+
assertNoHooks(options);
|
|
127
|
+
assertNoOtherModes(options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The milliseconds a cache lifetime names. */
|
|
131
|
+
function toMilliseconds(maxAge: number | string): number {
|
|
132
|
+
if (typeof maxAge === 'number') {
|
|
133
|
+
return maxAge;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const match = DURATION.exec(maxAge.trim());
|
|
137
|
+
if (match === null) {
|
|
138
|
+
throw unsupported(`maxAge: ${maxAge}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { amount, unit } = match.groups ?? {};
|
|
142
|
+
const scale = UNIT_MS.get(unit ?? 'ms');
|
|
143
|
+
if (scale === undefined) {
|
|
144
|
+
throw unsupported(`maxAge: ${maxAge}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return Number(amount) * scale;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The header a served file is cached with. */
|
|
151
|
+
function cacheControl(options: StaticAssetsOptions): string {
|
|
152
|
+
const milliseconds = toMilliseconds(options.maxAge ?? 0);
|
|
153
|
+
const seconds = Math.floor(milliseconds / SECOND);
|
|
154
|
+
const base = `public, max-age=${seconds}`;
|
|
155
|
+
if (options.immutable === true) {
|
|
156
|
+
return `${base}, immutable`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return base;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Writes the cache lifetime a deployment asked for. */
|
|
163
|
+
function setCacheControl(
|
|
164
|
+
context: NestContext,
|
|
165
|
+
options: StaticAssetsOptions,
|
|
166
|
+
): void {
|
|
167
|
+
if (options.maxAge === undefined) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
context.header(CACHE_CONTROL, cacheControl(options));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The index file a directory request is answered with. */
|
|
175
|
+
function indexOf(options: StaticAssetsOptions): string {
|
|
176
|
+
if (typeof options.index === 'string') {
|
|
177
|
+
return options.index;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return DEFAULT_INDEX;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The request path without the prefix the assets hang under. */
|
|
184
|
+
function withoutPrefix(
|
|
185
|
+
requestPath: string,
|
|
186
|
+
prefix: string | undefined,
|
|
187
|
+
): string {
|
|
188
|
+
if (prefix === undefined || prefix === '') {
|
|
189
|
+
return requestPath;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return requestPath.slice(prefix.length);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The paths one mount answers on. */
|
|
196
|
+
function mountPaths(
|
|
197
|
+
prefix: string | undefined,
|
|
198
|
+
): readonly string[] {
|
|
199
|
+
if (prefix === undefined || prefix === '') {
|
|
200
|
+
return ['/*'];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return [prefix, `${prefix}/*`];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The handler that serves one directory. */
|
|
207
|
+
function directoryHandler(
|
|
208
|
+
directory: string,
|
|
209
|
+
options: StaticAssetsOptions,
|
|
210
|
+
): MiddlewareHandler<NodeEnv> {
|
|
211
|
+
return serveStatic({
|
|
212
|
+
index: indexOf(options),
|
|
213
|
+
onFound: (_file: string, context: NestContext) => {
|
|
214
|
+
setCacheControl(context, options);
|
|
215
|
+
},
|
|
216
|
+
rewriteRequestPath: (path: string) =>
|
|
217
|
+
withoutPrefix(path, options.prefix),
|
|
218
|
+
root: directory,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Mounts the directories a deployment named, under the prefix
|
|
224
|
+
* it asked for.
|
|
225
|
+
*
|
|
226
|
+
* A request that names no file in them travels on, which is
|
|
227
|
+
* what Nest's own middleware does: the routes behind it answer,
|
|
228
|
+
* and an answer the deployment asked for is written before
|
|
229
|
+
* that.
|
|
230
|
+
*/
|
|
231
|
+
function mountStaticAssets(
|
|
232
|
+
hono: NestHono,
|
|
233
|
+
path: string | readonly string[],
|
|
234
|
+
options: StaticAssetsOptions,
|
|
235
|
+
): void {
|
|
236
|
+
assertSupported(options);
|
|
237
|
+
const mounts = mountPaths(options.prefix);
|
|
238
|
+
|
|
239
|
+
for (const directory of toDirectories(path)) {
|
|
240
|
+
for (const mount of mounts) {
|
|
241
|
+
hono.use(mount, directoryHandler(directory, options));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export { mountStaticAssets, toDirectories };
|
|
247
|
+
export type { StaticAssetsOptions };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VERSION_NEUTRAL,
|
|
3
|
+
VersioningType,
|
|
4
|
+
} from '@nestjs/common';
|
|
5
|
+
import type { VersioningOptions } from '@nestjs/common';
|
|
6
|
+
import type { NestHandler, NestRequest } from './bridge.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mirrors VersionValue in version-options.interface, which
|
|
10
|
+
*
|
|
11
|
+
* @nestjs/common does not export: reading it from an internal
|
|
12
|
+
* path would tie the package to a path Nest may move.
|
|
13
|
+
*/
|
|
14
|
+
type VersionValue =
|
|
15
|
+
| string
|
|
16
|
+
| typeof VERSION_NEUTRAL
|
|
17
|
+
| (string | typeof VERSION_NEUTRAL)[];
|
|
18
|
+
|
|
19
|
+
function readHeader(
|
|
20
|
+
request: NestRequest,
|
|
21
|
+
name: string,
|
|
22
|
+
): string | undefined {
|
|
23
|
+
return (
|
|
24
|
+
request.headers[name] ?? request.headers[name.toLowerCase()]
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isNeutral(version: VersionValue): boolean {
|
|
29
|
+
return (
|
|
30
|
+
Array.isArray(version) && version.includes(VERSION_NEUTRAL)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalises a version, or a list of them, into a list. The
|
|
36
|
+
* neutral marker is a symbol, so the list type is widened to
|
|
37
|
+
* match it.
|
|
38
|
+
*/
|
|
39
|
+
function toVersionList(
|
|
40
|
+
value: string | VersionValue,
|
|
41
|
+
): (string | symbol)[] {
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
return [value];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchesVersion(
|
|
49
|
+
version: VersionValue,
|
|
50
|
+
supplied: string | string[] | undefined,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (supplied === undefined) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const suppliedVersions = toVersionList(supplied);
|
|
56
|
+
const acceptedVersions = toVersionList(version);
|
|
57
|
+
return acceptedVersions.some((accepted) =>
|
|
58
|
+
suppliedVersions.includes(accepted),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reads the version parameter out of the `Accept` header, which
|
|
64
|
+
* looks like `application/json;v=1`.
|
|
65
|
+
*/
|
|
66
|
+
function readMediaTypeParameter(
|
|
67
|
+
request: NestRequest,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
const accept = readHeader(request, 'accept');
|
|
70
|
+
if (accept === undefined) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const [, parameter] = accept.split(';');
|
|
74
|
+
return parameter;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Answers every request whose version the extractor resolves to
|
|
79
|
+
* one this route serves.
|
|
80
|
+
*/
|
|
81
|
+
function createCustomFilter(
|
|
82
|
+
handler: NestHandler,
|
|
83
|
+
version: VersionValue,
|
|
84
|
+
extractor: (request: unknown) => string | string[],
|
|
85
|
+
): NestHandler {
|
|
86
|
+
return (request, response, next) => {
|
|
87
|
+
if (matchesVersion(version, extractor(request))) {
|
|
88
|
+
return handler(request, response, next);
|
|
89
|
+
}
|
|
90
|
+
return next();
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createMediaTypeFilter(
|
|
95
|
+
handler: NestHandler,
|
|
96
|
+
version: VersionValue,
|
|
97
|
+
key: string,
|
|
98
|
+
): NestHandler {
|
|
99
|
+
return (request, response, next) => {
|
|
100
|
+
const parameter = readMediaTypeParameter(request);
|
|
101
|
+
if (parameter === undefined) {
|
|
102
|
+
if (isNeutral(version)) {
|
|
103
|
+
return handler(request, response, next);
|
|
104
|
+
}
|
|
105
|
+
return next();
|
|
106
|
+
}
|
|
107
|
+
const [, supplied] = parameter.split(key);
|
|
108
|
+
if (matchesVersion(version, supplied)) {
|
|
109
|
+
return handler(request, response, next);
|
|
110
|
+
}
|
|
111
|
+
return next();
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function createHeaderFilter(
|
|
116
|
+
handler: NestHandler,
|
|
117
|
+
version: VersionValue,
|
|
118
|
+
name: string,
|
|
119
|
+
): NestHandler {
|
|
120
|
+
return (request, response, next) => {
|
|
121
|
+
const supplied = readHeader(request, name.toLowerCase());
|
|
122
|
+
if (supplied === undefined) {
|
|
123
|
+
if (isNeutral(version)) {
|
|
124
|
+
return handler(request, response, next);
|
|
125
|
+
}
|
|
126
|
+
return next();
|
|
127
|
+
}
|
|
128
|
+
if (matchesVersion(version, supplied)) {
|
|
129
|
+
return handler(request, response, next);
|
|
130
|
+
}
|
|
131
|
+
return next();
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Guards a route handler by request version for the versioning
|
|
137
|
+
* styles the route path does not already resolve. The logic
|
|
138
|
+
* mirrors Nest's own adapters, so a versioned route behaves the
|
|
139
|
+
* same way on Hono.
|
|
140
|
+
*
|
|
141
|
+
* URI versioning is a plain path prefix and needs no filter. A
|
|
142
|
+
* request that asks for an unknown version is passed on rather
|
|
143
|
+
* than rejected, which is how Nest keeps an older version
|
|
144
|
+
* serving the same route.
|
|
145
|
+
*/
|
|
146
|
+
function createVersionFilter(
|
|
147
|
+
handler: NestHandler,
|
|
148
|
+
version: VersionValue,
|
|
149
|
+
options: VersioningOptions,
|
|
150
|
+
): NestHandler {
|
|
151
|
+
if (
|
|
152
|
+
version === VERSION_NEUTRAL ||
|
|
153
|
+
options.type === VersioningType.URI
|
|
154
|
+
) {
|
|
155
|
+
return handler;
|
|
156
|
+
}
|
|
157
|
+
if (options.type === VersioningType.CUSTOM) {
|
|
158
|
+
return createCustomFilter(
|
|
159
|
+
handler,
|
|
160
|
+
version,
|
|
161
|
+
options.extractor,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (options.type === VersioningType.MEDIA_TYPE) {
|
|
165
|
+
return createMediaTypeFilter(handler, version, options.key);
|
|
166
|
+
}
|
|
167
|
+
return createHeaderFilter(handler, version, options.header);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { createVersionFilter, type VersionValue };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AbstractHttpAdapter } from '@nestjs/core';
|
|
2
|
+
|
|
3
|
+
import type { NestHandler } from './bridge.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A versioned route as the adapter contract declares it. It is
|
|
7
|
+
* derived from the base class rather than written out, because
|
|
8
|
+
* Nest types the value such a route resolves to as `Function`.
|
|
9
|
+
*/
|
|
10
|
+
type VersionedRoute = ReturnType<
|
|
11
|
+
AbstractHttpAdapter['applyVersionFilter']
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Narrows a version filter to the type the contract asks for.
|
|
16
|
+
*
|
|
17
|
+
* The contract says a versioned route resolves to a `Function`,
|
|
18
|
+
* which no handler answering with a response can satisfy. The
|
|
19
|
+
* router only ever calls the function it is handed, so the two
|
|
20
|
+
* differ in the type alone. Confining the assertion here keeps
|
|
21
|
+
* the rest of the adapter free of them, and `no-unsafe-type-
|
|
22
|
+
* assertion` is disabled for this file alone.
|
|
23
|
+
*/
|
|
24
|
+
function asVersionedRoute(
|
|
25
|
+
handler: NestHandler,
|
|
26
|
+
): VersionedRoute {
|
|
27
|
+
return handler as VersionedRoute;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { asVersionedRoute, type VersionedRoute };
|
package/src/views.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { NotFoundException } from '@nestjs/common';
|
|
5
|
+
|
|
6
|
+
import type { NestContext } from './context.ts';
|
|
7
|
+
import { toDirectories } from './static-assets.ts';
|
|
8
|
+
|
|
9
|
+
/** The data a template is rendered with. */
|
|
10
|
+
type ViewData = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders a template's source into the answer.
|
|
14
|
+
*
|
|
15
|
+
* The engine is handed the source rather than a file name, so
|
|
16
|
+
* every engine is reached the same way: the lines that compile
|
|
17
|
+
* and run a template belong to the application, which already
|
|
18
|
+
* knows which engine it uses.
|
|
19
|
+
*/
|
|
20
|
+
type ViewEngine = (
|
|
21
|
+
source: string,
|
|
22
|
+
data: ViewData,
|
|
23
|
+
) => string | Promise<string>;
|
|
24
|
+
|
|
25
|
+
/** The views a deployment configures. */
|
|
26
|
+
interface ViewOptions {
|
|
27
|
+
readonly directory?: string | readonly string[];
|
|
28
|
+
readonly engine: ViewEngine;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is ViewData {
|
|
32
|
+
return (
|
|
33
|
+
typeof value === 'object' &&
|
|
34
|
+
value !== null &&
|
|
35
|
+
!Array.isArray(value)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The value a handler returned, as the object a template reads. */
|
|
40
|
+
function asData(options: unknown): ViewData {
|
|
41
|
+
if (isRecord(options)) {
|
|
42
|
+
return options;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The engine a deployment configured, when it configured one. */
|
|
49
|
+
function engineOf(
|
|
50
|
+
views: ViewOptions | undefined,
|
|
51
|
+
): ViewEngine | undefined {
|
|
52
|
+
if (views === undefined) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return views.engine;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The directories a deployment configured, when it named any. */
|
|
60
|
+
function directoriesOf(
|
|
61
|
+
views: ViewOptions | undefined,
|
|
62
|
+
): readonly string[] {
|
|
63
|
+
if (views === undefined) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { directory } = views;
|
|
68
|
+
if (directory === undefined) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return toDirectories(directory);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The extension a view engine names. */
|
|
76
|
+
function toExtension(engine: string): string {
|
|
77
|
+
if (engine.startsWith('.')) {
|
|
78
|
+
return engine;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return `.${engine}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The file a view name reads, completed with the extension. */
|
|
85
|
+
function fileNameFor(view: string, extension: string): string {
|
|
86
|
+
if (extension === '' || path.extname(view) !== '') {
|
|
87
|
+
return view;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return `${view}${extension}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Reads the first template that exists in the directories. */
|
|
94
|
+
async function readFrom(
|
|
95
|
+
fileName: string,
|
|
96
|
+
directories: readonly string[],
|
|
97
|
+
position: number,
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const directory = directories[position];
|
|
100
|
+
if (directory === undefined) {
|
|
101
|
+
throw new NotFoundException(
|
|
102
|
+
`No view named ${fileName} was found.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
return await readFile(
|
|
108
|
+
path.join(directory, fileName),
|
|
109
|
+
'utf8',
|
|
110
|
+
);
|
|
111
|
+
} catch {
|
|
112
|
+
// The next directory may hold it; the last one reports.
|
|
113
|
+
return readFrom(fileName, directories, position + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Renders the templates a deployment configured.
|
|
119
|
+
*
|
|
120
|
+
* The engine is optional because an application that renders
|
|
121
|
+
* nothing never configures one: naming an engine is what makes
|
|
122
|
+
* it required, and that happens at startup rather than at the
|
|
123
|
+
* first request that renders.
|
|
124
|
+
*/
|
|
125
|
+
class ViewRenderer {
|
|
126
|
+
private readonly engine: ViewEngine | undefined;
|
|
127
|
+
private directories: readonly string[] = [];
|
|
128
|
+
private extension = '';
|
|
129
|
+
|
|
130
|
+
public constructor(views: ViewOptions | undefined) {
|
|
131
|
+
this.engine = engineOf(views);
|
|
132
|
+
this.directories = directoriesOf(views);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Names the engine, by the extension it renders. */
|
|
136
|
+
public useEngine(name: string): void {
|
|
137
|
+
if (this.engine === undefined) {
|
|
138
|
+
throw new TypeError(
|
|
139
|
+
'The adapter renders with the engine it was given, so ' +
|
|
140
|
+
'pass `views.engine` before naming one.',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.extension = toExtension(name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Names the directories a view is read from. */
|
|
148
|
+
public useDirectories(
|
|
149
|
+
directory: string | readonly string[],
|
|
150
|
+
): void {
|
|
151
|
+
this.directories = toDirectories(directory);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Renders one view into the response Nest handed over. */
|
|
155
|
+
public async render(
|
|
156
|
+
response: NestContext,
|
|
157
|
+
view: string,
|
|
158
|
+
options: unknown,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const { engine } = this;
|
|
161
|
+
if (engine === undefined) {
|
|
162
|
+
throw new TypeError(
|
|
163
|
+
'No view engine was configured, so no view renders.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fileName = fileNameFor(view, this.extension);
|
|
168
|
+
const source = await readFrom(
|
|
169
|
+
fileName,
|
|
170
|
+
this.directoriesFor(),
|
|
171
|
+
0,
|
|
172
|
+
);
|
|
173
|
+
const html = await engine(source, asData(options));
|
|
174
|
+
response.html(html);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The directories to read from, the working one by default. */
|
|
178
|
+
private directoriesFor(): readonly string[] {
|
|
179
|
+
if (this.directories.length === 0) {
|
|
180
|
+
return [process.cwd()];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return this.directories;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export { ViewRenderer };
|
|
188
|
+
export type { ViewData, ViewEngine, ViewOptions };
|