@travetto/web 6.0.0 → 6.0.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 +33 -27
- package/__index__.ts +3 -2
- package/package.json +3 -9
- package/src/decorator/common.ts +20 -16
- package/src/decorator/endpoint.ts +0 -1
- package/src/interceptor/accept.ts +1 -2
- package/src/interceptor/body.ts +7 -5
- package/src/interceptor/cache-control.ts +50 -0
- package/src/interceptor/compress.ts +8 -16
- package/src/interceptor/cookie.ts +1 -1
- package/src/interceptor/etag.ts +31 -16
- package/src/registry/controller.ts +9 -5
- package/src/registry/types.ts +9 -5
- package/src/types/cookie.ts +1 -1
- package/src/types/headers.ts +1 -42
- package/src/types/response.ts +2 -0
- package/src/util/body.ts +0 -15
- package/src/util/common.ts +27 -16
- package/src/util/cookie.ts +8 -49
- package/src/util/endpoint.ts +9 -4
- package/src/util/header.ts +161 -0
- package/src/util/keygrip.ts +43 -0
- package/support/test/dispatch-util.ts +7 -4
- package/support/test/suite/base.ts +3 -0
- package/src/interceptor/response-cache.ts +0 -47
- package/src/util/mime.ts +0 -36
package/src/registry/types.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { WebInterceptor } from '../types/interceptor.ts';
|
|
|
5
5
|
import type { WebChainedFilter, WebFilter } from '../types/filter.ts';
|
|
6
6
|
import { HttpMethod } from '../types/core.ts';
|
|
7
7
|
import { WebHeaders } from '../types/headers.ts';
|
|
8
|
-
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
import { WebResponse, WebResponseContext } from '../types/response.ts';
|
|
9
9
|
import { WebRequest } from '../types/request.ts';
|
|
10
10
|
|
|
11
11
|
export type EndpointFunction = TypedFunction<Any, unknown>;
|
|
@@ -78,6 +78,10 @@ interface CoreConfig {
|
|
|
78
78
|
* Response headers
|
|
79
79
|
*/
|
|
80
80
|
responseHeaders?: Record<string, string>;
|
|
81
|
+
/**
|
|
82
|
+
* Partial response context
|
|
83
|
+
*/
|
|
84
|
+
responseContext?: Partial<WebResponseContext>;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
|
@@ -167,14 +171,14 @@ export interface EndpointConfig extends CoreConfig, DescribableConfig {
|
|
|
167
171
|
* Full path including controller
|
|
168
172
|
*/
|
|
169
173
|
fullPath: string;
|
|
170
|
-
/**
|
|
171
|
-
* Response header map
|
|
172
|
-
*/
|
|
173
|
-
responseHeaderMap: WebHeaders;
|
|
174
174
|
/**
|
|
175
175
|
* Response finalizer
|
|
176
176
|
*/
|
|
177
177
|
responseFinalizer?: (res: WebResponse) => WebResponse;
|
|
178
|
+
/**
|
|
179
|
+
* Response headers finalized
|
|
180
|
+
*/
|
|
181
|
+
finalizedResponseHeaders: WebHeaders;
|
|
178
182
|
}
|
|
179
183
|
|
|
180
184
|
/**
|
package/src/types/cookie.ts
CHANGED
package/src/types/headers.ts
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
import { Any,
|
|
2
|
-
import { MimeType, MimeUtil } from '../util/mime.ts';
|
|
1
|
+
import { Any, castTo } from '@travetto/runtime';
|
|
3
2
|
|
|
4
3
|
type Prim = number | boolean | string;
|
|
5
4
|
type HeaderValue = Prim | Prim[] | readonly Prim[];
|
|
6
5
|
export type WebHeadersInit = Headers | Record<string, undefined | null | HeaderValue> | [string, HeaderValue][];
|
|
7
6
|
|
|
8
|
-
const FILENAME_EXTRACT = /filename[*]?=["]?([^";]*)["]?/;
|
|
9
|
-
|
|
10
7
|
/**
|
|
11
8
|
* Simple Headers wrapper with additional logic for common patterns
|
|
12
9
|
*/
|
|
13
10
|
export class WebHeaders extends Headers {
|
|
14
11
|
|
|
15
|
-
#parsedType?: MimeType;
|
|
16
|
-
|
|
17
12
|
constructor(o?: WebHeadersInit) {
|
|
18
13
|
const passed = (o instanceof Headers);
|
|
19
14
|
super(passed ? o : undefined);
|
|
@@ -56,42 +51,6 @@ export class WebHeaders extends Headers {
|
|
|
56
51
|
}
|
|
57
52
|
}
|
|
58
53
|
|
|
59
|
-
/**
|
|
60
|
-
* Vary a value
|
|
61
|
-
*/
|
|
62
|
-
vary(value: string): void {
|
|
63
|
-
this.append('Vary', value);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Get the fully parsed content type
|
|
68
|
-
*/
|
|
69
|
-
getContentType(): MimeType | undefined {
|
|
70
|
-
return this.#parsedType ??= MimeUtil.parse(this.get('Content-Type')!);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Read the filename from the content disposition
|
|
75
|
-
*/
|
|
76
|
-
getFilename(): string | undefined {
|
|
77
|
-
const [, match] = (this.get('Content-Disposition') ?? '').match(FILENAME_EXTRACT) ?? [];
|
|
78
|
-
return match;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Get requested byte range for a given request
|
|
83
|
-
*/
|
|
84
|
-
getRange(chunkSize: number = 100 * 1024): ByteRange | undefined {
|
|
85
|
-
const rangeHeader = this.get('Range');
|
|
86
|
-
if (rangeHeader) {
|
|
87
|
-
const [start, end] = rangeHeader.replace(/bytes=/, '').split('-')
|
|
88
|
-
.map(x => x ? parseInt(x, 10) : undefined);
|
|
89
|
-
if (start !== undefined) {
|
|
90
|
-
return { start, end: end ?? (start + chunkSize) };
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
54
|
/**
|
|
96
55
|
* Set header value with a prefix
|
|
97
56
|
*/
|
package/src/types/response.ts
CHANGED
package/src/util/body.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { TextDecoder } from 'node:util';
|
|
2
2
|
import { Readable } from 'node:stream';
|
|
3
|
-
import { buffer as toBuffer } from 'node:stream/consumers';
|
|
4
3
|
|
|
5
4
|
import { Any, BinaryUtil, castTo, hasToJSON, Util } from '@travetto/runtime';
|
|
6
5
|
|
|
@@ -15,20 +14,6 @@ const WebRawStreamSymbol = Symbol();
|
|
|
15
14
|
*/
|
|
16
15
|
export class WebBodyUtil {
|
|
17
16
|
|
|
18
|
-
/**
|
|
19
|
-
* Convert a node binary input to a buffer
|
|
20
|
-
*/
|
|
21
|
-
static async toBuffer(src: WebBinaryBody): Promise<Buffer> {
|
|
22
|
-
return Buffer.isBuffer(src) ? src : toBuffer(src);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Convert a node binary input to a readable
|
|
27
|
-
*/
|
|
28
|
-
static toReadable(src: WebBinaryBody): Readable {
|
|
29
|
-
return Buffer.isBuffer(src) ? Readable.from(src) : src;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
17
|
/**
|
|
33
18
|
* Generate multipart body
|
|
34
19
|
*/
|
package/src/util/common.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { AppError, ErrorCategory,
|
|
1
|
+
import { AppError, ErrorCategory, Util } from '@travetto/runtime';
|
|
2
2
|
|
|
3
3
|
import { WebResponse } from '../types/response.ts';
|
|
4
4
|
import { WebRequest } from '../types/request.ts';
|
|
5
|
+
import { WebError } from '../types/error.ts';
|
|
5
6
|
|
|
6
7
|
type List<T> = T[] | readonly T[];
|
|
7
8
|
type OrderedState<T> = { after?: List<T>, before?: List<T>, key: T };
|
|
8
9
|
|
|
9
10
|
const WebRequestParamsSymbol = Symbol();
|
|
10
11
|
|
|
11
|
-
export type
|
|
12
|
-
'must-revalidate' | 'public' | 'private' | 'no-cache' |
|
|
13
|
-
'no-store' | 'no-transform' | 'proxy-revalidate' | 'immutable' |
|
|
14
|
-
'must-understand' | 'stale-if-error' | 'stale-while-revalidate';
|
|
12
|
+
export type ByteSizeInput = `${number}${'mb' | 'kb' | 'gb' | 'b' | ''}` | number;
|
|
15
13
|
|
|
16
14
|
/**
|
|
17
15
|
* Mapping from error category to standard http error codes
|
|
@@ -33,6 +31,12 @@ export class WebCommonUtil {
|
|
|
33
31
|
gb: 2 ** 30,
|
|
34
32
|
};
|
|
35
33
|
|
|
34
|
+
static #convert(rule: string): RegExp {
|
|
35
|
+
const core = (rule.endsWith('/*') || !rule.includes('/')) ?
|
|
36
|
+
`${rule.replace(/[/].{0,20}$/, '')}\/.*` : rule;
|
|
37
|
+
return new RegExp(`^${core}[ ]{0,10}(;|$)`);
|
|
38
|
+
}
|
|
39
|
+
|
|
36
40
|
static #buildEdgeMap<T, U extends OrderedState<T>>(items: List<U>): Map<T, Set<T>> {
|
|
37
41
|
const edgeMap = new Map(items.map(x => [x.key, new Set(x.after ?? [])]));
|
|
38
42
|
|
|
@@ -51,6 +55,19 @@ export class WebCommonUtil {
|
|
|
51
55
|
return edgeMap;
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build matcher
|
|
61
|
+
*/
|
|
62
|
+
static mimeTypeMatcher(rules: string[] | string = []): (contentType: string) => boolean {
|
|
63
|
+
return Util.allowDeny<RegExp, [string]>(
|
|
64
|
+
rules,
|
|
65
|
+
this.#convert.bind(this),
|
|
66
|
+
(regex, mime) => regex.test(mime),
|
|
67
|
+
k => k
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
/**
|
|
55
72
|
* Produces a satisfied ordering for a list of orderable elements
|
|
56
73
|
*/
|
|
@@ -103,7 +120,7 @@ export class WebCommonUtil {
|
|
|
103
120
|
new AppError(err.message, { details: err }) :
|
|
104
121
|
new AppError(`${err}`);
|
|
105
122
|
|
|
106
|
-
const error: Error &
|
|
123
|
+
const error: Error & Partial<WebError> = body;
|
|
107
124
|
const statusCode = error.details?.statusCode ?? ERROR_CATEGORY_STATUS[error.category!] ?? 500;
|
|
108
125
|
|
|
109
126
|
return new WebResponse({ body, context: { httpStatusCode: statusCode } });
|
|
@@ -123,19 +140,13 @@ export class WebCommonUtil {
|
|
|
123
140
|
request[WebRequestParamsSymbol] ??= params;
|
|
124
141
|
}
|
|
125
142
|
|
|
126
|
-
/**
|
|
127
|
-
* Get a cache control value
|
|
128
|
-
*/
|
|
129
|
-
static getCacheControlValue(value: number | TimeSpan, flags: CacheControlFlag[] = []): string {
|
|
130
|
-
const delta = TimeUtil.asSeconds(value);
|
|
131
|
-
const finalFlags = delta === 0 ? ['no-cache'] : flags;
|
|
132
|
-
return [...finalFlags, `max-age=${delta}`].join(',');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
143
|
/**
|
|
136
144
|
* Parse byte size
|
|
137
145
|
*/
|
|
138
|
-
static parseByteSize(input:
|
|
146
|
+
static parseByteSize(input: ByteSizeInput): number {
|
|
147
|
+
if (typeof input === 'number') {
|
|
148
|
+
return input;
|
|
149
|
+
}
|
|
139
150
|
const [, num, unit] = input.toLowerCase().split(/(\d+)/);
|
|
140
151
|
return parseInt(num, 10) * (this.#unitMapping[unit] ?? 1);
|
|
141
152
|
}
|
package/src/util/cookie.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { AppError, castKey, castTo } from '@travetto/runtime';
|
|
1
|
+
import { AppError } from '@travetto/runtime';
|
|
3
2
|
|
|
4
3
|
import { Cookie, CookieGetOptions, CookieSetOptions } from '../types/cookie.ts';
|
|
4
|
+
import { KeyGrip } from './keygrip.ts';
|
|
5
|
+
import { WebHeaderUtil } from './header.ts';
|
|
5
6
|
|
|
6
7
|
const pairText = (c: Cookie): string => `${c.name}=${c.value}`;
|
|
7
8
|
const pair = (k: string, v: unknown): string => `${k}=${v}`;
|
|
@@ -10,55 +11,13 @@ type CookieJarOptions = { keys?: string[] } & CookieSetOptions;
|
|
|
10
11
|
|
|
11
12
|
export class CookieJar {
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
return header.split(/\s{0,4};\s{0,4}/g)
|
|
15
|
-
.map(x => x.trim())
|
|
16
|
-
.filter(x => !!x)
|
|
17
|
-
.map(item => {
|
|
18
|
-
const kv = item.split(/\s{0,4}=\s{0,4}/);
|
|
19
|
-
return { name: kv[0], value: kv[1] };
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
static parseSetCookieHeader(header: string): Cookie {
|
|
24
|
-
const parts = header.split(/\s{0,4};\s{0,4}/g);
|
|
25
|
-
const [name, value] = parts[0].split(/\s{0,4}=\s{0,4}/);
|
|
26
|
-
const c: Cookie = { name, value };
|
|
27
|
-
for (const p of parts.slice(1)) {
|
|
28
|
-
// eslint-disable-next-line prefer-const
|
|
29
|
-
let [k, v = ''] = p.split(/\s{0,4}=\s{0,4}/);
|
|
30
|
-
if (v[0] === '"') {
|
|
31
|
-
v = v.slice(1, -1);
|
|
32
|
-
}
|
|
33
|
-
if (k === 'expires') {
|
|
34
|
-
c[k] = new Date(v);
|
|
35
|
-
} else {
|
|
36
|
-
c[castKey(k)] = castTo(v || true);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return c;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
static responseSuffix(c: Cookie): string[] {
|
|
43
|
-
const parts = [];
|
|
44
|
-
if (c.path) { parts.push(pair('path', c.path)); }
|
|
45
|
-
if (c.expires) { parts.push(pair('expires', c.expires.toUTCString())); }
|
|
46
|
-
if (c.domain) { parts.push(pair('domain', c.domain)); }
|
|
47
|
-
if (c.priority) { parts.push(pair('priority', c.priority.toLowerCase())); }
|
|
48
|
-
if (c.sameSite) { parts.push(pair('samesite', c.sameSite.toLowerCase())); }
|
|
49
|
-
if (c.secure) { parts.push('secure'); }
|
|
50
|
-
if (c.httpOnly) { parts.push('httponly'); }
|
|
51
|
-
if (c.partitioned) { parts.push('partitioned'); }
|
|
52
|
-
return parts;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
#grip?: keygrip;
|
|
14
|
+
#grip?: KeyGrip;
|
|
56
15
|
#cookies: Record<string, Cookie> = {};
|
|
57
16
|
#setOptions: CookieSetOptions = {};
|
|
58
17
|
#deleteOptions: CookieSetOptions = { maxAge: 0, expires: undefined };
|
|
59
18
|
|
|
60
19
|
constructor({ keys, ...options }: CookieJarOptions = {}) {
|
|
61
|
-
this.#grip = keys?.length ? new
|
|
20
|
+
this.#grip = keys?.length ? new KeyGrip(keys) : undefined;
|
|
62
21
|
this.#setOptions = {
|
|
63
22
|
secure: false,
|
|
64
23
|
path: '/',
|
|
@@ -68,7 +27,7 @@ export class CookieJar {
|
|
|
68
27
|
}
|
|
69
28
|
|
|
70
29
|
#exportCookie(cookie: Cookie, response?: boolean): string[] {
|
|
71
|
-
const suffix = response ?
|
|
30
|
+
const suffix = response ? WebHeaderUtil.buildCookieSuffix(cookie) : null;
|
|
72
31
|
const payload = pairText(cookie);
|
|
73
32
|
const out = suffix ? [[payload, ...suffix].join(';')] : [payload];
|
|
74
33
|
if (cookie.signed) {
|
|
@@ -144,11 +103,11 @@ export class CookieJar {
|
|
|
144
103
|
}
|
|
145
104
|
|
|
146
105
|
importCookieHeader(header: string | null | undefined): this {
|
|
147
|
-
return this.import(
|
|
106
|
+
return this.import(WebHeaderUtil.parseCookieHeader(header ?? ''));
|
|
148
107
|
}
|
|
149
108
|
|
|
150
109
|
importSetCookieHeader(headers: string[] | null | undefined): this {
|
|
151
|
-
return this.import(headers?.map(
|
|
110
|
+
return this.import(headers?.map(WebHeaderUtil.parseSetCookieHeader) ?? []);
|
|
152
111
|
}
|
|
153
112
|
|
|
154
113
|
exportCookieHeader(): string {
|
package/src/util/endpoint.ts
CHANGED
|
@@ -143,10 +143,15 @@ export class EndpointUtil {
|
|
|
143
143
|
try {
|
|
144
144
|
const params = await this.extractParameters(endpoint, request);
|
|
145
145
|
const body = await endpoint.endpoint.apply(endpoint.instance, params);
|
|
146
|
-
const headers = endpoint.
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
for (const [k, v] of headers) {
|
|
146
|
+
const headers = endpoint.finalizedResponseHeaders;
|
|
147
|
+
let response: WebResponse;
|
|
148
|
+
if (body instanceof WebResponse) {
|
|
149
|
+
for (const [k, v] of headers) { body.headers.setIfAbsent(k, v); }
|
|
150
|
+
// Rewrite context
|
|
151
|
+
Object.assign(body.context, { ...endpoint.responseContext, ...body.context });
|
|
152
|
+
response = body;
|
|
153
|
+
} else {
|
|
154
|
+
response = new WebResponse({ body, headers, context: { ...endpoint.responseContext } });
|
|
150
155
|
}
|
|
151
156
|
return endpoint.responseFinalizer?.(response) ?? response;
|
|
152
157
|
} catch (err) {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { ByteRange, castKey, castTo } from '@travetto/runtime';
|
|
2
|
+
|
|
3
|
+
import { type Cookie } from '../types/cookie.ts';
|
|
4
|
+
import { WebHeaders } from '../types/headers.ts';
|
|
5
|
+
|
|
6
|
+
export type WebParsedHeader = { value: string, parameters: Record<string, string>, q?: number };
|
|
7
|
+
|
|
8
|
+
const SPLIT_EQ = /[ ]{0,10}=[ ]{0,10}/g;
|
|
9
|
+
const SPLIT_COMMA = /[ ]{0,10},[ ]{0,10}/g;
|
|
10
|
+
const SPLIT_SEMI = /[ ]{0,10};[ ]{0,10}/g;
|
|
11
|
+
const QUOTE = '"'.charCodeAt(0);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Web header utils
|
|
15
|
+
*/
|
|
16
|
+
export class WebHeaderUtil {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse cookie header
|
|
20
|
+
*/
|
|
21
|
+
static parseCookieHeader(header: string): Cookie[] {
|
|
22
|
+
const val = header.trim();
|
|
23
|
+
return !val ? [] : val.split(SPLIT_SEMI).map(item => {
|
|
24
|
+
const [name, value] = item.split(SPLIT_EQ);
|
|
25
|
+
return { name, value };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse cookie set header
|
|
31
|
+
*/
|
|
32
|
+
static parseSetCookieHeader(header: string): Cookie {
|
|
33
|
+
const parts = header.split(SPLIT_SEMI);
|
|
34
|
+
const [name, value] = parts[0].split(SPLIT_EQ);
|
|
35
|
+
const c: Cookie = { name, value };
|
|
36
|
+
for (const p of parts.slice(1)) {
|
|
37
|
+
const [k, pv = ''] = p.toLowerCase().split(SPLIT_EQ);
|
|
38
|
+
const v = pv.charCodeAt(0) === QUOTE ? pv.slice(1, -1) : pv;
|
|
39
|
+
if (k === 'expires') {
|
|
40
|
+
c[k] = new Date(v);
|
|
41
|
+
} else {
|
|
42
|
+
c[castKey(k)] = castTo(v || true);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return c;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse header segment
|
|
50
|
+
* @input input
|
|
51
|
+
*/
|
|
52
|
+
static parseHeaderSegment(input: string | null | undefined): WebParsedHeader {
|
|
53
|
+
if (!input) {
|
|
54
|
+
return { value: '', parameters: {} };
|
|
55
|
+
}
|
|
56
|
+
const [rv, ...parts] = input.split(SPLIT_SEMI);
|
|
57
|
+
const item: WebParsedHeader = { value: '', parameters: {} };
|
|
58
|
+
const value = rv.charCodeAt(0) === QUOTE ? rv.slice(1, -1) : rv;
|
|
59
|
+
if (value.includes('=')) {
|
|
60
|
+
parts.unshift(value);
|
|
61
|
+
} else {
|
|
62
|
+
item.value = value;
|
|
63
|
+
}
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
const [k, pv = ''] = part.split(SPLIT_EQ);
|
|
66
|
+
const v = (pv.charCodeAt(0) === QUOTE) ? pv.slice(1, -1) : pv;
|
|
67
|
+
item.parameters[k] = v;
|
|
68
|
+
if (k === 'q') {
|
|
69
|
+
item.q = parseFloat(v);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return item;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse full header
|
|
77
|
+
*/
|
|
78
|
+
static parseHeader(input: string): WebParsedHeader[] {
|
|
79
|
+
const v = input.trim();
|
|
80
|
+
if (!input) { return []; }
|
|
81
|
+
return v.split(SPLIT_COMMA).map(x => this.parseHeaderSegment(x));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build cookie suffix
|
|
86
|
+
*/
|
|
87
|
+
static buildCookieSuffix(c: Cookie): string[] {
|
|
88
|
+
const parts = [];
|
|
89
|
+
if (c.path) { parts.push(`path=${c.path}`); }
|
|
90
|
+
if (c.expires) { parts.push(`expires=${c.expires.toUTCString()}`); }
|
|
91
|
+
if (c.domain) { parts.push(`domain=${c.domain}`); }
|
|
92
|
+
if (c.priority) { parts.push(`priority=${c.priority.toLowerCase()}`); }
|
|
93
|
+
if (c.sameSite) { parts.push(`samesite=${c.sameSite.toLowerCase()}`); }
|
|
94
|
+
if (c.secure) { parts.push('secure'); }
|
|
95
|
+
if (c.httponly) { parts.push('httponly'); }
|
|
96
|
+
if (c.partitioned) { parts.push('partitioned'); }
|
|
97
|
+
return parts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Negotiate header
|
|
102
|
+
*/
|
|
103
|
+
static negotiateHeader<K extends string>(header: string, values: K[]): K | undefined {
|
|
104
|
+
if (header === '*' || header === '*/*') {
|
|
105
|
+
return values[0];
|
|
106
|
+
}
|
|
107
|
+
const sorted = this.parseHeader(header.toLowerCase()).filter(x => (x.q ?? 1) > 0).toSorted((a, b) => (b.q ?? 1) - (a.q ?? 1));
|
|
108
|
+
const set = new Set(values);
|
|
109
|
+
for (const { value } of sorted) {
|
|
110
|
+
const vk: K = castKey(value);
|
|
111
|
+
if (value === '*') {
|
|
112
|
+
return values[0];
|
|
113
|
+
} else if (set.has(vk)) {
|
|
114
|
+
return vk;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get requested byte range for a given request
|
|
122
|
+
*/
|
|
123
|
+
static getRange(headers: WebHeaders, chunkSize: number = 100 * 1024): ByteRange | undefined {
|
|
124
|
+
if (!headers.has('Range')) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { parameters } = this.parseHeaderSegment(headers.get('Range'));
|
|
128
|
+
if ('bytes' in parameters) {
|
|
129
|
+
const [start, end] = parameters.bytes.split('-')
|
|
130
|
+
.map(x => x ? parseInt(x, 10) : undefined);
|
|
131
|
+
if (start !== undefined) {
|
|
132
|
+
return { start, end: end ?? (start + chunkSize) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check freshness of the response using request and response headers.
|
|
139
|
+
*/
|
|
140
|
+
static isFresh(req: WebHeaders, res: WebHeaders): boolean {
|
|
141
|
+
const cacheControl = req.get('Cache-Control');
|
|
142
|
+
if (cacheControl?.includes('no-cache')) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const noneMatch = req.get('If-None-Match');
|
|
147
|
+
if (noneMatch) {
|
|
148
|
+
const etag = res.get('ETag');
|
|
149
|
+
const validTag = (v: string): boolean => v === etag || v === `W/${etag}` || `W/${v}` === etag;
|
|
150
|
+
return noneMatch === '*' || (!!etag && noneMatch.split(SPLIT_COMMA).some(validTag));
|
|
151
|
+
} else {
|
|
152
|
+
const modifiedSince = req.get('If-Modified-Since');
|
|
153
|
+
const lastModified = res.get('Last-Modified');
|
|
154
|
+
if (!modifiedSince || !lastModified) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const [a, b] = [Date.parse(lastModified), Date.parse(modifiedSince)];
|
|
158
|
+
return !(Number.isNaN(a) || Number.isNaN(b)) && a >= b;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import crypto, { BinaryToTextEncoding } from 'node:crypto';
|
|
2
|
+
import { AppError, castKey } from '@travetto/runtime';
|
|
3
|
+
|
|
4
|
+
const CHAR_MAPPING = { '/': '_', '+': '-', '=': '' };
|
|
5
|
+
|
|
6
|
+
function timeSafeCompare(a: string, b: string): boolean {
|
|
7
|
+
const key = crypto.randomBytes(32);
|
|
8
|
+
const ah = crypto.createHmac('sha256', key).update(a).digest();
|
|
9
|
+
const bh = crypto.createHmac('sha256', key).update(b).digest();
|
|
10
|
+
return ah.length === bh.length && crypto.timingSafeEqual(ah, bh);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class KeyGrip {
|
|
14
|
+
|
|
15
|
+
#keys: string[];
|
|
16
|
+
#algorithm: string;
|
|
17
|
+
#encoding: BinaryToTextEncoding;
|
|
18
|
+
|
|
19
|
+
constructor(keys: string[], algorithm = 'sha1', encoding: BinaryToTextEncoding = 'base64') {
|
|
20
|
+
if (!keys.length) {
|
|
21
|
+
throw new AppError('Keys must be defined');
|
|
22
|
+
}
|
|
23
|
+
this.#keys = keys;
|
|
24
|
+
this.#algorithm = algorithm;
|
|
25
|
+
this.#encoding = encoding;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
sign(data: string, key?: string): string {
|
|
29
|
+
return crypto
|
|
30
|
+
.createHmac(this.#algorithm, key ?? this.#keys[0])
|
|
31
|
+
.update(data)
|
|
32
|
+
.digest(this.#encoding)
|
|
33
|
+
.replace(/[/+=]/g, x => CHAR_MAPPING[castKey(x)]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
verify(data: string, digest: string): boolean {
|
|
37
|
+
return this.index(data, digest) > -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
index(data: string, digest: string): number {
|
|
41
|
+
return this.#keys.findIndex(key => timeSafeCompare(digest, this.sign(data, key)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { buffer
|
|
1
|
+
import { buffer } from 'node:stream/consumers';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
2
3
|
|
|
3
4
|
import { AppError, BinaryUtil, castTo } from '@travetto/runtime';
|
|
4
5
|
import { BindUtil } from '@travetto/schema';
|
|
@@ -9,6 +10,8 @@ import { DecompressInterceptor } from '../../src/interceptor/decompress.ts';
|
|
|
9
10
|
import { WebBodyUtil } from '../../src/util/body.ts';
|
|
10
11
|
import { WebCommonUtil } from '../../src/util/common.ts';
|
|
11
12
|
|
|
13
|
+
const toBuffer = (src: Buffer | Readable) => Buffer.isBuffer(src) ? src : buffer(src);
|
|
14
|
+
|
|
12
15
|
/**
|
|
13
16
|
* Utilities for supporting custom test dispatchers
|
|
14
17
|
*/
|
|
@@ -18,7 +21,7 @@ export class WebTestDispatchUtil {
|
|
|
18
21
|
if (request.body !== undefined) {
|
|
19
22
|
const sample = WebBodyUtil.toBinaryMessage(request);
|
|
20
23
|
sample.headers.forEach((v, k) => request.headers.set(k, Array.isArray(v) ? v.join(',') : v));
|
|
21
|
-
request.body = WebBodyUtil.markRaw(await
|
|
24
|
+
request.body = WebBodyUtil.markRaw(await toBuffer(sample.body!));
|
|
22
25
|
}
|
|
23
26
|
Object.assign(request.context, { httpQuery: BindUtil.flattenPaths(request.context.httpQuery ?? {}) });
|
|
24
27
|
return request;
|
|
@@ -31,7 +34,7 @@ export class WebTestDispatchUtil {
|
|
|
31
34
|
|
|
32
35
|
if (decompress) {
|
|
33
36
|
if (Buffer.isBuffer(result) || BinaryUtil.isReadable(result)) {
|
|
34
|
-
const bufferResult = result = await
|
|
37
|
+
const bufferResult = result = await toBuffer(result);
|
|
35
38
|
if (bufferResult.length) {
|
|
36
39
|
try {
|
|
37
40
|
result = await DecompressInterceptor.decompress(
|
|
@@ -74,7 +77,7 @@ export class WebTestDispatchUtil {
|
|
|
74
77
|
|
|
75
78
|
const body: RequestInit['body'] =
|
|
76
79
|
WebBodyUtil.isRaw(request.body) ?
|
|
77
|
-
|
|
80
|
+
await toBuffer(request.body) :
|
|
78
81
|
castTo(request.body);
|
|
79
82
|
|
|
80
83
|
return { path: finalPath, init: { headers, method, body } };
|
|
@@ -1,47 +0,0 @@
|
|
|
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
|
-
}
|