@travetto/web 7.1.4 → 8.0.0-alpha.0

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/src/util/body.ts CHANGED
@@ -1,72 +1,74 @@
1
1
  import { TextDecoder } from 'node:util';
2
- import { Readable } from 'node:stream';
3
2
 
4
- import { type Any, BinaryUtil, castTo, hasToJSON, JSONUtil, Util } from '@travetto/runtime';
3
+ import { type BinaryType, BinaryUtil, type BinaryArray, castTo, Util, CodecUtil, hasToJSON, BinaryMetadataUtil, JSONUtil } from '@travetto/runtime';
5
4
 
6
- import type { WebBinaryBody, WebMessage } from '../types/message.ts';
5
+ import type { WebMessage } from '../types/message.ts';
7
6
  import { WebHeaders } from '../types/headers.ts';
8
7
  import { WebError } from '../types/error.ts';
9
8
 
10
- const WebRawStreamSymbol = Symbol();
9
+ const WebRawBinarySymbol = Symbol();
10
+
11
+ const NULL_TERMINATOR = BinaryUtil.makeBinaryArray(0);
11
12
 
12
13
  /**
13
14
  * Utility classes for supporting web body operations
14
15
  */
15
16
  export class WebBodyUtil {
16
17
 
18
+ /** Get Metadata Headers */
19
+ static getMetadataHeaders(value: BinaryType): [string, string][] {
20
+ const metadata = BinaryMetadataUtil.read(value);
21
+ const length = BinaryMetadataUtil.readLength(metadata);
22
+
23
+ const result = [
24
+ ['Content-Type', metadata.contentType],
25
+ ['Content-Length', length],
26
+ ['Content-Encoding', metadata.contentEncoding],
27
+ ['Cache-Control', metadata.cacheControl],
28
+ ['Content-Language', metadata.contentLanguage],
29
+ ...(metadata.range ? [['Accept-Ranges', 'bytes']] : []),
30
+ ...(metadata.range ? [['Content-Range', `bytes ${metadata.range.start}-${metadata.range.end}/${metadata.size}`]] : []),
31
+ ...(metadata.filename ? [['Content-Disposition', `attachment; filename="${metadata.filename}"`]] : [])
32
+ ]
33
+ .map((pair) => [pair[0], pair[1]?.toString()])
34
+ .filter((pair): pair is [string, string] => pair[1] !== undefined);
35
+
36
+ return result;
37
+ }
38
+
17
39
  /**
18
40
  * Generate multipart body
19
41
  */
20
- static async * buildMultiPartBody(form: FormData, boundary: string): AsyncIterable<string | Buffer> {
21
- const nl = '\r\n';
22
- for (const [key, value] of form.entries()) {
23
- const data = value.slice();
24
- const filename = data instanceof File ? data.name : undefined;
25
- const size = data instanceof Blob ? data.size : data.length;
26
- const type = data instanceof Blob ? data.type : undefined;
27
- yield `--${boundary}${nl}`;
28
- yield `Content-Disposition: form-data; name="${key}"; filename="${filename ?? key}"${nl}`;
29
- yield `Content-Length: ${size}${nl}`;
30
- if (type) {
31
- yield `Content-Type: ${type}${nl}`;
42
+ static async * buildMultiPartBody(form: FormData, boundary: string): AsyncIterable<BinaryArray> {
43
+ const bytes = (value: string = '', suffix = '\r\n'): BinaryArray => CodecUtil.fromUTF8String(`${value}${suffix}`);
44
+ for (const [key, item] of form.entries()) {
45
+ yield bytes(`--${boundary}`);
46
+ const binaryValue = typeof item === 'string' ? CodecUtil.fromUTF8String(item) : item.slice();
47
+
48
+ // Headers
49
+ if (typeof item == 'string') {
50
+ BinaryMetadataUtil.write(binaryValue, { size: item.length });
32
51
  }
33
- yield nl;
34
- if (data instanceof Blob) {
35
- for await (const chunk of data.stream()) {
36
- yield chunk;
37
- }
38
- } else {
39
- yield data;
52
+
53
+ if (item instanceof File) {
54
+ yield bytes(`Content-Disposition: form-data; name="${key}"; filename="${item.name || key}"`);
40
55
  }
41
- yield nl;
42
- }
43
- yield `--${boundary}--${nl}`;
44
- }
45
56
 
46
- /** Get Blob Headers */
47
- static getBlobHeaders(value: Blob): [string, string][] {
48
- const meta = BinaryUtil.getBlobMeta(value);
49
-
50
- const toAdd: [string, string | undefined][] = [
51
- ['Content-Type', value.type],
52
- ['Content-Length', `${value.size}`],
53
- ['Content-Encoding', meta?.contentEncoding],
54
- ['Cache-Control', meta?.cacheControl],
55
- ['Content-Language', meta?.contentLanguage],
56
- ];
57
-
58
- if (meta?.range) {
59
- toAdd.push(
60
- ['Accept-Ranges', 'bytes'],
61
- ['Content-Range', `bytes ${meta.range.start}-${meta.range.end}/${meta.size}`],
62
- );
63
- }
57
+ for (const [header, value] of WebBodyUtil.getMetadataHeaders(binaryValue)) {
58
+ if (header.startsWith('Content-') && header !== 'Content-Disposition') {
59
+ yield bytes(`${header}: ${value}`);
60
+ }
61
+ }
64
62
 
65
- if (value instanceof File && value.name) {
66
- toAdd.push(['Content-disposition', `attachment; filename="${value.name}"`]);
63
+ yield bytes();
64
+ if (binaryValue instanceof Blob) {
65
+ yield* BinaryUtil.toBinaryStream(binaryValue);
66
+ } else {
67
+ yield binaryValue;
68
+ }
69
+ yield bytes();
67
70
  }
68
-
69
- return toAdd.filter((pair): pair is [string, string] => !!pair[1]);
71
+ yield bytes(`--${boundary}--`);
70
72
  }
71
73
 
72
74
  /**
@@ -89,49 +91,35 @@ export class WebBodyUtil {
89
91
  /**
90
92
  * Convert an existing web message to a binary web message
91
93
  */
92
- static toBinaryMessage(message: WebMessage): Omit<WebMessage<WebBinaryBody>, 'context'> {
93
- const body = message.body;
94
- if (Buffer.isBuffer(body) || BinaryUtil.isReadable(body)) {
95
- return castTo(message);
96
- }
94
+ static toBinaryMessage(message: WebMessage): Omit<WebMessage<BinaryType>, 'context'> {
95
+ const { body } = message;
96
+ const out: Omit<WebMessage<BinaryType>, 'context'> = { headers: new WebHeaders(message.headers), body: null! };
97
97
 
98
- const out: Omit<WebMessage<WebBinaryBody>, 'context'> = { headers: new WebHeaders(message.headers), body: null! };
99
- if (body instanceof Blob) {
100
- for (const [key, value] of this.getBlobHeaders(body)) {
98
+ if (BinaryUtil.isBinaryType(body)) {
99
+ for (const [key, value] of this.getMetadataHeaders(body)) {
101
100
  out.headers.set(key, value);
102
101
  }
103
- out.body = Readable.fromWeb(body.stream());
102
+ out.body = body;
104
103
  } else if (body instanceof FormData) {
105
104
  const boundary = `${'-'.repeat(24)}-multipart-${Util.uuid()}`;
106
105
  out.headers.set('Content-Type', `multipart/form-data; boundary=${boundary}`);
107
- out.body = Readable.from(this.buildMultiPartBody(body, boundary));
108
- } else if (BinaryUtil.isReadableStream(body)) {
109
- out.body = Readable.fromWeb(body);
110
- } else if (BinaryUtil.isAsyncIterable(body)) {
111
- out.body = Readable.from(body);
112
- } else if (body === null || body === undefined) {
113
- out.body = Buffer.alloc(0);
114
- } else if (BinaryUtil.isArrayBuffer(body)) {
115
- out.body = Buffer.from(body);
106
+ out.body = this.buildMultiPartBody(body, boundary);
116
107
  } else {
117
- let text: string;
108
+ let text: BinaryArray;
118
109
  if (typeof body === 'string') {
119
- text = body;
110
+ text = CodecUtil.fromUTF8String(body);
120
111
  } else if (hasToJSON(body)) {
121
- text = JSON.stringify(body.toJSON());
112
+ text = JSONUtil.toBinaryArray(body.toJSON());
122
113
  } else if (body instanceof Error) {
123
- text = JSON.stringify({ message: body.message });
114
+ text = JSONUtil.toBinaryArray({ message: body.message });
124
115
  } else {
125
- text = JSON.stringify(body);
116
+ text = JSONUtil.toBinaryArray(body);
126
117
  }
127
- out.body = Buffer.from(text, 'utf-8');
128
- }
129
-
130
- if (Buffer.isBuffer(out.body)) {
131
- out.headers.set('Content-Length', `${out.body.byteLength}`);
118
+ out.headers.set('Content-Length', `${text.byteLength}`);
119
+ out.body = text;
132
120
  }
133
121
 
134
- out.headers.setIfAbsent('Content-Type', this.defaultContentType(message.body));
122
+ out.headers.setIfAbsent('Content-Type', this.defaultContentType(body));
135
123
 
136
124
  return castTo(out);
137
125
  }
@@ -139,9 +127,9 @@ export class WebBodyUtil {
139
127
  /**
140
128
  * Set body and mark as unprocessed
141
129
  */
142
- static markRaw(body: WebBinaryBody | undefined): typeof body {
130
+ static markRawBinary(body: BinaryType | undefined): typeof body {
143
131
  if (body) {
144
- Object.defineProperty(body, WebRawStreamSymbol, { value: body });
132
+ Object.defineProperty(body, WebRawBinarySymbol, { value: body });
145
133
  }
146
134
  return body;
147
135
  }
@@ -149,9 +137,8 @@ export class WebBodyUtil {
149
137
  /**
150
138
  * Is the input raw
151
139
  */
152
- static isRaw(body: unknown): body is WebBinaryBody {
153
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
154
- return !!body && ((Buffer.isBuffer(body) || BinaryUtil.isReadable(body)) && (body as Any)[WebRawStreamSymbol] === body);
140
+ static isRawBinary(body: unknown): body is BinaryType {
141
+ return BinaryUtil.isBinaryType(body) && castTo<{ [WebRawBinarySymbol]: unknown }>(body)[WebRawBinarySymbol] === body;
155
142
  }
156
143
 
157
144
  /**
@@ -160,7 +147,7 @@ export class WebBodyUtil {
160
147
  static parseBody(type: string, body: string): unknown {
161
148
  switch (type) {
162
149
  case 'text': return body;
163
- case 'json': return JSONUtil.parseSafe(body);
150
+ case 'json': return JSONUtil.fromUTF8(body);
164
151
  case 'form': return Object.fromEntries(new URLSearchParams(body));
165
152
  }
166
153
  }
@@ -168,8 +155,8 @@ export class WebBodyUtil {
168
155
  /**
169
156
  * Read text from an input source
170
157
  */
171
- static async readText(input: Readable | Buffer, limit: number, encoding?: string): Promise<{ text: string, read: number }> {
172
- encoding ??= (Buffer.isBuffer(input) ? undefined : input.readableEncoding) ?? 'utf-8';
158
+ static async readText(input: BinaryType, limit: number, encoding?: string): Promise<{ text: string, read: number }> {
159
+ encoding ??= CodecUtil.detectEncoding(input) ?? 'utf-8';
173
160
 
174
161
  let decoder: TextDecoder;
175
162
  try {
@@ -178,26 +165,26 @@ export class WebBodyUtil {
178
165
  throw WebError.for('Specified Encoding Not Supported', 415, { encoding });
179
166
  }
180
167
 
181
- if (Buffer.isBuffer(input)) {
168
+ if (BinaryUtil.isBinaryArray(input)) {
182
169
  if (input.byteLength > limit) {
183
170
  throw WebError.for('Request Entity Too Large', 413, { received: input.byteLength, limit });
184
171
  }
185
172
  return { text: decoder.decode(input), read: input.byteLength };
186
173
  }
187
174
 
188
- let received = Buffer.isBuffer(input) ? input.byteOffset : 0;
175
+ let received = 0;
189
176
  const all: string[] = [];
190
177
 
191
178
  try {
192
- for await (const chunk of castTo<AsyncIterable<string | Buffer>>(input.iterator({ destroyOnReturn: false }))) {
193
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
194
- received += buffer.byteLength;
179
+ for await (const chunk of BinaryUtil.toBinaryStream(input)) {
180
+ const bytes = CodecUtil.readUtf8Chunk(chunk);
181
+ received += bytes.byteLength;
195
182
  if (received > limit) {
196
183
  throw WebError.for('Request Entity Too Large', 413, { received, limit });
197
184
  }
198
- all.push(decoder.decode(buffer, { stream: true }));
185
+ all.push(decoder.decode(bytes, { stream: true }));
199
186
  }
200
- all.push(decoder.decode(Buffer.alloc(0), { stream: false }));
187
+ all.push(decoder.decode(NULL_TERMINATOR, { stream: false }));
201
188
  return { text: all.join(''), read: received };
202
189
  } catch (error) {
203
190
  if (error instanceof Error && error.name === 'AbortError') {
@@ -1,4 +1,4 @@
1
- import { AppError, type ErrorCategory, Util } from '@travetto/runtime';
1
+ import { RuntimeError, type ErrorCategory, Util } from '@travetto/runtime';
2
2
 
3
3
  import { WebResponse } from '../types/response.ts';
4
4
  import type { WebRequest } from '../types/request.ts';
@@ -116,8 +116,8 @@ export class WebCommonUtil {
116
116
 
117
117
  const body = error instanceof Error ? error :
118
118
  (!!error && typeof error === 'object' && ('message' in error && typeof error.message === 'string')) ?
119
- new AppError(error.message, { details: error }) :
120
- new AppError(`${error}`);
119
+ new RuntimeError(error.message, { details: error }) :
120
+ new RuntimeError(`${error}`);
121
121
 
122
122
  const webError: Error & Partial<WebError> = body;
123
123
  const statusCode = webError.details?.statusCode ?? ERROR_CATEGORY_STATUS[webError.category!] ?? 500;
@@ -1,4 +1,4 @@
1
- import { AppError } from '@travetto/runtime';
1
+ import { RuntimeError } from '@travetto/runtime';
2
2
 
3
3
  import type { Cookie, CookieGetOptions, CookieSetOptions } from '../types/cookie.ts';
4
4
  import { KeyGrip } from './keygrip.ts';
@@ -7,40 +7,43 @@ import { WebHeaderUtil } from './header.ts';
7
7
  const pairText = (cookie: Cookie): string => `${cookie.name}=${cookie.value}`;
8
8
  const pair = (key: string, value: unknown): string => `${key}=${value}`;
9
9
 
10
- type CookieJarOptions = { keys?: string[] } & CookieSetOptions;
10
+ type CookieJarOptions = CookieSetOptions;
11
11
 
12
12
  export class CookieJar {
13
13
 
14
- #grip?: KeyGrip;
14
+ #grip: KeyGrip;
15
15
  #cookies: Record<string, Cookie> = {};
16
16
  #setOptions: CookieSetOptions = {};
17
17
  #deleteOptions: CookieSetOptions = { maxAge: 0, expires: undefined };
18
18
 
19
- constructor({ keys, ...options }: CookieJarOptions = {}) {
20
- this.#grip = keys?.length ? new KeyGrip(keys) : undefined;
19
+ constructor(options: CookieJarOptions = {}, keys?: string[] | KeyGrip) {
20
+ this.#grip = (keys instanceof KeyGrip ? keys : new KeyGrip(keys ?? []));
21
21
  this.#setOptions = {
22
22
  secure: false,
23
23
  path: '/',
24
- signed: !!keys?.length,
25
24
  ...options,
26
25
  };
27
26
  }
28
27
 
29
- #exportCookie(cookie: Cookie, response?: boolean): string[] {
28
+ get shouldSign(): boolean {
29
+ return this.#setOptions.signed ?? this.#grip.active;
30
+ }
31
+
32
+ async export(cookie: Cookie, response?: boolean): Promise<string[]> {
30
33
  const suffix = response ? WebHeaderUtil.buildCookieSuffix(cookie) : null;
31
34
  const payload = pairText(cookie);
32
35
  const out = suffix ? [[payload, ...suffix].join(';')] : [payload];
33
36
  if (cookie.signed) {
34
- const sigPair = pair(`${cookie.name}.sig`, this.#grip!.sign(payload));
37
+ const sigPair = pair(`${cookie.name}.sig`, await this.#grip.sign(payload));
35
38
  out.push(suffix ? [sigPair, ...suffix].join(';') : sigPair);
36
39
  }
37
40
  return out;
38
41
  }
39
42
 
40
- import(cookies: Cookie[]): this {
43
+ async import(cookies: Cookie[]): Promise<void> {
41
44
  const signatures: Record<string, string> = {};
42
45
  for (const cookie of cookies) {
43
- if (this.#setOptions.signed && cookie.name.endsWith('.sig')) {
46
+ if (this.shouldSign && cookie.name.endsWith('.sig')) {
44
47
  signatures[cookie.name.replace(/[.]sig$/, '')] = cookie.value!;
45
48
  } else {
46
49
  this.#cookies[cookie.name] = { signed: false, ...cookie };
@@ -55,19 +58,18 @@ export class CookieJar {
55
58
  cookie.signed = true;
56
59
 
57
60
  const computed = pairText(cookie);
58
- const index = this.#grip!.index(computed, value);
61
+ const result = await this.#grip.isValid(computed, value);
59
62
 
60
- if (index < 0) {
63
+ if (result === 'invalid') {
61
64
  delete this.#cookies[name];
62
- } else if (index >= 1) {
65
+ } else if (result === 'stale') {
63
66
  cookie.response = true;
64
67
  }
65
68
  }
66
- return this;
67
69
  }
68
70
 
69
71
  has(name: string, options: CookieGetOptions = {}): boolean {
70
- const needSigned = options.signed ?? this.#setOptions.signed;
72
+ const needSigned = options.signed ?? this.shouldSign;
71
73
  return name in this.#cookies && this.#cookies[name].signed === needSigned;
72
74
  }
73
75
 
@@ -78,45 +80,52 @@ export class CookieJar {
78
80
  }
79
81
 
80
82
  set(cookie: Cookie): void {
81
- const alias = this.#cookies[cookie.name] = {
83
+ const alias = {
82
84
  ...this.#setOptions,
83
85
  ...cookie,
84
86
  response: true,
85
87
  ...(cookie.value === null || cookie.value === undefined) ? this.#deleteOptions : {},
86
88
  };
87
89
 
90
+ alias.signed ??= this.shouldSign;
91
+
88
92
  if (!this.#setOptions.secure && alias.secure) {
89
- throw new AppError('Cannot send secure cookie over unencrypted connection');
93
+ throw new RuntimeError('Cannot send secure cookie over unencrypted connection');
90
94
  }
91
95
 
92
- if (alias.signed && !this.#grip) {
93
- throw new AppError('Signing keys required for signed cookies');
96
+ if (alias.signed && !this.#grip.active) {
97
+ throw new RuntimeError('Signing keys required for signed cookies');
94
98
  }
95
99
 
96
100
  if (alias.maxAge !== undefined && !alias.expires) {
97
101
  alias.expires = new Date(Date.now() + alias.maxAge);
98
102
  }
103
+
104
+ this.#cookies[cookie.name] = alias;
99
105
  }
100
106
 
101
107
  getAll(): Cookie[] {
102
108
  return Object.values(this.#cookies);
103
109
  }
104
110
 
105
- importCookieHeader(header: string | null | undefined): this {
111
+ importCookieHeader(header: string | null | undefined): Promise<void> {
106
112
  return this.import(WebHeaderUtil.parseCookieHeader(header ?? ''));
107
113
  }
108
114
 
109
- importSetCookieHeader(headers: string[] | null | undefined): this {
115
+ importSetCookieHeader(headers: string[] | null | undefined): Promise<void> {
110
116
  return this.import(headers?.map(WebHeaderUtil.parseSetCookieHeader) ?? []);
111
117
  }
112
118
 
113
- exportCookieHeader(): string {
114
- return this.getAll().flatMap(cookie => this.#exportCookie(cookie)).join('; ');
119
+ exportCookieHeader(): Promise<string> {
120
+ return Promise.all(this.getAll()
121
+ .map(cookie => this.export(cookie)))
122
+ .then(parts => parts.flat().join('; '));
115
123
  }
116
124
 
117
- exportSetCookieHeader(): string[] {
118
- return this.getAll()
125
+ exportSetCookieHeader(): Promise<string[]> {
126
+ return Promise.all(this.getAll()
119
127
  .filter(cookie => cookie.response)
120
- .flatMap(cookie => this.#exportCookie(cookie, true));
128
+ .map(cookie => this.export(cookie, true)))
129
+ .then(parts => parts.flat());
121
130
  }
122
131
  }
@@ -1,43 +1,63 @@
1
- import crypto from 'node:crypto';
2
- import { AppError, castKey } from '@travetto/runtime';
1
+ import { timingSafeEqual } from 'node:crypto';
3
2
 
4
- const CHAR_MAPPING = { '/': '_', '+': '-', '=': '' };
3
+ import { BinaryUtil, CodecUtil, type BinaryArray } from '@travetto/runtime';
5
4
 
6
- function timeSafeCompare(a: string, b: string): boolean {
7
- const key = crypto.randomBytes(32);
8
- const ah = crypto.createHmac('sha256', key).update(a).digest();
9
- const bh = crypto.createHmac('sha256', key).update(b).digest();
10
- return ah.length === bh.length && crypto.timingSafeEqual(ah, bh);
11
- }
5
+ const CHAR_MAPPING: Record<string, string> = { '/': '_', '+': '-', '=': '' };
12
6
 
13
7
  export class KeyGrip {
14
8
 
9
+ static async getRandomHmacKey(): Promise<CryptoKey> {
10
+ const keyBytes = crypto.getRandomValues(new Uint8Array(32));
11
+ return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
12
+ }
13
+
14
+ static async hmac(value: string, cryptoKey: CryptoKey): Promise<BinaryArray> {
15
+ const input = BinaryUtil.binaryArrayToBuffer(CodecUtil.fromUTF8String(value));
16
+ return BinaryUtil.binaryArrayToUint8Array(await crypto.subtle.sign('HMAC', cryptoKey, input));
17
+ }
18
+
15
19
  #keys: string[];
16
- #algorithm: string;
17
- #encoding: crypto.BinaryToTextEncoding;
20
+ #cryptoKeys = new Map<string, Promise<CryptoKey>>();
18
21
 
19
- constructor(keys: string[], algorithm = 'sha1', encoding: crypto.BinaryToTextEncoding = 'base64') {
20
- if (!keys.length) {
21
- throw new AppError('Keys must be defined');
22
- }
22
+ constructor(keys: string[]) {
23
23
  this.#keys = keys;
24
- this.#algorithm = algorithm;
25
- this.#encoding = encoding;
26
24
  }
27
25
 
28
- sign(data: string, key?: string): string {
29
- return crypto
30
- .createHmac(this.#algorithm, key ?? this.#keys[0])
31
- .update(data)
32
- .digest(this.#encoding)
33
- .replace(/[/+=]/g, ch => CHAR_MAPPING[castKey(ch)]);
26
+ get active(): boolean {
27
+ return this.#keys.length > 0;
34
28
  }
35
29
 
36
- verify(data: string, digest: string): boolean {
37
- return this.index(data, digest) > -1;
30
+ async getCryptoKey(key: string): Promise<CryptoKey> {
31
+ return this.#cryptoKeys.getOrInsertComputed(key, async () => {
32
+ const keyBytes = BinaryUtil.binaryArrayToBuffer(CodecUtil.fromUTF8String(key));
33
+ return crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
34
+ });
38
35
  }
39
36
 
40
- index(data: string, digest: string): number {
41
- return this.#keys.findIndex(key => timeSafeCompare(digest, this.sign(data, key)));
37
+ async sign(data: string, key?: string): Promise<string> {
38
+ const cryptoKey = await this.getCryptoKey(key ?? this.#keys[0]);
39
+ const signature = await KeyGrip.hmac(data, cryptoKey);
40
+ return CodecUtil.toBase64String(signature).replace(/[/+=]/g, ch => CHAR_MAPPING[ch]);
41
+ }
42
+
43
+ verify(data: string, digest: string): Promise<boolean> {
44
+ return this.isValid(data, digest).then(result => result !== 'invalid');
45
+ }
46
+
47
+ async isValid(data: string, digest: string): Promise<'valid' | 'invalid' | 'stale'> {
48
+ const key = await KeyGrip.getRandomHmacKey();
49
+ const digestBytes = BinaryUtil.binaryArrayToUint8Array(await KeyGrip.hmac(digest, key));
50
+
51
+ for (let i = 0; i < this.#keys.length; i++) {
52
+ const signedBytes = BinaryUtil.binaryArrayToUint8Array(await KeyGrip.hmac(await this.sign(data, this.#keys[i]), key));
53
+
54
+ if (
55
+ signedBytes.byteLength === digestBytes.byteLength &&
56
+ timingSafeEqual(digestBytes, signedBytes)
57
+ ) {
58
+ return i === 0 ? 'valid' : 'stale';
59
+ }
60
+ }
61
+ return 'invalid';
42
62
  }
43
63
  }
@@ -1,7 +1,4 @@
1
- import { buffer } from 'node:stream/consumers';
2
- import type { Readable } from 'node:stream';
3
-
4
- import { AppError, BinaryUtil, castTo, JSONUtil } from '@travetto/runtime';
1
+ import { BinaryUtil, castTo, type BinaryType, type BinaryArray, CodecUtil, JSONUtil } from '@travetto/runtime';
5
2
  import { BindUtil } from '@travetto/schema';
6
3
 
7
4
  import type { WebResponse } from '../../src/types/response.ts';
@@ -10,21 +7,26 @@ import { DecompressInterceptor } from '../../src/interceptor/decompress.ts';
10
7
  import { WebBodyUtil } from '../../src/util/body.ts';
11
8
  import { WebCommonUtil } from '../../src/util/common.ts';
12
9
 
13
- const toBuffer = (src: Buffer | Readable) => Buffer.isBuffer(src) ? src : buffer(src);
14
-
15
10
  /**
16
11
  * Utilities for supporting custom test dispatchers
17
12
  */
18
13
  export class WebTestDispatchUtil {
19
14
 
20
- static async applyRequestBody(request: WebRequest): Promise<WebRequest> {
15
+ static async applyRequestBody(request: WebRequest, toByteArray: true): Promise<WebRequest<BinaryArray>>;
16
+ static async applyRequestBody(request: WebRequest, toByteArray?: false): Promise<WebRequest<BinaryType>>;
17
+ static async applyRequestBody(request: WebRequest, toByteArray: boolean = false): Promise<WebRequest<BinaryType>> {
21
18
  if (request.body !== undefined) {
22
19
  const sample = WebBodyUtil.toBinaryMessage(request);
23
20
  sample.headers.forEach((v, k) => request.headers.set(k, Array.isArray(v) ? v.join(',') : v));
24
- request.body = WebBodyUtil.markRaw(await toBuffer(sample.body!));
21
+ if (toByteArray) {
22
+ sample.body = sample.body ?
23
+ await BinaryUtil.toBinaryArray(sample.body) :
24
+ BinaryUtil.makeBinaryArray(0);
25
+ }
26
+ request.body = WebBodyUtil.markRawBinary(sample.body);
25
27
  }
26
28
  Object.assign(request.context, { httpQuery: BindUtil.flattenPaths(request.context.httpQuery ?? {}) });
27
- return request;
29
+ return castTo(request);
28
30
  }
29
31
 
30
32
  static async finalizeResponseBody(response: WebResponse, decompress?: boolean): Promise<WebResponse> {
@@ -32,32 +34,32 @@ export class WebTestDispatchUtil {
32
34
 
33
35
  response.context.httpStatusCode = WebCommonUtil.getStatusCode(response);
34
36
 
35
- if (decompress) {
36
- if (Buffer.isBuffer(result) || BinaryUtil.isReadable(result)) {
37
- const bufferResult = result = await toBuffer(result);
38
- if (bufferResult.length) {
39
- try {
40
- result = await DecompressInterceptor.decompress(
41
- response.headers,
42
- bufferResult,
43
- { applies: true, supportedEncodings: ['br', 'deflate', 'gzip', 'identity'] }
44
- );
45
- } catch { }
46
- }
37
+ if (decompress && BinaryUtil.isBinaryType(result)) {
38
+ const bufferResult = result = await BinaryUtil.toBinaryArray(result);
39
+ if (bufferResult.byteLength) {
40
+ try {
41
+ result = await DecompressInterceptor.decompress(
42
+ response.headers,
43
+ bufferResult,
44
+ { applies: true, supportedEncodings: ['br', 'deflate', 'gzip', 'identity'] }
45
+ );
46
+ } catch { }
47
47
  }
48
48
  }
49
49
 
50
- const text = Buffer.isBuffer(result) ? result.toString('utf8') : (typeof result === 'string' ? result : undefined);
50
+ const isJSON = response.headers.get('Content-Type') === 'application/json';
51
+ const isText = response.headers.get('Content-Type')?.startsWith('text/') ?? false;
51
52
 
52
- if (text) {
53
- switch (response.headers.get('Content-Type')) {
54
- case 'application/json': result = JSONUtil.parseSafe(castTo(text)); break;
55
- case 'text/plain': result = text; break;
56
- }
53
+ if (BinaryUtil.isBinaryArray(result) && (isJSON || isText)) {
54
+ result = CodecUtil.toUTF8String(result);
55
+ }
56
+
57
+ if (typeof result === 'string' && isJSON) {
58
+ result = JSONUtil.fromUTF8(result, { reviver: JSONUtil.TRANSMIT_REVIVER });
57
59
  }
58
60
 
59
61
  if (response.context.httpStatusCode && response.context.httpStatusCode >= 400) {
60
- result = WebCommonUtil.catchResponse(AppError.fromJSON(result) ?? result).body;
62
+ result = WebCommonUtil.catchResponse(result).body;
61
63
  }
62
64
 
63
65
  response.body = result;
@@ -54,7 +54,7 @@ export abstract class BaseWebSuite {
54
54
 
55
55
  @AfterAll()
56
56
  async destroySever(): Promise<void> {
57
- await this.#cleanup?.();
57
+ this.#cleanup?.();
58
58
  this.#cleanup = undefined;
59
59
  }
60
60
 
@@ -1,6 +1,4 @@
1
- import { Readable } from 'node:stream';
2
-
3
- import { AppError, castTo } from '@travetto/runtime';
1
+ import { RuntimeError, BinaryUtil, castTo, CodecUtil } from '@travetto/runtime';
4
2
 
5
3
  import { Controller } from '../../../src/decorator/controller.ts';
6
4
  import { Get, Post, Put, Delete, Patch } from '../../../src/decorator/endpoint.ts';
@@ -59,13 +57,13 @@ export class TestController {
59
57
  @Get('/stream')
60
58
  @SetHeaders({ 'Content-Type': 'text/plain' })
61
59
  getStream() {
62
- return Readable.from(Buffer.from('hello'));
60
+ return BinaryUtil.toBinaryStream(CodecUtil.fromUTF8String('hello'));
63
61
  }
64
62
 
65
63
  @Get('/buffer')
66
64
  @Produces('text/plain')
67
65
  getBuffer() {
68
- return Buffer.from('hello');
66
+ return CodecUtil.fromUTF8String('hello');
69
67
  }
70
68
 
71
69
  @Get('/renderable')
@@ -98,6 +96,6 @@ export class TestController {
98
96
 
99
97
  @Post('/ip')
100
98
  notFound() {
101
- throw new AppError('Uh-oh', { category: 'general' });
99
+ throw new RuntimeError('Uh-oh', { category: 'general' });
102
100
  }
103
101
  }