@travetto/web 6.0.0-rc.3 → 6.0.0-rc.5

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
@@ -57,6 +57,7 @@ export interface WebRequestContext {
57
57
 
58
58
  /**
59
59
  * Web Request object
60
+ * @web_contextual
60
61
  */
61
62
  export class WebRequest<B = unknown> extends BaseWebMessage<B, Readonly<WebRequestContext>> {
62
63
 
@@ -73,6 +74,7 @@ export interface WebResponseContext {
73
74
 
74
75
  /**
75
76
  * Web Response as a simple object
77
+ * @web_invalid_parameter
76
78
  */
77
79
  export class WebResponse<B = unknown> extends BaseWebMessage<B, WebResponseContext> {
78
80
 
@@ -377,7 +379,7 @@ Out of the box, the web framework comes with a few interceptors, and more are co
377
379
  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)
378
380
  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)
379
381
  1. pre-request - Prepares the request for running - [TrustProxyInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/trust-proxy.ts#L23)
380
- 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), [BodyParseInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/body-parse.ts#L61), [CookiesInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cookies.ts#L56)
382
+ 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)
381
383
  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#L34), [ResponseCacheInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/response-cache.ts#L30)
382
384
  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.
383
385
 
@@ -460,8 +462,8 @@ export class DecompressConfig {
460
462
  }
461
463
  ```
462
464
 
463
- #### CookiesInterceptor
464
- [CookiesInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cookies.ts#L56) is responsible for processing inbound cookie headers and populating the appropriate data on the request, as well as sending the appropriate response data
465
+ #### CookieInterceptor
466
+ [CookieInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cookie.ts#L60) is responsible for processing inbound cookie headers and populating the appropriate data on the request, as well as sending the appropriate response data
465
467
 
466
468
  **Code: Cookies Config**
467
469
  ```typescript
@@ -473,7 +475,7 @@ export class CookieConfig implements CookieSetOptions {
473
475
  /**
474
476
  * Are they signed
475
477
  */
476
- signed = true;
478
+ signed?: boolean;
477
479
  /**
478
480
  * Supported only via http (not in JS)
479
481
  */
@@ -490,20 +492,24 @@ export class CookieConfig implements CookieSetOptions {
490
492
  /**
491
493
  * Is the cookie only valid for https
492
494
  */
493
- secure?: boolean = false;
495
+ secure?: boolean;
494
496
  /**
495
497
  * The domain of the cookie
496
498
  */
497
499
  domain?: string;
500
+ /**
501
+ * The default path of the cookie
502
+ */
503
+ path: string = '/';
498
504
  }
499
505
  ```
500
506
 
501
- #### BodyParseInterceptor
502
- [BodyParseInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/body-parse.ts#L61) handles the inbound request, and converting the body payload into an appropriate format.
507
+ #### BodyInterceptor
508
+ [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.
503
509
 
504
- **Code: Body Parse Config**
510
+ **Code: Body Config**
505
511
  ```typescript
506
- export class BodyParseConfig {
512
+ export class WebBodyConfig {
507
513
  /**
508
514
  * Parse request body
509
515
  */
@@ -523,10 +529,6 @@ export class BodyParseConfig {
523
529
 
524
530
  @Ignore()
525
531
  _limit: number | undefined;
526
-
527
- postConstruct(): void {
528
- this._limit = WebCommonUtil.parseByteSize(this.limit);
529
- }
530
532
  }
531
533
  ```
532
534
 
package/__index__.ts CHANGED
@@ -24,9 +24,9 @@ export * from './src/registry/visitor.ts';
24
24
  export * from './src/registry/types.ts';
25
25
 
26
26
  export * from './src/interceptor/accept.ts';
27
- export * from './src/interceptor/body-parse.ts';
27
+ export * from './src/interceptor/body.ts';
28
28
  export * from './src/interceptor/cors.ts';
29
- export * from './src/interceptor/cookies.ts';
29
+ export * from './src/interceptor/cookie.ts';
30
30
  export * from './src/interceptor/compress.ts';
31
31
  export * from './src/interceptor/context.ts';
32
32
  export * from './src/interceptor/decompress.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/web",
3
- "version": "6.0.0-rc.3",
3
+ "version": "6.0.0-rc.5",
4
4
  "description": "Declarative api for Web Applications with support for the dependency injection.",
5
5
  "keywords": [
6
6
  "web",
@@ -37,13 +37,12 @@
37
37
  "@types/negotiator": "^0.6.3",
38
38
  "find-my-way": "^9.3.0",
39
39
  "fresh": "^0.5.2",
40
- "iconv-lite": "^0.6.3",
41
40
  "keygrip": "^1.1.0",
42
41
  "negotiator": "^1.0.0"
43
42
  },
44
43
  "peerDependencies": {
45
44
  "@travetto/cli": "^6.0.0-rc.3",
46
- "@travetto/test": "^6.0.0-rc.2",
45
+ "@travetto/test": "^6.0.0-rc.3",
47
46
  "@travetto/transformer": "^6.0.0-rc.3"
48
47
  },
49
48
  "peerDependenciesMeta": {
package/src/context.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AsyncContextValue, AsyncContext } from '@travetto/context';
2
2
  import { Inject, Injectable } from '@travetto/di';
3
- import { AppError, castTo, Class, toConcrete } from '@travetto/runtime';
3
+ import { AppError, castTo, Class } from '@travetto/runtime';
4
4
 
5
5
  import { WebRequest } from './types/request.ts';
6
6
 
@@ -21,7 +21,7 @@ export class WebAsyncContext {
21
21
  }
22
22
 
23
23
  postConstruct(): void {
24
- this.registerSource(toConcrete<WebRequest>(), () => this.#request.get());
24
+ this.registerSource(WebRequest, () => this.#request.get());
25
25
  }
26
26
 
27
27
  withContext<T>(request: WebRequest, next: () => Promise<T>): Promise<T> {
@@ -24,10 +24,10 @@ export interface BodyContentParser {
24
24
  };
25
25
 
26
26
  /**
27
- * Web body parse configuration
27
+ * Web body configuration
28
28
  */
29
- @Config('web.bodyParse')
30
- export class BodyParseConfig {
29
+ @Config('web.body')
30
+ export class WebBodyConfig {
31
31
  /**
32
32
  * Parse request body
33
33
  */
@@ -47,25 +47,21 @@ export class BodyParseConfig {
47
47
 
48
48
  @Ignore()
49
49
  _limit: number | undefined;
50
-
51
- postConstruct(): void {
52
- this._limit = WebCommonUtil.parseByteSize(this.limit);
53
- }
54
50
  }
55
51
 
56
52
 
57
53
  /**
58
- * Parses the body input content
54
+ * Verifies content length, decodes character encodings, and parses body input string via the content type
59
55
  */
60
56
  @Injectable()
61
- export class BodyParseInterceptor implements WebInterceptor<BodyParseConfig> {
57
+ export class BodyInterceptor implements WebInterceptor<WebBodyConfig> {
62
58
 
63
59
  dependsOn = [AcceptInterceptor, DecompressInterceptor];
64
60
  category: WebInterceptorCategory = 'request';
65
61
  parsers: Record<string, BodyContentParser> = {};
66
62
 
67
63
  @Inject()
68
- config: BodyParseConfig;
64
+ config: WebBodyConfig;
69
65
 
70
66
  async postConstruct(): Promise<void> {
71
67
  // Load all the parser types
@@ -75,11 +71,11 @@ export class BodyParseInterceptor implements WebInterceptor<BodyParseConfig> {
75
71
  }
76
72
  }
77
73
 
78
- applies({ endpoint, config }: WebInterceptorContext<BodyParseConfig>): boolean {
74
+ applies({ endpoint, config }: WebInterceptorContext<WebBodyConfig>): boolean {
79
75
  return config.applies && endpoint.allowsBody;
80
76
  }
81
77
 
82
- async filter({ request, config, next }: WebChainedContext<BodyParseConfig>): Promise<WebResponse> {
78
+ async filter({ request, config, next }: WebChainedContext<WebBodyConfig>): Promise<WebResponse> {
83
79
  const input = request.body;
84
80
 
85
81
  if (!WebBodyUtil.isRaw(input)) {
@@ -88,8 +84,8 @@ export class BodyParseInterceptor implements WebInterceptor<BodyParseConfig> {
88
84
 
89
85
  const lengthRead = +(request.headers.get('Content-Length') || '');
90
86
  const length = Number.isNaN(lengthRead) ? undefined : lengthRead;
87
+ const limit = config._limit ??= WebCommonUtil.parseByteSize(config.limit);
91
88
 
92
- const limit = config._limit ?? Number.MAX_SAFE_INTEGER;
93
89
  if (length && length > limit) {
94
90
  throw WebError.for('Request entity too large', 413, { length, limit });
95
91
  }
@@ -25,7 +25,7 @@ export class CookieConfig implements CookieSetOptions {
25
25
  /**
26
26
  * Are they signed
27
27
  */
28
- signed = true;
28
+ signed?: boolean;
29
29
  /**
30
30
  * Supported only via http (not in JS)
31
31
  */
@@ -42,18 +42,22 @@ export class CookieConfig implements CookieSetOptions {
42
42
  /**
43
43
  * Is the cookie only valid for https
44
44
  */
45
- secure?: boolean = false;
45
+ secure?: boolean;
46
46
  /**
47
47
  * The domain of the cookie
48
48
  */
49
49
  domain?: string;
50
+ /**
51
+ * The default path of the cookie
52
+ */
53
+ path: string = '/';
50
54
  }
51
55
 
52
56
  /**
53
57
  * Loads cookies from the request, verifies, exposes, and then signs and sets
54
58
  */
55
59
  @Injectable()
56
- export class CookiesInterceptor implements WebInterceptor<CookieConfig> {
60
+ export class CookieInterceptor implements WebInterceptor<CookieConfig> {
57
61
 
58
62
  #cookieJar = new AsyncContextValue<CookieJar>(this);
59
63
 
@@ -77,8 +81,9 @@ export class CookiesInterceptor implements WebInterceptor<CookieConfig> {
77
81
 
78
82
  finalizeConfig({ config }: WebInterceptorContext<CookieConfig>): CookieConfig {
79
83
  const url = new URL(this.webConfig.baseUrl ?? 'x://localhost');
80
- config.secure ??= url.protocol === 'https';
84
+ config.secure ??= url.protocol === 'https:';
81
85
  config.domain ??= url.hostname;
86
+ config.signed ??= !!config.keys?.length;
82
87
  return config;
83
88
  }
84
89
 
@@ -87,11 +92,11 @@ export class CookiesInterceptor implements WebInterceptor<CookieConfig> {
87
92
  }
88
93
 
89
94
  async filter({ request, config, next }: WebChainedContext<CookieConfig>): Promise<WebResponse> {
90
- const jar = new CookieJar(request.headers.get('Cookie'), config);
95
+ const jar = new CookieJar(config).importCookieHeader(request.headers.get('Cookie'));
91
96
  this.#cookieJar.set(jar);
92
97
 
93
98
  const response = await next();
94
- for (const c of jar.export()) { response.headers.append('Set-Cookie', c); }
99
+ for (const c of jar.exportSetCookieHeader()) { response.headers.append('Set-Cookie', c); }
95
100
  return response;
96
101
  }
97
102
  }
@@ -15,4 +15,4 @@ export type Cookie = {
15
15
  };
16
16
 
17
17
  export type CookieGetOptions = { signed?: boolean };
18
- export type CookieSetOptions = Omit<Cookie, 'name' | 'value'>;
18
+ export type CookieSetOptions = Omit<Cookie, 'name' | 'value' | 'response'>;
@@ -18,6 +18,7 @@ export interface WebRequestContext {
18
18
 
19
19
  /**
20
20
  * Web Request object
21
+ * @web_contextual
21
22
  */
22
23
  export class WebRequest<B = unknown> extends BaseWebMessage<B, Readonly<WebRequestContext>> {
23
24
 
@@ -6,6 +6,7 @@ export interface WebResponseContext {
6
6
 
7
7
  /**
8
8
  * Web Response as a simple object
9
+ * @web_invalid_parameter
9
10
  */
10
11
  export class WebResponse<B = unknown> extends BaseWebMessage<B, WebResponseContext> {
11
12
 
package/src/util/body.ts CHANGED
@@ -1,5 +1,4 @@
1
- import iconv from 'iconv-lite';
2
-
1
+ import { TextDecoder } from 'node:util';
3
2
  import { Readable } from 'node:stream';
4
3
  import { buffer as toBuffer } from 'node:stream/consumers';
5
4
 
@@ -187,27 +186,33 @@ export class WebBodyUtil {
187
186
  static async readText(input: Readable | Buffer, limit: number, encoding?: string): Promise<{ text: string, read: number }> {
188
187
  encoding ??= (Buffer.isBuffer(input) ? undefined : input.readableEncoding) ?? 'utf-8';
189
188
 
190
- if (!iconv.encodingExists(encoding)) {
189
+ let decoder: TextDecoder;
190
+ try {
191
+ decoder = new TextDecoder(encoding);
192
+ } catch {
191
193
  throw WebError.for('Specified Encoding Not Supported', 415, { encoding });
192
194
  }
193
195
 
194
196
  if (Buffer.isBuffer(input)) {
195
- return { text: iconv.decode(input, encoding), read: input.byteLength };
197
+ if (input.byteLength > limit) {
198
+ throw WebError.for('Request Entity Too Large', 413, { received: input.byteLength, limit });
199
+ }
200
+ return { text: decoder.decode(input), read: input.byteLength };
196
201
  }
197
202
 
198
203
  let received = Buffer.isBuffer(input) ? input.byteOffset : 0;
199
- const decoder = iconv.getDecoder(encoding);
200
204
  const all: string[] = [];
201
205
 
202
206
  try {
203
- for await (const chunk of input.iterator({ destroyOnReturn: false })) {
204
- received += Buffer.isBuffer(chunk) ? chunk.byteLength : (typeof chunk === 'string' ? chunk.length : chunk.length);
207
+ for await (const chunk of castTo<AsyncIterable<string | Buffer>>(input.iterator({ destroyOnReturn: false }))) {
208
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
209
+ received += buffer.byteLength;
205
210
  if (received > limit) {
206
211
  throw WebError.for('Request Entity Too Large', 413, { received, limit });
207
212
  }
208
- all.push(decoder.write(chunk));
213
+ all.push(decoder.decode(buffer, { stream: true }));
209
214
  }
210
- all.push(decoder.end() ?? '');
215
+ all.push(decoder.decode(Buffer.alloc(0), { stream: false }));
211
216
  return { text: all.join(''), read: received };
212
217
  } catch (err) {
213
218
  if (err instanceof Error && err.name === 'AbortError') {
@@ -1,14 +1,26 @@
1
1
  import keygrip from 'keygrip';
2
2
  import { AppError, castKey, castTo } from '@travetto/runtime';
3
3
 
4
- import { Cookie, CookieGetOptions } from '../types/cookie.ts';
4
+ import { Cookie, CookieGetOptions, CookieSetOptions } from '../types/cookie.ts';
5
5
 
6
6
  const pairText = (c: Cookie): string => `${c.name}=${c.value}`;
7
7
  const pair = (k: string, v: unknown): string => `${k}=${v}`;
8
8
 
9
+ type CookieJarOptions = { keys?: string[] } & CookieSetOptions;
10
+
9
11
  export class CookieJar {
10
12
 
11
- static fromHeaderValue(header: string): Cookie {
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 {
12
24
  const parts = header.split(/\s{0,4};\s{0,4}/g);
13
25
  const [name, value] = parts[0].split(/\s{0,4}=\s{0,4}/);
14
26
  const c: Cookie = { name, value };
@@ -27,119 +39,125 @@ export class CookieJar {
27
39
  return c;
28
40
  }
29
41
 
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(';');
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;
51
53
  }
52
54
 
53
- #secure?: boolean;
54
55
  #grip?: keygrip;
55
56
  #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
- }
57
+ #setOptions: CookieSetOptions = {};
58
+ #deleteOptions: CookieSetOptions = { maxAge: 0, expires: undefined };
59
+
60
+ constructor({ keys, ...options }: CookieJarOptions = {}) {
61
+ this.#grip = keys?.length ? new keygrip(keys) : undefined;
62
+ this.#setOptions = {
63
+ secure: false,
64
+ path: '/',
65
+ signed: !!keys?.length,
66
+ ...options,
67
+ };
67
68
  }
68
69
 
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;
70
+ #exportCookie(cookie: Cookie, response?: boolean): string[] {
71
+ const suffix = response ? CookieJar.responseSuffix(cookie) : null;
72
+ const payload = pairText(cookie);
73
+ const out = suffix ? [[payload, ...suffix].join(';')] : [payload];
74
+ if (cookie.signed) {
75
+ const sigPair = pair(`${cookie.name}.sig`, this.#grip!.sign(payload));
76
+ out.push(suffix ? [sigPair, ...suffix].join(';') : sigPair);
84
77
  }
78
+ return out;
85
79
  }
86
80
 
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');
81
+ import(cookies: Cookie[]): this {
82
+ const signatures: Record<string, string> = {};
83
+ for (const cookie of cookies) {
84
+ if (this.#setOptions.signed && cookie.name.endsWith('.sig')) {
85
+ signatures[cookie.name.replace(/[.]sig$/, '')] = cookie.value!;
86
+ } else {
87
+ this.#cookies[cookie.name] = { signed: false, ...cookie };
88
+ }
92
89
  }
93
- return { ...c, name: `${c.name}.sig`, value: this.#grip.sign(pairText(c)) };
94
- }
95
90
 
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);
91
+ for (const [name, value] of Object.entries(signatures)) {
92
+ const cookie = this.#cookies[name];
93
+ if (!cookie) {
94
+ continue;
103
95
  }
104
- }
105
- for (const c of toCheck) {
106
- const sc = this.#checkSignature(c);
107
- if (sc) {
108
- this.set(sc);
96
+ cookie.signed = true;
97
+
98
+ const computed = pairText(cookie);
99
+ const index = this.#grip!.index(computed, value);
100
+
101
+ if (index < 0) {
102
+ delete this.#cookies[name];
103
+ } else if (index >= 1) {
104
+ cookie.response = true;
109
105
  }
110
106
  }
107
+ return this;
108
+ }
109
+
110
+ has(name: string, opts: CookieGetOptions = {}): boolean {
111
+ const needSigned = opts.signed ?? this.#setOptions.signed;
112
+ return name in this.#cookies && this.#cookies[name].signed === needSigned;
111
113
  }
112
114
 
113
115
  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
+ if (this.has(name, opts)) {
117
+ return this.#cookies[name]?.value;
118
+ }
116
119
  }
117
120
 
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;
121
+ set(cookie: Cookie): void {
122
+ const alias = this.#cookies[cookie.name] = {
123
+ ...this.#setOptions,
124
+ ...cookie,
125
+ response: true,
126
+ ...(cookie.value === null || cookie.value === undefined) ? this.#deleteOptions : {},
127
+ };
123
128
 
124
- if (c.value === null || c.value === undefined) {
125
- c.maxAge = -1;
126
- c.expires = undefined;
129
+ if (!this.#setOptions.secure && alias.secure) {
130
+ throw new AppError('Cannot send secure cookie over unencrypted connection');
127
131
  }
128
132
 
129
- if (c.signed) {
130
- const sc = this.#signCookie(c);
131
- this.#cookies[sc.name] = sc;
132
- sc.response = true;
133
+ if (alias.signed && !this.#grip) {
134
+ throw new AppError('Signing keys required for signed cookies');
133
135
  }
134
- }
135
136
 
136
- export(response = true): string[] {
137
- return this.getAll()
138
- .filter(x => !response || x.response)
139
- .map(c => CookieJar.toHeaderValue(c, response));
137
+ if (alias.maxAge !== undefined && !alias.expires) {
138
+ alias.expires = new Date(Date.now() + alias.maxAge);
139
+ }
140
140
  }
141
141
 
142
142
  getAll(): Cookie[] {
143
143
  return Object.values(this.#cookies);
144
144
  }
145
+
146
+ importCookieHeader(header: string | null | undefined): this {
147
+ return this.import(CookieJar.parseCookieHeader(header ?? ''));
148
+ }
149
+
150
+ importSetCookieHeader(headers: string[] | null | undefined): this {
151
+ return this.import(headers?.map(CookieJar.parseSetCookieHeader) ?? []);
152
+ }
153
+
154
+ exportCookieHeader(): string {
155
+ return this.getAll().flatMap(c => this.#exportCookie(c)).join('; ');
156
+ }
157
+
158
+ exportSetCookieHeader(): string[] {
159
+ return this.getAll()
160
+ .filter(x => x.response)
161
+ .flatMap(c => this.#exportCookie(c, true));
162
+ }
145
163
  }
@@ -44,21 +44,26 @@ export class WebTransformer {
44
44
  // If non-regex
45
45
  if (arg && ts.isStringLiteral(arg)) {
46
46
  const literal = LiteralUtil.toLiteral(arg);
47
- if (typeof literal !== 'string') {
48
- throw new Error(`Unexpected literal type: ${literal}`);
49
- }
50
- // If param name matches path param, default to @Path
47
+ // If param name matches path param, default to @PathParam
51
48
  detectedParamType = new RegExp(`:${name}\\b`).test(literal) ? 'PathParam' : 'QueryParam';
52
49
  } else {
53
50
  // Default to query for empty or regex endpoints
54
51
  detectedParamType = 'QueryParam';
55
52
  }
56
- } else if (epDec.ident.getText() !== 'All') { // Treat all separate
53
+ } else {
57
54
  // Treat as schema, and see if endpoint supports a body for default behavior on untyped
58
55
  detectedParamType = epDec.targets?.includes('@travetto/web:HttpRequestBody') ? 'Body' : 'QueryParam';
59
56
  config.name = '';
60
57
  }
61
58
 
59
+ if (paramType.key === 'managed' && paramType.original) {
60
+ if (DocUtil.hasDocTag(paramType.original, 'web_contextual')) {
61
+ throw new Error(`${paramType.name} must be registered using @ContextParam`);
62
+ } else if (DocUtil.hasDocTag(paramType.original, 'web_invalid_parameter')) {
63
+ throw new Error(`${paramType.name} is an invalid endpoint parameter`);
64
+ }
65
+ }
66
+
62
67
  node = SchemaTransformUtil.computeField(state, node, config);
63
68
 
64
69
  const modifiers = (node.modifiers ?? []).filter(x => x !== pDec);