@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.
Files changed (50) hide show
  1. package/README.md +734 -0
  2. package/__index__.ts +44 -0
  3. package/package.json +66 -0
  4. package/src/common/global.ts +30 -0
  5. package/src/config.ts +18 -0
  6. package/src/context.ts +49 -0
  7. package/src/decorator/common.ts +87 -0
  8. package/src/decorator/controller.ts +13 -0
  9. package/src/decorator/endpoint.ts +102 -0
  10. package/src/decorator/param.ts +64 -0
  11. package/src/interceptor/accept.ts +70 -0
  12. package/src/interceptor/body-parse.ts +123 -0
  13. package/src/interceptor/compress.ts +119 -0
  14. package/src/interceptor/context.ts +23 -0
  15. package/src/interceptor/cookies.ts +97 -0
  16. package/src/interceptor/cors.ts +94 -0
  17. package/src/interceptor/decompress.ts +91 -0
  18. package/src/interceptor/etag.ts +99 -0
  19. package/src/interceptor/logging.ts +71 -0
  20. package/src/interceptor/respond.ts +26 -0
  21. package/src/interceptor/response-cache.ts +47 -0
  22. package/src/interceptor/trust-proxy.ts +53 -0
  23. package/src/registry/controller.ts +288 -0
  24. package/src/registry/types.ts +229 -0
  25. package/src/registry/visitor.ts +52 -0
  26. package/src/router/base.ts +67 -0
  27. package/src/router/standard.ts +59 -0
  28. package/src/types/cookie.ts +18 -0
  29. package/src/types/core.ts +33 -0
  30. package/src/types/dispatch.ts +23 -0
  31. package/src/types/error.ts +10 -0
  32. package/src/types/filter.ts +7 -0
  33. package/src/types/headers.ts +108 -0
  34. package/src/types/interceptor.ts +54 -0
  35. package/src/types/message.ts +33 -0
  36. package/src/types/request.ts +22 -0
  37. package/src/types/response.ts +20 -0
  38. package/src/util/body.ts +220 -0
  39. package/src/util/common.ts +142 -0
  40. package/src/util/cookie.ts +145 -0
  41. package/src/util/endpoint.ts +277 -0
  42. package/src/util/mime.ts +36 -0
  43. package/src/util/net.ts +61 -0
  44. package/support/test/dispatch-util.ts +90 -0
  45. package/support/test/dispatcher.ts +15 -0
  46. package/support/test/suite/base.ts +61 -0
  47. package/support/test/suite/controller.ts +103 -0
  48. package/support/test/suite/schema.ts +275 -0
  49. package/support/test/suite/standard.ts +178 -0
  50. 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
+ }
@@ -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
+ }