@travetto/web 6.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +734 -0
- package/__index__.ts +44 -0
- package/package.json +66 -0
- package/src/common/global.ts +30 -0
- package/src/config.ts +18 -0
- package/src/context.ts +49 -0
- package/src/decorator/common.ts +87 -0
- package/src/decorator/controller.ts +13 -0
- package/src/decorator/endpoint.ts +102 -0
- package/src/decorator/param.ts +64 -0
- package/src/interceptor/accept.ts +70 -0
- package/src/interceptor/body-parse.ts +123 -0
- package/src/interceptor/compress.ts +119 -0
- package/src/interceptor/context.ts +23 -0
- package/src/interceptor/cookies.ts +97 -0
- package/src/interceptor/cors.ts +94 -0
- package/src/interceptor/decompress.ts +91 -0
- package/src/interceptor/etag.ts +99 -0
- package/src/interceptor/logging.ts +71 -0
- package/src/interceptor/respond.ts +26 -0
- package/src/interceptor/response-cache.ts +47 -0
- package/src/interceptor/trust-proxy.ts +53 -0
- package/src/registry/controller.ts +288 -0
- package/src/registry/types.ts +229 -0
- package/src/registry/visitor.ts +52 -0
- package/src/router/base.ts +67 -0
- package/src/router/standard.ts +59 -0
- package/src/types/cookie.ts +18 -0
- package/src/types/core.ts +33 -0
- package/src/types/dispatch.ts +23 -0
- package/src/types/error.ts +10 -0
- package/src/types/filter.ts +7 -0
- package/src/types/headers.ts +108 -0
- package/src/types/interceptor.ts +54 -0
- package/src/types/message.ts +33 -0
- package/src/types/request.ts +22 -0
- package/src/types/response.ts +20 -0
- package/src/util/body.ts +220 -0
- package/src/util/common.ts +142 -0
- package/src/util/cookie.ts +145 -0
- package/src/util/endpoint.ts +277 -0
- package/src/util/mime.ts +36 -0
- package/src/util/net.ts +61 -0
- package/support/test/dispatch-util.ts +90 -0
- package/support/test/dispatcher.ts +15 -0
- package/support/test/suite/base.ts +61 -0
- package/support/test/suite/controller.ts +103 -0
- package/support/test/suite/schema.ts +275 -0
- package/support/test/suite/standard.ts +178 -0
- package/support/transformer.web.ts +207 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import router from 'find-my-way';
|
|
2
|
+
|
|
3
|
+
import { AppError } from '@travetto/runtime';
|
|
4
|
+
import { Inject, Injectable } from '@travetto/di';
|
|
5
|
+
|
|
6
|
+
import { EndpointConfig } from '../registry/types.ts';
|
|
7
|
+
|
|
8
|
+
import { WebResponse } from '../types/response.ts';
|
|
9
|
+
import { HTTP_METHODS } from '../types/core.ts';
|
|
10
|
+
import type { WebFilterContext } from '../types/filter.ts';
|
|
11
|
+
import { WebConfig } from '../config.ts';
|
|
12
|
+
|
|
13
|
+
import { BaseWebRouter } from './base.ts';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_HTTP_METHOD = 'POST';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The web router
|
|
19
|
+
*/
|
|
20
|
+
@Injectable()
|
|
21
|
+
export class StandardWebRouter extends BaseWebRouter {
|
|
22
|
+
|
|
23
|
+
@Inject()
|
|
24
|
+
config: WebConfig;
|
|
25
|
+
|
|
26
|
+
#cache = new Map<Function, EndpointConfig>();
|
|
27
|
+
raw = router();
|
|
28
|
+
|
|
29
|
+
async register(endpoints: EndpointConfig[]): Promise<() => void> {
|
|
30
|
+
for (const ep of endpoints) {
|
|
31
|
+
const fullPath = ep.fullPath.replace(/[*][^*]+/g, '*'); // Flatten wildcards
|
|
32
|
+
const handler = (): void => { };
|
|
33
|
+
this.#cache.set(handler, ep);
|
|
34
|
+
this.raw[HTTP_METHODS[ep.httpMethod ?? DEFAULT_HTTP_METHOD].lower](fullPath, handler);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (): void => {
|
|
38
|
+
for (const ep of endpoints ?? []) {
|
|
39
|
+
this.raw.off(ep.httpMethod ?? DEFAULT_HTTP_METHOD, ep.fullPath);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Route and execute the request
|
|
46
|
+
*/
|
|
47
|
+
async dispatch({ request }: WebFilterContext): Promise<WebResponse> {
|
|
48
|
+
const httpMethod = request.context.httpMethod ?? DEFAULT_HTTP_METHOD;
|
|
49
|
+
const { params, handler } = this.raw.find(httpMethod, request.context.path) ?? {};
|
|
50
|
+
const endpoint = this.#cache.get(handler!);
|
|
51
|
+
if (!endpoint) {
|
|
52
|
+
return new WebResponse({
|
|
53
|
+
body: new AppError(`Unknown endpoint ${httpMethod} ${request.context.path}`, { category: 'notfound' }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
Object.assign(request.context, { pathParams: params });
|
|
57
|
+
return endpoint.filter!({ request });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Cookie = {
|
|
2
|
+
name: string;
|
|
3
|
+
value?: string;
|
|
4
|
+
expires?: Date;
|
|
5
|
+
signed?: boolean;
|
|
6
|
+
maxAge?: number;
|
|
7
|
+
path?: string;
|
|
8
|
+
domain?: string;
|
|
9
|
+
priority?: 'low' | 'medium' | 'high';
|
|
10
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
11
|
+
secure?: boolean;
|
|
12
|
+
httpOnly?: boolean;
|
|
13
|
+
partitioned?: boolean;
|
|
14
|
+
response?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type CookieGetOptions = { signed?: boolean };
|
|
18
|
+
export type CookieSetOptions = Omit<Cookie, 'name' | 'value'>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
type MethodConfig = { body: boolean, emptyStatusCode: number, cacheable: boolean };
|
|
2
|
+
function verb<
|
|
3
|
+
M extends string,
|
|
4
|
+
L extends string,
|
|
5
|
+
C extends Partial<MethodConfig>
|
|
6
|
+
>(method: M, lower: L, cfg: C): { method: M, lower: L } & C & MethodConfig {
|
|
7
|
+
return { body: false, cacheable: false, emptyStatusCode: 204, ...cfg, method, lower, };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const HTTP_METHODS = {
|
|
11
|
+
PUT: verb('PUT', 'put', { body: true }),
|
|
12
|
+
POST: verb('POST', 'post', { body: true, emptyStatusCode: 201 }),
|
|
13
|
+
PATCH: verb('PATCH', 'patch', { body: true }),
|
|
14
|
+
GET: verb('GET', 'get', { cacheable: true }),
|
|
15
|
+
DELETE: verb('DELETE', 'delete', {}),
|
|
16
|
+
HEAD: verb('HEAD', 'head', { cacheable: true }),
|
|
17
|
+
OPTIONS: verb('OPTIONS', 'options', {}),
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type HttpMethod = keyof typeof HTTP_METHODS;
|
|
21
|
+
export type HttpProtocol = 'http' | 'https';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* High level categories with a defined ordering
|
|
25
|
+
*/
|
|
26
|
+
export const WEB_INTERCEPTOR_CATEGORIES = [
|
|
27
|
+
'global', 'terminal', 'pre-request', 'request', 'response', 'application'
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* High level categories with a defined ordering
|
|
32
|
+
*/
|
|
33
|
+
export type WebInterceptorCategory = (typeof WEB_INTERCEPTOR_CATEGORIES)[number];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ControllerConfig, EndpointConfig } from '../registry/types.ts';
|
|
2
|
+
import { WebFilter } from './filter.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Defines the shape for a web dispatcher
|
|
6
|
+
* @concrete
|
|
7
|
+
*/
|
|
8
|
+
export interface WebDispatcher {
|
|
9
|
+
/**
|
|
10
|
+
* Dispatch a request, and return a promise when completed
|
|
11
|
+
*/
|
|
12
|
+
dispatch: WebFilter;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Web router pattern
|
|
17
|
+
*/
|
|
18
|
+
export interface WebRouter extends WebDispatcher {
|
|
19
|
+
/**
|
|
20
|
+
* Register a controller with the prepared endpoints
|
|
21
|
+
*/
|
|
22
|
+
register(endpoints: EndpointConfig[], controller: ControllerConfig): Promise<() => void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AnyMap, AppError, ErrorCategory } from '@travetto/runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Web Error
|
|
5
|
+
*/
|
|
6
|
+
export class WebError extends AppError<{ statusCode?: number }> {
|
|
7
|
+
static for(message: string, code: number, details?: AnyMap, category: ErrorCategory = 'data'): WebError {
|
|
8
|
+
return new WebError(message, { category, details: { ...details, statusCode: code } });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { WebResponse } from './response.ts';
|
|
2
|
+
import { WebRequest } from './request.ts';
|
|
3
|
+
|
|
4
|
+
export type WebFilterContext<C = {}> = { request: WebRequest } & C;
|
|
5
|
+
export type WebFilter<C extends WebFilterContext = WebFilterContext> = (context: C) => Promise<WebResponse>;
|
|
6
|
+
export type WebChainedContext<C = unknown> = WebFilterContext<{ next: () => Promise<WebResponse>, config: C }>;
|
|
7
|
+
export type WebChainedFilter<C = unknown> = WebFilter<WebChainedContext<C>>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Any, ByteRange, castTo } from '@travetto/runtime';
|
|
2
|
+
import { MimeType, MimeUtil } from '../util/mime.ts';
|
|
3
|
+
|
|
4
|
+
type Prim = number | boolean | string;
|
|
5
|
+
type HeaderValue = Prim | Prim[] | readonly Prim[];
|
|
6
|
+
export type WebHeadersInit = Headers | Record<string, undefined | null | HeaderValue> | [string, HeaderValue][];
|
|
7
|
+
|
|
8
|
+
const FILENAME_EXTRACT = /filename[*]?=["]?([^";]*)["]?/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Simple Headers wrapper with additional logic for common patterns
|
|
12
|
+
*/
|
|
13
|
+
export class WebHeaders extends Headers {
|
|
14
|
+
|
|
15
|
+
#parsedType?: MimeType;
|
|
16
|
+
|
|
17
|
+
constructor(o?: WebHeadersInit) {
|
|
18
|
+
const passed = (o instanceof Headers);
|
|
19
|
+
super(passed ? o : undefined);
|
|
20
|
+
|
|
21
|
+
if (o && !passed) {
|
|
22
|
+
for (const [k, v] of (Array.isArray(o) ? o : Object.entries(o))) {
|
|
23
|
+
if (v !== undefined && v !== null) {
|
|
24
|
+
this.append(k, castTo(v));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Set if key not already set */
|
|
31
|
+
setIfAbsent(key: string, value: string): void {
|
|
32
|
+
if (!this.has(key)) {
|
|
33
|
+
this.set(key, value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get a header value as a list, breaking on commas except for cookies
|
|
39
|
+
*/
|
|
40
|
+
getList(key: string): string[] | undefined {
|
|
41
|
+
const v = this.get(key);
|
|
42
|
+
if (!v) {
|
|
43
|
+
return;
|
|
44
|
+
} else if (v.toLowerCase() === 'set-cookie') {
|
|
45
|
+
return this.getSetCookie();
|
|
46
|
+
}
|
|
47
|
+
return v.split(key === 'cookie' ? /\s{0,3};\s{0,3}/ : /\s{0,3},\s{0,3}/);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// @ts-expect-error
|
|
51
|
+
forEach(set: (v: string | string[], k: string, headers: WebHeaders) => void): void;
|
|
52
|
+
forEach(set: (v: Any, k: string, headers: WebHeaders) => void): void;
|
|
53
|
+
forEach(set: (v: string | string[], k: string, headers: WebHeaders) => void): void {
|
|
54
|
+
for (const [k, v] of this.entries()) {
|
|
55
|
+
set(k === 'set-cookie' ? this.getSetCookie() : v, k, this);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
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
|
+
/**
|
|
96
|
+
* Set header value with a prefix
|
|
97
|
+
*/
|
|
98
|
+
setWithPrefix(key: string, value: string | undefined, prefix: string = ''): void {
|
|
99
|
+
value ? this.set(key, `${prefix} ${value}`.trim()) : this.delete(key);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get with prefix
|
|
104
|
+
*/
|
|
105
|
+
getWithPrefix(key: string, prefix: string = ''): string | undefined {
|
|
106
|
+
return this.get(key)?.replace(prefix, '').trim();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Class } from '@travetto/runtime';
|
|
2
|
+
|
|
3
|
+
import type { EndpointConfig } from '../registry/types.ts';
|
|
4
|
+
import type { WebChainedContext } from './filter.ts';
|
|
5
|
+
import { WebResponse } from './response.ts';
|
|
6
|
+
import { WebInterceptorCategory } from './core.ts';
|
|
7
|
+
|
|
8
|
+
export type WebInterceptorContext<C = unknown> = { endpoint: EndpointConfig, config: C };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Web interceptor structure
|
|
12
|
+
*
|
|
13
|
+
* @concrete
|
|
14
|
+
*/
|
|
15
|
+
export interface WebInterceptor<C = unknown> {
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The category an interceptor belongs to
|
|
19
|
+
*/
|
|
20
|
+
category: WebInterceptorCategory;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Config for interceptor
|
|
24
|
+
*/
|
|
25
|
+
config?: Readonly<C>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This interceptor must run after these
|
|
29
|
+
*/
|
|
30
|
+
dependsOn?: Class<WebInterceptor>[];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* This interceptor must run before these
|
|
34
|
+
*/
|
|
35
|
+
runsBefore?: Class<WebInterceptor>[];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Determines the current endpoint is applicable for the interceptor
|
|
39
|
+
* @param endpoint The endpoint to check
|
|
40
|
+
* @param config The root configuration
|
|
41
|
+
*/
|
|
42
|
+
applies?(context: WebInterceptorContext<C>): boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Finalize config before use
|
|
46
|
+
*/
|
|
47
|
+
finalizeConfig?(context: WebInterceptorContext<C>, inputs: Partial<C>[]): C;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Process the request
|
|
51
|
+
* @param {WebChainedContext} context The context of to process
|
|
52
|
+
*/
|
|
53
|
+
filter(context: WebChainedContext<C>): Promise<WebResponse>;
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { castTo } from '@travetto/runtime';
|
|
3
|
+
import { WebHeaders, WebHeadersInit } from './headers';
|
|
4
|
+
|
|
5
|
+
export type WebBinaryBody = Readable | Buffer;
|
|
6
|
+
|
|
7
|
+
export interface WebMessageInit<B = unknown, C = unknown> {
|
|
8
|
+
context?: C;
|
|
9
|
+
headers?: WebHeadersInit;
|
|
10
|
+
body?: B;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
export interface WebMessage<B = unknown, C = unknown> {
|
|
15
|
+
readonly context: C;
|
|
16
|
+
readonly headers: WebHeaders;
|
|
17
|
+
body?: B;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Common implementation for a rudimentary web message (request / response)
|
|
22
|
+
*/
|
|
23
|
+
export class BaseWebMessage<B = unknown, C = unknown> implements WebMessage<B, C> {
|
|
24
|
+
readonly context: C;
|
|
25
|
+
readonly headers: WebHeaders;
|
|
26
|
+
body?: B;
|
|
27
|
+
|
|
28
|
+
constructor(o: WebMessageInit<B, C> = {}) {
|
|
29
|
+
this.context = o.context ?? castTo<C>({});
|
|
30
|
+
this.headers = new WebHeaders(o.headers);
|
|
31
|
+
this.body = o.body;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { HttpMethod, HttpProtocol } from './core.ts';
|
|
2
|
+
import { BaseWebMessage } from './message.ts';
|
|
3
|
+
|
|
4
|
+
export interface WebConnection {
|
|
5
|
+
host?: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
ip?: string;
|
|
8
|
+
httpProtocol?: HttpProtocol;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WebRequestContext {
|
|
12
|
+
path: string;
|
|
13
|
+
pathParams?: Record<string, unknown>;
|
|
14
|
+
httpQuery?: Record<string, unknown>;
|
|
15
|
+
httpMethod?: HttpMethod;
|
|
16
|
+
connection?: WebConnection;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Web Request object
|
|
21
|
+
*/
|
|
22
|
+
export class WebRequest<B = unknown> extends BaseWebMessage<B, Readonly<WebRequestContext>> { }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseWebMessage } from './message.ts';
|
|
2
|
+
|
|
3
|
+
export interface WebResponseContext {
|
|
4
|
+
httpStatusCode?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Web Response as a simple object
|
|
9
|
+
*/
|
|
10
|
+
export class WebResponse<B = unknown> extends BaseWebMessage<B, WebResponseContext> {
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the redirect
|
|
14
|
+
* @param location Location to redirect to
|
|
15
|
+
* @param statusCode Status code
|
|
16
|
+
*/
|
|
17
|
+
static redirect(location: string, statusCode = 302): WebResponse<undefined> {
|
|
18
|
+
return new WebResponse({ context: { httpStatusCode: statusCode }, headers: { Location: location } });
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/util/body.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import iconv from 'iconv-lite';
|
|
2
|
+
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { buffer as toBuffer } from 'node:stream/consumers';
|
|
5
|
+
|
|
6
|
+
import { Any, BinaryUtil, castTo, hasToJSON, Util } from '@travetto/runtime';
|
|
7
|
+
|
|
8
|
+
import { WebBinaryBody, WebMessage } from '../types/message.ts';
|
|
9
|
+
import { WebHeaders } from '../types/headers.ts';
|
|
10
|
+
import { WebError } from '../types/error.ts';
|
|
11
|
+
|
|
12
|
+
const WebRawStreamSymbol = Symbol();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Utility classes for supporting web body operations
|
|
16
|
+
*/
|
|
17
|
+
export class WebBodyUtil {
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert a node binary input to a buffer
|
|
21
|
+
*/
|
|
22
|
+
static async toBuffer(src: WebBinaryBody): Promise<Buffer> {
|
|
23
|
+
return Buffer.isBuffer(src) ? src : toBuffer(src);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert a node binary input to a readable
|
|
28
|
+
*/
|
|
29
|
+
static toReadable(src: WebBinaryBody): Readable {
|
|
30
|
+
return Buffer.isBuffer(src) ? Readable.from(src) : src;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate multipart body
|
|
35
|
+
*/
|
|
36
|
+
static async * buildMultiPartBody(form: FormData, boundary: string): AsyncIterable<string | Buffer> {
|
|
37
|
+
const nl = '\r\n';
|
|
38
|
+
for (const [k, v] of form.entries()) {
|
|
39
|
+
const data = v.slice();
|
|
40
|
+
const filename = data instanceof File ? data.name : undefined;
|
|
41
|
+
const size = data instanceof Blob ? data.size : data.length;
|
|
42
|
+
const type = data instanceof Blob ? data.type : undefined;
|
|
43
|
+
yield `--${boundary}${nl}`;
|
|
44
|
+
yield `Content-Disposition: form-data; name="${k}"; filename="${filename ?? k}"${nl}`;
|
|
45
|
+
yield `Content-Length: ${size}${nl}`;
|
|
46
|
+
if (type) {
|
|
47
|
+
yield `Content-Type: ${type}${nl}`;
|
|
48
|
+
}
|
|
49
|
+
yield nl;
|
|
50
|
+
if (data instanceof Blob) {
|
|
51
|
+
for await (const chunk of data.stream()) {
|
|
52
|
+
yield chunk;
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
yield data;
|
|
56
|
+
}
|
|
57
|
+
yield nl;
|
|
58
|
+
}
|
|
59
|
+
yield `--${boundary}--${nl}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get Blob Headers */
|
|
63
|
+
static getBlobHeaders(value: Blob): [string, string][] {
|
|
64
|
+
const meta = BinaryUtil.getBlobMeta(value);
|
|
65
|
+
|
|
66
|
+
const toAdd: [string, string | undefined][] = [
|
|
67
|
+
['Content-Type', value.type],
|
|
68
|
+
['Content-Length', `${value.size}`],
|
|
69
|
+
['Content-Encoding', meta?.contentEncoding],
|
|
70
|
+
['Cache-Control', meta?.cacheControl],
|
|
71
|
+
['Content-Language', meta?.contentLanguage],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
if (meta?.range) {
|
|
75
|
+
toAdd.push(
|
|
76
|
+
['Accept-Ranges', 'bytes'],
|
|
77
|
+
['Content-Range', `bytes ${meta.range.start}-${meta.range.end}/${meta.size}`],
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (value instanceof File && value.name) {
|
|
82
|
+
toAdd.push(['Content-disposition', `attachment; filename="${value.name}"`]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return toAdd.filter((x): x is [string, string] => !!x[1]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build WebResponse based on return value
|
|
90
|
+
*/
|
|
91
|
+
static defaultContentType(value: unknown): string {
|
|
92
|
+
if (value === undefined || value === null) {
|
|
93
|
+
return '';
|
|
94
|
+
} else if (typeof value === 'string') {
|
|
95
|
+
return 'text/plain';
|
|
96
|
+
} else if (BinaryUtil.isBinaryType(value)) {
|
|
97
|
+
return 'application/octet-stream';
|
|
98
|
+
} else if (value instanceof FormData) {
|
|
99
|
+
return 'multipart/form-data';
|
|
100
|
+
} else {
|
|
101
|
+
return 'application/json';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Convert an existing web message to a binary web message
|
|
107
|
+
*/
|
|
108
|
+
static toBinaryMessage(message: WebMessage): Omit<WebMessage<WebBinaryBody>, 'context'> {
|
|
109
|
+
const body = message.body;
|
|
110
|
+
if (Buffer.isBuffer(body) || BinaryUtil.isReadable(body)) {
|
|
111
|
+
return castTo(message);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const out: Omit<WebMessage<WebBinaryBody>, 'context'> = { headers: new WebHeaders(message.headers), body: null! };
|
|
115
|
+
if (body instanceof Blob) {
|
|
116
|
+
for (const [k, v] of this.getBlobHeaders(body)) {
|
|
117
|
+
out.headers.set(k, v);
|
|
118
|
+
}
|
|
119
|
+
out.body = Readable.fromWeb(body.stream());
|
|
120
|
+
} else if (body instanceof FormData) {
|
|
121
|
+
const boundary = `${'-'.repeat(24)}-multipart-${Util.uuid()}`;
|
|
122
|
+
out.headers.set('Content-Type', `multipart/form-data; boundary=${boundary}`);
|
|
123
|
+
out.body = Readable.from(this.buildMultiPartBody(body, boundary));
|
|
124
|
+
} else if (BinaryUtil.isReadableStream(body)) {
|
|
125
|
+
out.body = Readable.fromWeb(body);
|
|
126
|
+
} else if (BinaryUtil.isAsyncIterable(body)) {
|
|
127
|
+
out.body = Readable.from(body);
|
|
128
|
+
} else if (body === null || body === undefined) {
|
|
129
|
+
out.body = Buffer.alloc(0);
|
|
130
|
+
} else if (BinaryUtil.isArrayBuffer(body)) {
|
|
131
|
+
out.body = Buffer.from(body);
|
|
132
|
+
} else {
|
|
133
|
+
let text: string;
|
|
134
|
+
if (typeof body === 'string') {
|
|
135
|
+
text = body;
|
|
136
|
+
} else if (hasToJSON(body)) {
|
|
137
|
+
text = JSON.stringify(body.toJSON());
|
|
138
|
+
} else if (body instanceof Error) {
|
|
139
|
+
text = JSON.stringify({ message: body.message });
|
|
140
|
+
} else {
|
|
141
|
+
text = JSON.stringify(body);
|
|
142
|
+
}
|
|
143
|
+
out.body = Buffer.from(text, 'utf-8');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (Buffer.isBuffer(out.body)) {
|
|
147
|
+
out.headers.set('Content-Length', `${out.body.byteLength}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
out.headers.setIfAbsent('Content-Type', this.defaultContentType(message.body));
|
|
151
|
+
|
|
152
|
+
return castTo(out);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Set body and mark as unprocessed
|
|
157
|
+
*/
|
|
158
|
+
static markRaw(val: WebBinaryBody | undefined): typeof val {
|
|
159
|
+
if (val) {
|
|
160
|
+
Object.defineProperty(val, WebRawStreamSymbol, { value: val });
|
|
161
|
+
}
|
|
162
|
+
return val;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Is the input raw
|
|
167
|
+
*/
|
|
168
|
+
static isRaw(val: unknown): val is WebBinaryBody {
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
170
|
+
return !!val && ((Buffer.isBuffer(val) || BinaryUtil.isReadable(val)) && (val as Any)[WebRawStreamSymbol] === val);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Simple parse support
|
|
175
|
+
*/
|
|
176
|
+
static parseBody(type: string, val: string): unknown {
|
|
177
|
+
switch (type) {
|
|
178
|
+
case 'text': return val;
|
|
179
|
+
case 'json': return JSON.parse(val);
|
|
180
|
+
case 'form': return Object.fromEntries(new URLSearchParams(val));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read text from an input source
|
|
186
|
+
*/
|
|
187
|
+
static async readText(input: Readable | Buffer, limit: number, encoding?: string): Promise<{ text: string, read: number }> {
|
|
188
|
+
encoding ??= (Buffer.isBuffer(input) ? undefined : input.readableEncoding) ?? 'utf-8';
|
|
189
|
+
|
|
190
|
+
if (!iconv.encodingExists(encoding)) {
|
|
191
|
+
throw WebError.for('Specified Encoding Not Supported', 415, { encoding });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (Buffer.isBuffer(input)) {
|
|
195
|
+
return { text: iconv.decode(input, encoding), read: input.byteLength };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let received = Buffer.isBuffer(input) ? input.byteOffset : 0;
|
|
199
|
+
const decoder = iconv.getDecoder(encoding);
|
|
200
|
+
const all: string[] = [];
|
|
201
|
+
|
|
202
|
+
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);
|
|
205
|
+
if (received > limit) {
|
|
206
|
+
throw WebError.for('Request Entity Too Large', 413, { received, limit });
|
|
207
|
+
}
|
|
208
|
+
all.push(decoder.write(chunk));
|
|
209
|
+
}
|
|
210
|
+
all.push(decoder.end() ?? '');
|
|
211
|
+
return { text: all.join(''), read: received };
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
214
|
+
throw WebError.for('Request Aborted', 400, { received });
|
|
215
|
+
} else {
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|