create-monolayer-app 0.2.2 → 0.3.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.
@@ -0,0 +1,2482 @@
1
+ import { createServer } from "node:http";
2
+ import path, { dirname, join } from "node:path";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import { crc32 } from "node:zlib";
6
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from "node:fs";
7
+ //#region src/server/context.ts
8
+ /**
9
+ * Execution context and multi-tenancy account scoping.
10
+ */
11
+ const DEFAULT_ACCOUNT_ID = "000000000000";
12
+ const DEFAULT_REGION = process.env.AWS_REGION || "us-east-1";
13
+ const asyncLocalStorage = new AsyncLocalStorage();
14
+ function getAccountId() {
15
+ const store = asyncLocalStorage.getStore();
16
+ return store ? store.accountId : DEFAULT_ACCOUNT_ID;
17
+ }
18
+ function getRegion() {
19
+ const store = asyncLocalStorage.getStore();
20
+ return store ? store.region : DEFAULT_REGION;
21
+ }
22
+ function withAccountContext(context, fn) {
23
+ const current = asyncLocalStorage.getStore();
24
+ const next = {
25
+ accountId: context.accountId ?? current?.accountId ?? DEFAULT_ACCOUNT_ID,
26
+ region: context.region ?? current?.region ?? DEFAULT_REGION
27
+ };
28
+ return asyncLocalStorage.run(next, fn);
29
+ }
30
+ /**
31
+ * Helper to serialize arbitrary key types (strings or tuples) to string keys.
32
+ */
33
+ function serializeKey(key) {
34
+ if (Array.isArray(key)) return JSON.stringify(key);
35
+ return String(key);
36
+ }
37
+ function asKey(value) {
38
+ return value;
39
+ }
40
+ function deserializeKey(rawKey) {
41
+ if (rawKey.startsWith("[") && rawKey.endsWith("]")) try {
42
+ return JSON.parse(rawKey);
43
+ } catch {
44
+ return asKey(rawKey);
45
+ }
46
+ return asKey(rawKey);
47
+ }
48
+ /**
49
+ * AccountScopedMap stores values partitioned by account ID.
50
+ * When operations (get, set, has, delete, keys, etc.) are performed, they are
51
+ * implicitly scoped to the current account ID from getAccountId().
52
+ */
53
+ var AccountScopedMap = class {
54
+ _data = /* @__PURE__ */ new Map();
55
+ makeCompositeKey(key, accountId = getAccountId()) {
56
+ return `${accountId}::${serializeKey(key)}`;
57
+ }
58
+ get(key, accountId) {
59
+ return this._data.get(this.makeCompositeKey(key, accountId));
60
+ }
61
+ set(key, value, accountId) {
62
+ this._data.set(this.makeCompositeKey(key, accountId), value);
63
+ return this;
64
+ }
65
+ has(key, accountId) {
66
+ return this._data.has(this.makeCompositeKey(key, accountId));
67
+ }
68
+ delete(key, accountId) {
69
+ return this._data.delete(this.makeCompositeKey(key, accountId));
70
+ }
71
+ keys(accountId = getAccountId()) {
72
+ const prefix = `${accountId}::`;
73
+ const result = [];
74
+ for (const k of this._data.keys()) if (k.startsWith(prefix)) result.push(deserializeKey(k.slice(prefix.length)));
75
+ return result;
76
+ }
77
+ entries(accountId = getAccountId()) {
78
+ const prefix = `${accountId}::`;
79
+ const result = [];
80
+ for (const [k, v] of this._data.entries()) if (k.startsWith(prefix)) result.push([deserializeKey(k.slice(prefix.length)), v]);
81
+ return result;
82
+ }
83
+ };
84
+ //#endregion
85
+ //#region src/server/utils.ts
86
+ /**
87
+ * General utilities: formatting, hashing, parsing, UUIDs, dates.
88
+ */
89
+ function nowIso() {
90
+ return (/* @__PURE__ */ new Date()).toISOString();
91
+ }
92
+ function isoToRfc7231(iso) {
93
+ const date = iso instanceof Date ? iso : new Date(iso);
94
+ if (Number.isNaN(date.getTime())) return (/* @__PURE__ */ new Date()).toUTCString();
95
+ return date.toUTCString();
96
+ }
97
+ function parseHttpDate(value) {
98
+ if (!value) return null;
99
+ const parsed = Date.parse(value);
100
+ if (Number.isNaN(parsed)) return null;
101
+ return new Date(parsed);
102
+ }
103
+ function newUuid() {
104
+ return randomUUID();
105
+ }
106
+ function md5Hex(data) {
107
+ return createHash("md5").update(data).digest("hex");
108
+ }
109
+ function sha256Hex(data) {
110
+ return createHash("sha256").update(data).digest("hex");
111
+ }
112
+ /**
113
+ * S3 percent-encoding for `encoding-type=url`.
114
+ * Encodes characters per RFC 3986, but leaves "/" intact when safeSlash=true.
115
+ */
116
+ function urlEncode(value, safeSlash = true) {
117
+ const encoded = encodeURIComponent(value).replaceAll(/[!'()*]/gu, (c) => {
118
+ return `%${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()}`;
119
+ });
120
+ if (safeSlash) return encoded.replaceAll("%2F", "/");
121
+ return encoded;
122
+ }
123
+ function urlDecode(value) {
124
+ try {
125
+ return decodeURIComponent(value);
126
+ } catch {
127
+ return value;
128
+ }
129
+ }
130
+ /**
131
+ * Parse query string into a Record<string, string | string[]>.
132
+ */
133
+ function parseQueryParams(query) {
134
+ const result = Object.create(null);
135
+ if (!query) return result;
136
+ const queryString = query.startsWith("?") ? query.slice(1) : query;
137
+ if (!queryString) return result;
138
+ const pairs = queryString.split("&");
139
+ for (const pair of pairs) {
140
+ if (!pair) continue;
141
+ const [rawKey, ...rest] = pair.split("=");
142
+ const rawVal = rest.join("=");
143
+ const key = urlDecode(rawKey);
144
+ const val = urlDecode(rawVal);
145
+ if (key in result) {
146
+ const existing = result[key];
147
+ if (Array.isArray(existing)) existing.push(val);
148
+ else result[key] = [existing, val];
149
+ } else result[key] = val;
150
+ }
151
+ return result;
152
+ }
153
+ /**
154
+ * Helper to retrieve first query param value, matching Python `_qp(params, key, default)`.
155
+ */
156
+ function qp(params, key, defaultValue = "") {
157
+ if (!params) return defaultValue;
158
+ const val = params[key];
159
+ if (val === void 0) return defaultValue;
160
+ if (Array.isArray(val)) return val.length > 0 ? val[0] : defaultValue;
161
+ return val;
162
+ }
163
+ //#endregion
164
+ //#region src/server/xml.ts
165
+ /**
166
+ * Zero-dependency XML parser, DOM builder, and serializer for S3 XML payloads.
167
+ */
168
+ const S3_NS = "http://s3.amazonaws.com/doc/2006-03-01/";
169
+ const XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
170
+ var ParseError = class extends Error {
171
+ constructor(message) {
172
+ super(message);
173
+ this.name = "ParseError";
174
+ }
175
+ };
176
+ function escapeXml(str) {
177
+ if (str === null || str === void 0) return "";
178
+ return String(str).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
179
+ }
180
+ function unescapeXml(str) {
181
+ return str.replaceAll("&quot;", "\"").replaceAll("&apos;", "'").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&").replaceAll(/&#(\d+);/gu, (_, dec) => String.fromCodePoint(Math.trunc(Number(dec)))).replaceAll(/&#x([0-9a-fA-F]+);/gu, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)));
182
+ }
183
+ /**
184
+ * Strips namespace prefix or Clark notation from a tag name.
185
+ * e.g. "{http://...}Rule" -> "Rule", "s3:Rule" -> "Rule", "Rule" -> "Rule"
186
+ */
187
+ function localTagName(tag) {
188
+ if (tag.startsWith("{")) {
189
+ const idx = tag.indexOf("}");
190
+ if (idx !== -1) return tag.slice(idx + 1);
191
+ }
192
+ const colonIdx = tag.indexOf(":");
193
+ if (colonIdx !== -1) return tag.slice(colonIdx + 1);
194
+ return tag;
195
+ }
196
+ function isString(value) {
197
+ return typeof value === "string";
198
+ }
199
+ var XmlElement = class XmlElement {
200
+ tag;
201
+ attrs;
202
+ text;
203
+ children;
204
+ constructor(tag, attrs = {}, text) {
205
+ this.tag = tag;
206
+ this.attrs = { ...attrs };
207
+ this.text = text;
208
+ this.children = [];
209
+ }
210
+ localName() {
211
+ return localTagName(this.tag);
212
+ }
213
+ subElement(tag, textOrAttrs, text) {
214
+ const el = isString(textOrAttrs) ? new XmlElement(tag, {}, textOrAttrs) : new XmlElement(tag, textOrAttrs || {}, text);
215
+ this.children.push(el);
216
+ return el;
217
+ }
218
+ find(tag) {
219
+ const targetLocal = localTagName(tag);
220
+ return this.children.find((c) => c.tag === tag || c.localName() === targetLocal);
221
+ }
222
+ findAll(tag) {
223
+ const targetLocal = localTagName(tag);
224
+ return this.children.filter((c) => c.tag === tag || c.localName() === targetLocal);
225
+ }
226
+ findText(tag) {
227
+ return this.find(tag)?.text;
228
+ }
229
+ findDescendant(tag) {
230
+ const targetLocal = localTagName(tag);
231
+ for (const c of this.children) {
232
+ if (c.tag === tag || c.localName() === targetLocal) return c;
233
+ const deep = c.findDescendant(tag);
234
+ if (deep) return deep;
235
+ }
236
+ }
237
+ findAllDescendants(tag) {
238
+ const targetLocal = localTagName(tag);
239
+ const result = [];
240
+ for (const c of this.children) {
241
+ if (c.tag === tag || c.localName() === targetLocal) result.push(c);
242
+ result.push(...c.findAllDescendants(tag));
243
+ }
244
+ return result;
245
+ }
246
+ iter() {
247
+ const result = [this];
248
+ for (const child of this.children) result.push(...child.iter());
249
+ return result;
250
+ }
251
+ toXml(includeDeclaration = false) {
252
+ let s = includeDeclaration ? `${XML_DECL}\n` : "";
253
+ s += `<${this.tag}`;
254
+ for (const [k, v] of Object.entries(this.attrs)) s += ` ${k}="${escapeXml(v)}"`;
255
+ if (this.children.length === 0 && this.text === void 0) {
256
+ s += "/>";
257
+ return s;
258
+ }
259
+ s += ">";
260
+ if (this.text !== void 0) s += escapeXml(this.text);
261
+ for (const child of this.children) s += child.toXml(false);
262
+ s += `</${this.tag}>`;
263
+ return s;
264
+ }
265
+ };
266
+ function parseXml(xml) {
267
+ let str = (typeof xml === "string" ? xml : xml.toString("utf-8")).trim();
268
+ if (!str) throw new ParseError("Empty XML document");
269
+ if (str.startsWith("<?xml")) {
270
+ const end = str.indexOf("?>");
271
+ if (end !== -1) str = str.slice(end + 2).trim();
272
+ }
273
+ let pos = 0;
274
+ const len = str.length;
275
+ let root = null;
276
+ const stack = [];
277
+ while (pos < len) {
278
+ const lt = str.indexOf("<", pos);
279
+ if (lt === -1) {
280
+ if (stack.length > 0) {
281
+ const text = unescapeXml(str.slice(pos).trim());
282
+ const top = stack.at(-1);
283
+ if (text && top) top.text = (top.text || "") + text;
284
+ }
285
+ break;
286
+ }
287
+ if (lt > pos && stack.length > 0) {
288
+ const text = unescapeXml(str.slice(pos, lt).trim());
289
+ const top = stack.at(-1);
290
+ if (text && top) top.text = (top.text || "") + text;
291
+ }
292
+ pos = lt + 1;
293
+ if (str.startsWith("!--", pos)) {
294
+ const end = str.indexOf("-->", pos + 3);
295
+ pos = end === -1 ? len : end + 3;
296
+ continue;
297
+ }
298
+ if (str.startsWith("![CDATA[", pos)) {
299
+ const end = str.indexOf("]]>", pos + 8);
300
+ const cdata = end === -1 ? str.slice(pos + 8) : str.slice(pos + 8, end);
301
+ const top = stack.at(-1);
302
+ if (top) top.text = (top.text || "") + cdata;
303
+ pos = end === -1 ? len : end + 3;
304
+ continue;
305
+ }
306
+ if (str.startsWith("?", pos) || str.startsWith("!", pos)) {
307
+ const gt = str.indexOf(">", pos);
308
+ pos = gt === -1 ? len : gt + 1;
309
+ continue;
310
+ }
311
+ if (str[pos] === "/") {
312
+ const gt = str.indexOf(">", pos);
313
+ if (gt === -1) throw new ParseError("Unclosed closing tag");
314
+ const closeTag = str.slice(pos + 1, gt).trim();
315
+ const open = stack.pop();
316
+ if (!open) throw new ParseError(`Unexpected closing tag: </${closeTag}>`);
317
+ if (localTagName(open.tag) !== localTagName(closeTag) && open.tag !== closeTag) throw new ParseError(`Mismatched closing tag: expected </${open.tag}>, got </${closeTag}>`);
318
+ pos = gt + 1;
319
+ continue;
320
+ }
321
+ const gt = str.indexOf(">", pos);
322
+ if (gt === -1) throw new ParseError("Unclosed opening tag");
323
+ let tagBody = str.slice(pos, gt).trim();
324
+ const selfClosing = tagBody.endsWith("/");
325
+ if (selfClosing) tagBody = tagBody.slice(0, -1).trim();
326
+ const spaceIdx = tagBody.search(/\s/u);
327
+ const tagName = spaceIdx === -1 ? tagBody : tagBody.slice(0, spaceIdx);
328
+ const attrs = {};
329
+ if (spaceIdx !== -1) {
330
+ const attrStr = tagBody.slice(spaceIdx).trim();
331
+ const attrRegex = /([a-zA-Z0-9_:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu;
332
+ let m;
333
+ while ((m = attrRegex.exec(attrStr)) !== null) attrs[m[1]] = unescapeXml(m[2] ?? m[3] ?? m[4] ?? "");
334
+ }
335
+ const el = new XmlElement(tagName, attrs);
336
+ const top = stack.at(-1);
337
+ if (top) top.children.push(el);
338
+ else {
339
+ if (root) throw new ParseError("Multiple root elements found in XML document");
340
+ root = el;
341
+ }
342
+ if (!selfClosing) stack.push(el);
343
+ pos = gt + 1;
344
+ }
345
+ const lastUnclosed = stack.at(-1);
346
+ if (lastUnclosed) throw new ParseError(`Unclosed element: <${lastUnclosed.tag}>`);
347
+ if (!root) throw new ParseError("Empty XML document");
348
+ return root;
349
+ }
350
+ function xmlBody(root) {
351
+ return Buffer.from(`${XML_DECL}\n${root.toXml(false)}`, "utf-8");
352
+ }
353
+ //#endregion
354
+ //#region src/server/errors.ts
355
+ function s3Error(code, message, status, resource = "") {
356
+ const root = new XmlElement("Error");
357
+ root.subElement("Code", code);
358
+ root.subElement("Message", message);
359
+ root.subElement("Resource", resource);
360
+ root.subElement("RequestId", newUuid());
361
+ return [
362
+ status,
363
+ { "Content-Type": "application/xml" },
364
+ xmlBody(root)
365
+ ];
366
+ }
367
+ function noSuchBucket(bucketName) {
368
+ return s3Error("NoSuchBucket", "The specified bucket does not exist", 404, `/${bucketName}`);
369
+ }
370
+ function noSuchKey(bucketName, key, deleteMarkerHeaders) {
371
+ const [status, headers, body] = s3Error("NoSuchKey", "The specified key does not exist.", 404, `/${bucketName}/${key}`);
372
+ if (deleteMarkerHeaders) Object.assign(headers, deleteMarkerHeaders);
373
+ return [
374
+ status,
375
+ headers,
376
+ body
377
+ ];
378
+ }
379
+ function noSuchVersion(bucketName, key) {
380
+ return s3Error("NoSuchVersion", "The specified version does not exist.", 404, `/${bucketName}/${key}`);
381
+ }
382
+ function preconditionFailed(message = "At least one of the pre-conditions you specified did not hold", resource = "") {
383
+ return s3Error("PreconditionFailed", message, 412, resource);
384
+ }
385
+ function invalidRange(bucketName, key, totalSize) {
386
+ const root = new XmlElement("Error");
387
+ root.subElement("Code", "InvalidRange");
388
+ root.subElement("Message", "The requested range is not satisfiable");
389
+ root.subElement("Resource", `/${bucketName}/${key}`);
390
+ root.subElement("RequestId", newUuid());
391
+ return [
392
+ 416,
393
+ {
394
+ "Content-Type": "application/xml",
395
+ "Content-Range": `bytes */${totalSize}`
396
+ },
397
+ xmlBody(root)
398
+ ];
399
+ }
400
+ function notModified(respHeaders) {
401
+ const payloadHeaders = /* @__PURE__ */ new Set([
402
+ "content-length",
403
+ "content-type",
404
+ "content-encoding",
405
+ "accept-ranges"
406
+ ]);
407
+ const headers = {};
408
+ for (const [k, v] of Object.entries(respHeaders)) {
409
+ const lk = k.toLowerCase();
410
+ if (!payloadHeaders.has(lk) && !lk.startsWith("x-amz-checksum-")) headers[k] = v;
411
+ }
412
+ return [
413
+ 304,
414
+ headers,
415
+ Buffer.alloc(0)
416
+ ];
417
+ }
418
+ //#endregion
419
+ //#region src/server/types.ts
420
+ const S3_CHECKSUM_ALGORITHMS = [
421
+ "CRC32",
422
+ "CRC32C",
423
+ "CRC64NVME",
424
+ "SHA1",
425
+ "SHA256"
426
+ ];
427
+ //#endregion
428
+ //#region src/server/checksums.ts
429
+ /**
430
+ * S3 Checksums implementation: CRC64NVME, CRC32C, CRC32, SHA1, SHA256,
431
+ * Content-MD5 validation, and composite multipart checksums.
432
+ */
433
+ const CRC64NVME_POLY = 11127430586519243189n;
434
+ const CRC64NVME_INIT = 18446744073709551615n;
435
+ const CRC64NVME_TABLE = (() => {
436
+ const table = /* @__PURE__ */ new BigInt64Array(256);
437
+ for (let i = 0; i < 256; i += 1) {
438
+ let crc = BigInt(i);
439
+ for (let j = 0; j < 8; j += 1) crc = (crc & 1n) === 0n ? crc >> 1n : crc >> 1n ^ CRC64NVME_POLY;
440
+ table[i] = crc;
441
+ }
442
+ return table;
443
+ })();
444
+ function crc64nvme(body) {
445
+ let crc = CRC64NVME_INIT;
446
+ for (let i = 0; i < body.length; i += 1) {
447
+ const idx = Number((crc ^ BigInt(body[i])) & 255n);
448
+ crc = (CRC64NVME_TABLE[idx] ^ crc >> 8n) & 18446744073709551615n;
449
+ }
450
+ return (crc ^ CRC64NVME_INIT) & 18446744073709551615n;
451
+ }
452
+ const CRC32C_POLY = 2197175160;
453
+ const CRC32C_INIT = 4294967295;
454
+ const CRC32C_TABLE = (() => {
455
+ const table = /* @__PURE__ */ new Uint32Array(256);
456
+ for (let i = 0; i < 256; i += 1) {
457
+ let crc = i >>> 0;
458
+ for (let j = 0; j < 8; j += 1) crc = (crc & 1) === 0 ? crc >>> 1 >>> 0 : (crc >>> 1 ^ CRC32C_POLY) >>> 0;
459
+ table[i] = crc;
460
+ }
461
+ return table;
462
+ })();
463
+ function crc32c(body) {
464
+ let crc = CRC32C_INIT;
465
+ for (let i = 0; i < body.length; i += 1) {
466
+ const idx = (crc ^ body[i]) & 255;
467
+ crc = (CRC32C_TABLE[idx] ^ crc >>> 8) >>> 0;
468
+ }
469
+ return (crc ^ CRC32C_INIT) >>> 0;
470
+ }
471
+ /**
472
+ * Compute the base64-encoded checksum for the given algorithm name.
473
+ */
474
+ function computeS3Checksum(algorithm, body) {
475
+ const algo = (algorithm || "").toUpperCase().replaceAll("_", "");
476
+ if (algo === "SHA256") return createHash("sha256").update(body).digest("base64");
477
+ if (algo === "SHA1") return createHash("sha1").update(body).digest("base64");
478
+ if (algo === "CRC32") {
479
+ const crc = crc32(body) >>> 0;
480
+ const buf = Buffer.alloc(4);
481
+ buf.writeUInt32BE(crc, 0);
482
+ return buf.toString("base64");
483
+ }
484
+ if (algo === "CRC32C") {
485
+ const crc = crc32c(body);
486
+ const buf = Buffer.alloc(4);
487
+ buf.writeUInt32BE(crc, 0);
488
+ return buf.toString("base64");
489
+ }
490
+ if (algo === "CRC64NVME") {
491
+ const crc = crc64nvme(body);
492
+ const buf = Buffer.alloc(8);
493
+ buf.writeBigUInt64BE(crc, 0);
494
+ return buf.toString("base64");
495
+ }
496
+ return null;
497
+ }
498
+ /**
499
+ * Validate Content-MD5 header on write requests.
500
+ */
501
+ function validateContentMd5(headers, body) {
502
+ const md5Header = headers["content-md5"] || "";
503
+ if (!md5Header) return null;
504
+ let expected;
505
+ try {
506
+ expected = Buffer.from(md5Header, "base64");
507
+ if (expected.toString("base64") !== md5Header.trim()) return s3Error("InvalidDigest", "The Content-MD5 you specified is not valid.", 400);
508
+ } catch {
509
+ return s3Error("InvalidDigest", "The Content-MD5 you specified is not valid.", 400);
510
+ }
511
+ if (expected.length !== 16) return s3Error("InvalidDigest", "The Content-MD5 you specified is not valid.", 400);
512
+ const actual = createHash("md5").update(body).digest();
513
+ if (!expected.equals(actual)) return s3Error("BadDigest", "The Content-MD5 you specified did not match what we received.", 400);
514
+ return null;
515
+ }
516
+ const CHECKSUM_HEADERS_LOWER = [
517
+ "crc32",
518
+ "crc32c",
519
+ "crc64nvme",
520
+ "sha1",
521
+ "sha256"
522
+ ];
523
+ function resolveObjectChecksums(body, headers) {
524
+ const provided = {};
525
+ const unverifiable = [];
526
+ for (const alg of CHECKSUM_HEADERS_LOWER) {
527
+ const headerVal = headers[`x-amz-checksum-${alg}`];
528
+ if (headerVal) {
529
+ const uAlg = alg.toUpperCase();
530
+ provided[uAlg] = headerVal;
531
+ if (computeS3Checksum(uAlg, Buffer.alloc(0)) === null) unverifiable.push(uAlg);
532
+ }
533
+ }
534
+ const sdkAlgRaw = headers["x-amz-sdk-checksum-algorithm"];
535
+ let sdkKey = null;
536
+ if (sdkAlgRaw) {
537
+ sdkKey = sdkAlgRaw.toUpperCase().replaceAll("_", "");
538
+ if (computeS3Checksum(sdkAlgRaw, Buffer.alloc(0)) === null) unverifiable.push(sdkKey);
539
+ }
540
+ if (unverifiable.length > 0) return {
541
+ checksums: {},
542
+ error: s3Error("InvalidRequest", `Checksum algorithm not supported: ${unverifiable.sort().join(", ")}. Supported: SHA256, SHA1, CRC32, CRC32C, CRC64NVME.`, 400)
543
+ };
544
+ for (const [alg, supplied] of Object.entries(provided)) {
545
+ const computed = computeS3Checksum(alg, body);
546
+ if (computed !== null && supplied !== computed) return {
547
+ checksums: {},
548
+ error: s3Error("BadDigest", `The ${alg} you specified did not match the calculated checksum.`, 400)
549
+ };
550
+ }
551
+ const checksums = { ...provided };
552
+ if (sdkAlgRaw && sdkKey) {
553
+ const computed = computeS3Checksum(sdkAlgRaw, body);
554
+ if (computed !== null) checksums[sdkKey] = computed;
555
+ }
556
+ return {
557
+ checksums,
558
+ error: null
559
+ };
560
+ }
561
+ function resolveMultipartChecksumAlgorithm(headers) {
562
+ const raw = headers["x-amz-checksum-algorithm"] || headers["x-amz-sdk-checksum-algorithm"];
563
+ if (!raw) return {
564
+ algorithm: null,
565
+ error: null
566
+ };
567
+ const algorithm = raw.toUpperCase().replaceAll("_", "");
568
+ if (!S3_CHECKSUM_ALGORITHMS.includes(algorithm)) return {
569
+ algorithm: null,
570
+ error: s3Error("InvalidRequest", `Checksum algorithm not supported: ${algorithm}. Supported: SHA256, SHA1, CRC32, CRC32C, CRC64NVME.`, 400)
571
+ };
572
+ return {
573
+ algorithm,
574
+ error: null
575
+ };
576
+ }
577
+ function resolvePartChecksums(body, headers, upload) {
578
+ const { checksums, error } = resolveObjectChecksums(body, headers);
579
+ if (error) return {
580
+ checksums: {},
581
+ error
582
+ };
583
+ const algorithm = upload.checksumAlgorithm;
584
+ if (algorithm && !(algorithm in checksums)) {
585
+ const computed = computeS3Checksum(algorithm, body);
586
+ if (computed !== null) checksums[algorithm] = computed;
587
+ }
588
+ return {
589
+ checksums,
590
+ error: null
591
+ };
592
+ }
593
+ function compositeChecksum(algorithm, partChecksums) {
594
+ if (!algorithm || partChecksums.length === 0) return null;
595
+ const chunks = [];
596
+ for (const val of partChecksums) {
597
+ if (!val) return null;
598
+ try {
599
+ chunks.push(Buffer.from(val, "base64"));
600
+ } catch {
601
+ return null;
602
+ }
603
+ }
604
+ const digest = computeS3Checksum(algorithm, Buffer.concat(chunks));
605
+ if (!digest) return null;
606
+ return `${digest}-${partChecksums.length}`;
607
+ }
608
+ //#endregion
609
+ //#region src/server/cors.ts
610
+ /** S3 permits a single wildcard in origins and header patterns. */
611
+ function matches(pattern, value) {
612
+ const wildcard = pattern.indexOf("*");
613
+ if (wildcard === -1) return pattern === value;
614
+ return value.length >= pattern.length - 1 && value.startsWith(pattern.slice(0, wildcard)) && value.endsWith(pattern.slice(wildcard + 1));
615
+ }
616
+ function corsResponseHeaders(configuration, method, headers) {
617
+ const origin = headers["origin"];
618
+ if (!origin || !configuration) return void 0;
619
+ const preflight = method === "OPTIONS";
620
+ const requestedMethod = preflight ? headers["access-control-request-method"] : method;
621
+ const requestedHeaders = preflight ? (headers["access-control-request-headers"] || "").split(",").map((header) => header.trim().toLowerCase()).filter(Boolean) : [];
622
+ try {
623
+ for (const rule of parseXml(configuration).findAll("CORSRule")) {
624
+ const origins = rule.findAll("AllowedOrigin").map((element) => element.text || "");
625
+ const methods = rule.findAll("AllowedMethod").map((element) => element.text || "");
626
+ const allowedHeaders = rule.findAll("AllowedHeader").map((element) => (element.text || "").toLowerCase());
627
+ if (!origins.some((pattern) => matches(pattern, origin)) || !methods.includes(requestedMethod) || !requestedHeaders.every((header) => allowedHeaders.some((pattern) => matches(pattern, header)))) continue;
628
+ const result = {
629
+ "Access-Control-Allow-Origin": origin,
630
+ Vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
631
+ };
632
+ if (preflight) {
633
+ result["Access-Control-Allow-Methods"] = methods.join(", ");
634
+ if (requestedHeaders.length) result["Access-Control-Allow-Headers"] = requestedHeaders.join(", ");
635
+ const maxAge = rule.findText("MaxAgeSeconds");
636
+ if (maxAge !== void 0) result["Access-Control-Max-Age"] = maxAge;
637
+ }
638
+ const exposed = rule.findAll("ExposeHeader").map((element) => element.text || "");
639
+ if (exposed.length) result["Access-Control-Expose-Headers"] = exposed.join(", ");
640
+ return result;
641
+ }
642
+ } catch {}
643
+ }
644
+ //#endregion
645
+ //#region src/server/lifecycle.ts
646
+ function days(element, name) {
647
+ const value = Number(element.findText(name));
648
+ if (!Number.isInteger(value) || value < 1) throw new Error("Lifecycle days must be positive integers.");
649
+ return value;
650
+ }
651
+ function onlyChildren(element, names) {
652
+ if (element.children.some((child) => !names.includes(child.localName()))) throw new Error("Unsupported lifecycle configuration.");
653
+ }
654
+ function parseLifecycleXml(body) {
655
+ const root = parseXml(body);
656
+ if (root.localName() !== "LifecycleConfiguration") throw new Error("Invalid lifecycle root.");
657
+ onlyChildren(root, ["Rule"]);
658
+ return root.findAll("Rule").map((element) => {
659
+ onlyChildren(element, [
660
+ "ID",
661
+ "Status",
662
+ "Filter",
663
+ "Expiration",
664
+ "Transition",
665
+ "AbortIncompleteMultipartUpload"
666
+ ]);
667
+ const status = element.findText("Status");
668
+ if (status !== "Enabled" && status !== "Disabled") throw new Error("Invalid lifecycle status.");
669
+ const rule = {
670
+ ID: element.findText("ID"),
671
+ Status: status
672
+ };
673
+ const filter = element.find("Filter");
674
+ if (filter) {
675
+ onlyChildren(filter, ["Prefix"]);
676
+ rule.Filter = { Prefix: filter.findText("Prefix") };
677
+ }
678
+ const expiration = element.find("Expiration");
679
+ if (expiration) {
680
+ onlyChildren(expiration, ["Days"]);
681
+ rule.Expiration = { Days: days(expiration, "Days") };
682
+ }
683
+ rule.Transitions = element.findAll("Transition").map((transition) => {
684
+ onlyChildren(transition, ["Days", "StorageClass"]);
685
+ if (transition.findText("StorageClass") !== "STANDARD_IA") throw new Error("Unsupported lifecycle transition.");
686
+ return {
687
+ Days: days(transition, "Days"),
688
+ StorageClass: "STANDARD_IA"
689
+ };
690
+ });
691
+ const abort = element.find("AbortIncompleteMultipartUpload");
692
+ if (abort) {
693
+ onlyChildren(abort, ["DaysAfterInitiation"]);
694
+ rule.AbortIncompleteMultipartUpload = { DaysAfterInitiation: days(abort, "DaysAfterInitiation") };
695
+ }
696
+ return rule;
697
+ });
698
+ }
699
+ //#endregion
700
+ //#region src/server/sigv4.ts
701
+ /**
702
+ * AWS Signature Version 4 (SigV4) presigned URL verification and header hoisting.
703
+ */
704
+ const SIGV4_UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
705
+ const EXTRA_URI_CHARACTERS = /[!'()*]/gu;
706
+ const PRESIGN_SIGNING_PARAMS = /* @__PURE__ */ new Set([
707
+ "x-amz-algorithm",
708
+ "x-amz-credential",
709
+ "x-amz-date",
710
+ "x-amz-expires",
711
+ "x-amz-signedheaders",
712
+ "x-amz-signature",
713
+ "x-amz-security-token",
714
+ "x-amz-content-sha256"
715
+ ]);
716
+ /**
717
+ * RFC 3986 URI encoding per SigV4 specification:
718
+ * Unreserved characters (A-Za-z0-9-_.~) remain literal; everything else is percent-encoded.
719
+ * If encodeSlash is false, "/" is preserved (used for path components).
720
+ */
721
+ function uriEncode(value, encodeSlash = true) {
722
+ const encoded = encodeURIComponent(value).replaceAll(EXTRA_URI_CHARACTERS, (character) => `%${character.codePointAt(0)?.toString(16).toUpperCase()}`);
723
+ return encodeSlash ? encoded : encoded.replaceAll("%2F", "/");
724
+ }
725
+ function sigv4SigningKey(secret, dateStamp, region, service) {
726
+ const hmac = (key, msg) => {
727
+ return createHmac("sha256", key).update(msg, "utf-8").digest();
728
+ };
729
+ return hmac(hmac(hmac(hmac(`AWS4${secret}`, dateStamp), region), service), "aws4_request");
730
+ }
731
+ function resolvePresignSecret(_accessKeyId) {
732
+ return process.env.AWS_SECRET_ACCESS_KEY || "test";
733
+ }
734
+ function hasAnyPresignParam(queryParams) {
735
+ for (const key of Object.keys(queryParams)) if (PRESIGN_SIGNING_PARAMS.has(key.toLowerCase())) return true;
736
+ return false;
737
+ }
738
+ function hasDuplicatePresignParams(queryParams) {
739
+ const seen = /* @__PURE__ */ new Set();
740
+ for (const [key, val] of Object.entries(queryParams)) {
741
+ const lk = key.toLowerCase();
742
+ if (PRESIGN_SIGNING_PARAMS.has(lk)) {
743
+ if (seen.has(lk) || Array.isArray(val)) return true;
744
+ seen.add(lk);
745
+ }
746
+ }
747
+ return false;
748
+ }
749
+ function verifyPresignedSigV4(method, path, headers, queryParams) {
750
+ if (!hasAnyPresignParam(queryParams)) return null;
751
+ const badSignature = () => {
752
+ return s3Error("SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided. Check your key and signing method.", 403, path);
753
+ };
754
+ if (hasDuplicatePresignParams(queryParams)) return s3Error("AuthorizationQueryParametersError", "Duplicate query parameter in request.", 400, path);
755
+ const signature = qp(queryParams, "X-Amz-Signature") || qp(queryParams, "x-amz-signature");
756
+ const algorithm = qp(queryParams, "X-Amz-Algorithm") || qp(queryParams, "x-amz-algorithm");
757
+ const credential = qp(queryParams, "X-Amz-Credential") || qp(queryParams, "x-amz-credential");
758
+ const amzDate = qp(queryParams, "X-Amz-Date") || qp(queryParams, "x-amz-date");
759
+ const signedHeaders = qp(queryParams, "X-Amz-SignedHeaders") || qp(queryParams, "x-amz-signedheaders");
760
+ if (!algorithm || algorithm !== "AWS4-HMAC-SHA256") return badSignature();
761
+ if (!signature || !credential || !amzDate || !signedHeaders) return badSignature();
762
+ const credParts = credential.split("/");
763
+ if (credParts.length !== 5 || credParts[4] !== "aws4_request" || credParts[3] !== "s3") return badSignature();
764
+ const [akid, dateStamp, region, service] = credParts;
765
+ const m = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/u);
766
+ if (!m || amzDate.slice(0, 8) !== dateStamp) return badSignature();
767
+ const expires = qp(queryParams, "X-Amz-Expires") || qp(queryParams, "x-amz-expires");
768
+ if (!/^\d+$/u.test(expires) || Number(expires) < 1 || Number(expires) > 604800) return s3Error("AuthorizationQueryParametersError", "X-Amz-Expires must be a valid integer between 1 and 604800.", 400, path);
769
+ const signedAt = /* @__PURE__ */ new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
770
+ if (!Number.isFinite(signedAt.getTime()) || signedAt.toISOString().replaceAll(/[-:]/gu, "").replace(".000", "") !== amzDate) return badSignature();
771
+ if (!akid || !region) return badSignature();
772
+ const requiredHeaders = signedHeaders.split(";");
773
+ const presentHeaders = new Set(Object.keys(headers).map((header) => header.toLowerCase()));
774
+ if (!requiredHeaders.includes("host") || new Set(requiredHeaders).size !== requiredHeaders.length || requiredHeaders.toSorted().join(";") !== signedHeaders || requiredHeaders.some((header) => !/^[a-z0-9-]+$/u.test(header) || !presentHeaders.has(header))) return badSignature();
775
+ if (Date.now() >= signedAt.getTime() + Number(expires) * 1e3) return s3Error("AccessDenied", "Request has expired", 403, path);
776
+ if (signedAt.getTime() > Date.now() + 9e5) return badSignature();
777
+ const pairs = [];
778
+ for (const [name, values] of Object.entries(queryParams)) {
779
+ if (name.toLowerCase() === "x-amz-signature") continue;
780
+ const vlist = Array.isArray(values) ? values : [values];
781
+ for (const v of vlist) pairs.push([uriEncode(name), uriEncode(v ?? "")]);
782
+ }
783
+ pairs.sort((a, b) => {
784
+ const left = a[0] === b[0] ? a[1] : a[0];
785
+ const right = a[0] === b[0] ? b[1] : b[0];
786
+ if (left < right) return -1;
787
+ return left > right ? 1 : 0;
788
+ });
789
+ const canonicalQs = pairs.map(([k, v]) => `${k}=${v}`).join("&");
790
+ let canonicalHeaders = "";
791
+ const headerList = signedHeaders.split(";").filter(Boolean);
792
+ const lowerHeaders = {};
793
+ for (const [k, v] of Object.entries(headers)) lowerHeaders[k.toLowerCase()] = v;
794
+ for (const hname of headerList) {
795
+ const lh = hname.toLowerCase();
796
+ const collapsed = (lowerHeaders[lh] ?? "").toString().trim().replaceAll(/\s+/gu, " ");
797
+ canonicalHeaders += `${lh}:${collapsed}\n`;
798
+ }
799
+ const canonicalRequest = [
800
+ method,
801
+ path,
802
+ canonicalQs,
803
+ canonicalHeaders,
804
+ signedHeaders,
805
+ SIGV4_UNSIGNED_PAYLOAD
806
+ ].join("\n");
807
+ const stringToSign = [
808
+ "AWS4-HMAC-SHA256",
809
+ amzDate,
810
+ `${dateStamp}/${region}/${service}/aws4_request`,
811
+ sha256Hex(canonicalRequest)
812
+ ].join("\n");
813
+ const signingKey = sigv4SigningKey(resolvePresignSecret(akid), dateStamp, region, service);
814
+ const computed = createHmac("sha256", signingKey).update(stringToSign, "utf-8").digest("hex");
815
+ const computedBuf = Buffer.from(computed.toLowerCase(), "utf-8");
816
+ const providedBuf = Buffer.from(signature.toLowerCase(), "utf-8");
817
+ if (computedBuf.length !== providedBuf.length || !timingSafeEqual(computedBuf, providedBuf)) return badSignature();
818
+ return null;
819
+ }
820
+ /**
821
+ * Merges hoisted `x-amz-*` query parameters into the request headers.
822
+ */
823
+ function mergeHoistedAmzHeaders(headers, queryParams) {
824
+ let hoisted = null;
825
+ for (const [name, values] of Object.entries(queryParams)) {
826
+ const lname = name.toLowerCase();
827
+ if (!lname.startsWith("x-amz-") || PRESIGN_SIGNING_PARAMS.has(lname)) continue;
828
+ if (lname in headers) continue;
829
+ const val = Array.isArray(values) ? values[0] : values;
830
+ if (val === void 0 || val === null) continue;
831
+ if (!hoisted) hoisted = { ...headers };
832
+ hoisted[lname] = String(val);
833
+ }
834
+ return hoisted ?? headers;
835
+ }
836
+ //#endregion
837
+ //#region src/server/storage-schema.ts
838
+ const BODY_REFERENCE = /^[a-f0-9]{64}$/u;
839
+ function record(value) {
840
+ return value !== null && typeof value === "object" && !Array.isArray(value);
841
+ }
842
+ function text(value) {
843
+ return typeof value === "string";
844
+ }
845
+ function timestamp(value) {
846
+ return text(value) && Number.isFinite(Date.parse(value));
847
+ }
848
+ function size(value) {
849
+ return Number.isSafeInteger(value) && Number(value) >= 0;
850
+ }
851
+ function strings(value) {
852
+ return record(value) && Object.values(value).every(text);
853
+ }
854
+ function optional(value, check) {
855
+ return value === void 0 || check(value);
856
+ }
857
+ function bodyFields(value, version) {
858
+ return text(value.etag) && timestamp(value.lastModified) && size(value.size) && optional(value.contentType, text) && optional(value.contentEncoding, text) && optional(value.metadata, strings) && optional(value.preservedHeaders, strings) && optional(value.checksums, strings) && optional(value.checksumType, (type) => type === "FULL_OBJECT" || type === "COMPOSITE") && optional(value.bodyRef, (ref) => text(ref) && BODY_REFERENCE.test(ref)) && (version < 2 || value.isDeleteMarker === true || text(value.bodyRef));
859
+ }
860
+ function object(value, version) {
861
+ return record(value) && value.body === null && (version < 2 || text(value.bodyRef)) && text(value.contentType) && strings(value.metadata) && strings(value.preservedHeaders) && strings(value.checksums) && optional(value.versionId, (id) => id === null || text(id)) && bodyFields(value, version);
862
+ }
863
+ function versions(value, version) {
864
+ return Array.isArray(value) && value.every((item) => record(item) && text(item.versionId) && typeof item.isLatest === "boolean" && optional(item.isDeleteMarker, (flag) => typeof flag === "boolean") && (item.data === void 0 || item.data === null) && bodyFields(item, version));
865
+ }
866
+ function positive(value) {
867
+ return size(value) && Number(value) > 0;
868
+ }
869
+ function lifecycle(value) {
870
+ return Array.isArray(value) && value.every((rule) => record(rule) && (rule.Status === "Enabled" || rule.Status === "Disabled") && optional(rule.ID, text) && optional(rule.Filter, (filter) => record(filter) && optional(filter.Prefix, text)) && optional(rule.Expiration, (expiry) => record(expiry) && positive(expiry.Days)) && optional(rule.AbortIncompleteMultipartUpload, (abort) => record(abort) && positive(abort.DaysAfterInitiation)) && optional(rule.Transitions, (transitions) => Array.isArray(transitions) && transitions.every((entry) => record(entry) && positive(entry.Days) && entry.StorageClass === "STANDARD_IA")));
871
+ }
872
+ function isValidState(value) {
873
+ if (!record(value)) throw new Error("Corrupted persistence state manifest: expected object.");
874
+ if (value.version !== void 0 && value.version !== 1 && value.version !== 2) throw new Error(`Unsupported persistence state manifest version: ${String(value.version)}`);
875
+ const version = Number(value.version ?? 1);
876
+ const map = (input, check) => record(input) && Object.entries(input).every(([key, entry]) => key.includes("::") && check(entry));
877
+ const optionalMap = (input, check) => version < 2 && input === void 0 || map(input, check);
878
+ if (!map(value.buckets_meta, (bucket) => record(bucket) && timestamp(bucket.created) && text(bucket.region) && record(bucket.objects) && Object.values(bucket.objects).every((entry) => object(entry, version))) || !optionalMap(value.bucket_versioning, (entry) => entry === "Enabled" || entry === "Suspended") || !optionalMap(value.bucket_cors, text) || !optionalMap(value.bucket_lifecycle, lifecycle) || !optionalMap(value.object_versions, (entry) => versions(entry, version))) throw new Error("Corrupted persistence state manifest: invalid state schema.");
879
+ return true;
880
+ }
881
+ function validateState(value) {
882
+ if (!isValidState(value)) throw new Error("Invalid persistence state.");
883
+ return value;
884
+ }
885
+ //#endregion
886
+ //#region src/server/storage.ts
887
+ /**
888
+ * In-memory storage engine and filesystem persistence for S3 emulator.
889
+ */
890
+ function writeAll(fd, bytes) {
891
+ let offset = 0;
892
+ while (offset < bytes.length) {
893
+ const written = writeSync(fd, bytes, offset, bytes.length - offset, offset);
894
+ if (written === 0) throw new Error("Persistence write made no progress.");
895
+ offset += written;
896
+ }
897
+ }
898
+ var S3Storage = class {
899
+ dataDir;
900
+ persist;
901
+ committedState;
902
+ buckets = new AccountScopedMap();
903
+ bucketVersioning = new AccountScopedMap();
904
+ bucketLifecycle = new AccountScopedMap();
905
+ bucketCors = new AccountScopedMap();
906
+ objectVersions = new AccountScopedMap();
907
+ multipartUploads = new AccountScopedMap();
908
+ completedMultipartUploads = new AccountScopedMap();
909
+ constructor(options = {}) {
910
+ this.dataDir = options.dataDir || process.env.S3_DATA_DIR || "/tmp/emulator/s3";
911
+ this.persist = options.persist ?? process.env.S3_PERSIST === "1";
912
+ if (this.persist) this.loadPersistedData();
913
+ }
914
+ objectDiskPath(bucket, key, accountId) {
915
+ return join(this.bucketDiskPath(bucket, accountId), createHash("sha256").update(key).digest("hex"), "body");
916
+ }
917
+ bucketDiskPath(bucket, accountId = getAccountId()) {
918
+ const digest = createHash("sha256").update(JSON.stringify([accountId, bucket])).digest("hex");
919
+ return join(this.dataDir, "buckets", digest);
920
+ }
921
+ versionDiskPath(bucket, key, versionId, accountId) {
922
+ const basePath = this.objectDiskPath(bucket, key, accountId);
923
+ return join(dirname(basePath), "versions", createHash("sha256").update(versionId).digest("hex"));
924
+ }
925
+ syncDirectory(directory) {
926
+ if (process.platform === "win32") return;
927
+ const fd = openSync(directory, "r");
928
+ try {
929
+ fsyncSync(fd);
930
+ } finally {
931
+ closeSync(fd);
932
+ }
933
+ }
934
+ atomicWrite(filePath, data) {
935
+ const tmp = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
936
+ const dir = dirname(filePath);
937
+ const missing = [];
938
+ for (let current = dir; !existsSync(current); current = dirname(current)) missing.push(current);
939
+ mkdirSync(dir, {
940
+ recursive: true,
941
+ mode: 448
942
+ });
943
+ for (const created of missing.toReversed()) this.syncDirectory(dirname(created));
944
+ try {
945
+ const fd = openSync(tmp, "wx", 384);
946
+ try {
947
+ writeAll(fd, Buffer.isBuffer(data) ? data : Buffer.from(data, "utf-8"));
948
+ fsyncSync(fd);
949
+ } finally {
950
+ closeSync(fd);
951
+ }
952
+ renameSync(tmp, filePath);
953
+ this.syncDirectory(dir);
954
+ } catch (error) {
955
+ if (existsSync(tmp)) unlinkSync(tmp);
956
+ throw error;
957
+ }
958
+ }
959
+ blobPath(reference) {
960
+ return join(this.dataDir, "blobs", reference);
961
+ }
962
+ persistBody(body) {
963
+ const reference = createHash("sha256").update(body).digest("hex");
964
+ const path = this.blobPath(reference);
965
+ if (existsSync(path)) this.syncDirectory(dirname(path));
966
+ else this.atomicWrite(path, body);
967
+ return reference;
968
+ }
969
+ readBody(bucketName, key, obj) {
970
+ if (obj?.body !== null && obj?.body !== void 0) return obj.body;
971
+ if (!this.persist) return Buffer.alloc(0);
972
+ if (obj?.bodyRef) {
973
+ if (!existsSync(this.blobPath(obj.bodyRef))) throw new Error(`Persisted object body not found for key: ${key}`);
974
+ return readFileSync(this.blobPath(obj.bodyRef));
975
+ }
976
+ if (obj?.versionId) {
977
+ const vpath = this.versionDiskPath(bucketName, key, obj.versionId);
978
+ return readFileSync(vpath);
979
+ }
980
+ const fpath = this.objectDiskPath(bucketName, key);
981
+ if (!existsSync(fpath)) throw new Error(`Persisted object body not found for key: ${key}`);
982
+ return readFileSync(fpath);
983
+ }
984
+ readVersionBody(bucketName, key, v) {
985
+ if (v.data !== null && v.data !== void 0) return v.data;
986
+ if (!this.persist) return Buffer.alloc(0);
987
+ if (v.bodyRef) return readFileSync(this.blobPath(v.bodyRef));
988
+ const vpath = this.versionDiskPath(bucketName, key, v.versionId);
989
+ if (existsSync(vpath)) return readFileSync(vpath);
990
+ throw new Error(`Persisted version body not found for key: ${key}, version: ${v.versionId}`);
991
+ }
992
+ deletePersistedObject(_bucketName, _key) {}
993
+ /** Commit immutable body references before releasing any in-memory content. */
994
+ persistState() {
995
+ if (!this.persist) return;
996
+ try {
997
+ const state = this.getState();
998
+ for (const [scopedKey, bucket] of this.buckets._data) {
999
+ const separator = scopedKey.indexOf("::");
1000
+ const accountId = scopedKey.slice(0, separator);
1001
+ const bucketName = scopedKey.slice(separator + 2);
1002
+ withAccountContext({ accountId }, () => {
1003
+ for (const [key, obj] of Object.entries(bucket.objects)) state.buckets_meta[scopedKey].objects[key].bodyRef = obj.bodyRef && obj.body === null ? obj.bodyRef : this.persistBody(this.readBody(bucketName, key, obj));
1004
+ for (const [[name, key], versions] of this.objectVersions.entries()) {
1005
+ if (name !== bucketName) continue;
1006
+ const stored = state.object_versions?.[`${accountId}::${JSON.stringify([name, key])}`];
1007
+ if (!stored) throw new Error("Missing version snapshot.");
1008
+ for (const [index, version] of versions.entries()) {
1009
+ if (version.isDeleteMarker) continue;
1010
+ stored[index].bodyRef = version.bodyRef && (version.data === null || version.data === void 0) ? version.bodyRef : this.persistBody(this.readVersionBody(name, key, version));
1011
+ stored[index].data = null;
1012
+ }
1013
+ }
1014
+ });
1015
+ }
1016
+ validateState(state);
1017
+ this.atomicWrite(join(this.dataDir, ".config", "state.json"), JSON.stringify(state));
1018
+ this.committedState = state;
1019
+ this.replaceState(state);
1020
+ } catch (error) {
1021
+ this.clearState();
1022
+ this.completedMultipartUploads._data.clear();
1023
+ this.loadPersistedData(false);
1024
+ throw error;
1025
+ }
1026
+ }
1027
+ clearState() {
1028
+ this.buckets._data.clear();
1029
+ this.bucketVersioning._data.clear();
1030
+ this.bucketLifecycle._data.clear();
1031
+ this.bucketCors._data.clear();
1032
+ this.objectVersions._data.clear();
1033
+ }
1034
+ replaceState(state) {
1035
+ this.clearState();
1036
+ this.restoreState(structuredClone(state));
1037
+ }
1038
+ loadPersistedData(reclaim = true) {
1039
+ const manifest = join(this.dataDir, ".config", "state.json");
1040
+ if (!existsSync(manifest)) return;
1041
+ let parsed;
1042
+ try {
1043
+ parsed = JSON.parse(readFileSync(manifest, "utf-8"));
1044
+ } catch {
1045
+ throw new Error("Corrupted persistence state manifest: invalid JSON.");
1046
+ }
1047
+ const state = validateState(parsed);
1048
+ this.committedState = state;
1049
+ this.replaceState(state);
1050
+ if (reclaim) this.reclaimBlobs();
1051
+ }
1052
+ reclaimBlobs() {
1053
+ const directory = join(this.dataDir, "blobs");
1054
+ if (!existsSync(directory) || !this.committedState) return;
1055
+ const references = /* @__PURE__ */ new Set();
1056
+ for (const bucket of Object.values(this.committedState.buckets_meta)) for (const object of Object.values(bucket.objects)) if (object.bodyRef) references.add(object.bodyRef);
1057
+ for (const versions of Object.values(this.committedState.object_versions ?? {})) for (const version of versions) if (version.bodyRef) references.add(version.bodyRef);
1058
+ for (const reference of references) if (!existsSync(this.blobPath(reference))) throw new Error("Persisted object body not found.");
1059
+ for (const name of readdirSync(directory)) if (!references.has(name)) unlinkSync(join(directory, name));
1060
+ }
1061
+ getState() {
1062
+ const bucketsMeta = {};
1063
+ for (const [scopedKey, bkt] of this.buckets._data.entries()) {
1064
+ const objectsMeta = Object.create(null);
1065
+ for (const [k, obj] of Object.entries(bkt.objects)) objectsMeta[k] = {
1066
+ ...obj,
1067
+ body: null
1068
+ };
1069
+ const { objects: _objects, ...meta } = bkt;
1070
+ bucketsMeta[scopedKey] = {
1071
+ ...meta,
1072
+ objects: objectsMeta
1073
+ };
1074
+ }
1075
+ return {
1076
+ version: 2,
1077
+ buckets_meta: bucketsMeta,
1078
+ bucket_versioning: Object.fromEntries(this.bucketVersioning._data),
1079
+ bucket_lifecycle: Object.fromEntries(this.bucketLifecycle._data),
1080
+ bucket_cors: Object.fromEntries(this.bucketCors._data),
1081
+ object_versions: structuredClone(Object.fromEntries(this.objectVersions._data))
1082
+ };
1083
+ }
1084
+ restoreState(state) {
1085
+ if (!state) return;
1086
+ if (state.buckets_meta) {
1087
+ if (typeof state.buckets_meta !== "object" || state.buckets_meta === null) throw new Error("Corrupted persistence state manifest: invalid buckets_meta.");
1088
+ for (const [scopedKey, meta] of Object.entries(state.buckets_meta)) if (!this.buckets._data.has(scopedKey)) {
1089
+ const objects = meta.objects || {};
1090
+ this.buckets._data.set(scopedKey, {
1091
+ ...meta,
1092
+ objects: Object.assign(Object.create(null), objects)
1093
+ });
1094
+ }
1095
+ }
1096
+ const importMap = (map, data) => {
1097
+ if (!data) return;
1098
+ for (const [k, v] of Object.entries(data)) map._data.set(k, v);
1099
+ };
1100
+ importMap(this.bucketVersioning, state.bucket_versioning);
1101
+ importMap(this.bucketLifecycle, state.bucket_lifecycle);
1102
+ importMap(this.bucketCors, state.bucket_cors);
1103
+ importMap(this.objectVersions, state.object_versions);
1104
+ }
1105
+ };
1106
+ //#endregion
1107
+ //#region src/server/service.ts
1108
+ /**
1109
+ * Core S3 Service Emulator implementation.
1110
+ */
1111
+ const PRESERVED_HEADERS = [
1112
+ "cache-control",
1113
+ "content-disposition",
1114
+ "content-language",
1115
+ "expires"
1116
+ ];
1117
+ const RESPONSE_OVERRIDE_PARAMS = [
1118
+ "response-cache-control",
1119
+ "response-content-disposition",
1120
+ "response-content-encoding",
1121
+ "response-content-language",
1122
+ "response-content-type",
1123
+ "response-expires"
1124
+ ];
1125
+ var S3Service = class {
1126
+ storage;
1127
+ constructor(options = {}) {
1128
+ this.storage = new S3Storage(options);
1129
+ }
1130
+ canonicalOwnerId() {
1131
+ return createHash("sha256").update(getAccountId()).digest("hex");
1132
+ }
1133
+ extractUserMetadata(headers) {
1134
+ const meta = {};
1135
+ for (const [k, v] of Object.entries(headers)) if (k.toLowerCase().startsWith("x-amz-meta-")) meta[k] = v;
1136
+ return meta;
1137
+ }
1138
+ buildObjectRecord(body, headers, etag, checksums) {
1139
+ const contentType = headers["content-type"] || "application/octet-stream";
1140
+ const contentEncoding = headers["content-encoding"];
1141
+ const preserved = {};
1142
+ for (const h of PRESERVED_HEADERS) if (headers[h] !== void 0) preserved[h] = headers[h];
1143
+ return {
1144
+ body,
1145
+ contentType,
1146
+ contentEncoding,
1147
+ etag: etag || `"${md5Hex(body)}"`,
1148
+ lastModified: nowIso(),
1149
+ size: body.length,
1150
+ metadata: this.extractUserMetadata(headers),
1151
+ preservedHeaders: preserved,
1152
+ checksums: checksums || {}
1153
+ };
1154
+ }
1155
+ parseRange(rangeHeader, total) {
1156
+ const m = rangeHeader.match(/^bytes=(\d*)-(\d*)$/u);
1157
+ if (!m) return null;
1158
+ const s = m[1];
1159
+ const e = m[2];
1160
+ if (s === "" && e === "") return null;
1161
+ if (s === "") {
1162
+ const suffix = Math.trunc(Number(e));
1163
+ if (Number.isNaN(suffix) || suffix === 0) return null;
1164
+ return [Math.max(0, total - suffix), total - 1];
1165
+ }
1166
+ const start = Math.trunc(Number(s));
1167
+ if (Number.isNaN(start) || start >= total) return null;
1168
+ const end = e === "" ? total - 1 : Math.min(Math.trunc(Number(e)), total - 1);
1169
+ if (Number.isNaN(end) || start > end) return null;
1170
+ return [start, end];
1171
+ }
1172
+ applyResponseOverrides(respHeaders, queryParams) {
1173
+ for (const [qkey, hkey] of [
1174
+ ["response-cache-control", "Cache-Control"],
1175
+ ["response-content-disposition", "Content-Disposition"],
1176
+ ["response-content-encoding", "Content-Encoding"],
1177
+ ["response-content-language", "Content-Language"],
1178
+ ["response-content-type", "Content-Type"],
1179
+ ["response-expires", "Expires"]
1180
+ ]) {
1181
+ const val = qp(queryParams, qkey);
1182
+ if (!val) continue;
1183
+ const targetLc = hkey.toLowerCase();
1184
+ for (const existing of Object.keys(respHeaders)) if (existing.toLowerCase() === targetLc) delete respHeaders[existing];
1185
+ respHeaders[hkey] = val;
1186
+ }
1187
+ }
1188
+ rejectResponseOverridesIfUnsigned(headers, queryParams, bucketName, key) {
1189
+ if (!RESPONSE_OVERRIDE_PARAMS.some((p) => Boolean(qp(queryParams, p)))) return null;
1190
+ if (headers["authorization"]) return null;
1191
+ if (qp(queryParams, "X-Amz-Signature") || qp(queryParams, "x-amz-signature") || qp(queryParams, "X-Amz-Algorithm") || qp(queryParams, "x-amz-algorithm")) return null;
1192
+ return s3Error("InvalidRequest", "Request specific response headers cannot be used for anonymous GET requests.", 400, `/${bucketName}/${key}`);
1193
+ }
1194
+ checkPutPreconditions(headers, existingObj) {
1195
+ const ifNoneMatch = (headers["if-none-match"] || "").trim();
1196
+ const ifMatch = (headers["if-match"] || "").trim();
1197
+ if (!ifNoneMatch && !ifMatch) return null;
1198
+ const existingEtag = existingObj ? existingObj.etag.replaceAll(/^"|"$/gu, "") : null;
1199
+ if (ifNoneMatch) {
1200
+ if (ifNoneMatch === "*") {
1201
+ if (existingObj !== void 0) return preconditionFailed();
1202
+ } else if (existingObj !== void 0 && ifNoneMatch.replaceAll(/^"|"$/gu, "") === existingEtag) return preconditionFailed();
1203
+ }
1204
+ if (ifMatch) {
1205
+ if (existingObj === void 0) return s3Error("NoSuchKey", "The specified key does not exist.", 404);
1206
+ if (ifMatch !== "*" && ifMatch.replaceAll(/^"|"$/gu, "") !== existingEtag) return preconditionFailed();
1207
+ }
1208
+ return null;
1209
+ }
1210
+ etagConditionMatches(fieldValue, etag) {
1211
+ if (fieldValue === "*") return true;
1212
+ for (let candidate of fieldValue.split(",")) {
1213
+ candidate = candidate.trim();
1214
+ if (candidate.toUpperCase().startsWith("W/")) candidate = candidate.slice(2);
1215
+ if (candidate.replaceAll(/^"|"$/gu, "") === etag) return true;
1216
+ }
1217
+ return false;
1218
+ }
1219
+ checkReadPreconditions(headers, obj, respHeaders) {
1220
+ const etag = (obj.etag || "").replaceAll(/^"|"$/gu, "");
1221
+ const mtime = parseHttpDate(obj.lastModified);
1222
+ const ifMatch = (headers["if-match"] || "").trim();
1223
+ if (ifMatch) {
1224
+ if (!this.etagConditionMatches(ifMatch, etag)) return preconditionFailed();
1225
+ } else {
1226
+ const unmod = parseHttpDate(headers["if-unmodified-since"]);
1227
+ if (unmod && mtime && mtime.getTime() > unmod.getTime()) return preconditionFailed();
1228
+ }
1229
+ const ifNoneMatch = (headers["if-none-match"] || "").trim();
1230
+ if (ifNoneMatch) {
1231
+ if (this.etagConditionMatches(ifNoneMatch, etag)) return notModified(respHeaders);
1232
+ } else {
1233
+ const mod = parseHttpDate(headers["if-modified-since"]);
1234
+ if (mod && mtime && mtime.getTime() <= mod.getTime()) return notModified(respHeaders);
1235
+ }
1236
+ return null;
1237
+ }
1238
+ objectResponseHeaders(obj, includeChecksums = false) {
1239
+ const h = {};
1240
+ h["Content-Type"] = obj.contentType;
1241
+ h["ETag"] = obj.etag;
1242
+ h["Last-Modified"] = isoToRfc7231(obj.lastModified);
1243
+ h["Content-Length"] = String(obj.size);
1244
+ h["Accept-Ranges"] = "bytes";
1245
+ if (obj.contentEncoding) h["Content-Encoding"] = obj.contentEncoding;
1246
+ for (const [k, val] of Object.entries(obj.preservedHeaders || {})) h[k] = val;
1247
+ Object.assign(h, obj.metadata || {});
1248
+ if (obj.versionId) h["x-amz-version-id"] = obj.versionId;
1249
+ if (includeChecksums) {
1250
+ const stored = obj.checksums || {};
1251
+ for (const [alg, val] of Object.entries(stored)) h[`x-amz-checksum-${alg.toLowerCase()}`] = val;
1252
+ if (Object.keys(stored).length > 0) h["x-amz-checksum-type"] = obj.checksumType || "FULL_OBJECT";
1253
+ }
1254
+ return h;
1255
+ }
1256
+ recordObjectVersion(bucketName, key, priorObj, obj, data) {
1257
+ const versioning = this.storage.bucketVersioning.get(bucketName);
1258
+ if (versioning !== "Enabled" && versioning !== "Suspended") return null;
1259
+ const vkey = [bucketName, key];
1260
+ let versions = this.storage.objectVersions.get(vkey);
1261
+ if (!versions) {
1262
+ versions = [];
1263
+ this.storage.objectVersions.set(vkey, versions);
1264
+ }
1265
+ let versionId;
1266
+ if (versioning === "Enabled") {
1267
+ if (priorObj && !priorObj.versionId && !versions.some((v) => v.versionId === "null")) versions.push({
1268
+ versionId: "null",
1269
+ lastModified: priorObj.lastModified,
1270
+ etag: priorObj.etag,
1271
+ size: priorObj.size,
1272
+ isLatest: false,
1273
+ data: this.storage.readBody(bucketName, key, priorObj),
1274
+ contentType: priorObj.contentType,
1275
+ contentEncoding: priorObj.contentEncoding,
1276
+ metadata: priorObj.metadata,
1277
+ preservedHeaders: priorObj.preservedHeaders,
1278
+ checksums: priorObj.checksums,
1279
+ checksumType: priorObj.checksumType
1280
+ });
1281
+ versionId = newUuid();
1282
+ obj.versionId = versionId;
1283
+ } else {
1284
+ versionId = "null";
1285
+ const filtered = versions.filter((v) => v.versionId !== "null");
1286
+ versions.length = 0;
1287
+ versions.push(...filtered);
1288
+ }
1289
+ for (const v of versions) v.isLatest = false;
1290
+ versions.push({
1291
+ versionId,
1292
+ lastModified: obj.lastModified,
1293
+ etag: obj.etag,
1294
+ size: obj.size,
1295
+ isLatest: true,
1296
+ data,
1297
+ contentType: obj.contentType,
1298
+ contentEncoding: obj.contentEncoding,
1299
+ metadata: obj.metadata,
1300
+ preservedHeaders: obj.preservedHeaders,
1301
+ checksums: obj.checksums,
1302
+ checksumType: obj.checksumType
1303
+ });
1304
+ return versioning === "Enabled" ? versionId : null;
1305
+ }
1306
+ recordDeleteMarker(bucketName, key, priorObj) {
1307
+ const vkey = [bucketName, key];
1308
+ let versions = this.storage.objectVersions.get(vkey);
1309
+ if (!versions) {
1310
+ versions = [];
1311
+ this.storage.objectVersions.set(vkey, versions);
1312
+ }
1313
+ const versioning = this.storage.bucketVersioning.get(bucketName);
1314
+ let markerId;
1315
+ if (versioning === "Enabled") {
1316
+ if (priorObj && !priorObj.versionId && !versions.some((v) => v.versionId === "null")) versions.push({
1317
+ versionId: "null",
1318
+ lastModified: priorObj.lastModified,
1319
+ etag: priorObj.etag,
1320
+ size: priorObj.size,
1321
+ isLatest: false,
1322
+ data: this.storage.readBody(bucketName, key, priorObj),
1323
+ contentType: priorObj.contentType,
1324
+ contentEncoding: priorObj.contentEncoding,
1325
+ metadata: priorObj.metadata,
1326
+ preservedHeaders: priorObj.preservedHeaders,
1327
+ checksums: priorObj.checksums,
1328
+ checksumType: priorObj.checksumType
1329
+ });
1330
+ markerId = newUuid();
1331
+ } else {
1332
+ markerId = "null";
1333
+ const filtered = versions.filter((v) => v.versionId !== "null");
1334
+ versions.length = 0;
1335
+ versions.push(...filtered);
1336
+ }
1337
+ for (const v of versions) v.isLatest = false;
1338
+ versions.push({
1339
+ versionId: markerId,
1340
+ lastModified: nowIso(),
1341
+ etag: "",
1342
+ size: 0,
1343
+ isLatest: true,
1344
+ isDeleteMarker: true
1345
+ });
1346
+ return markerId;
1347
+ }
1348
+ objectRecordFromVersion(v) {
1349
+ return {
1350
+ body: v.data ?? null,
1351
+ bodyRef: v.bodyRef,
1352
+ contentType: v.contentType || "application/octet-stream",
1353
+ contentEncoding: v.contentEncoding,
1354
+ etag: v.etag,
1355
+ lastModified: v.lastModified,
1356
+ size: v.size,
1357
+ metadata: v.metadata || {},
1358
+ preservedHeaders: v.preservedHeaders || {},
1359
+ checksums: v.checksums || {},
1360
+ checksumType: v.checksumType,
1361
+ versionId: v.versionId
1362
+ };
1363
+ }
1364
+ deleteMarkerReadRefused(version) {
1365
+ const [status, headers, body] = s3Error("MethodNotAllowed", "The specified method is not allowed against this resource.", 405);
1366
+ headers["x-amz-delete-marker"] = "true";
1367
+ headers["x-amz-version-id"] = version.versionId;
1368
+ if (version.lastModified) headers["Last-Modified"] = isoToRfc7231(version.lastModified);
1369
+ return [
1370
+ status,
1371
+ headers,
1372
+ body
1373
+ ];
1374
+ }
1375
+ deleteMarker404Headers(bucketName, key) {
1376
+ const versions = this.storage.objectVersions.get([bucketName, key]);
1377
+ if (!versions || versions.length === 0) return {};
1378
+ const latest = versions.at(-1);
1379
+ if (latest && latest.isDeleteMarker && latest.isLatest) return {
1380
+ "x-amz-delete-marker": "true",
1381
+ "x-amz-version-id": latest.versionId
1382
+ };
1383
+ return {};
1384
+ }
1385
+ deleteObjectVersion(bucket, bucketName, key, versionId) {
1386
+ const vkey = [bucketName, key];
1387
+ const versions = this.storage.objectVersions.get(vkey);
1388
+ if (!versions || versions.length === 0) {
1389
+ if (versionId === "null" && bucket.objects[key] && !bucket.objects[key].versionId) {
1390
+ this.purgeCurrentObject(bucketName, key, bucket);
1391
+ return [true, false];
1392
+ }
1393
+ return [false, false];
1394
+ }
1395
+ const idx = versions.findIndex((v) => v.versionId === versionId);
1396
+ if (idx === -1) {
1397
+ if (versionId === "null" && bucket.objects[key] && !bucket.objects[key].versionId) {
1398
+ this.purgeCurrentObject(bucketName, key, bucket);
1399
+ return [true, false];
1400
+ }
1401
+ return [false, false];
1402
+ }
1403
+ const removed = versions.splice(idx, 1)[0];
1404
+ const wasMarker = Boolean(removed.isDeleteMarker);
1405
+ if (versions.length === 0) {
1406
+ this.storage.objectVersions.delete(vkey);
1407
+ this.purgeCurrentObject(bucketName, key, bucket);
1408
+ return [true, wasMarker];
1409
+ }
1410
+ for (const v of versions) v.isLatest = false;
1411
+ const latest = versions.at(-1);
1412
+ if (latest) {
1413
+ latest.isLatest = true;
1414
+ if (latest.isDeleteMarker) delete bucket.objects[key];
1415
+ else bucket.objects[key] = this.objectRecordFromVersion(latest);
1416
+ }
1417
+ return [true, wasMarker];
1418
+ }
1419
+ purgeCurrentObject(bucketName, key, bucket) {
1420
+ delete bucket.objects[key];
1421
+ this.storage.deletePersistedObject(bucketName, key);
1422
+ }
1423
+ parseBucketKey(path) {
1424
+ const trimmed = path.replace(/^\/+/u, "");
1425
+ const slashIdx = trimmed.indexOf("/");
1426
+ if (slashIdx === -1) return [trimmed, ""];
1427
+ return [trimmed.slice(0, slashIdx), trimmed.slice(slashIdx + 1)];
1428
+ }
1429
+ handleRequest(method, path, headers, body, queryParams, signedPath) {
1430
+ const [bucket, key] = this.parseBucketKey(path);
1431
+ if (method === "OPTIONS") {
1432
+ this.ensureBucket(bucket);
1433
+ const corsHeaders = corsResponseHeaders(this.storage.bucketCors.get(bucket), method, headers);
1434
+ if (!this.storage.buckets.has(bucket)) return noSuchBucket(bucket);
1435
+ if (!corsHeaders) return s3Error("AccessDenied", "CORS request is not allowed.", 403);
1436
+ return [
1437
+ 200,
1438
+ corsHeaders,
1439
+ Buffer.alloc(0)
1440
+ ];
1441
+ }
1442
+ const sigError = verifyPresignedSigV4(method, signedPath === void 0 ? path : signedPath, headers, queryParams);
1443
+ if (sigError) {
1444
+ const [status, respHeaders, respBody] = sigError;
1445
+ const corsHeaders = corsResponseHeaders(this.storage.bucketCors.get(bucket) ?? `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><AllowedMethod>PUT</AllowedMethod><AllowedMethod>POST</AllowedMethod><AllowedMethod>DELETE</AllowedMethod><AllowedMethod>HEAD</AllowedMethod><AllowedHeader>*</AllowedHeader><ExposeHeader>ETag</ExposeHeader><MaxAgeSeconds>3000</MaxAgeSeconds></CORSRule></CORSConfiguration>`, method, headers);
1446
+ Object.assign(respHeaders, corsHeaders);
1447
+ respHeaders["x-amz-request-id"] = respHeaders["x-amz-request-id"] || newUuid();
1448
+ respHeaders["x-amz-id-2"] = respHeaders["x-amz-id-2"] || createHash("sha256").update(newUuid()).digest("base64");
1449
+ return [
1450
+ status,
1451
+ respHeaders,
1452
+ respBody
1453
+ ];
1454
+ }
1455
+ this.ensureBucket(bucket);
1456
+ const corsHeaders = corsResponseHeaders(this.storage.bucketCors.get(bucket), method, headers);
1457
+ const mergedHeaders = mergeHoistedAmzHeaders(headers, queryParams);
1458
+ this.expireMultipartUploads(bucket);
1459
+ const [status, respHeaders, respBody] = this.dispatch(method, bucket, key, mergedHeaders, body, queryParams);
1460
+ Object.assign(respHeaders, corsHeaders);
1461
+ if (method !== "GET" && method !== "HEAD" && status >= 200 && status < 300) this.storage.persistState();
1462
+ respHeaders["x-amz-request-id"] = respHeaders["x-amz-request-id"] || newUuid();
1463
+ respHeaders["x-amz-id-2"] = respHeaders["x-amz-id-2"] || createHash("sha256").update(newUuid()).digest("base64");
1464
+ if (method === "HEAD") return [
1465
+ status,
1466
+ respHeaders,
1467
+ Buffer.alloc(0)
1468
+ ];
1469
+ return [
1470
+ status,
1471
+ respHeaders,
1472
+ respBody
1473
+ ];
1474
+ }
1475
+ dispatch(method, bucket, key, headers, body, queryParams) {
1476
+ const unsupported = () => s3Error("NotImplemented", "This operation is not supported by the FileBucket emulator.", 501);
1477
+ const selectors = Object.keys(queryParams).filter((name) => !name.toLowerCase().startsWith("x-amz-") && ![
1478
+ "x-id",
1479
+ "accountId",
1480
+ "region"
1481
+ ].includes(name));
1482
+ const accepts = (...names) => selectors.every((name) => names.includes(name));
1483
+ if (Object.keys(headers).some((name) => name === "x-amz-acl" || name.startsWith("x-amz-grant-") || name.startsWith("x-amz-server-side-encryption") || name.startsWith("x-amz-copy-source-server-side-encryption") || name.startsWith("x-amz-object-lock-") || name === "x-amz-bucket-object-lock-enabled" || name.startsWith("x-amz-tagging") || name === "x-amz-website-redirect-location" || name === "x-amz-storage-class")) return unsupported();
1484
+ if (!bucket) return unsupported();
1485
+ if (key) {
1486
+ if (method === "GET" && accepts("versionId", ...RESPONSE_OVERRIDE_PARAMS)) return this.getObject(bucket, key, headers, queryParams);
1487
+ if (method === "PUT") {
1488
+ if ("uploadId" in queryParams && "partNumber" in queryParams && accepts("uploadId", "partNumber") && !("x-amz-copy-source" in headers)) return this.uploadPart(bucket, key, body, queryParams, headers);
1489
+ if (accepts()) return "x-amz-copy-source" in headers ? this.copyObject(bucket, key, headers) : this.putObject(bucket, key, body, headers);
1490
+ }
1491
+ if (method === "POST") {
1492
+ if ("uploads" in queryParams && accepts("uploads")) return this.createMultipartUpload(bucket, key, headers);
1493
+ if ("uploadId" in queryParams && accepts("uploadId")) return this.completeMultipartUpload(bucket, key, body, queryParams, headers);
1494
+ }
1495
+ if (method === "DELETE") {
1496
+ if ("uploadId" in queryParams && accepts("uploadId")) return this.abortMultipartUpload(bucket, key, queryParams);
1497
+ if (accepts("versionId")) return this.deleteObject(bucket, key, headers, queryParams);
1498
+ }
1499
+ } else {
1500
+ if (method === "HEAD" && accepts()) return [
1501
+ 200,
1502
+ {},
1503
+ Buffer.alloc(0)
1504
+ ];
1505
+ if (method === "PUT") {
1506
+ if ("versioning" in queryParams && accepts("versioning")) return this.putBucketVersioning(bucket, body);
1507
+ if ("cors" in queryParams && accepts("cors")) return this.putBucketCors(bucket, body);
1508
+ if ("lifecycle" in queryParams && accepts("lifecycle")) return this.putBucketLifecycle(bucket, body);
1509
+ }
1510
+ if (method === "GET") {
1511
+ if (qp(queryParams, "list-type") === "2" && accepts("list-type", "prefix", "delimiter", "max-keys", "continuation-token", "start-after", "encoding-type", "fetch-owner")) return this.listObjectsV2(bucket, queryParams);
1512
+ if ("versions" in queryParams && accepts("versions", "prefix", "delimiter", "max-keys", "key-marker", "version-id-marker", "encoding-type")) return this.listObjectVersions(bucket, queryParams);
1513
+ }
1514
+ if (method === "POST" && "delete" in queryParams && accepts("delete")) return this.deleteObjects(bucket, body);
1515
+ }
1516
+ return unsupported();
1517
+ }
1518
+ ensureBucket(name) {
1519
+ if (!name) return;
1520
+ let changed = false;
1521
+ if (!this.storage.buckets.has(name)) {
1522
+ this.storage.buckets.set(name, {
1523
+ created: nowIso(),
1524
+ objects: Object.create(null),
1525
+ region: getRegion()
1526
+ });
1527
+ changed = true;
1528
+ }
1529
+ if (!this.storage.bucketCors.has(name)) {
1530
+ this.storage.bucketCors.set(name, `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><AllowedMethod>PUT</AllowedMethod><AllowedMethod>POST</AllowedMethod><AllowedMethod>DELETE</AllowedMethod><AllowedMethod>HEAD</AllowedMethod><AllowedHeader>*</AllowedHeader><ExposeHeader>ETag</ExposeHeader><MaxAgeSeconds>3000</MaxAgeSeconds></CORSRule></CORSConfiguration>`);
1531
+ changed = true;
1532
+ }
1533
+ if (changed) this.storage.persistState();
1534
+ }
1535
+ putBucketVersioning(name, body) {
1536
+ if (!this.storage.buckets.has(name)) return noSuchBucket(name);
1537
+ try {
1538
+ const statusEl = parseXml(body).find("Status");
1539
+ if (statusEl?.text) this.storage.bucketVersioning.set(name, statusEl.text.trim());
1540
+ } catch {}
1541
+ return [
1542
+ 200,
1543
+ {},
1544
+ Buffer.alloc(0)
1545
+ ];
1546
+ }
1547
+ putBucketLifecycle(name, body) {
1548
+ if (!this.storage.buckets.has(name)) return noSuchBucket(name);
1549
+ try {
1550
+ const rules = parseLifecycleXml(body);
1551
+ this.storage.bucketLifecycle.set(name, rules);
1552
+ } catch {
1553
+ return s3Error("MalformedXML", "Invalid lifecycle configuration.", 400);
1554
+ }
1555
+ return [
1556
+ 200,
1557
+ { "x-amz-transition-default-minimum-object-size": "all_storage_classes_128K" },
1558
+ Buffer.alloc(0)
1559
+ ];
1560
+ }
1561
+ putBucketCors(name, body) {
1562
+ if (!this.storage.buckets.has(name)) return noSuchBucket(name);
1563
+ this.storage.bucketCors.set(name, body.toString("utf-8"));
1564
+ return [
1565
+ 200,
1566
+ {},
1567
+ Buffer.alloc(0)
1568
+ ];
1569
+ }
1570
+ putObject(bucketName, key, body, headers) {
1571
+ const bucket = this.storage.buckets.get(bucketName);
1572
+ if (!bucket) return noSuchBucket(bucketName);
1573
+ const md5Err = validateContentMd5(headers, body);
1574
+ if (md5Err) return md5Err;
1575
+ const existingObj = bucket.objects[key];
1576
+ const precondErr = this.checkPutPreconditions(headers, existingObj);
1577
+ if (precondErr) return precondErr;
1578
+ const { checksums, error: csumErr } = resolveObjectChecksums(body, headers);
1579
+ if (csumErr) return csumErr;
1580
+ const etag = `"${md5Hex(body)}"`;
1581
+ const obj = this.buildObjectRecord(body, headers, etag, checksums);
1582
+ const priorObj = bucket.objects[key];
1583
+ bucket.objects[key] = obj;
1584
+ const respHeaders = {};
1585
+ respHeaders["ETag"] = obj.etag;
1586
+ respHeaders["Content-Length"] = "0";
1587
+ const versionId = this.recordObjectVersion(bucketName, key, priorObj, obj, body);
1588
+ if (versionId) respHeaders["x-amz-version-id"] = versionId;
1589
+ return [
1590
+ 200,
1591
+ respHeaders,
1592
+ Buffer.alloc(0)
1593
+ ];
1594
+ }
1595
+ getObject(bucketName, key, headers, queryParams) {
1596
+ const bucket = this.storage.buckets.get(bucketName);
1597
+ if (!bucket) return noSuchBucket(bucketName);
1598
+ const unsignedErr = this.rejectResponseOverridesIfUnsigned(headers, queryParams, bucketName, key);
1599
+ if (unsignedErr) return unsignedErr;
1600
+ const versionId = qp(queryParams, "versionId");
1601
+ let obj = bucket.objects[key];
1602
+ let version;
1603
+ if (versionId) {
1604
+ version = this.storage.objectVersions.get([bucketName, key])?.find((entry) => entry.versionId === versionId);
1605
+ if (version?.isDeleteMarker) return this.deleteMarkerReadRefused(version);
1606
+ if (version) obj = this.objectRecordFromVersion(version);
1607
+ else if (versionId !== "null" || !obj || obj.versionId) return noSuchVersion(bucketName, key);
1608
+ }
1609
+ if (!obj) return noSuchKey(bucketName, key, this.deleteMarker404Headers(bucketName, key));
1610
+ const rangeHeader = headers["range"] || "";
1611
+ const respHeaders = this.objectResponseHeaders(obj, headers["x-amz-checksum-mode"]?.toUpperCase() === "ENABLED" && !rangeHeader);
1612
+ const precond = this.checkReadPreconditions(headers, obj, respHeaders);
1613
+ if (precond) return precond;
1614
+ const body = version ? this.storage.readVersionBody(bucketName, key, version) : this.storage.readBody(bucketName, key, obj);
1615
+ this.applyResponseOverrides(respHeaders, queryParams);
1616
+ if (rangeHeader) {
1617
+ const range = obj.size > 0 ? this.parseRange(rangeHeader, obj.size) : null;
1618
+ if (!range) return invalidRange(bucketName, key, obj.size);
1619
+ const [start, end] = range;
1620
+ const slice = body.subarray(start, end + 1);
1621
+ respHeaders["Content-Length"] = String(slice.length);
1622
+ respHeaders["Content-Range"] = `bytes ${start}-${end}/${obj.size}`;
1623
+ return [
1624
+ 206,
1625
+ respHeaders,
1626
+ slice
1627
+ ];
1628
+ }
1629
+ return [
1630
+ 200,
1631
+ respHeaders,
1632
+ body
1633
+ ];
1634
+ }
1635
+ deleteObject(bucketName, key, headers = {}, queryParams = {}) {
1636
+ const bucket = this.storage.buckets.get(bucketName);
1637
+ if (!bucket) return noSuchBucket(bucketName);
1638
+ if (headers["x-amz-if-match-size"] || headers["x-amz-if-match-last-modified-time"]) return s3Error("NotImplemented", "A header you provided implies functionality that is not implemented", 501, `/${bucketName}/${key}`);
1639
+ const ifMatch = (headers["if-match"] || "").trim();
1640
+ if (ifMatch) {
1641
+ const cur = bucket.objects[key];
1642
+ if (!cur) return s3Error("NoSuchKey", "The specified key does not exist.", 404, `/${bucketName}/${key}`);
1643
+ if (ifMatch !== "*" && ifMatch.replaceAll(/^"|"$/gu, "") !== cur.etag.replaceAll(/^"|"$/gu, "")) return preconditionFailed(void 0, `/${bucketName}/${key}`);
1644
+ }
1645
+ const versionId = qp(queryParams, "versionId");
1646
+ if (versionId) {
1647
+ const [, wasDeleteMarker] = this.deleteObjectVersion(bucket, bucketName, key, versionId);
1648
+ const respHeaders = {};
1649
+ respHeaders["x-amz-version-id"] = versionId;
1650
+ if (wasDeleteMarker) respHeaders["x-amz-delete-marker"] = "true";
1651
+ return [
1652
+ 204,
1653
+ respHeaders,
1654
+ Buffer.alloc(0)
1655
+ ];
1656
+ }
1657
+ const versioning = this.storage.bucketVersioning.get(bucketName);
1658
+ if (versioning === "Enabled" || versioning === "Suspended") {
1659
+ const deleteMarkerId = this.recordDeleteMarker(bucketName, key, bucket.objects[key]);
1660
+ delete bucket.objects[key];
1661
+ return [
1662
+ 204,
1663
+ {
1664
+ "x-amz-delete-marker": "true",
1665
+ "x-amz-version-id": deleteMarkerId
1666
+ },
1667
+ Buffer.alloc(0)
1668
+ ];
1669
+ }
1670
+ delete bucket.objects[key];
1671
+ this.storage.deletePersistedObject(bucketName, key);
1672
+ return [
1673
+ 204,
1674
+ {},
1675
+ Buffer.alloc(0)
1676
+ ];
1677
+ }
1678
+ deleteObjects(bucketName, body) {
1679
+ const bucket = this.storage.buckets.get(bucketName);
1680
+ if (!bucket) return noSuchBucket(bucketName);
1681
+ let root;
1682
+ try {
1683
+ root = parseXml(body);
1684
+ } catch {
1685
+ return s3Error("MalformedXML", "The XML you provided was not well-formed", 400);
1686
+ }
1687
+ const quiet = (root.findText("Quiet") || "").toLowerCase() === "true";
1688
+ const deleted = [];
1689
+ const errors = [];
1690
+ for (const objEl of root.findAll("Object")) {
1691
+ const k = objEl.findText("Key");
1692
+ if (!k) continue;
1693
+ const versionId = objEl.findText("VersionId") || "";
1694
+ if (objEl.findText("Size") || objEl.findText("LastModifiedTime")) {
1695
+ errors.push({
1696
+ key: k,
1697
+ versionId,
1698
+ code: "NotImplemented",
1699
+ msg: "A header you provided implies functionality that is not implemented"
1700
+ });
1701
+ continue;
1702
+ }
1703
+ const condEtag = objEl.findText("ETag");
1704
+ if (condEtag) {
1705
+ const cur = bucket.objects[k];
1706
+ if (!cur) {
1707
+ errors.push({
1708
+ key: k,
1709
+ versionId,
1710
+ code: "NoSuchKey",
1711
+ msg: "The specified key does not exist."
1712
+ });
1713
+ continue;
1714
+ }
1715
+ if (condEtag !== "*" && condEtag.replaceAll(/^"|"$/gu, "") !== cur.etag.replaceAll(/^"|"$/gu, "")) {
1716
+ errors.push({
1717
+ key: k,
1718
+ versionId,
1719
+ code: "PreconditionFailed",
1720
+ msg: "At least one of the preconditions you specified did not hold."
1721
+ });
1722
+ continue;
1723
+ }
1724
+ }
1725
+ if (versionId) {
1726
+ const [, wasMarker] = this.deleteObjectVersion(bucket, bucketName, k, versionId);
1727
+ deleted.push({
1728
+ key: k,
1729
+ versionId,
1730
+ wasMarker
1731
+ });
1732
+ } else if (this.storage.bucketVersioning.get(bucketName) === "Enabled" || this.storage.bucketVersioning.get(bucketName) === "Suspended") {
1733
+ const markerId = this.recordDeleteMarker(bucketName, k, bucket.objects[k]);
1734
+ delete bucket.objects[k];
1735
+ deleted.push({
1736
+ key: k,
1737
+ versionId: "",
1738
+ wasMarker: false,
1739
+ markerCreated: markerId
1740
+ });
1741
+ } else {
1742
+ delete bucket.objects[k];
1743
+ this.storage.deletePersistedObject(bucketName, k);
1744
+ deleted.push({
1745
+ key: k,
1746
+ versionId: "",
1747
+ wasMarker: false
1748
+ });
1749
+ }
1750
+ }
1751
+ const resp = new XmlElement("DeleteResult", { xmlns: S3_NS });
1752
+ if (!quiet) for (const d of deleted) {
1753
+ const el = resp.subElement("Deleted");
1754
+ el.subElement("Key", d.key);
1755
+ if (d.versionId) {
1756
+ el.subElement("VersionId", d.versionId);
1757
+ if (d.wasMarker) {
1758
+ el.subElement("DeleteMarker", "true");
1759
+ el.subElement("DeleteMarkerVersionId", d.versionId);
1760
+ }
1761
+ } else if (d.markerCreated) {
1762
+ el.subElement("DeleteMarker", "true");
1763
+ el.subElement("DeleteMarkerVersionId", d.markerCreated);
1764
+ }
1765
+ }
1766
+ for (const e of errors) {
1767
+ const el = resp.subElement("Error");
1768
+ el.subElement("Key", e.key);
1769
+ if (e.versionId) el.subElement("VersionId", e.versionId);
1770
+ el.subElement("Code", e.code);
1771
+ el.subElement("Message", e.msg);
1772
+ }
1773
+ return [
1774
+ 200,
1775
+ { "Content-Type": "application/xml" },
1776
+ xmlBody(resp)
1777
+ ];
1778
+ }
1779
+ copyObject(destBucketName, destKey, headers) {
1780
+ const [rawPath, srcQuery] = (headers["x-amz-copy-source"] || "").replace(/^\/+/u, "").split("?");
1781
+ const srcParts = urlDecode(rawPath).split("/");
1782
+ if (srcParts.length < 2) return s3Error("InvalidArgument", "Copy Source must mention the source bucket and key: /sourcebucket/sourcekey", 400);
1783
+ const srcBucketName = srcParts[0];
1784
+ const srcKey = srcParts.slice(1).join("/");
1785
+ const srcBucket = this.storage.buckets.get(srcBucketName);
1786
+ if (!srcBucket) return noSuchBucket(srcBucketName);
1787
+ const destBucket = this.storage.buckets.get(destBucketName);
1788
+ if (!destBucket) return noSuchBucket(destBucketName);
1789
+ let srcVersionId;
1790
+ if (srcQuery) srcVersionId = new URLSearchParams(srcQuery).get("versionId") ?? void 0;
1791
+ let srcObj;
1792
+ if (srcVersionId) {
1793
+ const ventry = (this.storage.objectVersions.get([srcBucketName, srcKey]) || []).find((v) => v.versionId === srcVersionId);
1794
+ if (!ventry || ventry.isDeleteMarker) return s3Error("NoSuchVersion", "The specified version does not exist.", 404, `/${srcBucketName}/${srcKey}`);
1795
+ srcObj = this.objectRecordFromVersion(ventry);
1796
+ } else {
1797
+ if (!(srcKey in srcBucket.objects)) return s3Error("NoSuchKey", "The specified key does not exist.", 404, `/${srcBucketName}/${srcKey}`);
1798
+ srcObj = srcBucket.objects[srcKey];
1799
+ }
1800
+ const copySrcVid = srcVersionId || srcObj.versionId;
1801
+ const srcEtag = srcObj.etag.replaceAll(/^"|"$/gu, "");
1802
+ const srcMtime = parseHttpDate(srcObj.lastModified);
1803
+ const ifMatch = headers["x-amz-copy-source-if-match"];
1804
+ if (ifMatch) {
1805
+ if (ifMatch.replaceAll(/^"|"$/gu, "") !== srcEtag) return preconditionFailed();
1806
+ } else {
1807
+ const unmod = parseHttpDate(headers["x-amz-copy-source-if-unmodified-since"]);
1808
+ if (unmod && srcMtime && srcMtime.getTime() > unmod.getTime()) return preconditionFailed();
1809
+ }
1810
+ const ifNoneMatch = headers["x-amz-copy-source-if-none-match"];
1811
+ if (ifNoneMatch) {
1812
+ if (ifNoneMatch.replaceAll(/^"|"$/gu, "") === srcEtag) return preconditionFailed();
1813
+ } else {
1814
+ const mod = parseHttpDate(headers["x-amz-copy-source-if-modified-since"]);
1815
+ if (mod && srcMtime && srcMtime.getTime() <= mod.getTime()) return preconditionFailed();
1816
+ }
1817
+ const directive = (headers["x-amz-metadata-directive"] || "COPY").toUpperCase();
1818
+ let metadata;
1819
+ let contentType = srcObj.contentType;
1820
+ let contentEncoding = srcObj.contentEncoding;
1821
+ let preserved = {};
1822
+ if (directive === "REPLACE") {
1823
+ metadata = this.extractUserMetadata(headers);
1824
+ contentType = headers["content-type"] || srcObj.contentType;
1825
+ contentEncoding = headers["content-encoding"] || srcObj.contentEncoding;
1826
+ for (const h of PRESERVED_HEADERS) if (headers[h] !== void 0) preserved[h] = headers[h];
1827
+ } else {
1828
+ metadata = {};
1829
+ Object.assign(metadata, srcObj.metadata);
1830
+ preserved = {};
1831
+ Object.assign(preserved, srcObj.preservedHeaders);
1832
+ }
1833
+ const srcBody = this.storage.readBody(srcBucketName, srcKey, srcObj);
1834
+ const destChecksums = { ...srcObj.checksums };
1835
+ if (headers["x-amz-sdk-checksum-algorithm"] || headers["x-amz-checksum-crc32"] || headers["x-amz-checksum-crc32c"] || headers["x-amz-checksum-crc64nvme"] || headers["x-amz-checksum-sha1"] || headers["x-amz-checksum-sha256"]) {
1836
+ const { checksums: resolved, error: csumErr } = resolveObjectChecksums(srcBody, headers);
1837
+ if (csumErr) return csumErr;
1838
+ Object.assign(destChecksums, resolved);
1839
+ }
1840
+ const newEtag = srcObj.etag;
1841
+ const lastModified = nowIso();
1842
+ const destObj = {
1843
+ body: srcBody,
1844
+ contentType,
1845
+ contentEncoding,
1846
+ etag: newEtag,
1847
+ lastModified,
1848
+ size: srcObj.size,
1849
+ metadata,
1850
+ preservedHeaders: preserved,
1851
+ checksums: destChecksums,
1852
+ checksumType: srcObj.checksumType
1853
+ };
1854
+ const destPriorObj = destBucket.objects[destKey];
1855
+ destBucket.objects[destKey] = destObj;
1856
+ const respHeaders = {};
1857
+ respHeaders["Content-Type"] = "application/xml";
1858
+ if (copySrcVid) respHeaders["x-amz-copy-source-version-id"] = copySrcVid;
1859
+ const versionId = this.recordObjectVersion(destBucketName, destKey, destPriorObj, destObj, srcBody);
1860
+ if (versionId) respHeaders["x-amz-version-id"] = versionId;
1861
+ const root = new XmlElement("CopyObjectResult", { xmlns: S3_NS });
1862
+ root.subElement("LastModified", lastModified);
1863
+ root.subElement("ETag", newEtag);
1864
+ return [
1865
+ 200,
1866
+ respHeaders,
1867
+ xmlBody(root)
1868
+ ];
1869
+ }
1870
+ collectListEntries(bucketObjects, prefix, delimiter, maxKeys, startAfter) {
1871
+ const rows = [];
1872
+ const seenPrefixes = /* @__PURE__ */ new Set();
1873
+ const sortedKeys = Object.keys(bucketObjects).filter((k) => k.startsWith(prefix)).toSorted();
1874
+ for (const k of sortedKeys) {
1875
+ if (delimiter) {
1876
+ const suffix = k.slice(prefix.length);
1877
+ const delimIdx = suffix.indexOf(delimiter);
1878
+ if (delimIdx !== -1) {
1879
+ const cp = prefix + suffix.slice(0, delimIdx + delimiter.length);
1880
+ if (!seenPrefixes.has(cp)) {
1881
+ seenPrefixes.add(cp);
1882
+ rows.push({
1883
+ value: cp,
1884
+ isPrefix: true
1885
+ });
1886
+ }
1887
+ continue;
1888
+ }
1889
+ }
1890
+ rows.push({
1891
+ value: k,
1892
+ isPrefix: false
1893
+ });
1894
+ }
1895
+ let filteredRows = rows;
1896
+ if (startAfter) filteredRows = rows.filter((r) => r.value > startAfter);
1897
+ const contents = [];
1898
+ const commonPrefixes = [];
1899
+ let isTruncated = false;
1900
+ let nextMarker = "";
1901
+ for (let i = 0; i < filteredRows.length; i += 1) {
1902
+ if (i >= maxKeys) {
1903
+ isTruncated = true;
1904
+ break;
1905
+ }
1906
+ const r = filteredRows[i];
1907
+ if (r.isPrefix) commonPrefixes.push(r.value);
1908
+ else contents.push(r.value);
1909
+ nextMarker = r.value;
1910
+ }
1911
+ return {
1912
+ contents,
1913
+ commonPrefixes,
1914
+ isTruncated,
1915
+ nextMarker
1916
+ };
1917
+ }
1918
+ listObjectsV2(bucketName, queryParams) {
1919
+ const bucket = this.storage.buckets.get(bucketName);
1920
+ if (!bucket) return noSuchBucket(bucketName);
1921
+ const prefix = qp(queryParams, "prefix");
1922
+ const delimiter = qp(queryParams, "delimiter");
1923
+ const maxKeys = Math.trunc(Number(qp(queryParams, "max-keys", "1000"))) || 1e3;
1924
+ const continuation = qp(queryParams, "continuation-token");
1925
+ const startAfter = qp(queryParams, "start-after");
1926
+ const encodingType = qp(queryParams, "encoding-type");
1927
+ const encode = encodingType === "url";
1928
+ let effectiveStart = startAfter;
1929
+ if (continuation) try {
1930
+ effectiveStart = Buffer.from(continuation, "base64").toString("utf-8");
1931
+ } catch {
1932
+ effectiveStart = continuation;
1933
+ }
1934
+ const { contents, commonPrefixes, isTruncated, nextMarker } = this.collectListEntries(bucket.objects, prefix, delimiter, maxKeys, effectiveStart);
1935
+ const root = new XmlElement("ListBucketResult", { xmlns: S3_NS });
1936
+ root.subElement("Name", bucketName);
1937
+ root.subElement("Prefix", encode && prefix ? urlEncode(prefix, true) : prefix);
1938
+ if (delimiter) root.subElement("Delimiter", encode ? urlEncode(delimiter, true) : delimiter);
1939
+ if (encodingType) root.subElement("EncodingType", encodingType);
1940
+ root.subElement("MaxKeys", String(maxKeys));
1941
+ root.subElement("KeyCount", String(contents.length + commonPrefixes.length));
1942
+ root.subElement("IsTruncated", isTruncated ? "true" : "false");
1943
+ if (continuation) root.subElement("ContinuationToken", continuation);
1944
+ if (startAfter) root.subElement("StartAfter", encode ? urlEncode(startAfter, true) : startAfter);
1945
+ if (isTruncated && nextMarker) {
1946
+ const token = Buffer.from(nextMarker, "utf-8").toString("base64");
1947
+ root.subElement("NextContinuationToken", token);
1948
+ }
1949
+ for (const k of contents) {
1950
+ const obj = bucket.objects[k];
1951
+ const c = root.subElement("Contents");
1952
+ c.subElement("Key", encode ? urlEncode(k, true) : k);
1953
+ c.subElement("LastModified", obj.lastModified);
1954
+ c.subElement("ETag", obj.etag);
1955
+ c.subElement("Size", String(obj.size));
1956
+ c.subElement("StorageClass", "STANDARD");
1957
+ }
1958
+ for (const cp of commonPrefixes.toSorted()) root.subElement("CommonPrefixes").subElement("Prefix", encode ? urlEncode(cp, true) : cp);
1959
+ return [
1960
+ 200,
1961
+ { "Content-Type": "application/xml" },
1962
+ xmlBody(root)
1963
+ ];
1964
+ }
1965
+ listObjectVersions(bucketName, queryParams) {
1966
+ const bucket = this.storage.buckets.get(bucketName);
1967
+ if (!bucket) return noSuchBucket(bucketName);
1968
+ const prefix = qp(queryParams, "prefix");
1969
+ const delimiter = qp(queryParams, "delimiter");
1970
+ const keyMarker = qp(queryParams, "key-marker");
1971
+ const versionIdMarker = qp(queryParams, "version-id-marker");
1972
+ const maxKeys = Math.trunc(Number(qp(queryParams, "max-keys", "1000"))) || 1e3;
1973
+ const root = new XmlElement("ListVersionsResult", { xmlns: S3_NS });
1974
+ root.subElement("Name", bucketName);
1975
+ root.subElement("Prefix", prefix);
1976
+ if (delimiter) root.subElement("Delimiter", delimiter);
1977
+ root.subElement("KeyMarker", keyMarker);
1978
+ root.subElement("VersionIdMarker", versionIdMarker);
1979
+ root.subElement("MaxKeys", String(maxKeys));
1980
+ const inPage = (k) => {
1981
+ if (!k.startsWith(prefix)) return false;
1982
+ if (versionIdMarker) return k >= keyMarker;
1983
+ return k > keyMarker;
1984
+ };
1985
+ const allKeys = /* @__PURE__ */ new Set();
1986
+ for (const k of Object.keys(bucket.objects)) if (inPage(k)) allKeys.add(k);
1987
+ for (const [bn, k] of this.storage.objectVersions.keys()) if (bn === bucketName && inPage(k)) allKeys.add(k);
1988
+ const sortedKeys = [...allKeys].toSorted();
1989
+ const commonPrefixes = /* @__PURE__ */ new Set();
1990
+ const remainingKeys = [];
1991
+ if (delimiter) for (const k of sortedKeys) {
1992
+ const suffix = k.slice(prefix.length);
1993
+ const cut = suffix.indexOf(delimiter);
1994
+ if (cut === -1) remainingKeys.push(k);
1995
+ else commonPrefixes.add(prefix + suffix.slice(0, cut + delimiter.length));
1996
+ }
1997
+ else remainingKeys.push(...sortedKeys);
1998
+ const stream = [];
1999
+ for (const cp of commonPrefixes) stream.push({
2000
+ type: "prefix",
2001
+ value: cp
2002
+ });
2003
+ for (const k of remainingKeys) stream.push({
2004
+ type: "key",
2005
+ value: k
2006
+ });
2007
+ stream.sort((a, b) => a.value.localeCompare(b.value));
2008
+ let count = 0;
2009
+ let isTruncated = false;
2010
+ let nextKeyMarker = null;
2011
+ let nextVersionIdMarker = null;
2012
+ const emitVersion = (k, v) => {
2013
+ if (v.isDeleteMarker) {
2014
+ const dm = root.subElement("DeleteMarker");
2015
+ dm.subElement("Key", k);
2016
+ dm.subElement("VersionId", v.versionId);
2017
+ dm.subElement("IsLatest", v.isLatest ? "true" : "false");
2018
+ dm.subElement("LastModified", v.lastModified);
2019
+ } else {
2020
+ const ver = root.subElement("Version");
2021
+ ver.subElement("Key", k);
2022
+ ver.subElement("VersionId", v.versionId);
2023
+ ver.subElement("IsLatest", v.isLatest ? "true" : "false");
2024
+ ver.subElement("LastModified", v.lastModified);
2025
+ ver.subElement("ETag", v.etag);
2026
+ ver.subElement("Size", String(v.size));
2027
+ ver.subElement("StorageClass", "STANDARD");
2028
+ }
2029
+ };
2030
+ for (const item of stream) {
2031
+ if (count >= maxKeys) {
2032
+ isTruncated = true;
2033
+ break;
2034
+ }
2035
+ if (item.type === "prefix") {
2036
+ root.subElement("CommonPrefixes").subElement("Prefix", item.value);
2037
+ nextKeyMarker = item.value;
2038
+ nextVersionIdMarker = null;
2039
+ count += 1;
2040
+ continue;
2041
+ }
2042
+ const k = item.value;
2043
+ const versions = this.storage.objectVersions.get([bucketName, k]);
2044
+ if (versions && versions.length > 0) {
2045
+ let skipping = Boolean(versionIdMarker && k === keyMarker);
2046
+ for (let i = versions.length - 1; i >= 0; i -= 1) {
2047
+ const v = versions[i];
2048
+ if (skipping) {
2049
+ if (v.versionId === versionIdMarker) skipping = false;
2050
+ continue;
2051
+ }
2052
+ if (count >= maxKeys) {
2053
+ isTruncated = true;
2054
+ break;
2055
+ }
2056
+ emitVersion(k, v);
2057
+ nextKeyMarker = k;
2058
+ nextVersionIdMarker = v.versionId;
2059
+ count += 1;
2060
+ }
2061
+ } else {
2062
+ const obj = bucket.objects[k];
2063
+ if (!obj) continue;
2064
+ const ver = root.subElement("Version");
2065
+ ver.subElement("Key", k);
2066
+ ver.subElement("VersionId", obj.versionId || "null");
2067
+ ver.subElement("IsLatest", "true");
2068
+ ver.subElement("LastModified", obj.lastModified);
2069
+ ver.subElement("ETag", obj.etag);
2070
+ ver.subElement("Size", String(obj.size));
2071
+ ver.subElement("StorageClass", "STANDARD");
2072
+ nextKeyMarker = k;
2073
+ nextVersionIdMarker = obj.versionId || "null";
2074
+ count += 1;
2075
+ }
2076
+ }
2077
+ root.subElement("IsTruncated", isTruncated ? "true" : "false");
2078
+ if (isTruncated && nextKeyMarker !== null) {
2079
+ root.subElement("NextKeyMarker", nextKeyMarker);
2080
+ root.subElement("NextVersionIdMarker", nextVersionIdMarker || "null");
2081
+ }
2082
+ return [
2083
+ 200,
2084
+ { "Content-Type": "application/xml" },
2085
+ xmlBody(root)
2086
+ ];
2087
+ }
2088
+ expireMultipartUploads(bucketName) {
2089
+ const rules = this.storage.bucketLifecycle.get(bucketName);
2090
+ if (!Array.isArray(rules)) return;
2091
+ for (const [uploadId, upload] of this.storage.multipartUploads.entries()) {
2092
+ if (upload.bucket !== bucketName) continue;
2093
+ for (const rule of rules) {
2094
+ const days = rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation;
2095
+ if (rule.Status !== "Enabled" || !days) continue;
2096
+ if (upload.key.startsWith(rule.Filter?.Prefix ?? "") && Date.now() - Date.parse(upload.created) >= days * 864e5) {
2097
+ this.storage.multipartUploads.delete(uploadId);
2098
+ break;
2099
+ }
2100
+ }
2101
+ }
2102
+ }
2103
+ createMultipartUpload(bucketName, key, headers) {
2104
+ if (!this.storage.buckets.get(bucketName)) return noSuchBucket(bucketName);
2105
+ const { algorithm: checksumAlgorithm, error: csumErr } = resolveMultipartChecksumAlgorithm(headers);
2106
+ if (csumErr) return csumErr;
2107
+ const uploadId = newUuid();
2108
+ const contentType = headers["content-type"] || "application/octet-stream";
2109
+ const contentEncoding = headers["content-encoding"];
2110
+ const metadata = this.extractUserMetadata(headers);
2111
+ const preserved = {};
2112
+ for (const h of PRESERVED_HEADERS) if (headers[h] !== void 0) preserved[h] = headers[h];
2113
+ this.storage.multipartUploads.set(uploadId, {
2114
+ bucket: bucketName,
2115
+ key,
2116
+ parts: {},
2117
+ metadata,
2118
+ contentType,
2119
+ contentEncoding,
2120
+ preservedHeaders: preserved,
2121
+ checksumAlgorithm,
2122
+ created: nowIso()
2123
+ });
2124
+ const root = new XmlElement("InitiateMultipartUploadResult", { xmlns: S3_NS });
2125
+ root.subElement("Bucket", bucketName);
2126
+ root.subElement("Key", key);
2127
+ root.subElement("UploadId", uploadId);
2128
+ const csumHeaders = {};
2129
+ if (checksumAlgorithm) {
2130
+ csumHeaders["x-amz-checksum-algorithm"] = checksumAlgorithm;
2131
+ csumHeaders["x-amz-checksum-type"] = "COMPOSITE";
2132
+ }
2133
+ return [
2134
+ 200,
2135
+ {
2136
+ "Content-Type": "application/xml",
2137
+ ...csumHeaders
2138
+ },
2139
+ xmlBody(root)
2140
+ ];
2141
+ }
2142
+ uploadPart(bucketName, key, body, queryParams, headers) {
2143
+ if (!this.storage.buckets.get(bucketName)) return noSuchBucket(bucketName);
2144
+ const uploadId = qp(queryParams, "uploadId");
2145
+ const partNumberStr = qp(queryParams, "partNumber");
2146
+ const upload = this.storage.multipartUploads.get(uploadId);
2147
+ if (!upload || upload.bucket !== bucketName || upload.key !== key) return s3Error("NoSuchUpload", "The specified multipart upload does not exist.", 404, `/${bucketName}/${key}`);
2148
+ const pn = Number(partNumberStr);
2149
+ if (!Number.isInteger(pn) || pn < 1 || pn > 1e4) return s3Error("InvalidArgument", "Part number must be an integer between 1 and 10000, inclusive.", 400);
2150
+ const md5Err = validateContentMd5(headers, body);
2151
+ if (md5Err) return md5Err;
2152
+ const { checksums: partChecksums, error: csumErr } = resolvePartChecksums(body, headers, upload);
2153
+ if (csumErr) return csumErr;
2154
+ const etag = `"${md5Hex(body)}"`;
2155
+ upload.parts[pn] = {
2156
+ body,
2157
+ etag,
2158
+ size: body.length,
2159
+ lastModified: nowIso(),
2160
+ checksums: partChecksums
2161
+ };
2162
+ const csumHeaders = {};
2163
+ for (const [alg, val] of Object.entries(partChecksums)) csumHeaders[`x-amz-checksum-${alg.toLowerCase()}`] = val;
2164
+ return [
2165
+ 200,
2166
+ {
2167
+ ETag: etag,
2168
+ ...csumHeaders
2169
+ },
2170
+ Buffer.alloc(0)
2171
+ ];
2172
+ }
2173
+ completeMultipartUpload(bucketName, key, body, queryParams, headers = {}) {
2174
+ const bucket = this.storage.buckets.get(bucketName);
2175
+ if (!bucket) return noSuchBucket(bucketName);
2176
+ const uploadId = qp(queryParams, "uploadId");
2177
+ const cached = this.storage.completedMultipartUploads.get(uploadId);
2178
+ if (cached && cached.bucket === bucketName && cached.key === key) return cached.response;
2179
+ const upload = this.storage.multipartUploads.get(uploadId);
2180
+ if (!upload || upload.bucket !== bucketName || upload.key !== key) return s3Error("NoSuchUpload", "The specified multipart upload does not exist.", 404, `/${bucketName}/${key}`);
2181
+ const precondErr = this.checkPutPreconditions(headers, bucket.objects[key]);
2182
+ if (precondErr) return precondErr;
2183
+ let root;
2184
+ try {
2185
+ root = parseXml(body);
2186
+ } catch {
2187
+ return s3Error("MalformedXML", "The XML you provided was not well-formed or did not validate against our published schema.", 400, `/${bucketName}/${key}`);
2188
+ }
2189
+ const orderedParts = [];
2190
+ for (const partEl of root.children) if (partEl.localName() === "Part") {
2191
+ let pnText;
2192
+ let etagText;
2193
+ const partChecksums = {};
2194
+ for (const child of partEl.children) {
2195
+ const local = child.localName();
2196
+ if (local === "PartNumber") pnText = child.text;
2197
+ else if (local === "ETag") etagText = child.text;
2198
+ else if (local.startsWith("Checksum")) partChecksums[local.slice(8).toUpperCase()] = child.text || "";
2199
+ }
2200
+ if (pnText === void 0) return s3Error("InvalidPart", "Each part requires a part number.", 400);
2201
+ if (pnText !== void 0) orderedParts.push({
2202
+ pn: Number(pnText),
2203
+ reqEtag: etagText,
2204
+ reqChecksums: partChecksums
2205
+ });
2206
+ }
2207
+ if (root.localName() !== "CompleteMultipartUpload" || orderedParts.length === 0 || orderedParts.length > 1e4) return s3Error("MalformedXML", "A completion request must contain between 1 and 10000 parts.", 400);
2208
+ let previousPartNumber = 0;
2209
+ for (const part of orderedParts) {
2210
+ if (!Number.isInteger(part.pn) || part.pn < 1 || part.pn > 1e4 || !part.reqEtag) return s3Error("InvalidPart", "Each part requires a valid part number and ETag.", 400);
2211
+ if (part.pn <= previousPartNumber) return s3Error("InvalidPartOrder", "Parts must be in ascending order without duplicates.", 400);
2212
+ previousPartNumber = part.pn;
2213
+ }
2214
+ const md5Digests = [];
2215
+ const combinedParts = [];
2216
+ const partDigests = [];
2217
+ const checksumAlgorithm = upload.checksumAlgorithm;
2218
+ for (const { pn, reqEtag, reqChecksums } of orderedParts) {
2219
+ const stored = upload.parts[pn];
2220
+ if (!stored) return s3Error("InvalidPart", "One or more of the specified parts could not be found.", 400);
2221
+ if (reqEtag && reqEtag.replaceAll(/^"|"$/gu, "") !== stored.etag.replaceAll(/^"|"$/gu, "")) return s3Error("InvalidPart", `One or more of the specified parts could not be found. The following part numbers are invalid: ${pn}`, 400);
2222
+ if (pn !== orderedParts.at(-1)?.pn && stored.size < 5242880) return s3Error("EntityTooSmall", "Each nonfinal part must contain at least 5 MiB.", 400);
2223
+ const storedChecksums = stored.checksums || {};
2224
+ for (const [alg, val] of Object.entries(reqChecksums)) if (val && storedChecksums[alg] !== val) return s3Error("InvalidPart", `One or more of the specified parts could not be found. The following part numbers are invalid: ${pn}`, 400);
2225
+ if (checksumAlgorithm) partDigests.push(storedChecksums[checksumAlgorithm]);
2226
+ md5Digests.push(createHash("md5").update(stored.body).digest());
2227
+ combinedParts.push(stored.body);
2228
+ }
2229
+ const composite = compositeChecksum(checksumAlgorithm, partDigests);
2230
+ if (checksumAlgorithm) {
2231
+ const requested = headers[`x-amz-checksum-${checksumAlgorithm.toLowerCase()}`];
2232
+ if (requested && requested !== composite) return s3Error("BadDigest", `The ${checksumAlgorithm} you specified did not match the calculated checksum.`, 400, `/${bucketName}/${key}`);
2233
+ }
2234
+ const finalEtag = `"${createHash("md5").update(Buffer.concat(md5Digests)).digest("hex")}-${orderedParts.length}"`;
2235
+ const combined = Buffer.concat(combinedParts);
2236
+ const obj = {
2237
+ body: combined,
2238
+ contentType: upload.contentType,
2239
+ contentEncoding: upload.contentEncoding,
2240
+ etag: finalEtag,
2241
+ lastModified: nowIso(),
2242
+ size: combined.length,
2243
+ metadata: upload.metadata,
2244
+ preservedHeaders: upload.preservedHeaders,
2245
+ checksums: composite && checksumAlgorithm ? { [checksumAlgorithm]: composite } : {},
2246
+ checksumType: composite ? "COMPOSITE" : void 0
2247
+ };
2248
+ const priorObj = bucket.objects[key];
2249
+ bucket.objects[key] = obj;
2250
+ this.storage.multipartUploads.delete(uploadId);
2251
+ const respHeaders = {};
2252
+ respHeaders["Content-Type"] = "application/xml";
2253
+ const versionId = this.recordObjectVersion(bucketName, key, priorObj, obj, combined);
2254
+ if (versionId) respHeaders["x-amz-version-id"] = versionId;
2255
+ const rootResult = new XmlElement("CompleteMultipartUploadResult", { xmlns: S3_NS });
2256
+ const requestHost = headers["host"];
2257
+ let s3Host = "http://localhost:4566";
2258
+ if (requestHost) s3Host = `${headers["x-forwarded-proto"] || "http"}://${requestHost}`;
2259
+ rootResult.subElement("Location", `${s3Host}/${bucketName}/${key}`);
2260
+ rootResult.subElement("Bucket", bucketName);
2261
+ rootResult.subElement("Key", key);
2262
+ rootResult.subElement("ETag", finalEtag);
2263
+ if (composite && checksumAlgorithm) {
2264
+ rootResult.subElement(`Checksum${checksumAlgorithm}`, composite);
2265
+ rootResult.subElement("ChecksumType", "COMPOSITE");
2266
+ }
2267
+ const response = [
2268
+ 200,
2269
+ respHeaders,
2270
+ xmlBody(rootResult)
2271
+ ];
2272
+ this.storage.completedMultipartUploads.set(uploadId, {
2273
+ bucket: bucketName,
2274
+ key,
2275
+ response
2276
+ });
2277
+ const completed = this.storage.completedMultipartUploads._data;
2278
+ if (completed.size > 1e3) {
2279
+ const oldest = completed.keys().next().value;
2280
+ if (oldest !== void 0) completed.delete(oldest);
2281
+ }
2282
+ return response;
2283
+ }
2284
+ abortMultipartUpload(bucketName, key, queryParams) {
2285
+ if (!this.storage.buckets.get(bucketName)) return noSuchBucket(bucketName);
2286
+ const uploadId = qp(queryParams, "uploadId");
2287
+ const upload = this.storage.multipartUploads.get(uploadId);
2288
+ if (!upload || upload.bucket !== bucketName || upload.key !== key) return s3Error("NoSuchUpload", "The specified multipart upload does not exist.", 404, `/${bucketName}/${key}`);
2289
+ this.storage.multipartUploads.delete(uploadId);
2290
+ return [
2291
+ 204,
2292
+ {},
2293
+ Buffer.alloc(0)
2294
+ ];
2295
+ }
2296
+ };
2297
+ //#endregion
2298
+ //#region src/server/server.ts
2299
+ function collectRequestBody(req) {
2300
+ return new Promise((resolve, reject) => {
2301
+ const chunks = [];
2302
+ req.on("data", (chunk) => chunks.push(chunk));
2303
+ req.on("end", () => resolve(Buffer.concat(chunks)));
2304
+ req.on("error", reject);
2305
+ });
2306
+ }
2307
+ /**
2308
+ * Creates a Node.js `http.Server` request listener that serves the local S3 emulator API.
2309
+ *
2310
+ * @param options - Server configuration options (region, persistence, data directory).
2311
+ * @returns A Node.js `RequestListener` function `(req, res) => Promise<void>`.
2312
+ *
2313
+ * @example
2314
+ * ```ts
2315
+ * import { createServer } from "node:http";
2316
+ * import { createS3NodeHandler } from "local-s3/server";
2317
+ *
2318
+ * const server = createServer(createS3NodeHandler({ persist: true }));
2319
+ * server.listen(4566, "127.0.0.1");
2320
+ * ```
2321
+ */
2322
+ function createS3NodeHandler(options = {}) {
2323
+ const s3 = new S3Service(options);
2324
+ return async (req, res) => {
2325
+ const method = (req.method || "GET").toUpperCase();
2326
+ const [pathPart, queryPart] = (req.url || "/").split("?");
2327
+ const queryParams = parseQueryParams(queryPart || "");
2328
+ const headers = {};
2329
+ for (const [k, v] of Object.entries(req.headers)) if (v !== void 0) headers[k.toLowerCase()] = Array.isArray(v) ? v.join(", ") : v;
2330
+ const accountId = headers["x-amz-account-id"] || qp(queryParams, "accountId") || void 0;
2331
+ const region = headers["x-amz-region"] || qp(queryParams, "region") || options.region || "us-east-1";
2332
+ let path = pathPart;
2333
+ const signedPath = path;
2334
+ try {
2335
+ path = decodeURIComponent(path);
2336
+ } catch {
2337
+ res.writeHead(400);
2338
+ res.end("Invalid URI encoding");
2339
+ return;
2340
+ }
2341
+ let body;
2342
+ try {
2343
+ body = await collectRequestBody(req);
2344
+ } catch {
2345
+ res.destroy();
2346
+ return;
2347
+ }
2348
+ try {
2349
+ const [status, respHeaders, respBody] = withAccountContext({
2350
+ accountId,
2351
+ region
2352
+ }, () => s3.handleRequest(method, path, headers, body, queryParams, signedPath));
2353
+ res.writeHead(status, respHeaders);
2354
+ res.end(respBody);
2355
+ } catch {
2356
+ if (!res.headersSent) {
2357
+ res.writeHead(500, { "Content-Type": "application/xml" });
2358
+ res.end("<Error><Code>InternalError</Code><Message>We encountered an internal error. Please try again.</Message></Error>");
2359
+ }
2360
+ }
2361
+ };
2362
+ }
2363
+ //#endregion
2364
+ //#region src/vite.ts
2365
+ const isColorSupported = !("NO_COLOR" in process.env) && (Boolean(process.env.FORCE_COLOR) || process.stdout.isTTY && process.env.TERM !== "dumb");
2366
+ function dim(text) {
2367
+ return isColorSupported ? `\u001B[2m${text}\u001B[22m` : text;
2368
+ }
2369
+ function boldGreen(text) {
2370
+ return isColorSupported ? `\u001B[1m\u001B[32m${text}\u001B[39m\u001B[22m` : text;
2371
+ }
2372
+ function boldRed(text) {
2373
+ return isColorSupported ? `\u001B[1m\u001B[31m${text}\u001B[39m\u001B[22m` : text;
2374
+ }
2375
+ function formatTime() {
2376
+ return new Intl.DateTimeFormat(void 0, {
2377
+ hour: "numeric",
2378
+ minute: "numeric",
2379
+ second: "numeric"
2380
+ }).format(/* @__PURE__ */ new Date());
2381
+ }
2382
+ const runningServersKey = Symbol.for("local-s3.vite.runningServers");
2383
+ const runtime = globalThis;
2384
+ runtime[runningServersKey] ??= /* @__PURE__ */ new Map();
2385
+ const runningServers = runtime[runningServersKey];
2386
+ /** Start the embedded S3 emulator for Vite development. */
2387
+ function s3Emulator(options = {}) {
2388
+ const cleanups = /* @__PURE__ */ new WeakMap();
2389
+ return {
2390
+ name: "vite-plugin-local-s3",
2391
+ apply: "serve",
2392
+ async configureServer(vite) {
2393
+ const host = "127.0.0.1";
2394
+ const port = options.port ?? 4566;
2395
+ const targetKey = `${host}:${port}`;
2396
+ const rootKey = `root:${vite.config.root}`;
2397
+ await Promise.all([
2398
+ runningServers.get(targetKey)?.(),
2399
+ runningServers.get(rootKey)?.(),
2400
+ vite.config.inlineConfig ? runningServers.get(vite.config.inlineConfig)?.() : void 0
2401
+ ]);
2402
+ const handler = createS3NodeHandler({
2403
+ region: "us-east-1",
2404
+ persist: true,
2405
+ dataDir: path.resolve(vite.config.root, ".emulator/s3")
2406
+ });
2407
+ const server = createServer(async (request, response) => {
2408
+ try {
2409
+ await handler(request, response);
2410
+ } catch (error) {
2411
+ vite.config.logger.error(`${dim(formatTime())} ${boldRed("[s3-emulator]")} Request failed: ${String(error)}`);
2412
+ if (response.headersSent) response.destroy();
2413
+ else {
2414
+ response.writeHead(500);
2415
+ response.end("Internal Server Error");
2416
+ }
2417
+ }
2418
+ });
2419
+ let closing;
2420
+ let boundKey;
2421
+ function stop() {
2422
+ closing ??= (async () => {
2423
+ try {
2424
+ await new Promise((resolve, reject) => {
2425
+ server.close((error) => {
2426
+ if (error && !("code" in error && error.code === "ERR_SERVER_NOT_RUNNING")) reject(error);
2427
+ else resolve();
2428
+ });
2429
+ server.closeAllConnections();
2430
+ });
2431
+ } finally {
2432
+ if (boundKey && runningServers.get(boundKey) === stop) runningServers.delete(boundKey);
2433
+ if (runningServers.get(targetKey) === stop) runningServers.delete(targetKey);
2434
+ if (runningServers.get(rootKey) === stop) runningServers.delete(rootKey);
2435
+ if (vite.config.inlineConfig && runningServers.get(vite.config.inlineConfig) === stop) runningServers.delete(vite.config.inlineConfig);
2436
+ }
2437
+ })();
2438
+ return closing;
2439
+ }
2440
+ runningServers.set(targetKey, stop);
2441
+ runningServers.set(rootKey, stop);
2442
+ if (vite.config.inlineConfig) runningServers.set(vite.config.inlineConfig, stop);
2443
+ for (const environment of Object.values(vite.environments)) cleanups.set(environment, stop);
2444
+ vite.httpServer?.on("close", () => {
2445
+ stop();
2446
+ });
2447
+ const originalClose = vite.close.bind(vite);
2448
+ vite.close = async () => {
2449
+ await stop();
2450
+ return originalClose();
2451
+ };
2452
+ try {
2453
+ await new Promise((resolve, reject) => {
2454
+ server.once("error", reject);
2455
+ server.listen(port, host, () => {
2456
+ server.off("error", reject);
2457
+ resolve();
2458
+ });
2459
+ });
2460
+ } catch (error) {
2461
+ await stop();
2462
+ await vite.close();
2463
+ throw new Error(`[s3-emulator] Cannot listen on ${host}:${port}. Check that the address is available and no other server is using the port.`, { cause: error });
2464
+ }
2465
+ server.on("error", (error) => {
2466
+ vite.config.logger.error(`${dim(formatTime())} ${boldRed("[s3-emulator]")} Server error: ${error.message}`);
2467
+ });
2468
+ const address = server.address();
2469
+ if (address && typeof address !== "string") {
2470
+ boundKey = `${host}:${address.port}`;
2471
+ runningServers.set(boundKey, stop);
2472
+ const endpoint = `http://${host}:${address.port}`;
2473
+ vite.config.logger.info(`${dim(formatTime())} ${boldGreen("[s3-emulator]")} Listening at ${endpoint}`);
2474
+ }
2475
+ },
2476
+ async closeBundle() {
2477
+ await cleanups.get(this.environment)?.();
2478
+ }
2479
+ };
2480
+ }
2481
+ //#endregion
2482
+ export { s3Emulator };