@travetto/web 6.0.0-rc.6 → 6.0.1
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 +51 -21
- package/__index__.ts +1 -1
- package/package.json +11 -12
- package/src/decorator/common.ts +20 -16
- package/src/decorator/endpoint.ts +0 -1
- package/src/interceptor/body.ts +2 -2
- package/src/interceptor/cache-control.ts +58 -0
- package/src/interceptor/etag.ts +31 -6
- package/src/registry/controller.ts +9 -5
- package/src/registry/types.ts +9 -5
- package/src/types/response.ts +2 -0
- package/src/util/common.ts +8 -16
- package/src/util/endpoint.ts +9 -4
- package/support/transformer.web.ts +1 -1
- package/src/interceptor/response-cache.ts +0 -47
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
|
|
5
|
+
## Declarative support creating for Web Applications
|
|
6
6
|
|
|
7
7
|
**Install: @travetto/web**
|
|
8
8
|
```bash
|
|
@@ -16,11 +16,10 @@ yarn add @travetto/web
|
|
|
16
16
|
The module provides a declarative API for creating and describing a Web application. Since the framework is declarative, decorators are used to configure almost everything. The general layout of an application is a collection of [@Controller](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/controller.ts#L9)s that employ some combination of [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)s to help manage which functionality is executed before the [Endpoint](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L14) code, within the [@Controller](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/controller.ts#L9). This module will look at:
|
|
17
17
|
* Request/Response Pattern
|
|
18
18
|
* Defining a [@Controller](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/controller.ts#L9)
|
|
19
|
-
* Defining an [Endpoint](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L14)
|
|
20
|
-
* Using [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)
|
|
19
|
+
* Defining an [Endpoint](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L14)
|
|
20
|
+
* Using a [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)
|
|
21
21
|
* Creating a Custom [WebInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/types/interceptor.ts#L15)
|
|
22
22
|
* Cookies
|
|
23
|
-
* Error Handling
|
|
24
23
|
|
|
25
24
|
## Request/Response Pattern
|
|
26
25
|
Unlike other frameworks (e.g. [express](https://expressjs.com), [fastify](https://www.fastify.io/)), this module takes an approach that is similar to [AWS Lambda](https://aws.amazon.com/lambda/)'s model for requests and responses. What you can see here is that [WebRequest](https://github.com/travetto/travetto/tree/main/module/web/src/types/request.ts#L11) and [WebResponse](https://github.com/travetto/travetto/tree/main/module/web/src/types/response.ts#L3) are very simple objects, with the focus being on the `payload` and `body`. This is intended to provide maximal compatibility with non-HTTP sources. The driving goal is to support more than just standard HTTP servers but also allow for seamless integration with tools like event queues, web sockets, etc.
|
|
@@ -69,6 +68,8 @@ import { BaseWebMessage } from './message.ts';
|
|
|
69
68
|
|
|
70
69
|
export interface WebResponseContext {
|
|
71
70
|
httpStatusCode?: number;
|
|
71
|
+
isPrivate?: boolean;
|
|
72
|
+
cacheableAge?: number;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
@@ -113,13 +114,13 @@ class SimpleController {
|
|
|
113
114
|
Once the controller is declared, each method of the controller is a candidate for being an endpoint. By design, everything is asynchronous, and so async/await is natively supported.
|
|
114
115
|
|
|
115
116
|
The most common pattern is to register HTTP-driven endpoints. The HTTP methods that are currently supported:
|
|
116
|
-
* [@Get](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#
|
|
117
|
-
* [@Post](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#
|
|
118
|
-
* [@Put](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#
|
|
119
|
-
* [@Delete](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#
|
|
120
|
-
* [@Patch](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#
|
|
121
|
-
* [@Head](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#
|
|
122
|
-
* [@Options](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#
|
|
117
|
+
* [@Get](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L42)
|
|
118
|
+
* [@Post](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L49)
|
|
119
|
+
* [@Put](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L56)
|
|
120
|
+
* [@Delete](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L69)
|
|
121
|
+
* [@Patch](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L63)
|
|
122
|
+
* [@Head](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L75)
|
|
123
|
+
* [@Options](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/endpoint.ts#L81)
|
|
123
124
|
|
|
124
125
|
Similar to the Controller, each endpoint decorator handles the following config:
|
|
125
126
|
* `title` - The definition of the endpoint
|
|
@@ -250,7 +251,7 @@ class ContextController {
|
|
|
250
251
|
}
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
**Note**: When referencing the [@ContextParam](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L61) values, the contract for idempotency needs to be carefully inspected, if expected. You can see in the example above that the [CacheControl](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/common.ts#
|
|
254
|
+
**Note**: When referencing the [@ContextParam](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L61) values, the contract for idempotency needs to be carefully inspected, if expected. You can see in the example above that the [CacheControl](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/common.ts#L48) decorator is used to ensure that the response is not cached.
|
|
254
255
|
|
|
255
256
|
### Validating Inputs
|
|
256
257
|
The module provides high level access for [Schema](https://github.com/travetto/travetto/tree/main/module/schema#readme "Data type registry for runtime validation, reflection and binding.") support, via decorators, for validating and typing request inputs.
|
|
@@ -378,7 +379,7 @@ Out of the box, the web framework comes with a few interceptors, and more are co
|
|
|
378
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)
|
|
379
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)
|
|
380
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)
|
|
381
|
-
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#
|
|
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)
|
|
382
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.
|
|
383
384
|
|
|
384
385
|
### Packaged Interceptors
|
|
@@ -515,7 +516,7 @@ export class WebBodyConfig {
|
|
|
515
516
|
/**
|
|
516
517
|
* Max body size limit
|
|
517
518
|
*/
|
|
518
|
-
limit:
|
|
519
|
+
limit: ByteSizeInput = '1mb';
|
|
519
520
|
/**
|
|
520
521
|
* How to interpret different content types
|
|
521
522
|
*/
|
|
@@ -556,7 +557,7 @@ export class CompressConfig {
|
|
|
556
557
|
```
|
|
557
558
|
|
|
558
559
|
#### EtagInterceptor
|
|
559
|
-
[EtagInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/etag.ts#
|
|
560
|
+
[EtagInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/etag.ts#L43) by default, will tag all cacheable HTTP responses, when the response value/length is known. Streams, and other async data sources do not have a pre-defined length, and so are ineligible for etagging.
|
|
560
561
|
|
|
561
562
|
**Code: ETag Config**
|
|
562
563
|
```typescript
|
|
@@ -569,9 +570,16 @@ export class EtagConfig {
|
|
|
569
570
|
* Should we generate a weak etag
|
|
570
571
|
*/
|
|
571
572
|
weak?: boolean;
|
|
573
|
+
/**
|
|
574
|
+
* Threshold for tagging avoids tagging small responses
|
|
575
|
+
*/
|
|
576
|
+
minimumSize: ByteSizeInput = '10kb';
|
|
572
577
|
|
|
573
578
|
@Ignore()
|
|
574
579
|
cacheable?: boolean;
|
|
580
|
+
|
|
581
|
+
@Ignore()
|
|
582
|
+
_minimumSize: number | undefined;
|
|
575
583
|
}
|
|
576
584
|
```
|
|
577
585
|
|
|
@@ -612,8 +620,12 @@ export class CorsConfig {
|
|
|
612
620
|
}
|
|
613
621
|
```
|
|
614
622
|
|
|
615
|
-
####
|
|
616
|
-
[
|
|
623
|
+
#### CacheControlInterceptor
|
|
624
|
+
[CacheControlInterceptor](https://github.com/travetto/travetto/tree/main/module/web/src/interceptor/cache-control.ts#L23) by default, enforces whatever caching policy is established on a given endpoint using the [CacheControl](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/common.ts#L48) decorator. Additionally, the interceptor retains knowledge if it is running on a private endpoint, or not. If the endpoint is deemed private it affects the caching header accordingly. If the endpoint directly returns a `Cache-Control` header, that takes precedence and all other logic is ignored.
|
|
625
|
+
|
|
626
|
+
This can be managed by setting `web.cache.applies: <boolean>` in your config.
|
|
627
|
+
|
|
628
|
+
**Note**: The [Web Auth](https://github.com/travetto/travetto/tree/main/module/auth-web#readme "Web authentication integration support for the Travetto framework") module will mark endpoints as private if they require authentication.
|
|
617
629
|
|
|
618
630
|
### Configuring Interceptors
|
|
619
631
|
All framework-provided interceptors, follow the same patterns for general configuration. This falls into three areas:
|
|
@@ -727,7 +739,28 @@ export class SimpleAuthInterceptor implements WebInterceptor {
|
|
|
727
739
|
```
|
|
728
740
|
|
|
729
741
|
## Cookie Support
|
|
730
|
-
|
|
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:
|
|
743
|
+
|
|
744
|
+
**Code: CookieJar contract**
|
|
745
|
+
```typescript
|
|
746
|
+
export class CookieJar {
|
|
747
|
+
static parseCookieHeader(header: string): Cookie[];
|
|
748
|
+
static parseSetCookieHeader(header: string): Cookie;
|
|
749
|
+
static responseSuffix(c: Cookie): string[];
|
|
750
|
+
constructor({ keys, ...options }: CookieJarOptions = {});
|
|
751
|
+
import(cookies: Cookie[]): this;
|
|
752
|
+
has(name: string, opts: CookieGetOptions = {}): boolean;
|
|
753
|
+
get(name: string, opts: CookieGetOptions = {}): string | undefined;
|
|
754
|
+
set(cookie: Cookie): void;
|
|
755
|
+
getAll(): Cookie[];
|
|
756
|
+
importCookieHeader(header: string | null | undefined): this;
|
|
757
|
+
importSetCookieHeader(headers: string[] | null | undefined): this;
|
|
758
|
+
exportCookieHeader(): string;
|
|
759
|
+
exportSetCookieHeader(): string[];
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
`.get()`/`.set()` will be the most commonly used methods, and should align with standard cookie reading/writing behavior.
|
|
731
764
|
|
|
732
765
|
**Code: Sample Cookie Usage**
|
|
733
766
|
```typescript
|
|
@@ -758,6 +791,3 @@ export class SimpleEndpoints {
|
|
|
758
791
|
}
|
|
759
792
|
}
|
|
760
793
|
```
|
|
761
|
-
|
|
762
|
-
## Full Config
|
|
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
|
@@ -31,7 +31,7 @@ export * from './src/interceptor/compress.ts';
|
|
|
31
31
|
export * from './src/interceptor/context.ts';
|
|
32
32
|
export * from './src/interceptor/decompress.ts';
|
|
33
33
|
export * from './src/interceptor/etag.ts';
|
|
34
|
-
export * from './src/interceptor/
|
|
34
|
+
export * from './src/interceptor/cache-control.ts';
|
|
35
35
|
export * from './src/interceptor/logging.ts';
|
|
36
36
|
export * from './src/interceptor/respond.ts';
|
|
37
37
|
export * from './src/interceptor/trust-proxy.ts';
|
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/web",
|
|
3
|
-
"version": "6.0.
|
|
4
|
-
"description": "Declarative
|
|
3
|
+
"version": "6.0.1",
|
|
4
|
+
"description": "Declarative support creating for Web Applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"web",
|
|
7
|
-
"dependency-injection",
|
|
8
7
|
"decorators",
|
|
9
8
|
"travetto",
|
|
10
9
|
"typescript"
|
|
@@ -26,12 +25,12 @@
|
|
|
26
25
|
"directory": "module/web"
|
|
27
26
|
},
|
|
28
27
|
"dependencies": {
|
|
29
|
-
"@travetto/config": "^6.0.0
|
|
30
|
-
"@travetto/context": "^6.0.0
|
|
31
|
-
"@travetto/di": "^6.0.0
|
|
32
|
-
"@travetto/registry": "^6.0.0
|
|
33
|
-
"@travetto/runtime": "^6.0.0
|
|
34
|
-
"@travetto/schema": "^6.0.0
|
|
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",
|
|
35
34
|
"@types/fresh": "^0.5.2",
|
|
36
35
|
"@types/keygrip": "^1.0.6",
|
|
37
36
|
"@types/negotiator": "^0.6.3",
|
|
@@ -41,9 +40,9 @@
|
|
|
41
40
|
"negotiator": "^1.0.0"
|
|
42
41
|
},
|
|
43
42
|
"peerDependencies": {
|
|
44
|
-
"@travetto/cli": "^6.0.0
|
|
45
|
-
"@travetto/test": "^6.0.
|
|
46
|
-
"@travetto/transformer": "^6.0.0
|
|
43
|
+
"@travetto/cli": "^6.0.0",
|
|
44
|
+
"@travetto/test": "^6.0.1",
|
|
45
|
+
"@travetto/transformer": "^6.0.0"
|
|
47
46
|
},
|
|
48
47
|
"peerDependenciesMeta": {
|
|
49
48
|
"@travetto/transformer": {
|
package/src/decorator/common.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { asConstructable, castTo, Class, TimeSpan } from '@travetto/runtime';
|
|
1
|
+
import { asConstructable, castTo, Class, TimeSpan, TimeUtil } from '@travetto/runtime';
|
|
2
2
|
|
|
3
3
|
import { ControllerRegistry } from '../registry/controller.ts';
|
|
4
4
|
import { EndpointConfig, ControllerConfig, DescribableConfig, EndpointDecorator, EndpointFunctionDescriptor } from '../registry/types.ts';
|
|
5
5
|
import { AcceptInterceptor } from '../interceptor/accept.ts';
|
|
6
6
|
import { WebInterceptor } from '../types/interceptor.ts';
|
|
7
|
-
import { WebCommonUtil, CacheControlFlag } from '../util/common.ts';
|
|
8
7
|
|
|
9
8
|
function register(config: Partial<EndpointConfig | ControllerConfig>): EndpointDecorator {
|
|
10
9
|
return function <T>(target: T | Class<T>, property?: string, descriptor?: EndpointFunctionDescriptor) {
|
|
@@ -40,27 +39,25 @@ export function SetHeaders(headers: EndpointConfig['responseHeaders']): Endpoint
|
|
|
40
39
|
*/
|
|
41
40
|
export function Produces(mime: string): EndpointDecorator { return SetHeaders({ 'Content-Type': mime }); }
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
* Specifies if endpoint should be conditional
|
|
45
|
-
*/
|
|
46
|
-
export function ConditionalRegister(handler: () => (boolean | Promise<boolean>)): EndpointDecorator {
|
|
47
|
-
return register({ conditional: handler });
|
|
48
|
-
}
|
|
42
|
+
type CacheControlInput = { cacheableAge?: number | TimeSpan, isPrivate?: boolean };
|
|
49
43
|
|
|
50
44
|
/**
|
|
51
45
|
* Set the max-age of a response based on the config
|
|
52
46
|
* @param value The value for the duration
|
|
53
|
-
* @param unit The unit of measurement
|
|
54
47
|
*/
|
|
55
|
-
export function CacheControl(
|
|
56
|
-
|
|
48
|
+
export function CacheControl(input: TimeSpan | number | CacheControlInput, extra?: Omit<CacheControlInput, 'cacheableAge'>): EndpointDecorator {
|
|
49
|
+
if (typeof input === 'string' || typeof input === 'number') {
|
|
50
|
+
input = { ...extra, cacheableAge: input };
|
|
51
|
+
}
|
|
52
|
+
const { cacheableAge, isPrivate } = input;
|
|
53
|
+
return register({
|
|
54
|
+
responseContext: {
|
|
55
|
+
...(cacheableAge !== undefined ? { cacheableAge: TimeUtil.asSeconds(cacheableAge) } : {}),
|
|
56
|
+
...isPrivate !== undefined ? { isPrivate } : {}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
/**
|
|
60
|
-
* Disable cache control, ensuring endpoint will not cache
|
|
61
|
-
*/
|
|
62
|
-
export const DisableCacheControl = (): EndpointDecorator => CacheControl(0);
|
|
63
|
-
|
|
64
61
|
/**
|
|
65
62
|
* Define an endpoint to support specific input types
|
|
66
63
|
* @param types The list of mime types to allow/deny
|
|
@@ -79,6 +76,13 @@ export function Accepts(types: [string, ...string[]]): EndpointDecorator {
|
|
|
79
76
|
export const ConfigureInterceptor =
|
|
80
77
|
ControllerRegistry.createInterceptorConfigDecorator.bind(ControllerRegistry);
|
|
81
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Specifies if endpoint should be conditional
|
|
81
|
+
*/
|
|
82
|
+
export function ConditionalRegister(handler: () => (boolean | Promise<boolean>)): EndpointDecorator {
|
|
83
|
+
return register({ conditional: handler });
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
/**
|
|
83
87
|
* Registers an interceptor exclusion filter
|
|
84
88
|
*/
|
|
@@ -20,7 +20,6 @@ export function Endpoint(config: EndpointDecConfig): EndpointFunctionDecorator {
|
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
23
|
function HttpEndpoint(method: HttpMethod, path: string): EndpointFunctionDecorator {
|
|
25
24
|
const { body: allowsBody, cacheable, emptyStatusCode } = HTTP_METHODS[method];
|
|
26
25
|
return Endpoint({
|
package/src/interceptor/body.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { WebInterceptorCategory } from '../types/core.ts';
|
|
|
9
9
|
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
10
10
|
|
|
11
11
|
import { WebBodyUtil } from '../util/body.ts';
|
|
12
|
-
import { WebCommonUtil } from '../util/common.ts';
|
|
12
|
+
import { ByteSizeInput, WebCommonUtil } from '../util/common.ts';
|
|
13
13
|
|
|
14
14
|
import { AcceptInterceptor } from './accept.ts';
|
|
15
15
|
import { DecompressInterceptor } from './decompress.ts';
|
|
@@ -35,7 +35,7 @@ export class WebBodyConfig {
|
|
|
35
35
|
/**
|
|
36
36
|
* Max body size limit
|
|
37
37
|
*/
|
|
38
|
-
limit:
|
|
38
|
+
limit: ByteSizeInput = '1mb';
|
|
39
39
|
/**
|
|
40
40
|
* How to interpret different content types
|
|
41
41
|
*/
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Injectable, Inject } from '@travetto/di';
|
|
2
|
+
import { Config } from '@travetto/config';
|
|
3
|
+
|
|
4
|
+
import { WebChainedContext } from '../types/filter.ts';
|
|
5
|
+
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
6
|
+
import { WebInterceptorCategory } from '../types/core.ts';
|
|
7
|
+
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
|
|
9
|
+
import { EtagInterceptor } from './etag.ts';
|
|
10
|
+
|
|
11
|
+
@Config('web.cache')
|
|
12
|
+
export class CacheControlConfig {
|
|
13
|
+
/**
|
|
14
|
+
* Generate response cache headers
|
|
15
|
+
*/
|
|
16
|
+
applies = true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Determines if we should cache all get requests
|
|
21
|
+
*/
|
|
22
|
+
@Injectable()
|
|
23
|
+
export class CacheControlInterceptor implements WebInterceptor {
|
|
24
|
+
|
|
25
|
+
category: WebInterceptorCategory = 'response';
|
|
26
|
+
dependsOn = [EtagInterceptor];
|
|
27
|
+
|
|
28
|
+
@Inject()
|
|
29
|
+
config: CacheControlConfig;
|
|
30
|
+
|
|
31
|
+
applies({ config, endpoint }: WebInterceptorContext<CacheControlConfig>): boolean {
|
|
32
|
+
return config.applies && endpoint.cacheable;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async filter({ next }: WebChainedContext<CacheControlConfig>): Promise<WebResponse> {
|
|
36
|
+
const response = await next();
|
|
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(','));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/interceptor/etag.ts
CHANGED
|
@@ -4,6 +4,7 @@ import fresh from 'fresh';
|
|
|
4
4
|
import { Injectable, Inject } from '@travetto/di';
|
|
5
5
|
import { Config } from '@travetto/config';
|
|
6
6
|
import { Ignore } from '@travetto/schema';
|
|
7
|
+
import { BinaryUtil } from '@travetto/runtime';
|
|
7
8
|
|
|
8
9
|
import { WebChainedContext } from '../types/filter.ts';
|
|
9
10
|
import { WebResponse } from '../types/response.ts';
|
|
@@ -11,6 +12,7 @@ import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
|
11
12
|
import { WebInterceptorCategory } from '../types/core.ts';
|
|
12
13
|
import { CompressInterceptor } from './compress.ts';
|
|
13
14
|
import { WebBodyUtil } from '../util/body.ts';
|
|
15
|
+
import { ByteSizeInput, WebCommonUtil } from '../util/common.ts';
|
|
14
16
|
|
|
15
17
|
@Config('web.etag')
|
|
16
18
|
export class EtagConfig {
|
|
@@ -22,9 +24,16 @@ export class EtagConfig {
|
|
|
22
24
|
* Should we generate a weak etag
|
|
23
25
|
*/
|
|
24
26
|
weak?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Threshold for tagging avoids tagging small responses
|
|
29
|
+
*/
|
|
30
|
+
minimumSize: ByteSizeInput = '10kb';
|
|
25
31
|
|
|
26
32
|
@Ignore()
|
|
27
33
|
cacheable?: boolean;
|
|
34
|
+
|
|
35
|
+
@Ignore()
|
|
36
|
+
_minimumSize: number | undefined;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
/**
|
|
@@ -52,21 +61,32 @@ export class EtagInterceptor implements WebInterceptor {
|
|
|
52
61
|
addTag(ctx: WebChainedContext<EtagConfig>, response: WebResponse): WebResponse {
|
|
53
62
|
const { request } = ctx;
|
|
54
63
|
|
|
55
|
-
const statusCode = response.context.httpStatusCode;
|
|
64
|
+
const statusCode = response.context.httpStatusCode ?? 200;
|
|
56
65
|
|
|
57
|
-
if (
|
|
66
|
+
if (
|
|
67
|
+
(statusCode >= 300 && statusCode !== 304) || // Ignore redirects
|
|
68
|
+
BinaryUtil.isReadableStream(response.body) // Ignore streams (unknown length)
|
|
69
|
+
) {
|
|
58
70
|
return response;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
const binaryResponse = new WebResponse({ ...response, ...WebBodyUtil.toBinaryMessage(response) });
|
|
62
|
-
|
|
74
|
+
|
|
75
|
+
const body = binaryResponse.body;
|
|
76
|
+
if (!Buffer.isBuffer(body)) {
|
|
77
|
+
return binaryResponse;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const minSize = ctx.config._minimumSize ??= WebCommonUtil.parseByteSize(ctx.config.minimumSize);
|
|
81
|
+
if (body.length < minSize) {
|
|
63
82
|
return binaryResponse;
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
const tag = this.computeTag(
|
|
85
|
+
const tag = this.computeTag(body);
|
|
67
86
|
binaryResponse.headers.set('ETag', `${ctx.config.weak ? 'W/' : ''}"${tag}"`);
|
|
68
87
|
|
|
69
|
-
if (
|
|
88
|
+
if (
|
|
89
|
+
ctx.config.cacheable &&
|
|
70
90
|
fresh({
|
|
71
91
|
'if-modified-since': request.headers.get('If-Modified-Since')!,
|
|
72
92
|
'if-none-match': request.headers.get('If-None-Match')!,
|
|
@@ -76,7 +96,12 @@ export class EtagInterceptor implements WebInterceptor {
|
|
|
76
96
|
'last-modified': binaryResponse.headers.get('Last-Modified')!
|
|
77
97
|
})
|
|
78
98
|
) {
|
|
79
|
-
|
|
99
|
+
// Remove length for the 304
|
|
100
|
+
binaryResponse.headers.delete('Content-Length');
|
|
101
|
+
return new WebResponse({
|
|
102
|
+
context: { ...response.context, httpStatusCode: 304 },
|
|
103
|
+
headers: binaryResponse.headers
|
|
104
|
+
});
|
|
80
105
|
}
|
|
81
106
|
|
|
82
107
|
return binaryResponse;
|
|
@@ -50,6 +50,7 @@ class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointCon
|
|
|
50
50
|
externalName: cls.name.replace(/(Controller|Web|Service)$/, ''),
|
|
51
51
|
endpoints: [],
|
|
52
52
|
contextParams: {},
|
|
53
|
+
responseHeaders: {}
|
|
53
54
|
};
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -68,7 +69,8 @@ class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointCon
|
|
|
68
69
|
interceptorConfigs: [],
|
|
69
70
|
name: endpoint.name,
|
|
70
71
|
endpoint,
|
|
71
|
-
|
|
72
|
+
responseHeaders: {},
|
|
73
|
+
finalizedResponseHeaders: new WebHeaders(),
|
|
72
74
|
responseFinalizer: undefined
|
|
73
75
|
};
|
|
74
76
|
|
|
@@ -199,7 +201,7 @@ class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointCon
|
|
|
199
201
|
* @param src Root describable (controller, endpoint)
|
|
200
202
|
* @param dest Target (controller, endpoint)
|
|
201
203
|
*/
|
|
202
|
-
|
|
204
|
+
mergeCommon(src: Partial<ControllerConfig | EndpointConfig>, dest: Partial<ControllerConfig | EndpointConfig>): void {
|
|
203
205
|
dest.filters = [...(dest.filters ?? []), ...(src.filters ?? [])];
|
|
204
206
|
dest.interceptorConfigs = [...(dest.interceptorConfigs ?? []), ...(src.interceptorConfigs ?? [])];
|
|
205
207
|
dest.interceptorExclude = dest.interceptorExclude ?? src.interceptorExclude;
|
|
@@ -207,6 +209,7 @@ class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointCon
|
|
|
207
209
|
dest.description = src.description || dest.description;
|
|
208
210
|
dest.documented = src.documented ?? dest.documented;
|
|
209
211
|
dest.responseHeaders = { ...src.responseHeaders, ...dest.responseHeaders };
|
|
212
|
+
dest.responseContext = { ...src.responseContext, ...dest.responseContext };
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
/**
|
|
@@ -231,7 +234,7 @@ class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointCon
|
|
|
231
234
|
srcConf.path = `/${srcConf.path}`;
|
|
232
235
|
}
|
|
233
236
|
|
|
234
|
-
this.
|
|
237
|
+
this.mergeCommon(config, srcConf);
|
|
235
238
|
|
|
236
239
|
return descriptor;
|
|
237
240
|
}
|
|
@@ -252,7 +255,7 @@ class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointCon
|
|
|
252
255
|
srcConf.contextParams = { ...srcConf.contextParams, ...config.contextParams };
|
|
253
256
|
|
|
254
257
|
|
|
255
|
-
this.
|
|
258
|
+
this.mergeCommon(config, srcConf);
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
/**
|
|
@@ -266,7 +269,8 @@ class $ControllerRegistry extends MetadataRegistry<ControllerConfig, EndpointCon
|
|
|
266
269
|
this.#endpointsById.set(ep.id, ep);
|
|
267
270
|
// Store full path from base for use in other contexts
|
|
268
271
|
ep.fullPath = `/${final.basePath}/${ep.path}`.replace(/[/]{1,4}/g, '/').replace(/(.)[/]$/, (_, a) => a);
|
|
269
|
-
ep.
|
|
272
|
+
ep.finalizedResponseHeaders = new WebHeaders({ ...final.responseHeaders, ...ep.responseHeaders });
|
|
273
|
+
ep.responseContext = { ...final.responseContext, ...ep.responseContext };
|
|
270
274
|
}
|
|
271
275
|
|
|
272
276
|
if (this.has(final.basePath)) {
|
package/src/registry/types.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { WebInterceptor } from '../types/interceptor.ts';
|
|
|
5
5
|
import type { WebChainedFilter, WebFilter } from '../types/filter.ts';
|
|
6
6
|
import { HttpMethod } from '../types/core.ts';
|
|
7
7
|
import { WebHeaders } from '../types/headers.ts';
|
|
8
|
-
import { WebResponse } from '../types/response.ts';
|
|
8
|
+
import { WebResponse, WebResponseContext } from '../types/response.ts';
|
|
9
9
|
import { WebRequest } from '../types/request.ts';
|
|
10
10
|
|
|
11
11
|
export type EndpointFunction = TypedFunction<Any, unknown>;
|
|
@@ -78,6 +78,10 @@ interface CoreConfig {
|
|
|
78
78
|
* Response headers
|
|
79
79
|
*/
|
|
80
80
|
responseHeaders?: Record<string, string>;
|
|
81
|
+
/**
|
|
82
|
+
* Partial response context
|
|
83
|
+
*/
|
|
84
|
+
responseContext?: Partial<WebResponseContext>;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
|
@@ -167,14 +171,14 @@ export interface EndpointConfig extends CoreConfig, DescribableConfig {
|
|
|
167
171
|
* Full path including controller
|
|
168
172
|
*/
|
|
169
173
|
fullPath: string;
|
|
170
|
-
/**
|
|
171
|
-
* Response header map
|
|
172
|
-
*/
|
|
173
|
-
responseHeaderMap: WebHeaders;
|
|
174
174
|
/**
|
|
175
175
|
* Response finalizer
|
|
176
176
|
*/
|
|
177
177
|
responseFinalizer?: (res: WebResponse) => WebResponse;
|
|
178
|
+
/**
|
|
179
|
+
* Response headers finalized
|
|
180
|
+
*/
|
|
181
|
+
finalizedResponseHeaders: WebHeaders;
|
|
178
182
|
}
|
|
179
183
|
|
|
180
184
|
/**
|
package/src/types/response.ts
CHANGED
package/src/util/common.ts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import { AppError, ErrorCategory
|
|
1
|
+
import { AppError, ErrorCategory } from '@travetto/runtime';
|
|
2
2
|
|
|
3
3
|
import { WebResponse } from '../types/response.ts';
|
|
4
4
|
import { WebRequest } from '../types/request.ts';
|
|
5
|
+
import { WebError } from '../types/error.ts';
|
|
5
6
|
|
|
6
7
|
type List<T> = T[] | readonly T[];
|
|
7
8
|
type OrderedState<T> = { after?: List<T>, before?: List<T>, key: T };
|
|
8
9
|
|
|
9
10
|
const WebRequestParamsSymbol = Symbol();
|
|
10
11
|
|
|
11
|
-
export type
|
|
12
|
-
'must-revalidate' | 'public' | 'private' | 'no-cache' |
|
|
13
|
-
'no-store' | 'no-transform' | 'proxy-revalidate' | 'immutable' |
|
|
14
|
-
'must-understand' | 'stale-if-error' | 'stale-while-revalidate';
|
|
12
|
+
export type ByteSizeInput = `${number}${'mb' | 'kb' | 'gb' | 'b' | ''}` | number;
|
|
15
13
|
|
|
16
14
|
/**
|
|
17
15
|
* Mapping from error category to standard http error codes
|
|
@@ -103,7 +101,7 @@ export class WebCommonUtil {
|
|
|
103
101
|
new AppError(err.message, { details: err }) :
|
|
104
102
|
new AppError(`${err}`);
|
|
105
103
|
|
|
106
|
-
const error: Error &
|
|
104
|
+
const error: Error & Partial<WebError> = body;
|
|
107
105
|
const statusCode = error.details?.statusCode ?? ERROR_CATEGORY_STATUS[error.category!] ?? 500;
|
|
108
106
|
|
|
109
107
|
return new WebResponse({ body, context: { httpStatusCode: statusCode } });
|
|
@@ -123,19 +121,13 @@ export class WebCommonUtil {
|
|
|
123
121
|
request[WebRequestParamsSymbol] ??= params;
|
|
124
122
|
}
|
|
125
123
|
|
|
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
124
|
/**
|
|
136
125
|
* Parse byte size
|
|
137
126
|
*/
|
|
138
|
-
static parseByteSize(input:
|
|
127
|
+
static parseByteSize(input: ByteSizeInput): number {
|
|
128
|
+
if (typeof input === 'number') {
|
|
129
|
+
return input;
|
|
130
|
+
}
|
|
139
131
|
const [, num, unit] = input.toLowerCase().split(/(\d+)/);
|
|
140
132
|
return parseInt(num, 10) * (this.#unitMapping[unit] ?? 1);
|
|
141
133
|
}
|
package/src/util/endpoint.ts
CHANGED
|
@@ -143,10 +143,15 @@ export class EndpointUtil {
|
|
|
143
143
|
try {
|
|
144
144
|
const params = await this.extractParameters(endpoint, request);
|
|
145
145
|
const body = await endpoint.endpoint.apply(endpoint.instance, params);
|
|
146
|
-
const headers = endpoint.
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
for (const [k, v] of headers) {
|
|
146
|
+
const headers = endpoint.finalizedResponseHeaders;
|
|
147
|
+
let response: WebResponse;
|
|
148
|
+
if (body instanceof WebResponse) {
|
|
149
|
+
for (const [k, v] of headers) { body.headers.setIfAbsent(k, v); }
|
|
150
|
+
// Rewrite context
|
|
151
|
+
Object.assign(body.context, { ...endpoint.responseContext, ...body.context });
|
|
152
|
+
response = body;
|
|
153
|
+
} else {
|
|
154
|
+
response = new WebResponse({ body, headers, context: { ...endpoint.responseContext } });
|
|
150
155
|
}
|
|
151
156
|
return endpoint.responseFinalizer?.(response) ?? response;
|
|
152
157
|
} catch (err) {
|
|
@@ -56,7 +56,7 @@ export class WebTransformer {
|
|
|
56
56
|
config.name = '';
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
if (paramType.key === 'managed') {
|
|
59
|
+
if (paramType.key === 'managed' && paramType.importName.startsWith('@travetto/')) {
|
|
60
60
|
if (paramType.name === 'WebResponse') {
|
|
61
61
|
throw new Error(`${paramType.name} must be registered using @ContextParam`);
|
|
62
62
|
} else if (paramType.name === 'WebRequest') {
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { Injectable, Inject } from '@travetto/di';
|
|
2
|
-
import { Config } from '@travetto/config';
|
|
3
|
-
|
|
4
|
-
import { WebChainedContext } from '../types/filter.ts';
|
|
5
|
-
import { WebInterceptor, WebInterceptorContext } from '../types/interceptor.ts';
|
|
6
|
-
import { WebInterceptorCategory } from '../types/core.ts';
|
|
7
|
-
import { WebResponse } from '../types/response.ts';
|
|
8
|
-
|
|
9
|
-
import { WebCommonUtil } from '../util/common.ts';
|
|
10
|
-
|
|
11
|
-
import { EtagInterceptor } from './etag.ts';
|
|
12
|
-
|
|
13
|
-
@Config('web.cache')
|
|
14
|
-
export class ResponseCacheConfig {
|
|
15
|
-
/**
|
|
16
|
-
* Generate response cache headers
|
|
17
|
-
*/
|
|
18
|
-
applies = true;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Determines how we cache
|
|
22
|
-
*/
|
|
23
|
-
mode: 'allow' | 'deny' = 'deny';
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Determines if we should cache all get requests
|
|
28
|
-
*/
|
|
29
|
-
@Injectable()
|
|
30
|
-
export class ResponseCacheInterceptor implements WebInterceptor {
|
|
31
|
-
|
|
32
|
-
category: WebInterceptorCategory = 'response';
|
|
33
|
-
dependsOn = [EtagInterceptor];
|
|
34
|
-
|
|
35
|
-
@Inject()
|
|
36
|
-
config: ResponseCacheConfig;
|
|
37
|
-
|
|
38
|
-
applies({ config, endpoint }: WebInterceptorContext<ResponseCacheConfig>): boolean {
|
|
39
|
-
return !!endpoint.cacheable && config.applies && config.mode === 'deny';
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async filter({ next }: WebChainedContext<ResponseCacheConfig>): Promise<WebResponse> {
|
|
43
|
-
const response = await next();
|
|
44
|
-
response.headers.setIfAbsent('Cache-Control', WebCommonUtil.getCacheControlValue(0));
|
|
45
|
-
return response;
|
|
46
|
-
}
|
|
47
|
-
}
|