-
Notifications
You must be signed in to change notification settings - Fork 0
/
statics.ts
391 lines (370 loc) · 10.8 KB
/
statics.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
import * as path from 'path';
import type mime from 'mime';
import { deepAssignWithOverwrite } from '@homer0/deep-assign';
import {
controllerCreator,
removeSlashes,
notUndefined,
type MiddlewareLike,
} from '../../utils';
import type { SendFile } from '../../services';
import type { Jimpex } from '../../app';
import type { DeepPartial, ExpressMiddleware, Router, RouterMethod } from '../../types';
type Mime = typeof mime;
/**
* The definition for each file the controller handles.
*
* @group Controllers/Statics
*/
export type StaticsControllerFile = {
/**
* The route, relative to the controller root, to the file.
*/
route: string;
/**
* The path to the file in the filesystem. Since the file is served using the
* {@link SendFile} service, whether the file is relative to the project root or the
* application executable depends on how the service is configured (relative to the
* executable by default).
*/
path: string;
/**
* A dictionary of headers for the response.
*/
headers?: Record<string, string>;
};
/**
* These are like "master paths" that get prepended to all the file paths and routes the
* controller use.
*
* @group Controllers/Statics
*/
export type StaticsControllerPathsOptions = {
/**
* A custom route to prefix all the file routes with.
*/
route: string;
/**
* A custom path to prefix all the file paths with.
*/
source: string;
};
/**
* The options to customize the controller.
*
* @group Controllers/Statics
*/
export type StaticsControllerOptions = {
/**
* A list of filenames, or definitions for the files to handle.
*/
files: Array<string | StaticsControllerFile>;
/**
* A dictionary with the allowed router (HTTP) methods the controller can use to serve
* the files. If `all` is set to `true`, the rest of the values will be ignored.
*
* @default {get: true, all: false}
*/
methods: Partial<Record<RouterMethod, boolean>>;
/**
* The "master paths" the controller can use to prefix the file paths and routes.
*
* @default {route: '', source: './',}
*/
paths: StaticsControllerPathsOptions;
};
/**
* The options to construct a {@link StaticsController}.
*
* @group Controllers/Statics
*/
export type StaticsControllerConstructorOptions =
DeepPartial<StaticsControllerOptions> & {
/**
* A dictionary with the dependencies to inject.
*/
inject: {
sendFile: SendFile;
mime: Mime;
};
};
/**
* A function to generate a list of middlewares that can be executed before the tontroller
* main middleware.
*
* @group Controllers/Statics
*/
export type StaticsControllerGetMiddlewaresFn = (app: Jimpex) => MiddlewareLike[];
/**
* The options for the controller creator that mounts {@link StaticsController}.
*
* @group Controllers/Statics
*/
export type StaticsControllerCreatorOptions = DeepPartial<StaticsControllerOptions> & {
/**
* A function to generate a list of middlewares that can be executed before the
* tontroller main middleware.
*/
getMiddlewares?: StaticsControllerGetMiddlewaresFn;
};
/**
* The options for {@link StaticsController._addRoute}.
*
* @access protected
* @group Controllers/Statics
*/
export type AddStaticRouteOptions = {
/**
* The reference for the router in which the middlewares will be added.
*/
router: Router;
/**
* The router method in which the middlewares will be added.
*/
method: RouterMethod;
/**
* The definition of the file to serve.
*/
file: StaticsControllerFile;
/**
* The middleware created by {@link StaticsController}, that will serve the file.
*/
fileMiddleware: ExpressMiddleware;
/**
* A list of extra middlewares to execute before the file middleware.
*/
middlewares: ExpressMiddleware[];
};
/**
* The controller class that allows the application to serve specific files from any
* folder to any route without the need of mounting directories as "static".
*
* @group Controller Classes
* @group Controllers/Statics
* @prettierignore
*/
export class StaticsController {
/**
* The service that serves static files.
*/
protected readonly _sendFile: SendFile;
/**
* The MIME type library. Since it's an ESM only module, Jimpex loads it on boot and makes
* it available on the container.
*/
protected readonly _mime: Mime;
/**
* The controller customization options.
*/
protected _options: StaticsControllerOptions;
/**
* A dictionary with the formatted definitions of the files that will be served.
* It uses the files' routes as keys, for easy access in the middleware.
*/
protected files: Record<string, StaticsControllerFile>;
/**
* @param options The options to construct the controller.
*/
constructor({ inject, ...options }: StaticsControllerConstructorOptions) {
this._sendFile = inject.sendFile;
this._mime = inject.mime;
this._options = this._validateOptions(
deepAssignWithOverwrite(
{
files: ['favicon.ico', 'index.html'],
methods: options.methods || {
all: false,
get: true,
},
paths: {
route: '',
source: './',
},
},
options,
),
);
this.files = this._createFiles();
}
/**
* Mounts the middlewares in the router in order to serve the files.
*
* @param router A reference to the application router.
* @param middlewares A list of extra middlewares to execute before the file
* middleware.
*/
addRoutes(router: Router, middlewares: ExpressMiddleware[] = []): Router {
const { methods } = this._options;
const use: RouterMethod[] = methods.all
? ['all']
: Object.keys(methods).reduce<RouterMethod[]>((acc, name) => {
const methodName = name as RouterMethod;
if (methods[methodName]) {
acc.push(methodName);
}
return acc;
}, []);
Object.keys(this.files).forEach((route) => {
const file = this.files[route as keyof typeof this.files]!;
const fileMiddleware = this._getMiddleware(file);
use.forEach((method) =>
this._addRoute({ router, method, file, fileMiddleware, middlewares }),
);
});
return router;
}
/**
* The controller options.
*/
get options(): Readonly<StaticsControllerOptions> {
return { ...this._options };
}
/**
* Generates the middleware that will serve the file.
*
* @param file The definition of the file to serve.
*/
protected _getMiddleware(file: StaticsControllerFile): ExpressMiddleware {
return (_, res, next) => {
const extension = path.parse(file.path).ext.substring(1);
const headers = {
'Content-Type': this._mime.getType(extension) || 'text/html',
...file.headers,
};
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value);
});
this._sendFile({
res,
filepath: file.path,
next,
});
};
}
/**
* Mounts the middleware(s) for a file in the router.
*
* @param options The information of the file and how it needs to be added.
*/
protected _addRoute({
router,
method,
file,
fileMiddleware,
middlewares,
}: AddStaticRouteOptions): void {
const { route } = file;
router[method](route, [...middlewares, fileMiddleware]);
}
/**
* Validates and formats the options sent to the constructor in order to get the final
* set that will be stored in the controller.
*
* @param options The options to validate.
* @throws If no files are specified.
* @throws If methods is not defined.
* @throws If no methods are enabled.
* @throws If there's an invalid HTTP method.
*/
protected _validateOptions(
options: StaticsControllerOptions,
): StaticsControllerOptions {
if (!options.files || !options.files.length) {
throw new Error('You need to specify a list of files');
}
if (!options.methods) {
throw new Error('You need to specify which HTTP methods are allowed for the files');
}
const methods = Object.keys(options.methods) as RouterMethod[];
const atLeastOne = methods.some((method) => options.methods[method]);
if (!atLeastOne) {
throw new Error('You need to enable at least one HTTP method to serve the files');
}
const allowedMethods: RouterMethod[] = [
'all',
'get',
'head',
'post',
'patch',
'put',
'delete',
'connect',
'options',
'trace',
];
const invalid = methods.find(
(method) => !allowedMethods.includes(method.toLowerCase() as RouterMethod),
);
if (invalid) {
throw new Error(`${invalid} is not a valid HTTP method`);
}
const newMethods = methods.reduce<Record<string, boolean>>((acc, method) => {
acc[method.toLowerCase()] = !!options.methods[method];
return acc;
}, {});
return {
...options,
methods: newMethods,
};
}
/**
* Parses the files received from the constructor's options, and formats them into
* proper definitions the controller can use.
*/
protected _createFiles(): Record<string, StaticsControllerFile> {
const { files, paths } = this._options;
const routePath = removeSlashes(paths.route, false, true);
return files.reduce<Record<string, StaticsControllerFile>>((acc, file) => {
let src;
let route;
let headers;
if (typeof file === 'object') {
({ route, path: src, headers } = file);
} else {
src = file;
route = file;
}
src = path.join(paths.source, src);
route = removeSlashes(route, true, false);
route = `${routePath}/${route}`;
acc[route] = {
path: src,
route,
headers: headers || {},
};
return acc;
}, {});
}
}
/**
* A controller that allows the application to server specific files from any folder to
* any route without the need of mounting directories as "static" folders.
*
* @group Controllers
* @group Controllers/Statics
*/
export const staticsController = controllerCreator(
({ getMiddlewares, ...options }: StaticsControllerCreatorOptions = {}) =>
(app) => {
const router = app.getRouter();
const ctrl = new StaticsController({
inject: {
sendFile: app.get('sendFile'),
mime: app.get('mime'),
},
...options,
});
let useMiddlewares: ExpressMiddleware[] | undefined;
if (getMiddlewares) {
useMiddlewares = getMiddlewares(app)
.map((middleware) => {
if ('middleware' in middleware) {
return middleware.connect(app) as ExpressMiddleware | undefined;
}
return middleware as ExpressMiddleware;
})
.filter(notUndefined);
}
return ctrl.addRoutes(router, useMiddlewares);
},
);