@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.
Files changed (50) hide show
  1. package/README.md +734 -0
  2. package/__index__.ts +44 -0
  3. package/package.json +66 -0
  4. package/src/common/global.ts +30 -0
  5. package/src/config.ts +18 -0
  6. package/src/context.ts +49 -0
  7. package/src/decorator/common.ts +87 -0
  8. package/src/decorator/controller.ts +13 -0
  9. package/src/decorator/endpoint.ts +102 -0
  10. package/src/decorator/param.ts +64 -0
  11. package/src/interceptor/accept.ts +70 -0
  12. package/src/interceptor/body-parse.ts +123 -0
  13. package/src/interceptor/compress.ts +119 -0
  14. package/src/interceptor/context.ts +23 -0
  15. package/src/interceptor/cookies.ts +97 -0
  16. package/src/interceptor/cors.ts +94 -0
  17. package/src/interceptor/decompress.ts +91 -0
  18. package/src/interceptor/etag.ts +99 -0
  19. package/src/interceptor/logging.ts +71 -0
  20. package/src/interceptor/respond.ts +26 -0
  21. package/src/interceptor/response-cache.ts +47 -0
  22. package/src/interceptor/trust-proxy.ts +53 -0
  23. package/src/registry/controller.ts +288 -0
  24. package/src/registry/types.ts +229 -0
  25. package/src/registry/visitor.ts +52 -0
  26. package/src/router/base.ts +67 -0
  27. package/src/router/standard.ts +59 -0
  28. package/src/types/cookie.ts +18 -0
  29. package/src/types/core.ts +33 -0
  30. package/src/types/dispatch.ts +23 -0
  31. package/src/types/error.ts +10 -0
  32. package/src/types/filter.ts +7 -0
  33. package/src/types/headers.ts +108 -0
  34. package/src/types/interceptor.ts +54 -0
  35. package/src/types/message.ts +33 -0
  36. package/src/types/request.ts +22 -0
  37. package/src/types/response.ts +20 -0
  38. package/src/util/body.ts +220 -0
  39. package/src/util/common.ts +142 -0
  40. package/src/util/cookie.ts +145 -0
  41. package/src/util/endpoint.ts +277 -0
  42. package/src/util/mime.ts +36 -0
  43. package/src/util/net.ts +61 -0
  44. package/support/test/dispatch-util.ts +90 -0
  45. package/support/test/dispatcher.ts +15 -0
  46. package/support/test/suite/base.ts +61 -0
  47. package/support/test/suite/controller.ts +103 -0
  48. package/support/test/suite/schema.ts +275 -0
  49. package/support/test/suite/standard.ts +178 -0
  50. package/support/transformer.web.ts +207 -0
