@amerged/ohmyhost-runtime 0.1.37 → 0.1.39
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 +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/http.d.ts +8 -2
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +39 -11
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mail-attachment.d.ts +14 -0
- package/dist/mail-attachment.d.ts.map +1 -0
- package/dist/mail-attachment.js +186 -0
- package/dist/mail-attachment.js.map +1 -0
- package/dist/mail.d.ts +1 -0
- package/dist/mail.d.ts.map +1 -1
- package/dist/mail.js +11 -9
- package/dist/mail.js.map +1 -1
- package/dist/storage-batch-stream.d.ts.map +1 -1
- package/dist/storage-batch-stream.js +24 -27
- package/dist/storage-batch-stream.js.map +1 -1
- package/dist/storage.d.ts +2 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +43 -51
- package/dist/storage.js.map +1 -1
- package/package.json +5 -1
- package/src/packages/customer-runtime/package.json +7 -2
- package/src/packages/customer-runtime/src/errors.ts +3 -0
- package/src/packages/customer-runtime/src/http.ts +63 -11
- package/src/packages/customer-runtime/src/index.ts +1 -1
- package/src/packages/customer-runtime/src/mail-attachment.ts +238 -0
- package/src/packages/customer-runtime/src/mail.ts +12 -6
- package/src/packages/customer-runtime/src/storage-batch-stream.ts +29 -25
- package/src/packages/customer-runtime/src/storage.ts +59 -48
- package/dist/mail-attachments.d.ts +0 -8
- package/dist/mail-attachments.d.ts.map +0 -1
- package/dist/mail-attachments.js +0 -32
- package/dist/mail-attachments.js.map +0 -1
- package/src/packages/customer-runtime/src/mail-attachments.ts +0 -53
|
@@ -4,9 +4,13 @@ const MAX_RESPONSE_BYTES = 32 * 1024;
|
|
|
4
4
|
const DEFAULT_TIMEOUT_MILLISECONDS = 10_000;
|
|
5
5
|
|
|
6
6
|
export interface GatewayConnection {
|
|
7
|
+
/** Injected gateway origin for server code; never expose the runtime key to a browser. */
|
|
7
8
|
readonly endpoint: string;
|
|
9
|
+
/** Environment capability key from OHMYHOST_MAIL_KEY or OHMYHOST_STORAGE_KEY. */
|
|
8
10
|
readonly key: string;
|
|
11
|
+
/** Use (request) => fetch(request); an explicit transport remains available for tests. */
|
|
9
12
|
readonly fetch: (request: Request) => Promise<Response>;
|
|
13
|
+
/** Gateway header timeout: 100–30000 ms, default 10000 ms. */
|
|
10
14
|
readonly timeoutMilliseconds?: number;
|
|
11
15
|
}
|
|
12
16
|
|
|
@@ -45,7 +49,7 @@ export function validateGatewayConnection(value: GatewayConnection): ValidatedGa
|
|
|
45
49
|
}
|
|
46
50
|
if (typeof value.fetch !== "function") {
|
|
47
51
|
configurationInvalid(
|
|
48
|
-
"fetch is not a function; pass (request) =>
|
|
52
|
+
"fetch is not a function; pass (request) => fetch(request) in server code",
|
|
49
53
|
);
|
|
50
54
|
}
|
|
51
55
|
const timeoutMilliseconds = value.timeoutMilliseconds ?? DEFAULT_TIMEOUT_MILLISECONDS;
|
|
@@ -112,19 +116,23 @@ export async function fetchWithTimeout(
|
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
export function exactRecord(
|
|
116
|
-
|
|
119
|
+
export function exactRecord(
|
|
120
|
+
value: unknown,
|
|
121
|
+
keys: readonly string[],
|
|
122
|
+
status: number | null = null,
|
|
123
|
+
): Record<string, unknown> {
|
|
124
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) invalidResponse(status);
|
|
117
125
|
const record = value as Record<string, unknown>;
|
|
118
126
|
const actual = Object.keys(record).sort();
|
|
119
127
|
const expected = [...keys].sort();
|
|
120
128
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
121
|
-
invalidResponse();
|
|
129
|
+
invalidResponse(status);
|
|
122
130
|
}
|
|
123
131
|
return record;
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
export function invalidResponse(): never {
|
|
127
|
-
throw new CustomerRuntimeGatewayError("gateway_response_invalid",
|
|
134
|
+
export function invalidResponse(status: number | null = null): never {
|
|
135
|
+
throw new CustomerRuntimeGatewayError("gateway_response_invalid", status, false);
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
/** A malformed platform ID is a configuration mistake, not a gateway answer. */
|
|
@@ -152,17 +160,61 @@ function assertServerRuntime(): void {
|
|
|
152
160
|
|
|
153
161
|
async function readBoundedJson(response: Response): Promise<unknown> {
|
|
154
162
|
if (response.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json") {
|
|
155
|
-
invalidResponse();
|
|
163
|
+
invalidResponse(response.status);
|
|
156
164
|
}
|
|
157
165
|
const declared = response.headers.get("content-length");
|
|
158
166
|
if (declared !== null && (!/^[0-9]+$/u.test(declared) || Number(declared) > MAX_RESPONSE_BYTES)) {
|
|
159
|
-
invalidResponse();
|
|
167
|
+
invalidResponse(response.status);
|
|
168
|
+
}
|
|
169
|
+
let bytes: Uint8Array;
|
|
170
|
+
try {
|
|
171
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
172
|
+
} catch {
|
|
173
|
+
throw new CustomerRuntimeGatewayError("gateway_unavailable", response.status, true);
|
|
160
174
|
}
|
|
161
|
-
|
|
162
|
-
if (bytes.byteLength > MAX_RESPONSE_BYTES) invalidResponse();
|
|
175
|
+
if (bytes.byteLength > MAX_RESPONSE_BYTES) invalidResponse(response.status);
|
|
163
176
|
try {
|
|
164
177
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
165
178
|
} catch {
|
|
166
|
-
return invalidResponse();
|
|
179
|
+
return invalidResponse(response.status);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Relay failures share one vocabulary across mail, storage and batch control responses. */
|
|
184
|
+
export function throwGatewayRelayError(status: number, body: unknown): void {
|
|
185
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return;
|
|
186
|
+
const code = (body as Record<string, unknown>)["code"];
|
|
187
|
+
if (
|
|
188
|
+
(status === 403 && (code === "gateway_not_enabled" || code === "gateway_route_denied")) ||
|
|
189
|
+
(status === 429 && code === "gateway_rate_limited") ||
|
|
190
|
+
(status === 503 &&
|
|
191
|
+
(code === "mail_gateway_unavailable" || code === "storage_gateway_unavailable"))
|
|
192
|
+
) {
|
|
193
|
+
exactRecord(body, ["code"], status);
|
|
194
|
+
if (code === "gateway_not_enabled") {
|
|
195
|
+
throw new CustomerRuntimeGatewayError(
|
|
196
|
+
code,
|
|
197
|
+
status,
|
|
198
|
+
false,
|
|
199
|
+
"enable the required mail or storage capability in ohmyhost.yaml and redeploy",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (code === "gateway_route_denied") {
|
|
203
|
+
throw new CustomerRuntimeGatewayError(
|
|
204
|
+
code,
|
|
205
|
+
status,
|
|
206
|
+
false,
|
|
207
|
+
"use the current runtime client's supported gateway routes through (request) => fetch(request)",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (code === "gateway_rate_limited") {
|
|
211
|
+
throw new CustomerRuntimeGatewayError(
|
|
212
|
+
code,
|
|
213
|
+
status,
|
|
214
|
+
true,
|
|
215
|
+
"wait before repeating this request with the same idempotency keys",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
throw new CustomerRuntimeGatewayError("gateway_unavailable", status, true);
|
|
167
219
|
}
|
|
168
220
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from "./errors.js";
|
|
2
2
|
export * from "./database.js";
|
|
3
3
|
export * from "./mail.js";
|
|
4
|
+
export * from "./mail-attachment.js";
|
|
4
5
|
export * from "./storage.js";
|
|
5
6
|
export type { GatewayConnection } from "./http.js";
|
|
6
7
|
export * from "./mail-webhook.js";
|
|
7
|
-
export * from "./mail-attachments.js";
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { CustomerRuntimeGatewayError } from "./errors.js";
|
|
2
|
+
import {
|
|
3
|
+
exactRecord,
|
|
4
|
+
fetchWithTimeout,
|
|
5
|
+
invalidResponse,
|
|
6
|
+
requireConfiguredUlid,
|
|
7
|
+
throwGatewayRelayError,
|
|
8
|
+
validateGatewayConnection,
|
|
9
|
+
type GatewayConnection,
|
|
10
|
+
} from "./http.js";
|
|
11
|
+
|
|
12
|
+
export interface MailAttachmentDownloadOptions {
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Server-only receiving access using OHMYHOST_MAIL_KEY and global fetch. */
|
|
17
|
+
export function createMailAttachmentClient(
|
|
18
|
+
configuration: GatewayConnection & {
|
|
19
|
+
readonly projectId: string;
|
|
20
|
+
/** Optional expected environment; otherwise use the canonical metadata download_path scope. */
|
|
21
|
+
readonly environmentId?: string;
|
|
22
|
+
},
|
|
23
|
+
) {
|
|
24
|
+
const connection = validateGatewayConnection(configuration);
|
|
25
|
+
const projectId = requireConfiguredUlid(
|
|
26
|
+
configuration.projectId,
|
|
27
|
+
"projectId",
|
|
28
|
+
"OHMYHOST_PROJECT_ID",
|
|
29
|
+
);
|
|
30
|
+
const environmentId =
|
|
31
|
+
configuration.environmentId === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: requireConfiguredUlid(
|
|
34
|
+
configuration.environmentId,
|
|
35
|
+
"environmentId",
|
|
36
|
+
"OHMYHOST_ENVIRONMENT_ID",
|
|
37
|
+
);
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
/** Consume to EOF for success, or cancel the stream when its bytes are no longer needed. */
|
|
40
|
+
async download(
|
|
41
|
+
downloadPath: string,
|
|
42
|
+
options: MailAttachmentDownloadOptions = {},
|
|
43
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
44
|
+
const match =
|
|
45
|
+
typeof downloadPath === "string"
|
|
46
|
+
? /^\/v1\/projects\/([0-7][0-9A-HJKMNP-TV-Z]{25})\/environments\/([0-7][0-9A-HJKMNP-TV-Z]{25})\/mail\/messages\/([0-7][0-9A-HJKMNP-TV-Z]{25})\/attachments\/([A-Za-z0-9_-]{1,128})$/u.exec(
|
|
47
|
+
downloadPath,
|
|
48
|
+
)
|
|
49
|
+
: null;
|
|
50
|
+
if (
|
|
51
|
+
match === null ||
|
|
52
|
+
match[1] !== projectId ||
|
|
53
|
+
(environmentId !== undefined && match[2] !== environmentId)
|
|
54
|
+
) {
|
|
55
|
+
throw new CustomerRuntimeGatewayError("gateway_request_invalid", null, false);
|
|
56
|
+
}
|
|
57
|
+
const body = JSON.stringify({
|
|
58
|
+
project_id: projectId,
|
|
59
|
+
environment_id: match[2],
|
|
60
|
+
message_id: match[3],
|
|
61
|
+
attachment_id: match[4],
|
|
62
|
+
});
|
|
63
|
+
if (new TextEncoder().encode(body).byteLength > 1024) {
|
|
64
|
+
throw new CustomerRuntimeGatewayError("gateway_request_invalid", null, false);
|
|
65
|
+
}
|
|
66
|
+
const cancellation = new AbortController();
|
|
67
|
+
const signal =
|
|
68
|
+
options.signal === undefined
|
|
69
|
+
? cancellation.signal
|
|
70
|
+
: AbortSignal.any([cancellation.signal, options.signal]);
|
|
71
|
+
if (signal.aborted) throw new CustomerRuntimeGatewayError("gateway_timeout", null, true);
|
|
72
|
+
const response = await fetchWithTimeout(
|
|
73
|
+
connection.fetch,
|
|
74
|
+
new Request(new URL("/v1/attachments/download", connection.endpoint), {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
authorization: `Bearer ${connection.key}`,
|
|
78
|
+
"content-type": "application/json",
|
|
79
|
+
},
|
|
80
|
+
body,
|
|
81
|
+
signal,
|
|
82
|
+
}),
|
|
83
|
+
connection.timeoutMilliseconds,
|
|
84
|
+
);
|
|
85
|
+
if (response.status !== 200) {
|
|
86
|
+
try {
|
|
87
|
+
throwAttachmentError(
|
|
88
|
+
response.status,
|
|
89
|
+
await readAttachmentError(response, connection.timeoutMilliseconds),
|
|
90
|
+
);
|
|
91
|
+
} finally {
|
|
92
|
+
cancellation.abort();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
response.body === null ||
|
|
97
|
+
response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !==
|
|
98
|
+
"application/octet-stream"
|
|
99
|
+
) {
|
|
100
|
+
cancellation.abort();
|
|
101
|
+
void response.body?.cancel().catch(ignoreCancellationError);
|
|
102
|
+
invalidResponse(response.status);
|
|
103
|
+
}
|
|
104
|
+
return attachmentStream(response.body, response.status, cancellation, signal);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A pull reads one source chunk; cancellation never drains or duplicates the attachment. */
|
|
110
|
+
function attachmentStream(
|
|
111
|
+
body: ReadableStream<Uint8Array>,
|
|
112
|
+
status: number,
|
|
113
|
+
cancellation: AbortController,
|
|
114
|
+
signal: AbortSignal,
|
|
115
|
+
): ReadableStream<Uint8Array> {
|
|
116
|
+
const source = body.getReader();
|
|
117
|
+
let finished = false;
|
|
118
|
+
let abort: () => void;
|
|
119
|
+
function finish(cancel: boolean) {
|
|
120
|
+
if (finished) return;
|
|
121
|
+
finished = true;
|
|
122
|
+
signal.removeEventListener("abort", abort);
|
|
123
|
+
if (cancel) {
|
|
124
|
+
cancellation.abort();
|
|
125
|
+
void source.cancel().catch(ignoreCancellationError);
|
|
126
|
+
}
|
|
127
|
+
source.releaseLock();
|
|
128
|
+
}
|
|
129
|
+
return new ReadableStream<Uint8Array>(
|
|
130
|
+
{
|
|
131
|
+
start(controller) {
|
|
132
|
+
abort = () => {
|
|
133
|
+
controller.error(new CustomerRuntimeGatewayError("gateway_timeout", status, true));
|
|
134
|
+
finish(true);
|
|
135
|
+
};
|
|
136
|
+
if (signal.aborted) abort();
|
|
137
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
138
|
+
},
|
|
139
|
+
async pull(controller) {
|
|
140
|
+
try {
|
|
141
|
+
const next = await source.read();
|
|
142
|
+
if (finished) return;
|
|
143
|
+
if (next.done) {
|
|
144
|
+
finish(false);
|
|
145
|
+
controller.close();
|
|
146
|
+
} else if (next.value instanceof Uint8Array) {
|
|
147
|
+
controller.enqueue(next.value);
|
|
148
|
+
} else {
|
|
149
|
+
invalidResponse(status);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
if (finished) return;
|
|
153
|
+
finish(true);
|
|
154
|
+
controller.error(new CustomerRuntimeGatewayError("gateway_unavailable", status, true));
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
cancel() {
|
|
158
|
+
finish(true);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{ highWaterMark: 0 },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Only small error records are buffered; binary successes never enter this parser. */
|
|
166
|
+
async function readAttachmentError(
|
|
167
|
+
response: Response,
|
|
168
|
+
timeoutMilliseconds: number,
|
|
169
|
+
): Promise<unknown> {
|
|
170
|
+
const declared = response.headers.get("content-length");
|
|
171
|
+
if (
|
|
172
|
+
response.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json" ||
|
|
173
|
+
response.body === null ||
|
|
174
|
+
(declared !== null && (!/^[0-9]+$/u.test(declared) || Number(declared) > 1024))
|
|
175
|
+
) {
|
|
176
|
+
void response.body?.cancel().catch(ignoreCancellationError);
|
|
177
|
+
invalidResponse(response.status);
|
|
178
|
+
}
|
|
179
|
+
const source = response.body.getReader();
|
|
180
|
+
const bytes = new Uint8Array(1024);
|
|
181
|
+
let length = 0;
|
|
182
|
+
let finished = false;
|
|
183
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
184
|
+
try {
|
|
185
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
186
|
+
timer = setTimeout(
|
|
187
|
+
() => reject(new CustomerRuntimeGatewayError("gateway_timeout", response.status, true)),
|
|
188
|
+
timeoutMilliseconds,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
for (;;) {
|
|
192
|
+
const next = await Promise.race([source.read(), expired]);
|
|
193
|
+
if (next.done) {
|
|
194
|
+
finished = true;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
if (!(next.value instanceof Uint8Array) || length + next.value.byteLength > bytes.byteLength)
|
|
198
|
+
invalidResponse(response.status);
|
|
199
|
+
bytes.set(next.value, length);
|
|
200
|
+
length += next.value.byteLength;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(
|
|
204
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)),
|
|
205
|
+
) as unknown;
|
|
206
|
+
} catch {
|
|
207
|
+
return invalidResponse(response.status);
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof CustomerRuntimeGatewayError) throw error;
|
|
211
|
+
throw new CustomerRuntimeGatewayError("gateway_unavailable", response.status, true);
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
if (!finished) void source.cancel().catch(ignoreCancellationError);
|
|
215
|
+
source.releaseLock();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function ignoreCancellationError(): void {
|
|
220
|
+
// The original download failure remains the public error.
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function throwAttachmentError(status: number, body: unknown): never {
|
|
224
|
+
throwGatewayRelayError(status, body);
|
|
225
|
+
const record = exactRecord(body, ["code"], status);
|
|
226
|
+
const code = record["code"];
|
|
227
|
+
if (status === 400 && code === "invalid_request")
|
|
228
|
+
throw new CustomerRuntimeGatewayError("gateway_request_invalid", status, false);
|
|
229
|
+
if (status === 401 && code === "unauthorized")
|
|
230
|
+
throw new CustomerRuntimeGatewayError("unauthorized", status, false);
|
|
231
|
+
if (status === 404 && code === "resource_not_found")
|
|
232
|
+
throw new CustomerRuntimeGatewayError("mail_message_not_found", status, false);
|
|
233
|
+
if (status === 410 && code === "mail_content_expired")
|
|
234
|
+
throw new CustomerRuntimeGatewayError("mail_content_expired", status, false);
|
|
235
|
+
if (status === 413 && code === "mail_attachment_too_large")
|
|
236
|
+
throw new CustomerRuntimeGatewayError("mail_attachment_too_large", status, false);
|
|
237
|
+
return invalidResponse(status);
|
|
238
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
postGatewayJson,
|
|
6
6
|
requireConfiguredUlid,
|
|
7
7
|
validateGatewayConnection,
|
|
8
|
+
throwGatewayRelayError,
|
|
8
9
|
type GatewayConnection,
|
|
9
10
|
} from "./http.js";
|
|
10
11
|
|
|
@@ -38,6 +39,7 @@ export type TransactionalMailResult =
|
|
|
38
39
|
readonly requestId: string;
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
/** Server-only transactional mail over the injected gateway URL and global fetch. */
|
|
41
43
|
export function createTransactionalMailClient(
|
|
42
44
|
configuration: GatewayConnection & { readonly projectId: string },
|
|
43
45
|
) {
|
|
@@ -72,8 +74,9 @@ export function createTransactionalMailClient(
|
|
|
72
74
|
raw?.["state"] === "accepted"
|
|
73
75
|
? ["state", "request_id", "message_id"]
|
|
74
76
|
: ["state", "request_id"],
|
|
77
|
+
response.status,
|
|
75
78
|
);
|
|
76
|
-
const requestId = requireUlid(stateRecord["request_id"]);
|
|
79
|
+
const requestId = requireUlid(stateRecord["request_id"], response.status);
|
|
77
80
|
if (stateRecord["state"] === "uncertain") {
|
|
78
81
|
return Object.freeze({ state: "uncertain" as const, requestId });
|
|
79
82
|
}
|
|
@@ -82,7 +85,7 @@ export function createTransactionalMailClient(
|
|
|
82
85
|
typeof stateRecord["message_id"] !== "string" ||
|
|
83
86
|
!/^[A-Za-z0-9._-]{1,256}$/u.test(stateRecord["message_id"])
|
|
84
87
|
) {
|
|
85
|
-
invalidResponse();
|
|
88
|
+
invalidResponse(response.status);
|
|
86
89
|
}
|
|
87
90
|
return Object.freeze({
|
|
88
91
|
state: "accepted" as const,
|
|
@@ -108,13 +111,15 @@ function validateMailInput(input: TransactionalMailInput): void {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
function throwMailError(status: number, body: unknown): never {
|
|
114
|
+
throwGatewayRelayError(status, body);
|
|
111
115
|
const raw = body as Record<string, unknown>;
|
|
112
116
|
const record = exactRecord(
|
|
113
117
|
body,
|
|
114
118
|
raw?.["request_id"] === undefined ? ["code"] : ["code", "request_id"],
|
|
119
|
+
status,
|
|
115
120
|
);
|
|
116
121
|
const code = record["code"];
|
|
117
|
-
if (record["request_id"] !== undefined) requireUlid(record["request_id"]);
|
|
122
|
+
if (record["request_id"] !== undefined) requireUlid(record["request_id"], status);
|
|
118
123
|
if (status === 400 && code === "invalid_request") {
|
|
119
124
|
throw new CustomerRuntimeGatewayError("gateway_request_invalid", status, false);
|
|
120
125
|
}
|
|
@@ -145,11 +150,12 @@ function throwMailError(status: number, body: unknown): never {
|
|
|
145
150
|
if (status === 503 && typeof code === "string") {
|
|
146
151
|
throw new CustomerRuntimeGatewayError("gateway_unavailable", status, true);
|
|
147
152
|
}
|
|
148
|
-
return invalidResponse();
|
|
153
|
+
return invalidResponse(status);
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
function requireUlid(value: unknown): string {
|
|
152
|
-
if (typeof value !== "string" || !/^[0-9A-HJKMNP-TV-Z]{26}$/u.test(value))
|
|
156
|
+
function requireUlid(value: unknown, status: number | null = null): string {
|
|
157
|
+
if (typeof value !== "string" || !/^[0-9A-HJKMNP-TV-Z]{26}$/u.test(value))
|
|
158
|
+
invalidResponse(status);
|
|
153
159
|
return value;
|
|
154
160
|
}
|
|
155
161
|
|
|
@@ -28,6 +28,7 @@ const MAX_HEADER_BYTES = 2048;
|
|
|
28
28
|
async function timedRead(
|
|
29
29
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
30
30
|
timeoutMilliseconds: number,
|
|
31
|
+
status: number,
|
|
31
32
|
signal?: AbortSignal,
|
|
32
33
|
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
33
34
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -35,13 +36,13 @@ async function timedRead(
|
|
|
35
36
|
try {
|
|
36
37
|
if (signal?.aborted) {
|
|
37
38
|
void reader.cancel().catch(() => undefined);
|
|
38
|
-
throw new CustomerRuntimeGatewayError("gateway_timeout",
|
|
39
|
+
throw new CustomerRuntimeGatewayError("gateway_timeout", status, true);
|
|
39
40
|
}
|
|
40
41
|
return await Promise.race([
|
|
41
42
|
reader.read(),
|
|
42
43
|
new Promise<never>((_resolve, reject) => {
|
|
43
44
|
const abort = () => {
|
|
44
|
-
reject(new CustomerRuntimeGatewayError("gateway_timeout",
|
|
45
|
+
reject(new CustomerRuntimeGatewayError("gateway_timeout", status, true));
|
|
45
46
|
void reader.cancel().catch(() => undefined);
|
|
46
47
|
};
|
|
47
48
|
timer = setTimeout(abort, timeoutMilliseconds);
|
|
@@ -53,8 +54,8 @@ async function timedRead(
|
|
|
53
54
|
]);
|
|
54
55
|
} catch (error) {
|
|
55
56
|
if (error instanceof CustomerRuntimeGatewayError) throw error;
|
|
56
|
-
if (signal?.aborted) throw new CustomerRuntimeGatewayError("gateway_timeout",
|
|
57
|
-
throw new CustomerRuntimeGatewayError("gateway_unavailable",
|
|
57
|
+
if (signal?.aborted) throw new CustomerRuntimeGatewayError("gateway_timeout", status, true);
|
|
58
|
+
throw new CustomerRuntimeGatewayError("gateway_unavailable", status, true);
|
|
58
59
|
} finally {
|
|
59
60
|
clearTimeout(timer);
|
|
60
61
|
if (abortListener !== undefined) signal?.removeEventListener("abort", abortListener);
|
|
@@ -72,7 +73,7 @@ export function decodeStorageBatch(
|
|
|
72
73
|
response.body === null
|
|
73
74
|
) {
|
|
74
75
|
void response.body?.cancel().catch(() => undefined);
|
|
75
|
-
invalidResponse();
|
|
76
|
+
invalidResponse(response.status);
|
|
76
77
|
}
|
|
77
78
|
const source = response.body.getReader();
|
|
78
79
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
@@ -101,9 +102,9 @@ export function decodeStorageBatch(
|
|
|
101
102
|
async function chunk(maximum: number): Promise<Uint8Array> {
|
|
102
103
|
if (cancelled) throw cancellationError();
|
|
103
104
|
while (offset === buffer.byteLength) {
|
|
104
|
-
const next = await timedRead(source, timeoutMilliseconds);
|
|
105
|
+
const next = await timedRead(source, timeoutMilliseconds, response.status);
|
|
105
106
|
if (cancelled) throw cancellationError();
|
|
106
|
-
if (next.done || !(next.value instanceof Uint8Array)) invalidResponse();
|
|
107
|
+
if (next.done || !(next.value instanceof Uint8Array)) invalidResponse(response.status);
|
|
107
108
|
buffer = next.value;
|
|
108
109
|
offset = 0;
|
|
109
110
|
}
|
|
@@ -126,7 +127,7 @@ export function decodeStorageBatch(
|
|
|
126
127
|
const length = new DataView((await exactBytes(4)).buffer).getUint32(0);
|
|
127
128
|
controlBytes += 4 + length;
|
|
128
129
|
if (length < 1 || length > MAX_HEADER_BYTES || controlBytes > MAX_CONTROL_BYTES)
|
|
129
|
-
invalidResponse();
|
|
130
|
+
invalidResponse(response.status);
|
|
130
131
|
const bytes = await exactBytes(length);
|
|
131
132
|
return parseHeader(bytes);
|
|
132
133
|
}
|
|
@@ -135,7 +136,7 @@ export function decodeStorageBatch(
|
|
|
135
136
|
if (buffer.byteLength - offset < 4) return undefined;
|
|
136
137
|
const length = new DataView(buffer.buffer, buffer.byteOffset + offset, 4).getUint32(0);
|
|
137
138
|
if (length < 1 || length > MAX_HEADER_BYTES || controlBytes + 4 + length > MAX_CONTROL_BYTES)
|
|
138
|
-
invalidResponse();
|
|
139
|
+
invalidResponse(response.status);
|
|
139
140
|
if (buffer.byteLength - offset < 4 + length) return undefined;
|
|
140
141
|
controlBytes += 4 + length;
|
|
141
142
|
const bytes = buffer.subarray(offset + 4, offset + 4 + length);
|
|
@@ -146,7 +147,7 @@ export function decodeStorageBatch(
|
|
|
146
147
|
try {
|
|
147
148
|
return JSON.parse(decoder.decode(bytes)) as unknown;
|
|
148
149
|
} catch {
|
|
149
|
-
return invalidResponse();
|
|
150
|
+
return invalidResponse(response.status);
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
const iterator = iterate();
|
|
@@ -173,7 +174,7 @@ export function decodeStorageBatch(
|
|
|
173
174
|
try {
|
|
174
175
|
for (let index = 0; index < keys.length; index++) {
|
|
175
176
|
const objectKey = keys[index];
|
|
176
|
-
if (objectKey === undefined) invalidResponse();
|
|
177
|
+
if (objectKey === undefined) invalidResponse(response.status);
|
|
177
178
|
if (!currentConsumed)
|
|
178
179
|
throw new CustomerRuntimeGatewayError(
|
|
179
180
|
"gateway_request_invalid",
|
|
@@ -190,8 +191,10 @@ export function decodeStorageBatch(
|
|
|
190
191
|
candidate?.["state"] === "found"
|
|
191
192
|
? ["index", "objectKey", "state", "contentType", "contentLength", "etag"]
|
|
192
193
|
: ["index", "objectKey", "state"],
|
|
194
|
+
response.status,
|
|
193
195
|
);
|
|
194
|
-
if (record["index"] !== index || record["objectKey"] !== keys[index])
|
|
196
|
+
if (record["index"] !== index || record["objectKey"] !== keys[index])
|
|
197
|
+
invalidResponse(response.status);
|
|
195
198
|
if (record["state"] === "not_found") {
|
|
196
199
|
yield Object.freeze({ state: "not_found", objectKey });
|
|
197
200
|
continue;
|
|
@@ -208,7 +211,7 @@ export function decodeStorageBatch(
|
|
|
208
211
|
typeof record["etag"] !== "string" ||
|
|
209
212
|
!/^[0-9a-f]{32}(?:-[1-9][0-9]{0,4})?$/u.test(record["etag"])
|
|
210
213
|
)
|
|
211
|
-
invalidResponse();
|
|
214
|
+
invalidResponse(response.status);
|
|
212
215
|
let remaining = Number(record["contentLength"]);
|
|
213
216
|
currentConsumed = remaining === 0;
|
|
214
217
|
const body = new ReadableStream<Uint8Array>(
|
|
@@ -268,18 +271,19 @@ export function decodeStorageBatch(
|
|
|
268
271
|
);
|
|
269
272
|
if (bodyFailure !== undefined) throw bodyFailure;
|
|
270
273
|
const buffered = bufferedHeader();
|
|
271
|
-
const terminal = exactRecord(
|
|
272
|
-
|
|
273
|
-
"count",
|
|
274
|
-
|
|
274
|
+
const terminal = exactRecord(
|
|
275
|
+
buffered === undefined ? await header() : buffered,
|
|
276
|
+
["state", "count"],
|
|
277
|
+
response.status,
|
|
278
|
+
);
|
|
275
279
|
if (
|
|
276
280
|
terminal["state"] !== "completed" ||
|
|
277
281
|
terminal["count"] !== keys.length ||
|
|
278
282
|
offset !== buffer.byteLength
|
|
279
283
|
)
|
|
280
|
-
invalidResponse();
|
|
281
|
-
const end = await timedRead(source, timeoutMilliseconds);
|
|
282
|
-
if (!end.done) invalidResponse();
|
|
284
|
+
invalidResponse(response.status);
|
|
285
|
+
const end = await timedRead(source, timeoutMilliseconds, response.status);
|
|
286
|
+
if (!end.done) invalidResponse(response.status);
|
|
283
287
|
if (cancelled) throw cancellationError();
|
|
284
288
|
finished = true;
|
|
285
289
|
} finally {
|
|
@@ -303,7 +307,7 @@ export async function readStorageBatchJson(
|
|
|
303
307
|
response.body === null
|
|
304
308
|
) {
|
|
305
309
|
void response.body?.cancel().catch(() => undefined);
|
|
306
|
-
invalidResponse();
|
|
310
|
+
invalidResponse(response.status);
|
|
307
311
|
}
|
|
308
312
|
const reader = response.body.getReader();
|
|
309
313
|
const chunks: Uint8Array[] = [];
|
|
@@ -311,14 +315,14 @@ export async function readStorageBatchJson(
|
|
|
311
315
|
let finished = false;
|
|
312
316
|
try {
|
|
313
317
|
for (;;) {
|
|
314
|
-
const next = await timedRead(reader, timeoutMilliseconds, signal);
|
|
318
|
+
const next = await timedRead(reader, timeoutMilliseconds, response.status, signal);
|
|
315
319
|
if (next.done) {
|
|
316
320
|
finished = true;
|
|
317
321
|
break;
|
|
318
322
|
}
|
|
319
|
-
if (!(next.value instanceof Uint8Array)) invalidResponse();
|
|
323
|
+
if (!(next.value instanceof Uint8Array)) invalidResponse(response.status);
|
|
320
324
|
length += next.value.byteLength;
|
|
321
|
-
if (length > MAX_CONTROL_BYTES) invalidResponse();
|
|
325
|
+
if (length > MAX_CONTROL_BYTES) invalidResponse(response.status);
|
|
322
326
|
chunks.push(next.value);
|
|
323
327
|
}
|
|
324
328
|
} finally {
|
|
@@ -334,6 +338,6 @@ export async function readStorageBatchJson(
|
|
|
334
338
|
try {
|
|
335
339
|
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
|
336
340
|
} catch {
|
|
337
|
-
return invalidResponse();
|
|
341
|
+
return invalidResponse(response.status);
|
|
338
342
|
}
|
|
339
343
|
}
|