@travetto/web 6.0.0-rc.2
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/README.md +734 -0
- package/__index__.ts +44 -0
- package/package.json +66 -0
- package/src/common/global.ts +30 -0
- package/src/config.ts +18 -0
- package/src/context.ts +49 -0
- package/src/decorator/common.ts +87 -0
- package/src/decorator/controller.ts +13 -0
- package/src/decorator/endpoint.ts +102 -0
- package/src/decorator/param.ts +64 -0
- package/src/interceptor/accept.ts +70 -0
- package/src/interceptor/body-parse.ts +123 -0
- package/src/interceptor/compress.ts +119 -0
- package/src/interceptor/context.ts +23 -0
- package/src/interceptor/cookies.ts +97 -0
- package/src/interceptor/cors.ts +94 -0
- package/src/interceptor/decompress.ts +91 -0
- package/src/interceptor/etag.ts +99 -0
- package/src/interceptor/logging.ts +71 -0
- package/src/interceptor/respond.ts +26 -0
- package/src/interceptor/response-cache.ts +47 -0
- package/src/interceptor/trust-proxy.ts +53 -0
- package/src/registry/controller.ts +288 -0
- package/src/registry/types.ts +229 -0
- package/src/registry/visitor.ts +52 -0
- package/src/router/base.ts +67 -0
- package/src/router/standard.ts +59 -0
- package/src/types/cookie.ts +18 -0
- package/src/types/core.ts +33 -0
- package/src/types/dispatch.ts +23 -0
- package/src/types/error.ts +10 -0
- package/src/types/filter.ts +7 -0
- package/src/types/headers.ts +108 -0
- package/src/types/interceptor.ts +54 -0
- package/src/types/message.ts +33 -0
- package/src/types/request.ts +22 -0
- package/src/types/response.ts +20 -0
- package/src/util/body.ts +220 -0
- package/src/util/common.ts +142 -0
- package/src/util/cookie.ts +145 -0
- package/src/util/endpoint.ts +277 -0
- package/src/util/mime.ts +36 -0
- package/src/util/net.ts +61 -0
- package/support/test/dispatch-util.ts +90 -0
- package/support/test/dispatcher.ts +15 -0
- package/support/test/suite/base.ts +61 -0
- package/support/test/suite/controller.ts +103 -0
- package/support/test/suite/schema.ts +275 -0
- package/support/test/suite/standard.ts +178 -0
- package/support/transformer.web.ts +207 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { buffer } from 'node:stream/consumers';
|
|
2
|
+
import { BrotliOptions, constants, createBrotliCompress, createDeflate, createGzip, ZlibOptions } from 'node:zlib';
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
5
|
+
import Negotiator from 'negotiator';
|
|
6
|
+
|
|
7
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
8
|
+
import { Config } from '@travetto/config';
|
|
9
|
+
import { castTo } from '@travetto/runtime';
|
|
10
|
+
|
|
11
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
12
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
13
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
14
|
+
import { WebResponse } from '../types/response.ts';
|
|
15
|
+
import { WebBodyUtil } from '../util/body.ts';
|
|
16
|
+
import { WebError } from '../types/error.ts';
|
|
17
|
+
|
|
18
|
+
const COMPRESSORS = {
|
|
19
|
+
gzip: createGzip,
|
|
20
|
+
deflate: createDeflate,
|
|
21
|
+
br: createBrotliCompress,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type WebCompressEncoding = keyof typeof COMPRESSORS | 'identity';
|
|
25
|
+
|
|
26
|
+
@Config('web.compress')
|
|
27
|
+
export class CompressConfig {
|
|
28
|
+
/**
|
|
29
|
+
* Attempting to compressing responses
|
|
30
|
+
*/
|
|
31
|
+
applies: boolean = true;
|
|
32
|
+
/**
|
|
33
|
+
* Raw encoding options
|
|
34
|
+
*/
|
|
35
|
+
raw?: (ZlibOptions & BrotliOptions) | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Preferred encodings
|
|
38
|
+
*/
|
|
39
|
+
preferredEncodings?: WebCompressEncoding[] = ['br', 'gzip', 'identity'];
|
|
40
|
+
/**
|
|
41
|
+
* Supported encodings
|
|
42
|
+
*/
|
|
43
|
+
supportedEncodings: WebCompressEncoding[] = ['br', 'gzip', 'identity', 'deflate'];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Enables compression support
|
|
48
|
+
*/
|
|
49
|
+
@Injectable()
|
|
50
|
+
export class CompressInterceptor implements WebInterceptor {
|
|
51
|
+
|
|
52
|
+
category: WebInterceptorCategory = 'response';
|
|
53
|
+
|
|
54
|
+
@Inject()
|
|
55
|
+
config: CompressConfig;
|
|
56
|
+
|
|
57
|
+
async compress(ctx: WebChainedContext, response: WebResponse): Promise<WebResponse> {
|
|
58
|
+
const { raw = {}, preferredEncodings = [], supportedEncodings } = this.config;
|
|
59
|
+
const { request } = ctx;
|
|
60
|
+
|
|
61
|
+
response.headers.vary('Accept-Encoding');
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
!response.body ||
|
|
65
|
+
response.headers.has('Content-Encoding') ||
|
|
66
|
+
response.headers.get('Cache-Control')?.includes('no-transform')
|
|
67
|
+
) {
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const accepts = request.headers.get('Accept-Encoding');
|
|
72
|
+
const type: WebCompressEncoding | undefined =
|
|
73
|
+
castTo(new Negotiator({ headers: { 'accept-encoding': accepts ?? '*' } })
|
|
74
|
+
.encoding([...supportedEncodings, ...preferredEncodings]));
|
|
75
|
+
|
|
76
|
+
if (accepts && (!type || !accepts.includes(type))) {
|
|
77
|
+
throw WebError.for(`Please accept one of: ${supportedEncodings.join(', ')}. ${accepts} is not supported`, 406);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (type === 'identity' || !type) {
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const binaryResponse = new WebResponse({ context: response.context, ...WebBodyUtil.toBinaryMessage(response) });
|
|
85
|
+
const chunkSize = raw.chunkSize ?? constants.Z_DEFAULT_CHUNK;
|
|
86
|
+
const len = Buffer.isBuffer(binaryResponse.body) ? binaryResponse.body.byteLength : undefined;
|
|
87
|
+
|
|
88
|
+
if (len !== undefined && len >= 0 && len < chunkSize || !binaryResponse.body) {
|
|
89
|
+
return binaryResponse;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const opts = type === 'br' ? { params: { [constants.BROTLI_PARAM_QUALITY]: 4, ...raw.params }, ...raw } : { ...raw };
|
|
93
|
+
const stream = COMPRESSORS[type](opts);
|
|
94
|
+
|
|
95
|
+
// If we are compressing
|
|
96
|
+
binaryResponse.headers.set('Content-Encoding', type);
|
|
97
|
+
|
|
98
|
+
if (Buffer.isBuffer(binaryResponse.body)) {
|
|
99
|
+
stream.end(binaryResponse.body);
|
|
100
|
+
const out = await buffer(stream);
|
|
101
|
+
binaryResponse.body = out;
|
|
102
|
+
binaryResponse.headers.set('Content-Length', `${out.byteLength}`);
|
|
103
|
+
} else {
|
|
104
|
+
binaryResponse.body.pipe(stream);
|
|
105
|
+
binaryResponse.body = stream;
|
|
106
|
+
binaryResponse.headers.delete('Content-Length');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return binaryResponse;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
applies({ config }: WebInterceptorContext<CompressConfig>): boolean {
|
|
113
|
+
return config.applies;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async filter(ctx: WebChainedContext): Promise<WebResponse> {
|
|
117
|
+
return this.compress(ctx, await ctx.next());
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
2
|
+
|
|
3
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
4
|
+
import { WebResponse } from '../types/response.ts';
|
|
5
|
+
import { WebInterceptor } from '../types/interceptor.ts';
|
|
6
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
7
|
+
import { WebAsyncContext } from '../context.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Enables access to contextual data when running in a web application
|
|
11
|
+
*/
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class AsyncContextInterceptor implements WebInterceptor {
|
|
14
|
+
|
|
15
|
+
category: WebInterceptorCategory = 'global';
|
|
16
|
+
|
|
17
|
+
@Inject()
|
|
18
|
+
context: WebAsyncContext;
|
|
19
|
+
|
|
20
|
+
filter({ request, next }: WebChainedContext): Promise<WebResponse> {
|
|
21
|
+
return this.context.withContext(request, next);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
2
|
+
import { Config } from '@travetto/config';
|
|
3
|
+
import { Secret } from '@travetto/schema';
|
|
4
|
+
import { AsyncContext, AsyncContextValue } from '@travetto/context';
|
|
5
|
+
|
|
6
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
7
|
+
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
9
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
10
|
+
|
|
11
|
+
import { WebConfig } from '../config.ts';
|
|
12
|
+
import { Cookie, CookieSetOptions } from '../types/cookie.ts';
|
|
13
|
+
import { CookieJar } from '../util/cookie.ts';
|
|
14
|
+
import { WebAsyncContext } from '../context.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Web cookie configuration
|
|
18
|
+
*/
|
|
19
|
+
@Config('web.cookie')
|
|
20
|
+
export class CookieConfig implements CookieSetOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Support reading/sending cookies
|
|
23
|
+
*/
|
|
24
|
+
applies = true;
|
|
25
|
+
/**
|
|
26
|
+
* Are they signed
|
|
27
|
+
*/
|
|
28
|
+
signed = true;
|
|
29
|
+
/**
|
|
30
|
+
* Supported only via http (not in JS)
|
|
31
|
+
*/
|
|
32
|
+
httpOnly = true;
|
|
33
|
+
/**
|
|
34
|
+
* Enforce same site policy
|
|
35
|
+
*/
|
|
36
|
+
sameSite: Cookie['sameSite'] = 'lax';
|
|
37
|
+
/**
|
|
38
|
+
* The signing keys
|
|
39
|
+
*/
|
|
40
|
+
@Secret()
|
|
41
|
+
keys?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Is the cookie only valid for https
|
|
44
|
+
*/
|
|
45
|
+
secure?: boolean = false;
|
|
46
|
+
/**
|
|
47
|
+
* The domain of the cookie
|
|
48
|
+
*/
|
|
49
|
+
domain?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Loads cookies from the request, verifies, exposes, and then signs and sets
|
|
54
|
+
*/
|
|
55
|
+
@Injectable()
|
|
56
|
+
export class CookiesInterceptor implements WebInterceptor<CookieConfig> {
|
|
57
|
+
|
|
58
|
+
#cookieJar = new AsyncContextValue<CookieJar>(this);
|
|
59
|
+
|
|
60
|
+
category: WebInterceptorCategory = 'request';
|
|
61
|
+
|
|
62
|
+
@Inject()
|
|
63
|
+
config: CookieConfig;
|
|
64
|
+
|
|
65
|
+
@Inject()
|
|
66
|
+
webConfig: WebConfig;
|
|
67
|
+
|
|
68
|
+
@Inject()
|
|
69
|
+
webAsyncContext: WebAsyncContext;
|
|
70
|
+
|
|
71
|
+
@Inject()
|
|
72
|
+
context: AsyncContext;
|
|
73
|
+
|
|
74
|
+
postConstruct(): void {
|
|
75
|
+
this.webAsyncContext.registerSource(CookieJar, () => this.#cookieJar.get());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
finalizeConfig({ config }: WebInterceptorContext<CookieConfig>): CookieConfig {
|
|
79
|
+
const url = new URL(this.webConfig.baseUrl ?? 'x://localhost');
|
|
80
|
+
config.secure ??= url.protocol === 'https';
|
|
81
|
+
config.domain ??= url.hostname;
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
applies({ config }: WebInterceptorContext<CookieConfig>): boolean {
|
|
86
|
+
return config.applies;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async filter({ request, config, next }: WebChainedContext<CookieConfig>): Promise<WebResponse> {
|
|
90
|
+
const jar = new CookieJar(request.headers.get('Cookie'), config);
|
|
91
|
+
this.#cookieJar.set(jar);
|
|
92
|
+
|
|
93
|
+
const response = await next();
|
|
94
|
+
for (const c of jar.export()) { response.headers.append('Set-Cookie', c); }
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Config } from '@travetto/config';
|
|
2
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
3
|
+
import { Ignore } from '@travetto/schema';
|
|
4
|
+
|
|
5
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
6
|
+
import { HTTP_METHODS, HttpMethod, WebInterceptorCategory } from '../types/core.ts';
|
|
7
|
+
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
import { WebRequest } from '../types/request.ts';
|
|
9
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
10
|
+
import { WebCommonUtil } from '../util/common.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Web cors support
|
|
14
|
+
*/
|
|
15
|
+
@Config('web.cors')
|
|
16
|
+
export class CorsConfig {
|
|
17
|
+
/**
|
|
18
|
+
* Send CORS headers on responses
|
|
19
|
+
*/
|
|
20
|
+
applies = true;
|
|
21
|
+
/**
|
|
22
|
+
* Allowed origins
|
|
23
|
+
*/
|
|
24
|
+
origins?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Allowed http methods
|
|
27
|
+
*/
|
|
28
|
+
methods?: HttpMethod[];
|
|
29
|
+
/**
|
|
30
|
+
* Allowed http headers
|
|
31
|
+
*/
|
|
32
|
+
headers?: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Support credentials?
|
|
35
|
+
*/
|
|
36
|
+
credentials?: boolean;
|
|
37
|
+
|
|
38
|
+
@Ignore()
|
|
39
|
+
resolved: {
|
|
40
|
+
origins: Set<string>;
|
|
41
|
+
methods: string;
|
|
42
|
+
headers: string;
|
|
43
|
+
credentials: boolean;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Interceptor that will provide cors support across all requests
|
|
49
|
+
*/
|
|
50
|
+
@Injectable()
|
|
51
|
+
export class CorsInterceptor implements WebInterceptor<CorsConfig> {
|
|
52
|
+
|
|
53
|
+
category: WebInterceptorCategory = 'response';
|
|
54
|
+
|
|
55
|
+
@Inject()
|
|
56
|
+
config: CorsConfig;
|
|
57
|
+
|
|
58
|
+
finalizeConfig({ config }: WebInterceptorContext<CorsConfig>): CorsConfig {
|
|
59
|
+
config.resolved = {
|
|
60
|
+
origins: new Set(config.origins ?? []),
|
|
61
|
+
methods: (config.methods ?? Object.keys(HTTP_METHODS)).join(',').toUpperCase(),
|
|
62
|
+
headers: (config.headers ?? []).join(','),
|
|
63
|
+
credentials: !!config.credentials,
|
|
64
|
+
};
|
|
65
|
+
return config;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
applies({ config }: WebInterceptorContext<CorsConfig>): boolean {
|
|
69
|
+
return config.applies;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
decorate(request: WebRequest, resolved: CorsConfig['resolved'], response: WebResponse,): WebResponse {
|
|
73
|
+
const origin = request.headers.get('Origin');
|
|
74
|
+
if (resolved.origins.size === 0 || resolved.origins.has(origin!)) {
|
|
75
|
+
for (const [k, v] of [
|
|
76
|
+
['Access-Control-Allow-Origin', origin || '*'],
|
|
77
|
+
['Access-Control-Allow-Credentials', `${resolved.credentials}`],
|
|
78
|
+
['Access-Control-Allow-Methods', resolved.methods],
|
|
79
|
+
['Access-Control-Allow-Headers', resolved.headers || request.headers.get('Access-Control-Request-Headers') || '*'],
|
|
80
|
+
]) {
|
|
81
|
+
response.headers.setIfAbsent(k, v);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async filter({ request, config: { resolved }, next }: WebChainedContext<CorsConfig>): Promise<WebResponse> {
|
|
88
|
+
try {
|
|
89
|
+
return this.decorate(request, resolved, await next());
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw this.decorate(request, resolved, WebCommonUtil.catchResponse(err));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import zlib from 'node:zlib';
|
|
3
|
+
import util from 'node:util';
|
|
4
|
+
|
|
5
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
6
|
+
import { Config } from '@travetto/config';
|
|
7
|
+
import { castTo } from '@travetto/runtime';
|
|
8
|
+
|
|
9
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
10
|
+
import { WebResponse } from '../types/response.ts';
|
|
11
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
12
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
13
|
+
import { WebHeaders } from '../types/headers.ts';
|
|
14
|
+
|
|
15
|
+
import { WebBodyUtil } from '../util/body.ts';
|
|
16
|
+
import { WebError } from '../types/error.ts';
|
|
17
|
+
|
|
18
|
+
const STREAM_DECOMPRESSORS = {
|
|
19
|
+
gzip: zlib.createGunzip,
|
|
20
|
+
deflate: zlib.createInflate,
|
|
21
|
+
br: zlib.createBrotliDecompress,
|
|
22
|
+
identity: (): Readable => null!
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const BUFFER_DECOMPRESSORS = {
|
|
26
|
+
gzip: util.promisify(zlib.gunzip),
|
|
27
|
+
deflate: util.promisify(zlib.inflate),
|
|
28
|
+
br: util.promisify(zlib.brotliDecompress),
|
|
29
|
+
identity: (): Readable => null!
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type WebDecompressEncoding = keyof typeof BUFFER_DECOMPRESSORS;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Web body parse configuration
|
|
36
|
+
*/
|
|
37
|
+
@Config('web.decompress')
|
|
38
|
+
export class DecompressConfig {
|
|
39
|
+
/**
|
|
40
|
+
* Parse request body
|
|
41
|
+
*/
|
|
42
|
+
applies: boolean = true;
|
|
43
|
+
/**
|
|
44
|
+
* Supported encodings
|
|
45
|
+
*/
|
|
46
|
+
supportedEncodings: WebDecompressEncoding[] = ['br', 'gzip', 'deflate', 'identity'];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decompress body
|
|
51
|
+
*/
|
|
52
|
+
@Injectable()
|
|
53
|
+
export class DecompressInterceptor implements WebInterceptor<DecompressConfig> {
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
static async decompress(headers: WebHeaders, input: Buffer | Readable, config: DecompressConfig): Promise<typeof input> {
|
|
57
|
+
const encoding: WebDecompressEncoding | 'identity' = castTo(headers.getList('Content-Encoding')?.[0]) ?? 'identity';
|
|
58
|
+
|
|
59
|
+
if (!config.supportedEncodings.includes(encoding)) {
|
|
60
|
+
throw WebError.for(`Unsupported Content-Encoding: ${encoding}`, 415);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (encoding === 'identity') {
|
|
64
|
+
return input;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (Buffer.isBuffer(input)) {
|
|
68
|
+
return BUFFER_DECOMPRESSORS[encoding](input);
|
|
69
|
+
} else {
|
|
70
|
+
return input.pipe(STREAM_DECOMPRESSORS[encoding]());
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dependsOn = [];
|
|
75
|
+
category: WebInterceptorCategory = 'request';
|
|
76
|
+
|
|
77
|
+
@Inject()
|
|
78
|
+
config: DecompressConfig;
|
|
79
|
+
|
|
80
|
+
applies({ config }: WebInterceptorContext<DecompressConfig>): boolean {
|
|
81
|
+
return config.applies;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async filter({ request, config, next }: WebChainedContext<DecompressConfig>): Promise<WebResponse> {
|
|
85
|
+
if (WebBodyUtil.isRaw(request.body)) {
|
|
86
|
+
const updatedBody = await DecompressInterceptor.decompress(request.headers, request.body, config);
|
|
87
|
+
request.body = WebBodyUtil.markRaw(updatedBody);
|
|
88
|
+
}
|
|
89
|
+
return next();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fresh from 'fresh';
|
|
3
|
+
|
|
4
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
5
|
+
import { Config } from '@travetto/config';
|
|
6
|
+
import { Ignore } from '@travetto/schema';
|
|
7
|
+
|
|
8
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
9
|
+
import { WebResponse } from '../types/response.ts';
|
|
10
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
11
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
12
|
+
import { CompressInterceptor } from './compress.ts';
|
|
13
|
+
import { WebBodyUtil } from '../util/body.ts';
|
|
14
|
+
|
|
15
|
+
@Config('web.etag')
|
|
16
|
+
export class EtagConfig {
|
|
17
|
+
/**
|
|
18
|
+
* Attempt ETag generation
|
|
19
|
+
*/
|
|
20
|
+
applies = true;
|
|
21
|
+
/**
|
|
22
|
+
* Should we generate a weak etag
|
|
23
|
+
*/
|
|
24
|
+
weak?: boolean;
|
|
25
|
+
|
|
26
|
+
@Ignore()
|
|
27
|
+
cacheable?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Enables etag support
|
|
32
|
+
*/
|
|
33
|
+
@Injectable()
|
|
34
|
+
export class EtagInterceptor implements WebInterceptor {
|
|
35
|
+
|
|
36
|
+
category: WebInterceptorCategory = 'response';
|
|
37
|
+
dependsOn = [CompressInterceptor];
|
|
38
|
+
|
|
39
|
+
@Inject()
|
|
40
|
+
config: EtagConfig;
|
|
41
|
+
|
|
42
|
+
computeTag(body: Buffer): string {
|
|
43
|
+
return body.byteLength === 0 ?
|
|
44
|
+
'2jmj7l5rSw0yVb/vlWAYkK/YBwk' :
|
|
45
|
+
crypto
|
|
46
|
+
.createHash('sha1')
|
|
47
|
+
.update(body.toString('utf8'), 'utf8')
|
|
48
|
+
.digest('base64')
|
|
49
|
+
.substring(0, 27);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addTag(ctx: WebChainedContext<EtagConfig>, response: WebResponse): WebResponse {
|
|
53
|
+
const { request } = ctx;
|
|
54
|
+
|
|
55
|
+
const statusCode = response.context.httpStatusCode;
|
|
56
|
+
|
|
57
|
+
if (statusCode && (statusCode >= 300 && statusCode !== 304)) {
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const binaryResponse = new WebResponse({ ...response, ...WebBodyUtil.toBinaryMessage(response) });
|
|
62
|
+
if (!Buffer.isBuffer(binaryResponse.body)) {
|
|
63
|
+
return binaryResponse;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const tag = this.computeTag(binaryResponse.body);
|
|
67
|
+
binaryResponse.headers.set('ETag', `${ctx.config.weak ? 'W/' : ''}"${tag}"`);
|
|
68
|
+
|
|
69
|
+
if (ctx.config.cacheable &&
|
|
70
|
+
fresh({
|
|
71
|
+
'if-modified-since': request.headers.get('If-Modified-Since')!,
|
|
72
|
+
'if-none-match': request.headers.get('If-None-Match')!,
|
|
73
|
+
'cache-control': request.headers.get('Cache-Control')!,
|
|
74
|
+
}, {
|
|
75
|
+
etag: binaryResponse.headers.get('ETag')!,
|
|
76
|
+
'last-modified': binaryResponse.headers.get('Last-Modified')!
|
|
77
|
+
})
|
|
78
|
+
) {
|
|
79
|
+
return new WebResponse({ context: { httpStatusCode: 304 } });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return binaryResponse;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
finalizeConfig({ config, endpoint }: WebInterceptorContext<EtagConfig>): EtagConfig {
|
|
86
|
+
if (endpoint.cacheable) {
|
|
87
|
+
return { ...config, cacheable: true };
|
|
88
|
+
}
|
|
89
|
+
return config;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
applies({ config }: WebInterceptorContext<EtagConfig>): boolean {
|
|
93
|
+
return config.applies;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async filter(ctx: WebChainedContext<EtagConfig>): Promise<WebResponse> {
|
|
97
|
+
return this.addTag(ctx, await ctx.next());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
2
|
+
import { Config } from '@travetto/config';
|
|
3
|
+
|
|
4
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
5
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
6
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
7
|
+
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Web logging configuration
|
|
11
|
+
*/
|
|
12
|
+
@Config('web.log')
|
|
13
|
+
export class WebLogConfig {
|
|
14
|
+
/**
|
|
15
|
+
* Enable logging of all requests
|
|
16
|
+
*/
|
|
17
|
+
applies = true;
|
|
18
|
+
/**
|
|
19
|
+
* Should errors be dumped as full stack traces
|
|
20
|
+
*/
|
|
21
|
+
showStackTrace = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Logging interceptor, to show activity for all requests
|
|
26
|
+
*/
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class LoggingInterceptor implements WebInterceptor {
|
|
29
|
+
|
|
30
|
+
category: WebInterceptorCategory = 'terminal';
|
|
31
|
+
|
|
32
|
+
@Inject()
|
|
33
|
+
config: WebLogConfig;
|
|
34
|
+
|
|
35
|
+
applies({ config }: WebInterceptorContext<WebLogConfig>): boolean {
|
|
36
|
+
return config.applies;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async filter({ request, next }: WebChainedContext): Promise<WebResponse> {
|
|
40
|
+
const createdDate = Date.now();
|
|
41
|
+
const response = await next();
|
|
42
|
+
const duration = Date.now() - createdDate;
|
|
43
|
+
|
|
44
|
+
const err = response.body instanceof Error ? response.body : undefined;
|
|
45
|
+
const code = response.context.httpStatusCode ??= (!!err ? 500 : 200);
|
|
46
|
+
|
|
47
|
+
const logMessage = {
|
|
48
|
+
method: request.context.httpMethod,
|
|
49
|
+
path: request.context.path,
|
|
50
|
+
query: request.context.httpQuery,
|
|
51
|
+
params: request.context.pathParams,
|
|
52
|
+
statusCode: code,
|
|
53
|
+
duration,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (code < 400) {
|
|
57
|
+
console.info('Request', logMessage);
|
|
58
|
+
} else if (code < 500) {
|
|
59
|
+
console.warn('Request', logMessage);
|
|
60
|
+
} else {
|
|
61
|
+
console.error('Request', logMessage);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.config.showStackTrace && err) {
|
|
65
|
+
console.error(err.message, { error: err });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Injectable } from '@travetto/di';
|
|
2
|
+
|
|
3
|
+
import { WebInterceptor } from '../types/interceptor.ts';
|
|
4
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
5
|
+
import { WebResponse } from '../types/response.ts';
|
|
6
|
+
|
|
7
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
8
|
+
import { LoggingInterceptor } from './logging.ts';
|
|
9
|
+
import { WebCommonUtil } from '../util/common.ts';
|
|
10
|
+
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class RespondInterceptor implements WebInterceptor {
|
|
13
|
+
|
|
14
|
+
category: WebInterceptorCategory = 'terminal';
|
|
15
|
+
dependsOn = [LoggingInterceptor];
|
|
16
|
+
|
|
17
|
+
async filter(ctx: WebChainedContext): Promise<WebResponse> {
|
|
18
|
+
let res;
|
|
19
|
+
try {
|
|
20
|
+
res = await ctx.next();
|
|
21
|
+
} catch (err) {
|
|
22
|
+
res = WebCommonUtil.catchResponse(err);
|
|
23
|
+
}
|
|
24
|
+
return res;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
2
|
+
import { Config } from '@travetto/config';
|
|
3
|
+
|
|
4
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
5
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
6
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
7
|
+
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
|
|
9
|
+
import { WebCommonUtil } from '../util/common.ts';
|
|
10
|
+
|
|
11
|
+
import { EtagInterceptor } from './etag.ts';
|
|
12
|
+
|
|
13
|
+
@Config('web.cache')
|
|
14
|
+
export class ResponseCacheConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Generate response cache headers
|
|
17
|
+
*/
|
|
18
|
+
applies = true;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determines how we cache
|
|
22
|
+
*/
|
|
23
|
+
mode: 'allow' | 'deny' = 'deny';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Determines if we should cache all get requests
|
|
28
|
+
*/
|
|
29
|
+
@Injectable()
|
|
30
|
+
export class ResponseCacheInterceptor implements WebInterceptor {
|
|
31
|
+
|
|
32
|
+
category: WebInterceptorCategory = 'response';
|
|
33
|
+
dependsOn = [EtagInterceptor];
|
|
34
|
+
|
|
35
|
+
@Inject()
|
|
36
|
+
config: ResponseCacheConfig;
|
|
37
|
+
|
|
38
|
+
applies({ config, endpoint }: WebInterceptorContext<ResponseCacheConfig>): boolean {
|
|
39
|
+
return !!endpoint.cacheable && config.applies && config.mode === 'deny';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async filter({ next }: WebChainedContext<ResponseCacheConfig>): Promise<WebResponse> {
|
|
43
|
+
const response = await next();
|
|
44
|
+
response.headers.setIfAbsent('Cache-Control', WebCommonUtil.getCacheControlValue(0));
|
|
45
|
+
return response;
|
|
46
|
+
}
|
|
47
|
+
}
|