@amerged/ohmyhost-runtime 0.1.23 → 0.1.25
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/database-kysely.d.ts +5 -0
- package/dist/database-kysely.d.ts.map +1 -0
- package/dist/database-kysely.js +95 -0
- package/dist/database-kysely.js.map +1 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +43 -7
- package/dist/database.js.map +1 -1
- package/dist/errors.d.ts +5 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +6 -5
- package/dist/errors.js.map +1 -1
- package/dist/http.d.ts +4 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +31 -12
- package/dist/http.js.map +1 -1
- package/dist/mail-attachments.d.ts.map +1 -1
- package/dist/mail-attachments.js +3 -4
- package/dist/mail-attachments.js.map +1 -1
- package/dist/mail.d.ts.map +1 -1
- package/dist/mail.js +16 -11
- package/dist/mail.js.map +1 -1
- package/dist/storage-batch-stream.d.ts +31 -0
- package/dist/storage-batch-stream.d.ts.map +1 -0
- package/dist/storage-batch-stream.js +282 -0
- package/dist/storage-batch-stream.js.map +1 -0
- package/dist/storage.d.ts +11 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +167 -14
- package/dist/storage.js.map +1 -1
- package/package.json +13 -1
- package/src/packages/customer-runtime/package.json +53 -0
- package/src/packages/customer-runtime/src/database-kysely.ts +127 -0
- package/src/packages/customer-runtime/{database.ts → src/database.ts} +50 -8
- package/src/packages/customer-runtime/{errors.ts → src/errors.ts} +13 -4
- package/src/packages/customer-runtime/{http.ts → src/http.ts} +43 -10
- package/src/packages/customer-runtime/{mail-attachments.ts → src/mail-attachments.ts} +12 -4
- package/src/packages/customer-runtime/{mail.ts → src/mail.ts} +28 -14
- package/src/packages/customer-runtime/src/storage-batch-stream.ts +318 -0
- package/src/packages/customer-runtime/{storage.ts → src/storage.ts} +236 -13
- /package/src/packages/customer-runtime/{index.ts → src/index.ts} +0 -0
- /package/src/packages/customer-runtime/{mail-webhook.ts → src/mail-webhook.ts} +0 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { CustomerRuntimeGatewayError } from "./errors.js";
|
|
2
|
+
import { exactRecord, invalidResponse } from "./http.js";
|
|
3
|
+
|
|
4
|
+
export type StorageReadResult =
|
|
5
|
+
| Readonly<{ state: "not_found"; objectKey: string }>
|
|
6
|
+
| Readonly<{
|
|
7
|
+
state: "found";
|
|
8
|
+
objectKey: string;
|
|
9
|
+
contentType: string;
|
|
10
|
+
contentLength: number;
|
|
11
|
+
etag: string;
|
|
12
|
+
body: ReadableStream<Uint8Array>;
|
|
13
|
+
}>;
|
|
14
|
+
export type StorageDeleteResult =
|
|
15
|
+
| Readonly<{ state: "completed"; objectKey: string; deletionId: string }>
|
|
16
|
+
| Readonly<{ state: "pending"; objectKey: string; retryAt: string }>
|
|
17
|
+
| Readonly<{
|
|
18
|
+
state: "failed";
|
|
19
|
+
objectKey: string;
|
|
20
|
+
code: "object_not_found" | "storage_gateway_unavailable";
|
|
21
|
+
retryable: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
export const STORAGE_BATCH_CONTENT_TYPE = "application/vnd.ohmyhost.storage-batch.v1";
|
|
24
|
+
const MAX_CONTROL_BYTES = 4 * 1024 * 1024;
|
|
25
|
+
const MAX_HEADER_BYTES = 2048;
|
|
26
|
+
|
|
27
|
+
/** Pending reads are bounded; errors never expose provider text. */
|
|
28
|
+
async function timedRead(
|
|
29
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
30
|
+
timeoutMilliseconds: number,
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
let abortListener: (() => void) | undefined;
|
|
35
|
+
try {
|
|
36
|
+
if (signal?.aborted) {
|
|
37
|
+
void reader.cancel().catch(() => undefined);
|
|
38
|
+
throw new CustomerRuntimeGatewayError("gateway_timeout", null, true);
|
|
39
|
+
}
|
|
40
|
+
return await Promise.race([
|
|
41
|
+
reader.read(),
|
|
42
|
+
new Promise<never>((_resolve, reject) => {
|
|
43
|
+
const abort = () => {
|
|
44
|
+
reject(new CustomerRuntimeGatewayError("gateway_timeout", null, true));
|
|
45
|
+
void reader.cancel().catch(() => undefined);
|
|
46
|
+
};
|
|
47
|
+
timer = setTimeout(abort, timeoutMilliseconds);
|
|
48
|
+
if (signal !== undefined) {
|
|
49
|
+
abortListener = abort;
|
|
50
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof CustomerRuntimeGatewayError) throw error;
|
|
56
|
+
if (signal?.aborted) throw new CustomerRuntimeGatewayError("gateway_timeout", null, true);
|
|
57
|
+
throw new CustomerRuntimeGatewayError("gateway_unavailable", null, true);
|
|
58
|
+
} finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
if (abortListener !== undefined) signal?.removeEventListener("abort", abortListener);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Yields one bounded body at a time; completion requires the final frame and exact EOF. */
|
|
65
|
+
export function decodeStorageBatch(
|
|
66
|
+
response: Response,
|
|
67
|
+
keys: readonly string[],
|
|
68
|
+
timeoutMilliseconds: number,
|
|
69
|
+
): AsyncIterable<StorageReadResult> {
|
|
70
|
+
if (
|
|
71
|
+
response.headers.get("content-type") !== STORAGE_BATCH_CONTENT_TYPE ||
|
|
72
|
+
response.body === null
|
|
73
|
+
) {
|
|
74
|
+
void response.body?.cancel().catch(() => undefined);
|
|
75
|
+
invalidResponse();
|
|
76
|
+
}
|
|
77
|
+
const source = response.body.getReader();
|
|
78
|
+
let buffer: Uint8Array = new Uint8Array();
|
|
79
|
+
let offset = 0;
|
|
80
|
+
let controlBytes = 0;
|
|
81
|
+
let started = false;
|
|
82
|
+
let finished = false;
|
|
83
|
+
let cancelled = false;
|
|
84
|
+
let released = false;
|
|
85
|
+
let activeBody: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
86
|
+
function cancellationError() {
|
|
87
|
+
return new CustomerRuntimeGatewayError(
|
|
88
|
+
"gateway_request_invalid",
|
|
89
|
+
null,
|
|
90
|
+
false,
|
|
91
|
+
"this storage read was cancelled; start another readMany() to read more objects",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
function closeSource() {
|
|
95
|
+
if (released) return;
|
|
96
|
+
released = true;
|
|
97
|
+
if (!finished) void source.cancel().catch(() => undefined);
|
|
98
|
+
source.releaseLock();
|
|
99
|
+
}
|
|
100
|
+
async function chunk(maximum: number): Promise<Uint8Array> {
|
|
101
|
+
if (cancelled) throw cancellationError();
|
|
102
|
+
while (offset === buffer.byteLength) {
|
|
103
|
+
const next = await timedRead(source, timeoutMilliseconds);
|
|
104
|
+
if (cancelled) throw cancellationError();
|
|
105
|
+
if (next.done || !(next.value instanceof Uint8Array)) invalidResponse();
|
|
106
|
+
buffer = next.value;
|
|
107
|
+
offset = 0;
|
|
108
|
+
}
|
|
109
|
+
const length = Math.min(maximum, buffer.byteLength - offset);
|
|
110
|
+
const bytes = buffer.subarray(offset, offset + length);
|
|
111
|
+
offset += length;
|
|
112
|
+
return bytes;
|
|
113
|
+
}
|
|
114
|
+
async function exactBytes(length: number): Promise<Uint8Array> {
|
|
115
|
+
const bytes = new Uint8Array(length);
|
|
116
|
+
let written = 0;
|
|
117
|
+
while (written < length) {
|
|
118
|
+
const next = await chunk(length - written);
|
|
119
|
+
bytes.set(next, written);
|
|
120
|
+
written += next.byteLength;
|
|
121
|
+
}
|
|
122
|
+
return bytes;
|
|
123
|
+
}
|
|
124
|
+
async function header(): Promise<unknown> {
|
|
125
|
+
const length = new DataView((await exactBytes(4)).buffer).getUint32(0);
|
|
126
|
+
controlBytes += 4 + length;
|
|
127
|
+
if (length < 1 || length > MAX_HEADER_BYTES || controlBytes > MAX_CONTROL_BYTES)
|
|
128
|
+
invalidResponse();
|
|
129
|
+
const bytes = await exactBytes(length);
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
|
132
|
+
} catch {
|
|
133
|
+
return invalidResponse();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const iterator = iterate();
|
|
137
|
+
// Returning an unstarted async generator skips its finally block.
|
|
138
|
+
return {
|
|
139
|
+
[Symbol.asyncIterator]() {
|
|
140
|
+
return {
|
|
141
|
+
next: () => (cancelled ? Promise.reject(cancellationError()) : iterator.next()),
|
|
142
|
+
async return() {
|
|
143
|
+
cancelled = true;
|
|
144
|
+
activeBody?.error(cancellationError());
|
|
145
|
+
activeBody = undefined;
|
|
146
|
+
closeSource();
|
|
147
|
+
if (!started) return { done: true as const, value: undefined };
|
|
148
|
+
return iterator.return(undefined);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
async function* iterate(): AsyncGenerator<StorageReadResult, void, unknown> {
|
|
154
|
+
started = true;
|
|
155
|
+
let currentConsumed = true;
|
|
156
|
+
let bodyFailure: unknown;
|
|
157
|
+
try {
|
|
158
|
+
for (let index = 0; index < keys.length; index++) {
|
|
159
|
+
const objectKey = keys[index];
|
|
160
|
+
if (objectKey === undefined) invalidResponse();
|
|
161
|
+
if (!currentConsumed)
|
|
162
|
+
throw new CustomerRuntimeGatewayError(
|
|
163
|
+
"gateway_request_invalid",
|
|
164
|
+
null,
|
|
165
|
+
false,
|
|
166
|
+
"consume or cancel each object's body before reading the next object",
|
|
167
|
+
);
|
|
168
|
+
if (bodyFailure !== undefined) throw bodyFailure;
|
|
169
|
+
const value = await header();
|
|
170
|
+
const candidate = value as Record<string, unknown>;
|
|
171
|
+
const record = exactRecord(
|
|
172
|
+
value,
|
|
173
|
+
candidate?.["state"] === "found"
|
|
174
|
+
? ["index", "objectKey", "state", "contentType", "contentLength", "etag"]
|
|
175
|
+
: ["index", "objectKey", "state"],
|
|
176
|
+
);
|
|
177
|
+
if (record["index"] !== index || record["objectKey"] !== keys[index]) invalidResponse();
|
|
178
|
+
if (record["state"] === "not_found") {
|
|
179
|
+
yield Object.freeze({ state: "not_found", objectKey });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
record["state"] !== "found" ||
|
|
184
|
+
typeof record["contentType"] !== "string" ||
|
|
185
|
+
!/^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/u.test(
|
|
186
|
+
record["contentType"],
|
|
187
|
+
) ||
|
|
188
|
+
!Number.isSafeInteger(record["contentLength"]) ||
|
|
189
|
+
Number(record["contentLength"]) < 0 ||
|
|
190
|
+
Number(record["contentLength"]) > 1_073_741_824 ||
|
|
191
|
+
typeof record["etag"] !== "string" ||
|
|
192
|
+
!/^[0-9a-f]{32}(?:-[1-9][0-9]{0,4})?$/u.test(record["etag"])
|
|
193
|
+
)
|
|
194
|
+
invalidResponse();
|
|
195
|
+
let remaining = Number(record["contentLength"]);
|
|
196
|
+
currentConsumed = remaining === 0;
|
|
197
|
+
const body = new ReadableStream<Uint8Array>(
|
|
198
|
+
{
|
|
199
|
+
start(controller) {
|
|
200
|
+
if (remaining === 0) controller.close();
|
|
201
|
+
else activeBody = controller;
|
|
202
|
+
},
|
|
203
|
+
async pull(controller) {
|
|
204
|
+
try {
|
|
205
|
+
const bytes = await chunk(remaining);
|
|
206
|
+
remaining -= bytes.byteLength;
|
|
207
|
+
controller.enqueue(bytes);
|
|
208
|
+
if (remaining === 0) {
|
|
209
|
+
currentConsumed = true;
|
|
210
|
+
activeBody = undefined;
|
|
211
|
+
controller.close();
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
bodyFailure = error;
|
|
215
|
+
currentConsumed = true;
|
|
216
|
+
activeBody = undefined;
|
|
217
|
+
controller.error(error);
|
|
218
|
+
closeSource();
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
async cancel() {
|
|
222
|
+
activeBody = undefined;
|
|
223
|
+
try {
|
|
224
|
+
while (remaining > 0) remaining -= (await chunk(remaining)).byteLength;
|
|
225
|
+
currentConsumed = true;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
bodyFailure = error;
|
|
228
|
+
currentConsumed = true;
|
|
229
|
+
closeSource();
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{ highWaterMark: 0 },
|
|
235
|
+
);
|
|
236
|
+
yield Object.freeze({
|
|
237
|
+
state: "found",
|
|
238
|
+
objectKey,
|
|
239
|
+
contentType: record["contentType"],
|
|
240
|
+
contentLength: Number(record["contentLength"]),
|
|
241
|
+
etag: record["etag"],
|
|
242
|
+
body,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (!currentConsumed)
|
|
246
|
+
throw new CustomerRuntimeGatewayError(
|
|
247
|
+
"gateway_request_invalid",
|
|
248
|
+
null,
|
|
249
|
+
false,
|
|
250
|
+
"consume or cancel the last object's body before completing the read",
|
|
251
|
+
);
|
|
252
|
+
if (bodyFailure !== undefined) throw bodyFailure;
|
|
253
|
+
const terminal = exactRecord(await header(), ["state", "count"]);
|
|
254
|
+
if (
|
|
255
|
+
terminal["state"] !== "completed" ||
|
|
256
|
+
terminal["count"] !== keys.length ||
|
|
257
|
+
offset !== buffer.byteLength
|
|
258
|
+
)
|
|
259
|
+
invalidResponse();
|
|
260
|
+
const end = await timedRead(source, timeoutMilliseconds);
|
|
261
|
+
if (!end.done) invalidResponse();
|
|
262
|
+
if (cancelled) throw cancellationError();
|
|
263
|
+
finished = true;
|
|
264
|
+
} finally {
|
|
265
|
+
activeBody?.error(bodyFailure ?? cancellationError());
|
|
266
|
+
activeBody = undefined;
|
|
267
|
+
closeSource();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Control JSON has its own four-MiB budget and never buffers object payloads. */
|
|
273
|
+
export async function readStorageBatchJson(
|
|
274
|
+
response: Response,
|
|
275
|
+
timeoutMilliseconds: number,
|
|
276
|
+
signal?: AbortSignal,
|
|
277
|
+
): Promise<unknown> {
|
|
278
|
+
const declared = response.headers.get("content-length");
|
|
279
|
+
if (
|
|
280
|
+
response.headers.get("content-type")?.split(";", 1)[0]?.trim() !== "application/json" ||
|
|
281
|
+
(declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > MAX_CONTROL_BYTES)) ||
|
|
282
|
+
response.body === null
|
|
283
|
+
) {
|
|
284
|
+
void response.body?.cancel().catch(() => undefined);
|
|
285
|
+
invalidResponse();
|
|
286
|
+
}
|
|
287
|
+
const reader = response.body.getReader();
|
|
288
|
+
const chunks: Uint8Array[] = [];
|
|
289
|
+
let length = 0;
|
|
290
|
+
let finished = false;
|
|
291
|
+
try {
|
|
292
|
+
for (;;) {
|
|
293
|
+
const next = await timedRead(reader, timeoutMilliseconds, signal);
|
|
294
|
+
if (next.done) {
|
|
295
|
+
finished = true;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
if (!(next.value instanceof Uint8Array)) invalidResponse();
|
|
299
|
+
length += next.value.byteLength;
|
|
300
|
+
if (length > MAX_CONTROL_BYTES) invalidResponse();
|
|
301
|
+
chunks.push(next.value);
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
if (!finished) void reader.cancel().catch(() => undefined);
|
|
305
|
+
reader.releaseLock();
|
|
306
|
+
}
|
|
307
|
+
const bytes = new Uint8Array(length);
|
|
308
|
+
let offset = 0;
|
|
309
|
+
for (const chunk of chunks) {
|
|
310
|
+
bytes.set(chunk, offset);
|
|
311
|
+
offset += chunk.byteLength;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
|
315
|
+
} catch {
|
|
316
|
+
return invalidResponse();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { CustomerRuntimeGatewayError } from "./errors.js";
|
|
2
2
|
import {
|
|
3
|
+
configurationInvalid,
|
|
3
4
|
exactRecord,
|
|
4
5
|
fetchWithTimeout,
|
|
5
6
|
invalidResponse,
|
|
6
7
|
postGatewayJson,
|
|
8
|
+
requireConfiguredUlid,
|
|
7
9
|
validateGatewayConnection,
|
|
8
10
|
type GatewayConnection,
|
|
9
11
|
} from "./http.js";
|
|
12
|
+
import {
|
|
13
|
+
decodeStorageBatch,
|
|
14
|
+
readStorageBatchJson,
|
|
15
|
+
type StorageReadResult,
|
|
16
|
+
type StorageDeleteResult,
|
|
17
|
+
} from "./storage-batch-stream.js";
|
|
18
|
+
export type { StorageReadResult, StorageDeleteResult } from "./storage-batch-stream.js";
|
|
10
19
|
|
|
11
20
|
export interface StorageCapability {
|
|
12
21
|
readonly operation: "GET" | "PUT";
|
|
@@ -33,11 +42,19 @@ export function createPrivateStorageClient(
|
|
|
33
42
|
) {
|
|
34
43
|
const connection = validateGatewayConnection(configuration);
|
|
35
44
|
if (typeof configuration.capabilityFetch !== "function") {
|
|
36
|
-
|
|
45
|
+
configurationInvalid("capabilityFetch is not a function; pass (request) => fetch(request)");
|
|
37
46
|
}
|
|
38
47
|
const capabilityFetch = configuration.capabilityFetch;
|
|
39
|
-
const projectId =
|
|
40
|
-
|
|
48
|
+
const projectId = requireConfiguredUlid(
|
|
49
|
+
configuration.projectId,
|
|
50
|
+
"projectId",
|
|
51
|
+
"OHMYHOST_PROJECT_ID",
|
|
52
|
+
);
|
|
53
|
+
const environmentId = requireConfiguredUlid(
|
|
54
|
+
configuration.environmentId,
|
|
55
|
+
"environmentId",
|
|
56
|
+
"OHMYHOST_ENVIRONMENT_ID",
|
|
57
|
+
);
|
|
41
58
|
const authenticatedBody = Object.freeze({ projectId, environmentId });
|
|
42
59
|
|
|
43
60
|
async function reserveUpload(
|
|
@@ -59,7 +76,7 @@ export function createPrivateStorageClient(
|
|
|
59
76
|
8 * 1024,
|
|
60
77
|
input.idempotencyKey,
|
|
61
78
|
);
|
|
62
|
-
if (response.status !== 200) throwStorageError(response.status, response.body);
|
|
79
|
+
if (response.status !== 200) throwStorageError(response.status, response.body, environmentId);
|
|
63
80
|
const record = response.body as Record<string, unknown>;
|
|
64
81
|
if (record?.["state"] === "completed") {
|
|
65
82
|
const completed = exactRecord(response.body, ["state", "transfer_id"]);
|
|
@@ -88,7 +105,7 @@ export function createPrivateStorageClient(
|
|
|
88
105
|
8 * 1024,
|
|
89
106
|
);
|
|
90
107
|
if (response.status !== 200 && response.status !== 202) {
|
|
91
|
-
throwStorageError(response.status, response.body);
|
|
108
|
+
throwStorageError(response.status, response.body, environmentId);
|
|
92
109
|
}
|
|
93
110
|
const record = exactRecord(response.body, ["state", "transfer_id"]);
|
|
94
111
|
if (record["state"] !== "completed" && record["state"] !== "pending") invalidResponse();
|
|
@@ -109,7 +126,7 @@ export function createPrivateStorageClient(
|
|
|
109
126
|
},
|
|
110
127
|
8 * 1024,
|
|
111
128
|
);
|
|
112
|
-
if (response.status !== 200) throwStorageError(response.status, response.body);
|
|
129
|
+
if (response.status !== 200) throwStorageError(response.status, response.body, environmentId);
|
|
113
130
|
const record = exactRecord(response.body, ["state", "capability"]);
|
|
114
131
|
if (record["state"] !== "ready") invalidResponse();
|
|
115
132
|
return parseCapability(record["capability"], "GET", objectKey);
|
|
@@ -124,7 +141,7 @@ export function createPrivateStorageClient(
|
|
|
124
141
|
8 * 1024,
|
|
125
142
|
input.idempotencyKey,
|
|
126
143
|
);
|
|
127
|
-
if (response.status !== 200) throwStorageError(response.status, response.body);
|
|
144
|
+
if (response.status !== 200) throwStorageError(response.status, response.body, environmentId);
|
|
128
145
|
const record = exactRecord(response.body, ["state", "deletion_id"]);
|
|
129
146
|
if (record["state"] !== "completed") invalidResponse();
|
|
130
147
|
return Object.freeze({
|
|
@@ -151,13 +168,177 @@ export function createPrivateStorageClient(
|
|
|
151
168
|
}),
|
|
152
169
|
connection.timeoutMilliseconds,
|
|
153
170
|
);
|
|
171
|
+
// The create-only PUT answers 412 when an earlier attempt already stored these bytes under
|
|
172
|
+
// this reservation; recording them is the retry's whole remaining work.
|
|
173
|
+
if (uploaded.status === 412) return completeUpload(reservation.transferId);
|
|
154
174
|
if (uploaded.status < 200 || uploaded.status >= 300) {
|
|
155
|
-
throw new CustomerRuntimeGatewayError(
|
|
175
|
+
throw new CustomerRuntimeGatewayError(
|
|
176
|
+
"storage_upload_rejected",
|
|
177
|
+
uploaded.status,
|
|
178
|
+
uploaded.status >= 500 || uploaded.status === 429,
|
|
179
|
+
`the signed upload answered ${String(uploaded.status)}. For 5xx or 429, repeat upload() with the same idempotencyKey and objectKey.`,
|
|
180
|
+
);
|
|
156
181
|
}
|
|
157
182
|
return completeUpload(reservation.transferId);
|
|
158
183
|
}
|
|
159
184
|
|
|
160
|
-
|
|
185
|
+
async function batchRequest(
|
|
186
|
+
path: string,
|
|
187
|
+
body: Readonly<Record<string, unknown>>,
|
|
188
|
+
signal?: AbortSignal,
|
|
189
|
+
) {
|
|
190
|
+
const serialized = JSON.stringify({ ...authenticatedBody, ...body });
|
|
191
|
+
if (new TextEncoder().encode(serialized).byteLength > 4 * 1024 * 1024) invalidRequest();
|
|
192
|
+
return fetchWithTimeout(
|
|
193
|
+
connection.fetch,
|
|
194
|
+
new Request(new URL(path, connection.endpoint), {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { authorization: `Bearer ${connection.key}`, "content-type": "application/json" },
|
|
197
|
+
body: serialized,
|
|
198
|
+
...(signal === undefined ? {} : { signal }),
|
|
199
|
+
}),
|
|
200
|
+
connection.timeoutMilliseconds,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
async function readMany(input: {
|
|
204
|
+
readonly objectKeys: readonly string[];
|
|
205
|
+
}): Promise<AsyncIterable<StorageReadResult>> {
|
|
206
|
+
if (
|
|
207
|
+
typeof input !== "object" ||
|
|
208
|
+
input === null ||
|
|
209
|
+
Object.keys(input).join(",") !== "objectKeys"
|
|
210
|
+
)
|
|
211
|
+
invalidRequest();
|
|
212
|
+
const objectKeys = validateBatchKeys(input.objectKeys);
|
|
213
|
+
const response = await batchRequest("/v1/downloads/batch", { objectKeys });
|
|
214
|
+
if (response.status !== 200)
|
|
215
|
+
throwStorageError(
|
|
216
|
+
response.status,
|
|
217
|
+
await readStorageBatchJson(response, connection.timeoutMilliseconds),
|
|
218
|
+
environmentId,
|
|
219
|
+
);
|
|
220
|
+
return decodeStorageBatch(response, objectKeys, connection.timeoutMilliseconds);
|
|
221
|
+
}
|
|
222
|
+
async function deleteMany(input: {
|
|
223
|
+
readonly objects: readonly { readonly objectKey: string; readonly idempotencyKey: string }[];
|
|
224
|
+
}): Promise<readonly StorageDeleteResult[]> {
|
|
225
|
+
if (
|
|
226
|
+
typeof input !== "object" ||
|
|
227
|
+
input === null ||
|
|
228
|
+
Object.keys(input).join(",") !== "objects" ||
|
|
229
|
+
!Array.isArray(input.objects) ||
|
|
230
|
+
input.objects.length === 0 ||
|
|
231
|
+
input.objects.length > 4000
|
|
232
|
+
)
|
|
233
|
+
invalidRequest();
|
|
234
|
+
const objects = input.objects.map((item) => {
|
|
235
|
+
if (
|
|
236
|
+
typeof item !== "object" ||
|
|
237
|
+
item === null ||
|
|
238
|
+
Object.keys(item).sort().join(",") !== "idempotencyKey,objectKey" ||
|
|
239
|
+
typeof item.objectKey !== "string" ||
|
|
240
|
+
typeof item.idempotencyKey !== "string"
|
|
241
|
+
)
|
|
242
|
+
invalidRequest();
|
|
243
|
+
validateStorageMutation(item.idempotencyKey, item.objectKey);
|
|
244
|
+
return { objectKey: item.objectKey, idempotencyKey: item.idempotencyKey };
|
|
245
|
+
});
|
|
246
|
+
validateBatchKeys(objects.map((item) => item.objectKey));
|
|
247
|
+
if (new Set(objects.map((item) => item.idempotencyKey)).size !== objects.length)
|
|
248
|
+
invalidRequest();
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const deadline = Date.now() + connection.timeoutMilliseconds;
|
|
251
|
+
const timer = setTimeout(() => controller.abort(), connection.timeoutMilliseconds);
|
|
252
|
+
try {
|
|
253
|
+
const response = await batchRequest("/v1/deletions/batch", { objects }, controller.signal);
|
|
254
|
+
const value = await readStorageBatchJson(
|
|
255
|
+
response,
|
|
256
|
+
connection.timeoutMilliseconds,
|
|
257
|
+
controller.signal,
|
|
258
|
+
);
|
|
259
|
+
if (response.status !== 200) throwStorageError(response.status, value, environmentId);
|
|
260
|
+
const record = exactRecord(value, ["state", "results"]);
|
|
261
|
+
if (!Array.isArray(record["results"]) || record["results"].length !== objects.length)
|
|
262
|
+
invalidResponse();
|
|
263
|
+
const results = record["results"].map((value, index): StorageDeleteResult => {
|
|
264
|
+
const candidate = value as Record<string, unknown>;
|
|
265
|
+
const item = exactRecord(
|
|
266
|
+
value,
|
|
267
|
+
candidate?.["state"] === "completed"
|
|
268
|
+
? ["object_key", "state", "deletion_id"]
|
|
269
|
+
: candidate?.["state"] === "pending"
|
|
270
|
+
? ["object_key", "state", "retry_at"]
|
|
271
|
+
: ["object_key", "state", "code", "retryable"],
|
|
272
|
+
);
|
|
273
|
+
const requested = objects[index];
|
|
274
|
+
if (requested === undefined || item["object_key"] !== requested.objectKey)
|
|
275
|
+
invalidResponse();
|
|
276
|
+
const objectKey = requested.objectKey;
|
|
277
|
+
if (item["state"] === "completed")
|
|
278
|
+
return Object.freeze({
|
|
279
|
+
state: "completed",
|
|
280
|
+
objectKey,
|
|
281
|
+
deletionId: requireUlid(item["deletion_id"]),
|
|
282
|
+
});
|
|
283
|
+
if (item["state"] === "pending") {
|
|
284
|
+
return Object.freeze({
|
|
285
|
+
state: "pending",
|
|
286
|
+
objectKey,
|
|
287
|
+
retryAt: requireRetryAt(item["retry_at"]),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
item["state"] !== "failed" ||
|
|
292
|
+
(item["code"] !== "object_not_found" && item["code"] !== "storage_gateway_unavailable") ||
|
|
293
|
+
typeof item["retryable"] !== "boolean" ||
|
|
294
|
+
(item["code"] === "object_not_found" && item["retryable"] !== false)
|
|
295
|
+
)
|
|
296
|
+
invalidResponse();
|
|
297
|
+
return Object.freeze({
|
|
298
|
+
state: "failed",
|
|
299
|
+
objectKey,
|
|
300
|
+
code: item["code"],
|
|
301
|
+
retryable: item["retryable"],
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
if (
|
|
305
|
+
record["state"] !==
|
|
306
|
+
(results.every((item) => item.state === "completed") ? "completed" : "partial")
|
|
307
|
+
)
|
|
308
|
+
invalidResponse();
|
|
309
|
+
if (controller.signal.aborted || Date.now() >= deadline) {
|
|
310
|
+
controller.abort();
|
|
311
|
+
throw new CustomerRuntimeGatewayError("gateway_timeout", null, true);
|
|
312
|
+
}
|
|
313
|
+
return Object.freeze(results);
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return Object.freeze({
|
|
319
|
+
reserveUpload,
|
|
320
|
+
completeUpload,
|
|
321
|
+
createSignedRead,
|
|
322
|
+
deleteObject,
|
|
323
|
+
upload,
|
|
324
|
+
readMany,
|
|
325
|
+
deleteMany,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function validateBatchKeys(value: readonly string[]): readonly string[] {
|
|
330
|
+
if (
|
|
331
|
+
!Array.isArray(value) ||
|
|
332
|
+
value.length === 0 ||
|
|
333
|
+
value.length > 4000 ||
|
|
334
|
+
new Set(value).size !== value.length
|
|
335
|
+
)
|
|
336
|
+
invalidRequest();
|
|
337
|
+
for (const key of value) {
|
|
338
|
+
if (typeof key !== "string") invalidRequest();
|
|
339
|
+
validateObjectKey(key);
|
|
340
|
+
}
|
|
341
|
+
return Object.freeze(Array.from(value));
|
|
161
342
|
}
|
|
162
343
|
|
|
163
344
|
function parseCapability(value: unknown, operation: "GET" | "PUT", objectKey: string) {
|
|
@@ -214,12 +395,18 @@ function exactHeaders(value: unknown): Readonly<Record<string, string>> {
|
|
|
214
395
|
return Object.freeze(Object.fromEntries(entries) as Record<string, string>);
|
|
215
396
|
}
|
|
216
397
|
|
|
217
|
-
function throwStorageError(status: number, body: unknown): never {
|
|
398
|
+
function throwStorageError(status: number, body: unknown, environmentId: string): never {
|
|
399
|
+
const candidate = body as Record<string, unknown>;
|
|
218
400
|
const record = exactRecord(
|
|
219
401
|
body,
|
|
220
|
-
|
|
221
|
-
? ["code", "
|
|
222
|
-
: ["code"]
|
|
402
|
+
candidate?.["code"] === "storage_delete_pending"
|
|
403
|
+
? ["code", "retry_at"]
|
|
404
|
+
: candidate?.["code"] === "data_change_in_progress" ||
|
|
405
|
+
candidate?.["code"] === "storage_binding_data_changed"
|
|
406
|
+
? ["code"]
|
|
407
|
+
: typeof body === "object" && body !== null && "transfer_id" in body
|
|
408
|
+
? ["code", "transfer_id"]
|
|
409
|
+
: ["code"],
|
|
223
410
|
);
|
|
224
411
|
const code = record["code"];
|
|
225
412
|
if (status === 401 && code === "unauthorized") {
|
|
@@ -232,6 +419,31 @@ function throwStorageError(status: number, body: unknown): never {
|
|
|
232
419
|
requireUlid(record["transfer_id"]);
|
|
233
420
|
throw new CustomerRuntimeGatewayError("storage_upload_expired", status, false);
|
|
234
421
|
}
|
|
422
|
+
if (status === 409 && code === "data_change_in_progress") {
|
|
423
|
+
throw new CustomerRuntimeGatewayError(
|
|
424
|
+
"data_change_in_progress",
|
|
425
|
+
status,
|
|
426
|
+
true,
|
|
427
|
+
"a data change is running for this project; wait for that existing operation to finish, then repeat the same storage request with the same idempotency keys",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (status === 409 && code === "storage_binding_data_changed") {
|
|
431
|
+
throw new CustomerRuntimeGatewayError(
|
|
432
|
+
"storage_binding_data_changed",
|
|
433
|
+
status,
|
|
434
|
+
false,
|
|
435
|
+
`environment ${environmentId} now uses a different data area; create a new deployment plan and redeploy that environment. Repeating this request will not update this deployment's binding`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (status === 409 && code === "storage_delete_pending") {
|
|
439
|
+
const retryAt = requireRetryAt(record["retry_at"]);
|
|
440
|
+
throw new CustomerRuntimeGatewayError(
|
|
441
|
+
"storage_delete_pending",
|
|
442
|
+
status,
|
|
443
|
+
true,
|
|
444
|
+
`a previously accepted upload or deletion is still settling; wait until ${retryAt}, then repeat deleteObject() with the same objectKey and idempotencyKey`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
235
447
|
if (status === 409 && typeof code === "string") {
|
|
236
448
|
throw new CustomerRuntimeGatewayError("storage_conflict", status, false);
|
|
237
449
|
}
|
|
@@ -244,6 +456,17 @@ function throwStorageError(status: number, body: unknown): never {
|
|
|
244
456
|
return invalidResponse();
|
|
245
457
|
}
|
|
246
458
|
|
|
459
|
+
function requireRetryAt(value: unknown): string {
|
|
460
|
+
if (
|
|
461
|
+
typeof value !== "string" ||
|
|
462
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value) ||
|
|
463
|
+
!Number.isFinite(Date.parse(value)) ||
|
|
464
|
+
new Date(value).toISOString() !== value
|
|
465
|
+
)
|
|
466
|
+
invalidResponse();
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
|
|
247
470
|
function validateStorageMutation(idempotencyKey: string, objectKey: string): void {
|
|
248
471
|
if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(idempotencyKey)) invalidRequest();
|
|
249
472
|
validateObjectKey(objectKey);
|
|
File without changes
|
|
File without changes
|