@travetto/web 6.0.0-rc.2 → 6.0.0-rc.4

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
@@ -20,7 +20,6 @@ The module provides a declarative API for creating and describing a Web applicat
20
20
  * Using [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)s
21
21
  * Creating a Custom [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)
22
22
  * Cookies
23
- * SSL Support
24
23
  * Error Handling
25
24
 
26
25
  ## Request/Response Pattern
@@ -38,18 +37,53 @@ export class BaseWebMessage<B = unknown, C = unknown> implements WebMessage<B, C
38
37
 
39
38
  **Code: Request Shape**
40
39
  ```typescript
40
+ import { HttpMethod, HttpProtocol } from './core.ts';
41
+ import { BaseWebMessage } from './message.ts';
42
+
43
+ export interface WebConnection {
44
+ host?: string;
45
+ port?: number;
46
+ ip?: string;
47
+ httpProtocol?: HttpProtocol;
48
+ }
49
+
50
+ export interface WebRequestContext {
51
+ path: string;
52
+ pathParams?: Record<string, unknown>;
53
+ httpQuery?: Record<string, unknown>;
54
+ httpMethod?: HttpMethod;
55
+ connection?: WebConnection;
56
+ }
41
57
 
58
+ /**
59
+ * Web Request object
60
+ */
61
+ export class WebRequest<B = unknown> extends BaseWebMessage<B, Readonly<WebRequestContext>> {
62
+
63
+ }
42
64
  ```
43
65
 
44
66
  **Code: Response Shape**
45
67
  ```typescript
68
+ import { BaseWebMessage } from './message.ts';
69
+
70
+ export interface WebResponseContext {
71
+ httpStatusCode?: number;
72
+ }
73
+
74
+ /**
75
+ * Web Response as a simple object
76
+ */
46
77
  export class WebResponse<B = unknown> extends BaseWebMessage<B, WebResponseContext> {
78
+
47
79
  /**
48
80
  * Build the redirect
49
81
  * @param location Location to redirect to
50
82
  * @param statusCode Status code
51
83
  */
52
- static redirect(location: string, statusCode = 302): WebResponse<undefined>;
84
+ static redirect(location: string, statusCode = 302): WebResponse<undefined> {
85
+ return new WebResponse({ context: { httpStatusCode: statusCode }, headers: { Location: location } });
86
+ }
53
87
  }
54
88
  ```
55
89
 
@@ -60,6 +94,7 @@ To start, we must define a [@Controller](https://github.com/travetto/travetto/tr
60
94
  * `path` - The required context path the controller will operate atop
61
95
  * `title` - The definition of the controller
62
96
  * `description` - High level description fo the controller
97
+
63
98
  Additionally, the module is predicated upon [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support."), and so all standard injection techniques (constructor, fields) work for registering dependencies.
64
99
 
65
100
  [JSDoc](http://usejsdoc.org/about-getting-started.html) comments can also be used to define the `title` attribute.
@@ -85,9 +120,11 @@ The most common pattern is to register HTTP-driven endpoints. The HTTP methods
85
120
  * [@Patch](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L64)
86
121
  * [@Head](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L76)
87
122
  * [@Options](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L82)
123
+
88
124
  Similar to the Controller, each endpoint decorator handles the following config:
89
125
  * `title` - The definition of the endpoint
90
126
  * `description` - High level description fo the endpoint
127
+
91
128
  [JSDoc](http://usejsdoc.org/about-getting-started.html) comments can also be used to define the `title` attribute, as well as describing the parameters using `@param` tags in the comment.
92
129
 
93
130
  The return type of the method will also be used to describe the `responseType` if not specified manually.
@@ -121,11 +158,13 @@ Endpoints can be configured to describe and enforce parameter behavior. Request
121
158
  * [@QueryParam](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L43) - Query params - can be either a single value or bind to a whole object
122
159
  * [@Body](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L55) - Request body
123
160
  * [@HeaderParam](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L49) - Header values
161
+
124
162
  Each [@Param](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L24) can be configured to indicate:
125
163
  * `name` - Name of param, field name, defaults to handler parameter name if necessary
126
164
  * `description` - Description of param, pulled from [JSDoc](http://usejsdoc.org/about-getting-started.html), or defaults to name if empty
127
165
  * `required?` - Is the field required?, defaults to whether or not the parameter itself is optional
128
166
  * `type` - The class of the type to be enforced, pulled from parameter type
167
+
129
168
  [JSDoc](http://usejsdoc.org/about-getting-started.html) comments can also be used to describe parameters using `@param` tags in the comment.
130
169
 
131
170
  **Code: Full-fledged Controller with Endpoints**
@@ -720,15 +759,5 @@ export class SimpleEndpoints {
720
759
  }
721
760
  ```
722
761
 
723
- ## SSL Support
724
- Additionally the framework supports SSL out of the box, by allowing you to specify your public and private keys for the cert. In dev mode, the framework will also automatically generate a self-signed cert if:
725
- * SSL support is configured
726
- * [node-forge](https://www.npmjs.com/package/node-forge) is installed
727
- * Not running in prod
728
- * No keys provided
729
- This is useful for local development where you implicitly trust the cert.
730
-
731
- SSL support can be enabled by setting `web.ssl.active: true` in your config. The key/cert can be specified as string directly in the config file/environment variables. The key/cert can also be specified as a path to be picked up by [RuntimeResources](https://github.com/travetto/travetto/tree/main/module/runtime/src/resources.ts#L8).
732
-
733
762
  ## Full Config
734
763
  The entire [WebConfig](https://github.com/travetto/travetto/tree/main/module/web/src/config.ts#L7) which will show the full set of valid configuration parameters for the web module.
package/__index__.ts CHANGED
@@ -26,7 +26,7 @@ export * from './src/registry/types.ts';
26
26
  export * from './src/interceptor/accept.ts';
27
27
  export * from './src/interceptor/body-parse.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.2",
3
+ "version": "6.0.0-rc.4",
4
4
  "description": "Declarative api for Web Applications with support for the dependency injection.",
5
5
  "keywords": [
6
6
  "web",
@@ -42,7 +42,7 @@
42
42
  "negotiator": "^1.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@travetto/cli": "^6.0.0-rc.2",
45
+ "@travetto/cli": "^6.0.0-rc.3",
46
46
  "@travetto/test": "^6.0.0-rc.2",
47
47
  "@travetto/transformer": "^6.0.0-rc.3"
48
48
  },
@@ -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'>;
@@ -19,4 +19,6 @@ export interface WebRequestContext {
19
19
  /**
20
20
  * Web Request object
21
21
  */
22
- export class WebRequest<B = unknown> extends BaseWebMessage<B, Readonly<WebRequestContext>> { }
22
+ export class WebRequest<B = unknown> extends BaseWebMessage<B, Readonly<WebRequestContext>> {
23
+
24
+ }
@@ -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
  }