@travetto/web 6.0.1 → 6.0.3

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  <!-- Please modify https://github.com/travetto/travetto/tree/main/module/web/DOC.tsx and execute "npx trv doc" to rebuild -->
3
3
  # Web API
4
4
 
5
- ## Declarative support creating for Web Applications
5
+ ## Declarative support for creating Web Applications
6
6
 
7
7
  **Install: @travetto/web**
8
8
  ```bash
@@ -88,7 +88,7 @@ export class WebResponse<B = unknown> extends BaseWebMessage<B, WebResponseConte
88
88
  }
89
89
  ```
90
90
 
91
- These objects do not represent the underlying sockets provided by various http servers, but in fact are simple wrappers that track the flow through the call stack of the various [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)s and the [Endpoint](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L14) handler. One of the biggest departures here, is that the response is not an entity that is passed around from call-site to call-site, but is is solely a return-value. This doesn't mean the return value has to be static and pre-allocated, on the contrary streams are still supported. The difference here is that the streams/asynchronous values will be consumed until the response is sent back to the user. The [CompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/compress.ts#L50) is a good reference for transforming a [WebResponse](https://github.com/travetto/travetto/tree/main/module/web/src/types/response.ts#L3) that can either be a stream or a fixed value.
91
+ These objects do not represent the underlying sockets provided by various http servers, but in fact are simple wrappers that track the flow through the call stack of the various [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)s and the [Endpoint](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L14) handler. One of the biggest departures here, is that the response is not an entity that is passed around from call-site to call-site, but is is solely a return-value. This doesn't mean the return value has to be static and pre-allocated, on the contrary streams are still supported. The difference here is that the streams/asynchronous values will be consumed until the response is sent back to the user. The [CompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/compress.ts#L44) is a good reference for transforming a [WebResponse](https://github.com/travetto/travetto/tree/main/module/web/src/types/response.ts#L3) that can either be a stream or a fixed value.
92
92
 
93
93
  ## Defining a Controller