@@ -0,0 +1,142 @@
1
+ import { AppError, ErrorCategory, TimeSpan, TimeUtil } from '@travetto/runtime';
2
+
3
+ import { WebResponse } from '../types/response.ts';
4
+ import { WebRequest } from '../types/request.ts';
5
+
6
+ type List<T> = T[] | readonly T[];
7
+ type OrderedState<T> = { after?: List<T>, before?: List<T>, key: T };
8
+
9
+ const WebRequestParamsSymbol = Symbol();
10
+
11
+ export type CacheControlFlag =
12
+ 'must-revalidate' | 'public' | 'private' | 'no-cache' |
13
+ 'no-store' | 'no-transform' | 'proxy-revalidate' | 'immutable' |
14
+ 'must-understand' | 'stale-if-error' | 'stale-while-revalidate';
15
+
16
+ /**
17
+ * Mapping from error category to standard http error codes
18
+ */
19
+ const ERROR_CATEGORY_STATUS: Record<ErrorCategory, number> = {
20
+ general: 500,
21
+ notfound: 404,
22
+ data: 400,
23
+ permissions: 403,
24
+ authentication: 401,
25
+ timeout: 408,
26
+ unavailable: 503,
27
+ };
28
+
29
+ export class WebCommonUtil {
30
+ static #unitMapping: Record<string, number> = {
31
+ kb: 2 ** 10,
32
+ mb: 2 ** 20,
33
+ gb: 2 ** 30,
34
+ };
35
+
36
+ static #buildEdgeMap<T, U extends OrderedState<T>>(items: List<U>): Map<T, Set<T>> {
37
+ const edgeMap = new Map(items.map(x => [x.key, new Set(x.after ?? [])]));
38
+
39
+ // Build out edge map
40
+ for (const input of items) {
41
+ for (const bf of input.before ?? []) {
42
+ if (edgeMap.has(bf)) {
43
+ edgeMap.get(bf)!.add(input.key);
44
+ }
45
+ }
46
+ const afterSet = edgeMap.get(input.key)!;
47
+ for (const el of input.after ?? []) {
48
+ afterSet.add(el);
49
+ }
50
+ }
51
+ return edgeMap;
52
+ }
53
+
54
+ /**
55
+ * Produces a satisfied ordering for a list of orderable elements
56
+ */
57
+ static ordered<T, U extends OrderedState<T>>(items: List<U>): U[] {
58
+ const edgeMap = this.#buildEdgeMap<T, U>(items);
59
+
60
+ // Loop through all items again
61
+ const keys: T[] = [];
62
+ while (edgeMap.size > 0) {
63
+
64
+ // Find node with no dependencies
65
+ const key = [...edgeMap].find(([, after]) => after.size === 0)?.[0];
66
+ if (!key) {
67
+ throw new Error(`Unsatisfiable dependency: ${[...edgeMap.keys()]}`);
68
+ }
69
+
70
+ // Store, and remove
71
+ keys.push(key);
72
+ edgeMap.delete(key);
73
+
74
+ // Remove node from all other elements in `all`
75
+ for (const [, rem] of edgeMap) {
76
+ rem.delete(key);
77
+ }
78
+ }
79
+
80
+ const inputMap = new Map(items.map(x => [x.key, x]));
81
+ return keys.map(k => inputMap.get(k)!);
82
+ }
83
+
84
+ /**
85
+ * Get status code
86
+ */
87
+ static getStatusCode(response: WebResponse): number {
88
+ return (response.headers.has('Content-Range') && response.context.httpStatusCode === 200) ?
89
+ 206 :
90
+ response.context.httpStatusCode ?? 200;
91
+ }
92
+
93
+ /**
94
+ * From catch value
95
+ */
96
+ static catchResponse(err: unknown): WebResponse<Error> {
97
+ if (err instanceof WebResponse) {
98
+ return err;
99
+ }
100
+
101
+ const body = err instanceof Error ? err :
102
+ (!!err && typeof err === 'object' && ('message' in err && typeof err.message === 'string')) ?
103
+ new AppError(err.message, { details: err }) :
104
+ new AppError(`${err}`);
105
+
106
+ const error: Error & { category?: ErrorCategory, details?: { statusCode: number } } = body;
107
+ const statusCode = error.details?.statusCode ?? ERROR_CATEGORY_STATUS[error.category!] ?? 500;
108
+
109
+ return new WebResponse({ body, context: { httpStatusCode: statusCode } });
110
+ }
111
+
112
+ /**
113
+ * Get request parameters
114
+ */
115
+ static getRequestParams(request: WebRequest & { [WebRequestParamsSymbol]?: unknown[] }): unknown[] {
116
+ return request[WebRequestParamsSymbol] ?? [];
117
+ }
118
+
119
+ /**
120
+ * Set request parameters
121
+ */
122
+ static setRequestParams(request: WebRequest & { [WebRequestParamsSymbol]?: unknown[] }, params: unknown[]): void {
123
+ request[WebRequestParamsSymbol] ??= params;
124
+ }
125
+
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
+ /**
136
+ * Parse byte size
137
+ */
138
+ static parseByteSize(input: `${number}${'mb' | 'kb' | 'gb' | 'b' | ''}`): number {
139
+ const [, num, unit] = input.toLowerCase().split(/(\d+)/);
140
+ return parseInt(num, 10) * (this.#unitMapping[unit] ?? 1);
141
+ }
142
+ }
@@ -0,0 +1,145 @@
1
+ import keygrip from 'keygrip';
2
+ import { AppError, castKey, castTo } from '@travetto/runtime';
3
+
4
+ import { Cookie, CookieGetOptions } from '../types/cookie.ts';
5
+
6
+ const pairText = (c: Cookie): string => `${c.name}=${c.value}`;
7
+ const pair = (k: string, v: unknown): string => `${k}=${v}`;
8
+
9
+ export class CookieJar {
10
+
11
+ static fromHeaderValue(header: string): Cookie {
12
+ const parts = header.split(/\s{0,4};\s{0,4}/g);
13
+ const [name, value] = parts[0].split(/\s{0,4}=\s{0,4}/);
14
+ const c: Cookie = { name, value };
15
+ for (const p of parts.slice(1)) {
16
+ // eslint-disable-next-line prefer-const
17
+ let [k, v = ''] = p.split(/\s{0,4}=\s{0,4}/);
18
+ if (v[0] === '"') {
19
+ v = v.slice(1, -1);
20
+ }
21
+ if (k === 'expires') {
22
+ c[k] = new Date(v);
23
+ } else {
24
+ c[castKey(k)] = castTo(v || true);
25
+ }
26
+ }
27
+ return c;
28
+ }
29
+
30
+ static toHeaderValue(c: Cookie, response = true): string {
31
+ const header = [pair(c.name, c.value)];
32
+ if (response) {
33
+ if (!c.value) {
34
+ c.expires = new Date(0);
35
+ c.maxAge = undefined;
36
+ }
37
+ if (c.maxAge) {
38
+ c.expires = new Date(Date.now() + c.maxAge);
39
+ }
40
+
41
+ if (c.path) { header.push(pair('path', c.path)); }
42
+ if (c.expires) { header.push(pair('expires', c.expires.toUTCString())); }
43
+ if (c.domain) { header.push(pair('domain', c.domain)); }
44
+ if (c.priority) { header.push(pair('priority', c.priority.toLowerCase())); }
45
+ if (c.sameSite) { header.push(pair('samesite', c.sameSite.toLowerCase())); }
46
+ if (c.secure) { header.push('secure'); }
47
+ if (c.httpOnly) { header.push('httponly'); }
48
+ if (c.partitioned) { header.push('partitioned'); }
49
+ }
50
+ return header.join(';');
51
+ }
52
+
53
+ #secure?: boolean;
54
+ #grip?: keygrip;
55
+ #cookies: Record<string, Cookie> = {};
56
+
57
+ constructor(input?: string | string[] | null | undefined | Cookie[] | CookieJar, options?: { keys?: string[], secure?: boolean }) {
58
+ this.#grip = options?.keys?.length ? new keygrip(options.keys) : undefined;
59
+ this.#secure = options?.secure ?? false;
60
+ if (input instanceof CookieJar) {
61
+ this.#cookies = { ...input.#cookies };
62
+ } else if (Array.isArray(input)) {
63
+ this.#import(input);
64
+ } else {
65
+ this.#import(input?.split(/\s{0,4},\s{0,4}/) ?? []);
66
+ }
67
+ }
68
+
69
+ #checkSignature(c: Cookie): Cookie | undefined {
70
+ if (!this.#grip) { return; }
71
+ const key = pairText(c);
72
+ const sc = this.#cookies[`${c.name}.sig`];
73
+ if (!sc.value) { return; }
74
+
75
+ const index = this.#grip.index(key, sc.value);
76
+ c.signed = index >= 0;
77
+ sc.signed = false;
78
+ sc.secure = c.secure;
79
+
80
+ if (index >= 1) {
81
+ sc.value = this.#grip.sign(key);
82
+ sc.response = true;
83
+ return sc;
84
+ }
85
+ }
86
+
87
+ #signCookie(c: Cookie): Cookie {
88
+ if (!this.#grip) {
89
+ throw new AppError('.keys required for signed cookies');
90
+ } else if (!this.#secure && c.secure) {
91
+ throw new AppError('Cannot send secure cookie over unencrypted connection');
92
+ }
93
+ return { ...c, name: `${c.name}.sig`, value: this.#grip.sign(pairText(c)) };
94
+ }
95
+
96
+ #import(inputs: (string | Cookie)[]): void {
97
+ const toCheck = [];
98
+ for (const input of inputs) {
99
+ const c = typeof input === 'string' ? CookieJar.fromHeaderValue(input) : input;
100
+ this.#cookies[c.name] = c;
101
+ if (this.#grip && !c.name.endsWith('.sig')) {
102
+ toCheck.push(c);
103
+ }
104
+ }
105
+ for (const c of toCheck) {
106
+ const sc = this.#checkSignature(c);
107
+ if (sc) {
108
+ this.set(sc);
109
+ }
110
+ }
111
+ }
112
+
113
+ get(name: string, opts: CookieGetOptions = {}): string | undefined {
114
+ const c = this.#cookies[name];
115
+ return (c?.signed || !(opts.signed ?? !!this.#grip)) ? c?.value : undefined;
116
+ }
117
+
118
+ set(c: Cookie): void {
119
+ this.#cookies[c.name] = c;
120
+ c.secure ??= this.#secure;
121
+ c.signed ??= !!this.#grip;
122
+ c.response = true;
123
+
124
+ if (c.value === null || c.value === undefined) {
125
+ c.maxAge = -1;
126
+ c.expires = undefined;
127
+ }
128
+
129
+ if (c.signed) {
130
+ const sc = this.#signCookie(c);
131
+ this.#cookies[sc.name] = sc;
132
+ sc.response = true;
133
+ }
134
+ }
135
+
136
+ export(response = true): string[] {
137
+ return this.getAll()
138
+ .filter(x => !response || x.response)
139
+ .map(c => CookieJar.toHeaderValue(c, response));
140
+ }
141
+
142
+ getAll(): Cookie[] {
143
+ return Object.values(this.#cookies);
144
+ }
145
+ }
@@ -0,0 +1,277 @@
1
+ import { asConstructable, castTo, Class, Runtime, TypedObject } from '@travetto/runtime';
2
+ import { BindUtil, FieldConfig, SchemaRegistry, SchemaValidator, ValidationResultError } from '@travetto/schema';
3
+ import { DependencyRegistry } from '@travetto/di';
4
+ import { RetargettingProxy } from '@travetto/registry';
5
+
6
+ import { WebChainedFilter, WebChainedContext, WebFilter } from '../types/filter.ts';
7
+ import { WebResponse } from '../types/response.ts';
8
+ import { WebInterceptor } from '../types/interceptor.ts';
9
+ import { WebRequest } from '../types/request.ts';
10
+ import { WEB_INTERCEPTOR_CATEGORIES } from '../types/core.ts';
11
+ import { EndpointConfig, ControllerConfig, EndpointParamConfig } from '../registry/types.ts';
12
+ import { ControllerRegistry } from '../registry/controller.ts';
13
+ import { WebCommonUtil } from './common.ts';
14
+
15
+
16
+ const WebQueryExpandedSymbol = Symbol();
17
+
18
+ /**
19
+ * Endpoint specific utilities
20
+ */
21
+ export class EndpointUtil {
22
+
23
+ static #compareEndpoints(a: number[], b: number[]): number {
24
+ const al = a.length;
25
+ const bl = b.length;
26
+ if (al !== bl) {
27
+ return bl - al;
28
+ }
29
+ let i = 0;
30
+ while (i < al) {
31
+ if (a[i] !== b[i]) {
32
+ return b[i] - a[i];
33
+ }
34
+ i += 1;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ static MissingParamSymbol = Symbol();
40
+
41
+ /**
42
+ * Create a full filter chain given the provided filters
43
+ * @param filters Filters to chain
44
+ */
45
+ static createFilterChain(filters: { filter: WebChainedFilter, config?: unknown }[]): WebChainedFilter {
46
+ const len = filters.length - 1;
47
+ return function filterChain(ctx: WebChainedContext, idx: number = 0): Promise<WebResponse> {
48
+ const { filter, config } = filters[idx]!;
49
+ const chainedNext = idx === len ? ctx.next : filterChain.bind(null, ctx, idx + 1);
50
+ return filter({ request: ctx.request, next: chainedNext, config });
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Resolve interceptors with configs
56
+ * @param interceptors
57
+ * @param endpoint
58
+ * @param controller
59
+ */
60
+ static resolveInterceptorsWithConfig(
61
+ interceptors: WebInterceptor[],
62
+ endpoint: EndpointConfig,
63
+ controller?: ControllerConfig
64
+ ): [WebInterceptor, unknown][] {
65
+
66
+ const inputByClass = Map.groupBy(
67
+ [...controller?.interceptorConfigs ?? [], ...endpoint.interceptorConfigs ?? []],
68
+ x => x[0]
69
+ );
70
+
71
+ const configs = new Map<Class, unknown>(interceptors.map(inst => {
72
+ const cls = asConstructable<WebInterceptor>(inst).constructor;
73
+ const inputs = (inputByClass.get(cls) ?? []).map(x => x[1]);
74
+ const config = Object.assign({}, inst.config, ...inputs);
75
+ return [cls, inst.finalizeConfig?.({ config, endpoint }, castTo(inputs)) ?? config];
76
+ }));
77
+
78
+ return interceptors.map(inst => [
79
+ inst,
80
+ configs.get(asConstructable(inst).constructor)
81
+ ]);
82
+ }
83
+
84
+ /**
85
+ * Extract parameter from request
86
+ */
87
+ static extractParameter(request: WebRequest, param: EndpointParamConfig, field: FieldConfig, value?: unknown): unknown {
88
+ if (value !== undefined && value !== this.MissingParamSymbol) {
89
+ return value;
90
+ } else if (param.extract) {
91
+ return param.extract(request, param);
92
+ }
93
+
94
+ const name = param.name!;
95
+ switch (param.location) {
96
+ case 'path': return request.context.pathParams?.[name];
97
+ case 'header': return field.array ? request.headers.getList(name) : request.headers.get(name);
98
+ case 'body': return request.body;
99
+ case 'query': {
100
+ const withQuery: typeof request & { [WebQueryExpandedSymbol]?: Record<string, unknown> } = request;
101
+ const q = withQuery[WebQueryExpandedSymbol] ??= BindUtil.expandPaths(request.context.httpQuery ?? {});
102
+ return param.prefix ? q[param.prefix] : (field.type.Ⲑid ? q : q[name]);
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Extract all parameters for a given endpoint/request/response combo
109
+ * @param endpoint The endpoint to extract for
110
+ * @param req The request
111
+ * @param res The response
112
+ */
113
+ static async extractParameters(endpoint: EndpointConfig, request: WebRequest): Promise<unknown[]> {
114
+ const cls = endpoint.class;
115
+ const method = endpoint.name;
116
+ const vals = WebCommonUtil.getRequestParams(request);
117
+
118
+ try {
119
+ const fields = SchemaRegistry.getMethodSchema(cls, method);
120
+ const extracted = endpoint.params.map((c, i) => this.extractParameter(request, c, fields[i], vals?.[i]));
121
+ const params = BindUtil.coerceMethodParams(cls, method, extracted);
122
+ await SchemaValidator.validateMethod(cls, method, params, endpoint.params.map(x => x.prefix));
123
+ return params;
124
+ } catch (err) {
125
+ if (err instanceof ValidationResultError) {
126
+ for (const el of err.details?.errors ?? []) {
127
+ if (el.kind === 'required') {
128
+ const config = endpoint.params.find(x => x.name === el.path);
129
+ if (config) {
130
+ el.message = `Missing ${config.location.replace(/s$/, '')}: ${config.name}`;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ throw err;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Endpoint invocation code
141
+ */
142
+ static async invokeEndpoint(endpoint: EndpointConfig, { request }: WebChainedContext): Promise<WebResponse> {
143
+ try {
144
+ const params = await this.extractParameters(endpoint, request);
145
+ const body = await endpoint.endpoint.apply(endpoint.instance, params);
146
+ const headers = endpoint.responseHeaderMap;
147
+ const response = body instanceof WebResponse ? body : new WebResponse({ body, headers });
148
+ if (response === body) {
149
+ for (const [k, v] of headers) { response.headers.setIfAbsent(k, v); }
150
+ }
151
+ return endpoint.responseFinalizer?.(response) ?? response;
152
+ } catch (err) {
153
+ throw WebCommonUtil.catchResponse(err);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Create a full endpoint handler
159
+ * @param interceptors Interceptors to apply
160
+ * @param endpoint The endpoint to call
161
+ * @param controller The controller to tie to
162
+ */
163
+ static createEndpointHandler(
164
+ interceptors: WebInterceptor[],
165
+ endpoint: EndpointConfig,
166
+ controller?: ControllerConfig
167
+ ): WebFilter {
168
+
169
+ // Filter interceptors if needed
170
+ for (const filter of [controller?.interceptorExclude, endpoint.interceptorExclude]) {
171
+ interceptors = filter ? interceptors.filter(x => !filter(x)) : interceptors;
172
+ }
173
+
174
+ const interceptorFilters =
175
+ this.resolveInterceptorsWithConfig(interceptors, endpoint, controller)
176
+ .filter(([inst, config]) => inst.applies?.({ endpoint, config }) ?? true)
177
+ .map(([inst, config]) => ({ filter: inst.filter.bind(inst), config }));
178
+
179
+ const endpointFilters = [
180
+ ...(controller?.filters ?? []).map(fn => fn.bind(controller?.instance)),
181
+ ...(endpoint.filters ?? []).map(fn => fn.bind(endpoint.instance)),
182
+ ...(endpoint.params.filter(cfg => cfg.resolve).map(fn => fn.resolve!))
183
+ ]
184
+ .map(fn => ({ filter: fn }));
185
+
186
+ const result = this.createFilterChain([
187
+ ...interceptorFilters,
188
+ ...endpointFilters,
189
+ { filter: this.invokeEndpoint.bind(this, endpoint) }
190
+ ]);
191
+
192
+ return castTo(result);
193
+ }
194
+
195
+
196
+ /**
197
+ * Get bound endpoints, honoring the conditional status
198
+ */
199
+ static async getBoundEndpoints(c: Class): Promise<EndpointConfig[]> {
200
+ const config = ControllerRegistry.get(c);
201
+
202
+ // Skip registering conditional controllers
203
+ if (config.conditional && !await config.conditional()) {
204
+ return [];
205
+ }
206
+
207
+ config.instance = await DependencyRegistry.getInstance(config.class);
208
+
209
+ if (Runtime.dynamic) {
210
+ config.instance = RetargettingProxy.unwrap(config.instance);
211
+ }
212
+
213
+ // Filter out conditional endpoints
214
+ const endpoints = (await Promise.all(
215
+ config.endpoints.map(ep => Promise.resolve(ep.conditional?.() ?? true).then(v => v ? ep : undefined))
216
+ )).filter(x => !!x);
217
+
218
+ if (!endpoints.length) {
219
+ return [];
220
+ }
221
+
222
+ for (const ep of endpoints) {
223
+ ep.instance = config.instance;
224
+ }
225
+
226
+ return endpoints;
227
+ }
228
+
229
+ /**
230
+ * Order endpoints by a set of rules, to ensure consistent registration and that precedence is honored
231
+ */
232
+ static orderEndpoints(endpoints: EndpointConfig[]): EndpointConfig[] {
233
+ return endpoints
234
+ .map(ep => {
235
+ const parts = ep.path.replace(/^[/]|[/]$/g, '').split('/');
236
+ return [ep, parts.map(x => /[*]/.test(x) ? 1 : /:/.test(x) ? 2 : 3)] as const;
237
+ })
238
+ .toSorted((a, b) => this.#compareEndpoints(a[1], b[1]) || a[0].path.localeCompare(b[0].path))
239
+ .map(([ep,]) => ep);
240
+ }
241
+
242
+
243
+ /**
244
+ * Order interceptors
245
+ */
246
+ static orderInterceptors(instances: WebInterceptor[]): WebInterceptor[] {
247
+ const cats = WEB_INTERCEPTOR_CATEGORIES.map(x => ({
248
+ key: x,
249
+ start: castTo<Class<WebInterceptor>>({ name: `${x}Start` }),
250
+ end: castTo<Class<WebInterceptor>>({ name: `${x}End` }),
251
+ }));
252
+
253
+ const categoryMapping = TypedObject.fromEntries(cats.map(x => [x.key, x]));
254
+
255
+ const ordered = instances.map(x => {
256
+ const group = categoryMapping[x.category];
257
+ const after = [...x.dependsOn ?? [], group.start];
258
+ const before = [...x.runsBefore ?? [], group.end];
259
+ return ({ key: x.constructor, before, after, target: x, placeholder: false });
260
+ });
261
+
262
+ // Add category sets into the ordering
263
+ let i = 0;
264
+ for (const cat of cats) {
265
+ const prevEnd = cats[i - 1]?.end ? [cats[i - 1].end] : [];
266
+ ordered.push(
267
+ { key: cat.start, before: [cat.end], after: prevEnd, placeholder: true, target: undefined! },
268
+ { key: cat.end, before: [], after: [cat.start], placeholder: true, target: undefined! }
269
+ );
270
+ i += 1;
271
+ }
272
+
273
+ return WebCommonUtil.ordered(ordered)
274
+ .filter(x => !x.placeholder) // Drop out the placeholders
275
+ .map(x => x.target);
276
+ }
277
+ }
@@ -0,0 +1,36 @@
1
+ import { Util } from '@travetto/runtime';
2
+
3
+ export type MimeType = { type: string, subtype: string, full: string, parameters: Record<string, string> };
4
+
5
+ /**
6
+ * Utils for checking mime patterns
7
+ */
8
+ export class MimeUtil {
9
+
10
+ static #convert(rule: string): RegExp {
11
+ const core = (rule.endsWith('/*') || !rule.includes('/')) ?
12
+ `${rule.replace(/[/].{0,20}$/, '')}\/.*` : rule;
13
+ return new RegExp(`^${core}[ ]{0,10}(;|$)`);
14
+ }
15
+
16
+ static parse(mimeType?: string): MimeType | undefined {
17
+ if (mimeType) {
18
+ const [full, ...params] = mimeType.split(/;/).map(x => x.trim());
19
+ const [type, subtype] = full.split('/');
20
+ const parameters = Object.fromEntries(params.map(v => v.split('=')).map(([k, v]) => [k.toLowerCase(), v]));
21
+ return { type, subtype, full, parameters };
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Build matcher
27
+ */
28
+ static matcher(rules: string[] | string = []): (contentType: string) => boolean {
29
+ return Util.allowDeny<RegExp, [string]>(
30
+ rules,
31
+ this.#convert.bind(this),
32
+ (regex, mime) => regex.test(mime),
33
+ k => k
34
+ );
35
+ }
36
+ }
@@ -0,0 +1,61 @@
1
+ import os from 'node:os';
2
+ import net from 'node:net';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ import { ExecUtil } from '@travetto/runtime';
6
+
7
+ /** Net utilities */
8
+ export class NetUtil {
9
+
10
+ /** Is an error an address in use error */
11
+ static isPortUsedError(err: unknown): err is Error & { port: number } {
12
+ return !!err && err instanceof Error && err.message.includes('EADDRINUSE');
13
+ }
14
+
15
+ /** Get the port process id */
16
+ static async getPortProcessId(port: number): Promise<number | undefined> {
17
+ const proc = spawn('lsof', ['-t', '-i', `tcp:${port}`]);
18
+ const result = await ExecUtil.getResult(proc, { catch: true });
19
+ const [pid] = result.stdout.trim().split(/\n/g);
20
+ if (pid && +pid > 0) {
21
+ return +pid;
22
+ }
23
+ }
24
+
25
+ /** Free port if in use */
26
+ static async freePort(port: number): Promise<void> {
27
+ const pid = await this.getPortProcessId(port);
28
+ if (pid) {
29
+ process.kill(pid);
30
+ }
31
+ }
32
+
33
+ /** Find free port */
34
+ static async getFreePort(): Promise<number> {
35
+ return new Promise<number>((resolve, reject) => {
36
+ const server = net.createServer();
37
+ server.unref();
38
+ server.on('error', reject);
39
+
40
+ server.listen({ port: 0 }, () => {
41
+ const addr = server.address();
42
+ if (!addr || typeof addr === 'string') {
43
+ reject(new Error('Unable to get a free port'));
44
+ return;
45
+ }
46
+ const { port } = addr;
47
+ server.close(() => { resolve(port); });
48
+ });
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Get local address for listening
54
+ */
55
+ static getLocalAddress(): string {
56
+ const useIPv4 = !![...Object.values(os.networkInterfaces())]
57
+ .find(interfaces => interfaces?.find(nic => nic.family === 'IPv4'));
58
+
59
+ return useIPv4 ? '0.0.0.0' : '::';
60
+ }
61
+ }