94
94
  To start, we must define a [@Controller](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/controller.ts#L9), which is only allowed on classes. Controllers can be configured with:
@@ -378,8 +378,8 @@ Out of the box, the web framework comes with a few interceptors, and more are co
378
378
  1. global - Intended to run outside of the request flow - [AsyncContextInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/context.ts#L13)
379
379
  1. terminal - Handles once request and response are finished building - [LoggingInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/logging.ts#L28), [RespondInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/respond.ts#L12)
380
380
  1. pre-request - Prepares the request for running - [TrustProxyInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/trust-proxy.ts#L23)
381
- 1. request - Handles inbound request, validation, and body preparation - [DecompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/decompress.ts#L53), [AcceptInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/accept.ts#L34), [BodyInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/body.ts#L57), [CookieInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cookie.ts#L60)
382
- 1. response - Prepares outbound response - [CompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/compress.ts#L50), [CorsInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cors.ts#L51), [EtagInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/etag.ts#L43), [CacheControlInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cache-control.ts#L23)
381
+ 1. request - Handles inbound request, validation, and body preparation - [DecompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/decompress.ts#L53), [AcceptInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/accept.ts#L33), [BodyInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/body.ts#L58), [CookieInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cookie.ts#L60)
382
+ 1. response - Prepares outbound response - [CompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/compress.ts#L44), [CorsInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cors.ts#L51), [EtagInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/etag.ts#L43), [CacheControlInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cache-control.ts#L23)
383
383
  1. application - Lives outside of the general request/response behavior, [Web Auth](https://github.com/travetto/travetto/tree/main/module/auth-web#readme "Web authentication integration support for the Travetto framework") uses this for login and logout flows.
384
384
 
385
385
  ### Packaged Interceptors
@@ -425,7 +425,7 @@ export class TrustProxyConfig {
425
425
  ```
426
426
 
427
427
  #### AcceptInterceptor
428
- [AcceptInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/accept.ts#L34) handles verifying the inbound request matches the allowed content-types. This acts as a standard gate-keeper for spurious input.
428
+ [AcceptInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/accept.ts#L33) handles verifying the inbound request matches the allowed content-types. This acts as a standard gate-keeper for spurious input.
429
429
 
430
430
  **Code: Accept Config**
431
431
  ```typescript
@@ -478,7 +478,7 @@ export class CookieConfig implements CookieSetOptions {
478
478
  /**
479
479
  * Supported only via http (not in JS)
480
480
  */
481
- httpOnly = true;
481
+ httponly = true;
482
482
  /**
483
483
  * Enforce same site policy
484
484
  */
@@ -504,7 +504,7 @@ export class CookieConfig implements CookieSetOptions {
504
504
  ```
505
505
 
506
506
  #### BodyInterceptor
507
- [BodyInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/body.ts#L57) handles the inbound request, and converting the body payload into an appropriate format.
507
+ [BodyInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/body.ts#L58) handles the inbound request, and converting the body payload into an appropriate format.
508
508
 
509
509
  **Code: Body Config**
510
510
  ```typescript
@@ -532,7 +532,7 @@ export class WebBodyConfig {
532
532
  ```
533
533
 
534
534
  #### CompressInterceptor
535
- [CompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/compress.ts#L50) by default, will compress all valid outbound responses over a certain size, or for streams will cache every response. This relies on Node's [Buffer](https://nodejs.org/api/zlib.html) support for compression.
535
+ [CompressInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/compress.ts#L44) by default, will compress all valid outbound responses over a certain size, or for streams will cache every response. This relies on Node's [Buffer](https://nodejs.org/api/zlib.html) support for compression.
536
536
 
537
537
  **Code: Compress Config**
538
538
  ```typescript
@@ -545,10 +545,6 @@ export class CompressConfig {
545
545
  * Raw encoding options
546
546
  */
547
547
  raw?: (ZlibOptions & BrotliOptions) | undefined;
548
- /**
549
- * Preferred encodings
550
- */
551
- preferredEncodings?: WebCompressEncoding[] = ['br', 'gzip', 'identity'];
552
548
  /**
553
549
  * Supported encodings
554
550
  */
@@ -739,14 +735,11 @@ export class SimpleAuthInterceptor implements WebInterceptor {
739
735
  ```
740
736
 
741
737
  ## Cookie Support
742
- Cookies are a unique element, within the framework, as they sit on the request and response flows. Ideally we would separate these out, but given the support for key rotation, there is a scenario in which reading a cookie on the request, will result in a cookie needing to be written on the response. Because of this, cookies are treated as being outside the normal [WebRequest](https://github.com/travetto/travetto/tree/main/module/web/src/types/request.ts#L11) activity, and is exposed as the [@ContextParam](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L61) [CookieJar](https://github.com/travetto/travetto/tree/main/module/web/src/util/cookie.ts#L11). The [CookieJar](https://github.com/travetto/travetto/tree/main/module/web/src/util/cookie.ts#L11) has a fairly basic contract:
738
+ Cookies are a unique element, within the framework, as they sit on the request and response flows. Ideally we would separate these out, but given the support for key rotation, there is a scenario in which reading a cookie on the request, will result in a cookie needing to be written on the response. Because of this, cookies are treated as being outside the normal [WebRequest](https://github.com/travetto/travetto/tree/main/module/web/src/types/request.ts#L11) activity, and is exposed as the [@ContextParam](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L61) [CookieJar](https://github.com/travetto/travetto/tree/main/module/web/src/util/cookie.ts#L12). The [CookieJar](https://github.com/travetto/travetto/tree/main/module/web/src/util/cookie.ts#L12) has a fairly basic contract:
743
739
 
744
740
  **Code: CookieJar contract**
745
741
  ```typescript
746
742
  export class CookieJar {
747
- static parseCookieHeader(header: string): Cookie[];
748
- static parseSetCookieHeader(header: string): Cookie;
749
- static responseSuffix(c: Cookie): string[];
750
743
  constructor({ keys, ...options }: CookieJarOptions = {});
751
744
  import(cookies: Cookie[]): this;
752
745
  has(name: string, opts: CookieGetOptions = {}): boolean;
package/__index__.ts CHANGED
@@ -38,7 +38,8 @@ export * from './src/interceptor/trust-proxy.ts';
38
38
 
39
39
  export * from './src/util/body.ts';
40
40
  export * from './src/util/endpoint.ts';
41
- export * from './src/util/mime.ts';
41
+ export * from './src/util/header.ts';
42
42
  export * from './src/util/cookie.ts';
43
43
  export * from './src/util/common.ts';
44
+ export * from './src/util/keygrip.ts';
44
45
  export * from './src/util/net.ts';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@travetto/web",
3
- "version": "6.0.1",
4
- "description": "Declarative support creating for Web Applications",
3
+ "version": "6.0.3",
4
+ "description": "Declarative support for creating Web Applications",
5
5
  "keywords": [
6
6
  "web",
7
7
  "decorators",
@@ -25,24 +25,18 @@
25
25
  "directory": "module/web"
26
26
  },
27
27
  "dependencies": {
28
- "@travetto/config": "^6.0.0",
29
- "@travetto/context": "^6.0.0",
30
- "@travetto/di": "^6.0.0",
31
- "@travetto/registry": "^6.0.0",
32
- "@travetto/runtime": "^6.0.0",
33
- "@travetto/schema": "^6.0.0",
34
- "@types/fresh": "^0.5.2",
35
- "@types/keygrip": "^1.0.6",
36
- "@types/negotiator": "^0.6.3",
37
- "find-my-way": "^9.3.0",
38
- "fresh": "^0.5.2",
39
- "keygrip": "^1.1.0",
40
- "negotiator": "^1.0.0"
28
+ "@travetto/config": "^6.0.1",
29
+ "@travetto/context": "^6.0.1",
30
+ "@travetto/di": "^6.0.1",
31
+ "@travetto/registry": "^6.0.1",
32
+ "@travetto/runtime": "^6.0.1",
33
+ "@travetto/schema": "^6.0.1",
34
+ "find-my-way": "^9.3.0"
41
35
  },
42
36
  "peerDependencies": {
43
- "@travetto/cli": "^6.0.0",
44
- "@travetto/test": "^6.0.1",
45
- "@travetto/transformer": "^6.0.0"
37
+ "@travetto/cli": "^6.0.1",
38
+ "@travetto/test": "^6.0.2",
39
+ "@travetto/transformer": "^6.0.1"
46
40
  },
47
41
  "peerDependenciesMeta": {
48
42
  "@travetto/transformer": {
@@ -2,7 +2,6 @@ import { Injectable, Inject } from '@travetto/di';
2
2
  import { Config } from '@travetto/config';
3
3
  import { Ignore } from '@travetto/schema';
4
4
 
5
- import { MimeUtil } from '../util/mime.ts';
6
5
  import { WebCommonUtil } from '../util/common.ts';
7
6
 
8
7
  import { WebChainedContext } from '../types/filter.ts';
@@ -39,7 +38,7 @@ export class AcceptInterceptor implements WebInterceptor<AcceptConfig> {
39
38
  config: AcceptConfig;
40
39
 
41
40
  finalizeConfig({ config }: WebInterceptorContext<AcceptConfig>): AcceptConfig {
42
- config.matcher = MimeUtil.matcher(config.types ?? []);
41
+ config.matcher = WebCommonUtil.mimeTypeMatcher(config.types ?? []);
43
42
  return config;
44
43
  }
45
44
 
@@ -14,6 +14,7 @@ import { ByteSizeInput, WebCommonUtil } from '../util/common.ts';
14
14
  import { AcceptInterceptor } from './accept.ts';
15
15
  import { DecompressInterceptor } from './decompress.ts';
16
16
  import { WebError } from '../types/error.ts';
17
+ import { WebHeaderUtil } from '../util/header.ts';
17
18
 
18
19
  /**
19
20
  * @concrete
@@ -90,12 +91,13 @@ export class BodyInterceptor implements WebInterceptor<WebBodyConfig> {
90
91
  throw WebError.for('Request entity too large', 413, { length, limit });
91
92
  }
92
93
 
93
- const contentType = request.headers.getContentType();
94
- if (!contentType) {
94
+ const contentType = WebHeaderUtil.parseHeaderSegment(request.headers.get('Content-Type'));
95
+ if (!contentType.value) {
95
96
  return next();
96
97
  }
97
98
 
98
- const parserType = config.parsingTypes[contentType.full] ?? config.parsingTypes[contentType.type];
99
+ const [baseType,] = contentType.value.split('/');
100
+ const parserType = config.parsingTypes[contentType.value] ?? config.parsingTypes[baseType];
99
101
  if (!parserType) {
100
102
  return next();
101
103
  }
@@ -35,23 +35,15 @@ export class CacheControlInterceptor implements WebInterceptor {
35
35
  async filter({ next }: WebChainedContext<CacheControlConfig>): Promise<WebResponse> {
36
36
  const response = await next();
37
37
  if (!response.headers.has('Cache-Control')) {
38
- const parts: string[] = [];
39
- if (response.context.isPrivate !== undefined) {
40
- parts.push(response.context.isPrivate ? 'private' : 'public');
41
- }
42
- if (response.context.cacheableAge !== undefined) {
43
- parts.push(
44
- ...(response.context.cacheableAge <= 0 ?
45
- ['no-store', 'max-age=0'] :
46
- [`max-age=${response.context.cacheableAge}`]
47
- )
48
- );
49
- } else if (response.context.isPrivate) { // If private, but no age, don't store
50
- parts.push('no-store');
51
- }
52
- if (parts.length) {
53
- response.headers.set('Cache-Control', parts.join(','));
38
+ const parts: string[] = [response.context.isPrivate ? 'private' : 'public'];
39
+ const age = response.context.cacheableAge ?? (response.context.isPrivate ? 0 : undefined);
40
+ if (age !== undefined) {
41
+ if (age <= 0) {
42
+ parts.push('no-store');
43
+ }
44
+ parts.push(`max-age=${Math.max(age, 0)}`);
54
45
  }
46
+ response.headers.set('Cache-Control', parts.join(','));
55
47
  }
56
48
  return response;
57
49
  }
@@ -1,20 +1,18 @@
1
1
  import { buffer } from 'node:stream/consumers';
2
2
  import { BrotliOptions, constants, createBrotliCompress, createDeflate, createGzip, ZlibOptions } from 'node:zlib';
3
3
 
4
- // eslint-disable-next-line @typescript-eslint/naming-convention
5
- import Negotiator from 'negotiator';
6
-
7
4
  import { Injectable, Inject } from '@travetto/di';
8
5
  import { Config } from '@travetto/config';
9
- import { castTo } from '@travetto/runtime';
10
6
 
11
7
  import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
12
8
  import { WebInterceptorCategory } from '../types/core.ts';
13
9
  import { WebChainedContext } from '../types/filter.ts';
14
10
  import { WebResponse } from '../types/response.ts';
15
- import { WebBodyUtil } from '../util/body.ts';
16
11
  import { WebError } from '../types/error.ts';
17
12
 
13
+ import { WebBodyUtil } from '../util/body.ts';
14
+ import { WebHeaderUtil } from '../util/header.ts';
15
+
18
16
  const COMPRESSORS = {
19
17
  gzip: createGzip,
20
18
  deflate: createDeflate,
@@ -33,10 +31,6 @@ export class CompressConfig {
33
31
  * Raw encoding options
34
32
  */
35
33
  raw?: (ZlibOptions & BrotliOptions) | undefined;
36
- /**
37
- * Preferred encodings
38
- */
39
- preferredEncodings?: WebCompressEncoding[] = ['br', 'gzip', 'identity'];
40
34
  /**
41
35
  * Supported encodings
42
36
  */
@@ -55,10 +49,10 @@ export class CompressInterceptor implements WebInterceptor {
55
49
  config: CompressConfig;
56
50
 
57
51
  async compress(ctx: WebChainedContext, response: WebResponse): Promise<WebResponse> {
58
- const { raw = {}, preferredEncodings = [], supportedEncodings } = this.config;
52
+ const { raw = {}, supportedEncodings } = this.config;
59
53
  const { request } = ctx;
60
54
 
61
- response.headers.vary('Accept-Encoding');
55
+ response.headers.append('Vary', 'Accept-Encoding');
62
56
 
63
57
  if (
64
58
  !response.body ||
@@ -69,15 +63,13 @@ export class CompressInterceptor implements WebInterceptor {
69
63
  }
70
64
 
71
65
  const accepts = request.headers.get('Accept-Encoding');
72
- const type: WebCompressEncoding | undefined =
73
- castTo(new Negotiator({ headers: { 'accept-encoding': accepts ?? '*' } })
74
- .encoding([...supportedEncodings, ...preferredEncodings]));
66
+ const type = WebHeaderUtil.negotiateHeader(accepts ?? '*', supportedEncodings);
75
67
 
76
- if (accepts && (!type || !accepts.includes(type))) {
68
+ if (!type) {
77
69
  throw WebError.for(`Please accept one of: ${supportedEncodings.join(', ')}. ${accepts} is not supported`, 406);
78
70
  }
79
71
 
80
- if (type === 'identity' || !type) {
72
+ if (type === 'identity') {
81
73
  return response;
82
74
  }
83
75
 
@@ -29,7 +29,7 @@ export class CookieConfig implements CookieSetOptions {
29
29
  /**
30
30
  * Supported only via http (not in JS)
31
31
  */
32
- httpOnly = true;
32
+ httponly = true;
33
33
  /**
34
34
  * Enforce same site policy
35
35
  */
@@ -1,5 +1,4 @@
1
1
  import crypto from 'node:crypto';
2
- import fresh from 'fresh';
3
2
 
4
3
  import { Injectable, Inject } from '@travetto/di';
5
4
  import { Config } from '@travetto/config';
@@ -13,6 +12,7 @@ import { WebInterceptorCategory } from '../types/core.ts';
13
12
  import { CompressInterceptor } from './compress.ts';
14
13
  import { WebBodyUtil } from '../util/body.ts';
15
14
  import { ByteSizeInput, WebCommonUtil } from '../util/common.ts';
15
+ import { WebHeaderUtil } from '../util/header.ts';
16
16
 
17
17
  @Config('web.etag')
18
18
  export class EtagConfig {
@@ -85,17 +85,7 @@ export class EtagInterceptor implements WebInterceptor {
85
85
  const tag = this.computeTag(body);
86
86
  binaryResponse.headers.set('ETag', `${ctx.config.weak ? 'W/' : ''}"${tag}"`);
87
87
 
88
- if (
89
- ctx.config.cacheable &&
90
- fresh({
91
- 'if-modified-since': request.headers.get('If-Modified-Since')!,
92
- 'if-none-match': request.headers.get('If-None-Match')!,
93
- 'cache-control': request.headers.get('Cache-Control')!,
94
- }, {
95
- etag: binaryResponse.headers.get('ETag')!,
96
- 'last-modified': binaryResponse.headers.get('Last-Modified')!
97
- })
98
- ) {
88
+ if (ctx.config.cacheable && WebHeaderUtil.isFresh(request.headers, binaryResponse.headers)) {
99
89
  // Remove length for the 304
100
90
  binaryResponse.headers.delete('Content-Length');
101
91
  return new WebResponse({
@@ -9,7 +9,7 @@ export type Cookie = {
9
9
  priority?: 'low' | 'medium' | 'high';
10
10
  sameSite?: 'strict' | 'lax' | 'none';
11
11
  secure?: boolean;
12
- httpOnly?: boolean;
12
+ httponly?: boolean;
13
13
  partitioned?: boolean;
14
14
  response?: boolean;
15
15
  };
@@ -1,26 +1,21 @@
1
- import { Any, ByteRange, castTo } from '@travetto/runtime';
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);
20
15
 
21
16
  if (o && !passed) {
22
17
  for (const [k, v] of (Array.isArray(o) ? o : Object.entries(o))) {
23
- if (v !== undefined && v !== null) {
18
+ if (v !== undefined && v !== null && !k.startsWith(':')) {
24
19
  this.append(k, castTo(v));
25
20
  }
26
21
  }
@@ -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/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
  */
@@ -1,4 +1,4 @@
1
- import { AppError, ErrorCategory } from '@travetto/runtime';
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';
@@ -24,12 +24,18 @@ const ERROR_CATEGORY_STATUS: Record<ErrorCategory, number> = {
24
24
  unavailable: 503,
25
25
  };
26
26
 
27
+ const UNIT_MAPPING: Record<string, number> = {
28
+ kb: 2 ** 10,
29
+ mb: 2 ** 20,
30
+ gb: 2 ** 30,
31
+ };
32
+
27
33
  export class WebCommonUtil {
28
- static #unitMapping: Record<string, number> = {
29
- kb: 2 ** 10,
30
- mb: 2 ** 20,
31
- gb: 2 ** 30,
32
- };
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
+ }
33
39
 
34
40
  static #buildEdgeMap<T, U extends OrderedState<T>>(items: List<U>): Map<T, Set<T>> {
35
41
  const edgeMap = new Map(items.map(x => [x.key, new Set(x.after ?? [])]));
@@ -49,6 +55,19 @@ export class WebCommonUtil {
49
55
  return edgeMap;
50
56
  }
51
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
+
52
71
  /**
53
72
  * Produces a satisfied ordering for a list of orderable elements
54
73
  */
@@ -129,6 +148,6 @@ export class WebCommonUtil {
129
148
  return input;
130
149
  }
131
150
  const [, num, unit] = input.toLowerCase().split(/(\d+)/);
132
- return parseInt(num, 10) * (this.#unitMapping[unit] ?? 1);
151
+ return parseInt(num, 10) * (UNIT_MAPPING[unit] ?? 1);
133
152
  }
134
153
  }
@@ -1,7 +1,8 @@
1
- import keygrip from 'keygrip';
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
- static parseCookieHeader(header: string): Cookie[] {
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 keygrip(keys) : undefined;
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 ? CookieJar.responseSuffix(cookie) : null;
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(CookieJar.parseCookieHeader(header ?? ''));
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(CookieJar.parseSetCookieHeader) ?? []);
110
+ return this.import(headers?.map(WebHeaderUtil.parseSetCookieHeader) ?? []);
152
111
  }
153
112
 
154
113
  exportCookieHeader(): string {
@@ -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
+ }
package/src/util/net.ts CHANGED
@@ -58,4 +58,18 @@ export class NetUtil {
58
58
 
59
59
  return useIPv4 ? '0.0.0.0' : '::';
60
60
  }
61
+
62
+ /**
63
+ * Free a port if it is in use, typically used to resolve port conflicts.
64
+ * @param err The error that may indicate a port conflict
65
+ * @returns Returns true if the port was freed, false if not handled
66
+ */
67
+ static async freePortOnConflict(err: unknown): Promise<boolean> {
68
+ if (NetUtil.isPortUsedError(err) && typeof err.port === 'number') {
69
+ await NetUtil.freePort(err.port);
70
+ return true;
71
+ } else {
72
+ return false;
73
+ }
74
+ }
61
75
  }
@@ -1,4 +1,5 @@
1
- import { buffer as toBuffer } from 'node:stream/consumers';
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 WebBodyUtil.toBuffer(sample.body!));
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 WebBodyUtil.toBuffer(result);
37
+ const bufferResult = result = await toBuffer(result);
35
38
  if (bufferResult.length) {
36
39
  try {
37
40
  result = await DecompressInterceptor.decompress(
@@ -61,30 +64,11 @@ export class WebTestDispatchUtil {
61
64
  return response;
62
65
  }
63
66
 
64
- static async toFetchRequestInit(request: WebRequest): Promise<{ init: RequestInit, path: string }> {
65
- const { context: { httpQuery: query, httpMethod: method, path }, headers } = request;
66
-
67
- let q = '';
68
- if (query && Object.keys(query).length) {
69
- const pairs = Object.fromEntries(Object.entries(query).map(([k, v]) => [k, v === null || v === undefined ? '' : `${v}`] as const));
70
- q = `?${new URLSearchParams(pairs).toString()}`;
67
+ static buildPath(request: WebRequest): string {
68
+ const params = new URLSearchParams();
69
+ for (const [k, v] of Object.entries(request.context.httpQuery ?? {})) {
70
+ params.set(k, v === null || v === undefined ? '' : `${v}`);
71
71
  }
72
-
73
- const finalPath = `${path}${q}`;
74
-
75
- const body: RequestInit['body'] =
76
- WebBodyUtil.isRaw(request.body) ?
77
- Buffer.isBuffer(request.body) ? request.body : await toBuffer(request.body) :
78
- castTo(request.body);
79
-
80
- return { path: finalPath, init: { headers, method, body } };
81
- }
82
-
83
- static async fromFetchResponse(response: Response): Promise<WebResponse> {
84
- return new WebResponse({
85
- body: Buffer.from(await response.arrayBuffer()),
86
- context: { httpStatusCode: response.status },
87
- headers: response.headers
88
- });
72
+ return [request.context.path, params.toString()].join('?').replace(/[?]$/, '');
89
73
  }
90
74
  }
@@ -18,8 +18,11 @@ export class WebTestConfig implements ConfigSource {
18
18
  cookie: { secure: false },
19
19
  trustProxy: { ips: ['*'] },
20
20
  http: {
21
- ssl: { active: false },
21
+ tls: false,
22
22
  port: -1,
23
+ },
24
+ etag: {
25
+ minimumSize: 1
23
26
  }
24
27
  }
25
28
  },
package/src/util/mime.ts DELETED
@@ -1,36 +0,0 @@
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
- }