@tranquilload/adapters 0.1.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.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +62 -0
  2. package/dist/from-file.cjs +12 -0
  3. package/dist/from-file.cjs.map +1 -0
  4. package/dist/from-file.d.cts +8 -0
  5. package/dist/from-file.d.cts.map +1 -0
  6. package/dist/from-file.d.mts +8 -0
  7. package/dist/from-file.d.mts.map +1 -0
  8. package/dist/from-file.mjs +11 -0
  9. package/dist/from-file.mjs.map +1 -0
  10. package/dist/from-node-readable.cjs +10 -0
  11. package/dist/from-node-readable.cjs.map +1 -0
  12. package/dist/from-node-readable.d.cts +7 -0
  13. package/dist/from-node-readable.d.cts.map +1 -0
  14. package/dist/from-node-readable.d.mts +7 -0
  15. package/dist/from-node-readable.d.mts.map +1 -0
  16. package/dist/from-node-readable.mjs +9 -0
  17. package/dist/from-node-readable.mjs.map +1 -0
  18. package/dist/network-multiplier.cjs +26 -0
  19. package/dist/network-multiplier.cjs.map +1 -0
  20. package/dist/network-multiplier.d.cts +25 -0
  21. package/dist/network-multiplier.d.cts.map +1 -0
  22. package/dist/network-multiplier.d.mts +25 -0
  23. package/dist/network-multiplier.d.mts.map +1 -0
  24. package/dist/network-multiplier.mjs +25 -0
  25. package/dist/network-multiplier.mjs.map +1 -0
  26. package/dist/optimal-part-size.cjs +14 -0
  27. package/dist/optimal-part-size.cjs.map +1 -0
  28. package/dist/optimal-part-size.d.cts +23 -0
  29. package/dist/optimal-part-size.d.cts.map +1 -0
  30. package/dist/optimal-part-size.d.mts +23 -0
  31. package/dist/optimal-part-size.d.mts.map +1 -0
  32. package/dist/optimal-part-size.mjs +13 -0
  33. package/dist/optimal-part-size.mjs.map +1 -0
  34. package/dist/s3-multipart-upload.cjs +60 -0
  35. package/dist/s3-multipart-upload.cjs.map +1 -0
  36. package/dist/s3-multipart-upload.d.cts +41 -0
  37. package/dist/s3-multipart-upload.d.cts.map +1 -0
  38. package/dist/s3-multipart-upload.d.mts +41 -0
  39. package/dist/s3-multipart-upload.d.mts.map +1 -0
  40. package/dist/s3-multipart-upload.mjs +58 -0
  41. package/dist/s3-multipart-upload.mjs.map +1 -0
  42. package/dist/simple-http-upload.cjs +26 -0
  43. package/dist/simple-http-upload.cjs.map +1 -0
  44. package/dist/simple-http-upload.d.cts +13 -0
  45. package/dist/simple-http-upload.d.cts.map +1 -0
  46. package/dist/simple-http-upload.d.mts +13 -0
  47. package/dist/simple-http-upload.d.mts.map +1 -0
  48. package/dist/simple-http-upload.mjs +25 -0
  49. package/dist/simple-http-upload.mjs.map +1 -0
  50. package/package.json +73 -0
  51. package/src/protocols/s3-multipart-upload.test.ts +127 -0
  52. package/src/protocols/s3-multipart-upload.ts +92 -0
  53. package/src/protocols/simple-http-upload.test.ts +75 -0
  54. package/src/protocols/simple-http-upload.ts +38 -0
  55. package/src/resilience/network-multiplier.test.ts +57 -0
  56. package/src/resilience/network-multiplier.ts +52 -0
  57. package/src/resilience/optimal-part-size.test.ts +57 -0
  58. package/src/resilience/optimal-part-size.ts +34 -0
  59. package/src/sources/from-file.test.ts +37 -0
  60. package/src/sources/from-file.ts +6 -0
  61. package/src/sources/from-node-readable.test.ts +43 -0
  62. package/src/sources/from-node-readable.ts +5 -0
  63. package/tsconfig.json +8 -0
  64. package/tsdown.config.ts +16 -0
  65. package/vitest.config.ts +13 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"s3-multipart-upload.mjs","names":[],"sources":["../src/protocols/s3-multipart-upload.ts"],"sourcesContent":["import { CompleteUploadError, PartUploadError, PresignedUrlError } from \"@tranquilload/core/errors\"\nimport type { CompletedPart } from \"@tranquilload/core/multipart\"\n\nexport const S3_MIN_PART_SIZE = 5 * 1024 * 1024 // 5 MiB\n\nexport interface S3Client {\n createMultipartUpload(params: {\n Bucket: string\n Key: string\n }): Promise<{ UploadId?: string }>\n\n completeMultipartUpload(params: {\n Bucket: string\n Key: string\n UploadId: string\n MultipartUpload: { Parts: ReadonlyArray<{ PartNumber: number; ETag: string }> }\n }): Promise<unknown>\n}\n\nexport interface S3MultipartUploadOptions {\n bucket: string\n key: string\n chunkSize?: number\n getPresignedUrl: (partNumber: number, uploadId: string) => string | Promise<string>\n s3Client: S3Client\n}\n\nexport function s3MultipartUpload(options: S3MultipartUploadOptions): {\n chunkSize: number\n initiate: () => Promise<{ uploadId: string }>\n uploadPart: (partNumber: number, chunk: Uint8Array) => Promise<string>\n completeUpload: (uploadId: string, parts: ReadonlyArray<CompletedPart>) => Promise<void>\n} {\n const { bucket, key, chunkSize = S3_MIN_PART_SIZE, getPresignedUrl, s3Client } = options\n\n if (chunkSize < S3_MIN_PART_SIZE) {\n throw new Error(\n `S3 requires chunkSize >= ${S3_MIN_PART_SIZE} bytes (5 MiB), received ${chunkSize} bytes`\n )\n }\n\n let storedUploadId = \"\"\n\n const initiate = async (): Promise<{ uploadId: string }> => {\n const result = await s3Client.createMultipartUpload({ Bucket: bucket, Key: key })\n if (!result.UploadId) throw new Error(\"S3 CreateMultipartUpload did not return an UploadId\")\n storedUploadId = result.UploadId\n return { uploadId: storedUploadId }\n }\n\n const uploadPart = async (partNumber: number, chunk: Uint8Array): Promise<string> => {\n let url: string\n try {\n url = await Promise.resolve(getPresignedUrl(partNumber, storedUploadId))\n } catch (cause) {\n throw new PresignedUrlError(cause)\n }\n const response = await fetch(url, { method: \"PUT\", body: chunk as unknown as BodyInit })\n if (!response.ok) {\n throw new PartUploadError(\n partNumber,\n 0,\n new Error(`S3 PUT failed: HTTP ${response.status} ${response.statusText}`)\n )\n }\n const rawEtag = response.headers.get(\"ETag\")\n if (!rawEtag) {\n throw new PartUploadError(partNumber, 0, new Error(\"S3 response missing ETag header\"))\n }\n return rawEtag.replace(/\"/g, \"\")\n }\n\n const completeUpload = async (\n uploadId: string,\n parts: ReadonlyArray<CompletedPart>\n ): Promise<void> => {\n try {\n await s3Client.completeMultipartUpload({\n Bucket: bucket,\n Key: key,\n UploadId: uploadId,\n MultipartUpload: {\n Parts: parts.map((p) => ({ PartNumber: p.partNumber, ETag: p.etag })),\n },\n })\n } catch (cause) {\n throw new CompleteUploadError(cause)\n }\n }\n\n return { chunkSize, initiate, uploadPart, completeUpload }\n}\n"],"mappings":";;AAGA,MAAa,mBAAmB,IAAI,OAAO;AAwB3C,SAAgB,kBAAkB,SAKhC;CACA,MAAM,EAAE,QAAQ,KAAK,YAAY,kBAAkB,iBAAiB,aAAa;AAEjF,KAAI,YAAA,QACF,OAAM,IAAI,MACR,4BAA4B,iBAAiB,2BAA2B,UAAU,QACnF;CAGH,IAAI,iBAAiB;CAErB,MAAM,WAAW,YAA2C;EAC1D,MAAM,SAAS,MAAM,SAAS,sBAAsB;GAAE,QAAQ;GAAQ,KAAK;GAAK,CAAC;AACjF,MAAI,CAAC,OAAO,SAAU,OAAM,IAAI,MAAM,sDAAsD;AAC5F,mBAAiB,OAAO;AACxB,SAAO,EAAE,UAAU,gBAAgB;;CAGrC,MAAM,aAAa,OAAO,YAAoB,UAAuC;EACnF,IAAI;AACJ,MAAI;AACF,SAAM,MAAM,QAAQ,QAAQ,gBAAgB,YAAY,eAAe,CAAC;WACjE,OAAO;AACd,SAAM,IAAI,kBAAkB,MAAM;;EAEpC,MAAM,WAAW,MAAM,MAAM,KAAK;GAAE,QAAQ;GAAO,MAAM;GAA8B,CAAC;AACxF,MAAI,CAAC,SAAS,GACZ,OAAM,IAAI,gBACR,YACA,mBACA,IAAI,MAAM,uBAAuB,SAAS,OAAO,GAAG,SAAS,aAAa,CAC3E;EAEH,MAAM,UAAU,SAAS,QAAQ,IAAI,OAAO;AAC5C,MAAI,CAAC,QACH,OAAM,IAAI,gBAAgB,YAAY,mBAAG,IAAI,MAAM,kCAAkC,CAAC;AAExF,SAAO,QAAQ,QAAQ,MAAM,GAAG;;CAGlC,MAAM,iBAAiB,OACrB,UACA,UACkB;AAClB,MAAI;AACF,SAAM,SAAS,wBAAwB;IACrC,QAAQ;IACR,KAAK;IACL,UAAU;IACV,iBAAiB,EACf,OAAO,MAAM,KAAK,OAAO;KAAE,YAAY,EAAE;KAAY,MAAM,EAAE;KAAM,EAAE,EACtE;IACF,CAAC;WACK,OAAO;AACd,SAAM,IAAI,oBAAoB,MAAM;;;AAIxC,QAAO;EAAE;EAAW;EAAU;EAAY;EAAgB"}
@@ -0,0 +1,26 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _tranquilload_core_errors = require("@tranquilload/core/errors");
3
+ //#region src/protocols/simple-http-upload.ts
4
+ function simpleHttpUpload(options) {
5
+ const { url, method = "PUT", headers, signal } = options;
6
+ const upload = async (stream) => {
7
+ let response;
8
+ try {
9
+ response = await fetch(url, {
10
+ method,
11
+ headers,
12
+ body: stream,
13
+ signal
14
+ });
15
+ } catch (cause) {
16
+ if (cause instanceof Error && cause.name === "AbortError") throw new _tranquilload_core_errors.AbortError();
17
+ throw new _tranquilload_core_errors.CompleteUploadError(cause);
18
+ }
19
+ if (!response.ok) throw new _tranquilload_core_errors.CompleteUploadError(/* @__PURE__ */ new Error(`HTTP ${response.status} ${response.statusText}`));
20
+ };
21
+ return { upload };
22
+ }
23
+ //#endregion
24
+ exports.simpleHttpUpload = simpleHttpUpload;
25
+
26
+ //# sourceMappingURL=simple-http-upload.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple-http-upload.cjs","names":["AbortError","CompleteUploadError"],"sources":["../src/protocols/simple-http-upload.ts"],"sourcesContent":["import { AbortError, CompleteUploadError } from \"@tranquilload/core/errors\"\n\nexport interface SimpleHttpUploadOptions {\n url: string\n method?: \"PUT\" | \"POST\"\n headers?: Record<string, string>\n signal?: AbortSignal\n}\n\nexport function simpleHttpUpload(options: SimpleHttpUploadOptions): {\n upload: (stream: ReadableStream<Uint8Array>) => Promise<void>\n} {\n const { url, method = \"PUT\", headers, signal } = options\n\n const upload = async (stream: ReadableStream<Uint8Array>): Promise<void> => {\n let response: Response\n try {\n response = await fetch(url, {\n method,\n headers,\n body: stream as unknown as BodyInit,\n signal,\n })\n } catch (cause) {\n if (cause instanceof Error && cause.name === \"AbortError\") {\n throw new AbortError()\n }\n throw new CompleteUploadError(cause)\n }\n if (!response.ok) {\n throw new CompleteUploadError(\n new Error(`HTTP ${response.status} ${response.statusText}`)\n )\n }\n }\n\n return { upload }\n}\n"],"mappings":";;;AASA,SAAgB,iBAAiB,SAE/B;CACA,MAAM,EAAE,KAAK,SAAS,OAAO,SAAS,WAAW;CAEjD,MAAM,SAAS,OAAO,WAAsD;EAC1E,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,MAAM,KAAK;IAC1B;IACA;IACA,MAAM;IACN;IACD,CAAC;WACK,OAAO;AACd,OAAI,iBAAiB,SAAS,MAAM,SAAS,aAC3C,OAAM,IAAIA,0BAAAA,YAAY;AAExB,SAAM,IAAIC,0BAAAA,oBAAoB,MAAM;;AAEtC,MAAI,CAAC,SAAS,GACZ,OAAM,IAAIA,0BAAAA,oCACR,IAAI,MAAM,QAAQ,SAAS,OAAO,GAAG,SAAS,aAAa,CAC5D;;AAIL,QAAO,EAAE,QAAQ"}
@@ -0,0 +1,13 @@
1
+ //#region src/protocols/simple-http-upload.d.ts
2
+ interface SimpleHttpUploadOptions {
3
+ url: string;
4
+ method?: "PUT" | "POST";
5
+ headers?: Record<string, string>;
6
+ signal?: AbortSignal;
7
+ }
8
+ declare function simpleHttpUpload(options: SimpleHttpUploadOptions): {
9
+ upload: (stream: ReadableStream<Uint8Array>) => Promise<void>;
10
+ };
11
+ //#endregion
12
+ export { SimpleHttpUploadOptions, simpleHttpUpload };
13
+ //# sourceMappingURL=simple-http-upload.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple-http-upload.d.cts","names":[],"sources":["../src/protocols/simple-http-upload.ts"],"mappings":";UAEiB,uBAAA;EACf,GAAA;EACA,MAAA;EACA,OAAA,GAAU,MAAA;EACV,MAAA,GAAS,WAAA;AAAA;AAAA,iBAGK,gBAAA,CAAiB,OAAA,EAAS,uBAAA;EACxC,MAAA,GAAS,MAAA,EAAQ,cAAA,CAAe,UAAA,MAAgB,OAAA;AAAA"}
@@ -0,0 +1,13 @@
1
+ //#region src/protocols/simple-http-upload.d.ts
2
+ interface SimpleHttpUploadOptions {
3
+ url: string;
4
+ method?: "PUT" | "POST";
5
+ headers?: Record<string, string>;
6
+ signal?: AbortSignal;
7
+ }
8
+ declare function simpleHttpUpload(options: SimpleHttpUploadOptions): {
9
+ upload: (stream: ReadableStream<Uint8Array>) => Promise<void>;
10
+ };
11
+ //#endregion
12
+ export { SimpleHttpUploadOptions, simpleHttpUpload };
13
+ //# sourceMappingURL=simple-http-upload.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple-http-upload.d.mts","names":[],"sources":["../src/protocols/simple-http-upload.ts"],"mappings":";UAEiB,uBAAA;EACf,GAAA;EACA,MAAA;EACA,OAAA,GAAU,MAAA;EACV,MAAA,GAAS,WAAA;AAAA;AAAA,iBAGK,gBAAA,CAAiB,OAAA,EAAS,uBAAA;EACxC,MAAA,GAAS,MAAA,EAAQ,cAAA,CAAe,UAAA,MAAgB,OAAA;AAAA"}
@@ -0,0 +1,25 @@
1
+ import { AbortError, CompleteUploadError } from "@tranquilload/core/errors";
2
+ //#region src/protocols/simple-http-upload.ts
3
+ function simpleHttpUpload(options) {
4
+ const { url, method = "PUT", headers, signal } = options;
5
+ const upload = async (stream) => {
6
+ let response;
7
+ try {
8
+ response = await fetch(url, {
9
+ method,
10
+ headers,
11
+ body: stream,
12
+ signal
13
+ });
14
+ } catch (cause) {
15
+ if (cause instanceof Error && cause.name === "AbortError") throw new AbortError();
16
+ throw new CompleteUploadError(cause);
17
+ }
18
+ if (!response.ok) throw new CompleteUploadError(/* @__PURE__ */ new Error(`HTTP ${response.status} ${response.statusText}`));
19
+ };
20
+ return { upload };
21
+ }
22
+ //#endregion
23
+ export { simpleHttpUpload };
24
+
25
+ //# sourceMappingURL=simple-http-upload.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple-http-upload.mjs","names":[],"sources":["../src/protocols/simple-http-upload.ts"],"sourcesContent":["import { AbortError, CompleteUploadError } from \"@tranquilload/core/errors\"\n\nexport interface SimpleHttpUploadOptions {\n url: string\n method?: \"PUT\" | \"POST\"\n headers?: Record<string, string>\n signal?: AbortSignal\n}\n\nexport function simpleHttpUpload(options: SimpleHttpUploadOptions): {\n upload: (stream: ReadableStream<Uint8Array>) => Promise<void>\n} {\n const { url, method = \"PUT\", headers, signal } = options\n\n const upload = async (stream: ReadableStream<Uint8Array>): Promise<void> => {\n let response: Response\n try {\n response = await fetch(url, {\n method,\n headers,\n body: stream as unknown as BodyInit,\n signal,\n })\n } catch (cause) {\n if (cause instanceof Error && cause.name === \"AbortError\") {\n throw new AbortError()\n }\n throw new CompleteUploadError(cause)\n }\n if (!response.ok) {\n throw new CompleteUploadError(\n new Error(`HTTP ${response.status} ${response.statusText}`)\n )\n }\n }\n\n return { upload }\n}\n"],"mappings":";;AASA,SAAgB,iBAAiB,SAE/B;CACA,MAAM,EAAE,KAAK,SAAS,OAAO,SAAS,WAAW;CAEjD,MAAM,SAAS,OAAO,WAAsD;EAC1E,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,MAAM,KAAK;IAC1B;IACA;IACA,MAAM;IACN;IACD,CAAC;WACK,OAAO;AACd,OAAI,iBAAiB,SAAS,MAAM,SAAS,aAC3C,OAAM,IAAI,YAAY;AAExB,SAAM,IAAI,oBAAoB,MAAM;;AAEtC,MAAI,CAAC,SAAS,GACZ,OAAM,IAAI,oCACR,IAAI,MAAM,QAAQ,SAAS,OAAO,GAAG,SAAS,aAAa,CAC5D;;AAIL,QAAO,EAAE,QAAQ"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@tranquilload/adapters",
3
+ "version": "0.1.0",
4
+ "description": "Adapters for Tranquilload (S3, File, Node, HTTP)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Schrubitteflau/Tranquilload.git"
9
+ },
10
+ "author": {
11
+ "name": "Schrubitteflau",
12
+ "url": "https://github.com/Schrubitteflau"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Schrubitteflau/Tranquilload/issues"
16
+ },
17
+ "homepage": "https://github.com/Schrubitteflau/Tranquilload#readme",
18
+ "type": "module",
19
+ "exports": {
20
+ "./fromFile": {
21
+ "types": "./dist/from-file.d.mts",
22
+ "import": "./dist/from-file.mjs",
23
+ "require": "./dist/from-file.cjs"
24
+ },
25
+ "./fromNodeReadable": {
26
+ "types": "./dist/from-node-readable.d.mts",
27
+ "import": "./dist/from-node-readable.mjs",
28
+ "require": "./dist/from-node-readable.cjs"
29
+ },
30
+ "./s3MultipartUpload": {
31
+ "types": "./dist/s3-multipart-upload.d.mts",
32
+ "import": "./dist/s3-multipart-upload.mjs",
33
+ "require": "./dist/s3-multipart-upload.cjs"
34
+ },
35
+ "./simpleHttpUpload": {
36
+ "types": "./dist/simple-http-upload.d.mts",
37
+ "import": "./dist/simple-http-upload.mjs",
38
+ "require": "./dist/simple-http-upload.cjs"
39
+ },
40
+ "./networkMultiplier": {
41
+ "types": "./dist/network-multiplier.d.mts",
42
+ "import": "./dist/network-multiplier.mjs",
43
+ "require": "./dist/network-multiplier.cjs"
44
+ },
45
+ "./optimalPartSize": {
46
+ "types": "./dist/optimal-part-size.d.mts",
47
+ "import": "./dist/optimal-part-size.mjs",
48
+ "require": "./dist/optimal-part-size.cjs"
49
+ }
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "peerDependencies": {
55
+ "effect": ">=3.19.19",
56
+ "@tranquilload/core": "^0.1.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^25.5.0",
60
+ "@effect/vitest": "^0.27.0",
61
+ "effect": "3.19.19",
62
+ "tsdown": "^0.21.0",
63
+ "typescript": "^5.5.0",
64
+ "vitest": "^3.2.0",
65
+ "@tranquilload/core": "0.1.0"
66
+ },
67
+ "scripts": {
68
+ "build": "tsdown",
69
+ "dev": "tsdown --watch",
70
+ "test": "vitest run",
71
+ "typecheck": "tsc --noEmit"
72
+ }
73
+ }
@@ -0,0 +1,127 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+ import { CompleteUploadError, PresignedUrlError } from "@tranquilload/core/errors"
3
+ import { s3MultipartUpload, S3_MIN_PART_SIZE } from "./s3-multipart-upload.js"
4
+
5
+ const makeMockS3Client = (overrides = {}) => ({
6
+ createMultipartUpload: vi.fn().mockResolvedValue({ UploadId: "upload-123" }),
7
+ completeMultipartUpload: vi.fn().mockResolvedValue({}),
8
+ ...overrides,
9
+ })
10
+
11
+ afterEach(() => {
12
+ vi.unstubAllGlobals()
13
+ })
14
+
15
+ describe("s3MultipartUpload", () => {
16
+ it("throws synchronously when chunkSize < 5 MiB", () => {
17
+ expect(() =>
18
+ s3MultipartUpload({
19
+ bucket: "b",
20
+ key: "k",
21
+ chunkSize: 1024,
22
+ getPresignedUrl: vi.fn(),
23
+ s3Client: makeMockS3Client(),
24
+ })
25
+ ).toThrow("S3 requires chunkSize >= 5242880 bytes (5 MiB)")
26
+ })
27
+
28
+ it("initiate calls createMultipartUpload with bucket and key", async () => {
29
+ const s3Client = makeMockS3Client()
30
+ const adapter = s3MultipartUpload({
31
+ bucket: "my-bucket",
32
+ key: "my-key",
33
+ getPresignedUrl: vi.fn(),
34
+ s3Client,
35
+ })
36
+ const result = await adapter.initiate()
37
+ expect(s3Client.createMultipartUpload).toHaveBeenCalledWith({
38
+ Bucket: "my-bucket",
39
+ Key: "my-key",
40
+ })
41
+ expect(result).toEqual({ uploadId: "upload-123" })
42
+ })
43
+
44
+ it("uploadPart calls getPresignedUrl with partNumber and uploadId then PUTs chunk", async () => {
45
+ const getPresignedUrl = vi.fn().mockResolvedValue("https://s3.example.com/presigned")
46
+ const s3Client = makeMockS3Client()
47
+ const fetchMock = vi.fn().mockResolvedValue({
48
+ ok: true,
49
+ headers: new Headers({ ETag: '"etag-abc"' }),
50
+ })
51
+ vi.stubGlobal("fetch", fetchMock)
52
+
53
+ const adapter = s3MultipartUpload({ bucket: "b", key: "k", getPresignedUrl, s3Client })
54
+ await adapter.initiate()
55
+ const etag = await adapter.uploadPart(1, new Uint8Array([1, 2, 3]))
56
+
57
+ expect(getPresignedUrl).toHaveBeenCalledWith(1, "upload-123")
58
+ expect(fetchMock).toHaveBeenCalledWith("https://s3.example.com/presigned", {
59
+ method: "PUT",
60
+ body: expect.any(Uint8Array),
61
+ })
62
+ expect(etag).toBe("etag-abc")
63
+ })
64
+
65
+ it("uploadPart returns ETag stripped of quotes", async () => {
66
+ vi.stubGlobal(
67
+ "fetch",
68
+ vi.fn().mockResolvedValue({
69
+ ok: true,
70
+ headers: new Headers({ ETag: '"quoted-etag"' }),
71
+ })
72
+ )
73
+ const adapter = s3MultipartUpload({
74
+ bucket: "b",
75
+ key: "k",
76
+ getPresignedUrl: vi.fn().mockResolvedValue("https://s3.example.com/presigned"),
77
+ s3Client: makeMockS3Client(),
78
+ })
79
+ await adapter.initiate()
80
+ const etag = await adapter.uploadPart(1, new Uint8Array())
81
+ expect(etag).toBe("quoted-etag")
82
+ })
83
+
84
+ it("uploadPart rejects with PresignedUrlError when getPresignedUrl rejects", async () => {
85
+ const getPresignedUrl = vi.fn().mockRejectedValue(new Error("no URL"))
86
+ const adapter = s3MultipartUpload({
87
+ bucket: "b",
88
+ key: "k",
89
+ getPresignedUrl,
90
+ s3Client: makeMockS3Client(),
91
+ })
92
+ await adapter.initiate()
93
+ await expect(adapter.uploadPart(1, new Uint8Array())).rejects.toBeInstanceOf(PresignedUrlError)
94
+ })
95
+
96
+ it("completeUpload calls s3Client.completeMultipartUpload with correct structure", async () => {
97
+ const s3Client = makeMockS3Client()
98
+ const adapter = s3MultipartUpload({
99
+ bucket: "b",
100
+ key: "k",
101
+ getPresignedUrl: vi.fn(),
102
+ s3Client,
103
+ })
104
+ await adapter.completeUpload("upload-123", [{ partNumber: 1, etag: "etag-1" }])
105
+ expect(s3Client.completeMultipartUpload).toHaveBeenCalledWith({
106
+ Bucket: "b",
107
+ Key: "k",
108
+ UploadId: "upload-123",
109
+ MultipartUpload: { Parts: [{ PartNumber: 1, ETag: "etag-1" }] },
110
+ })
111
+ })
112
+
113
+ it("completeUpload rejects with CompleteUploadError when s3Client fails", async () => {
114
+ const s3Client = makeMockS3Client({
115
+ completeMultipartUpload: vi.fn().mockRejectedValue(new Error("S3 error")),
116
+ })
117
+ const adapter = s3MultipartUpload({
118
+ bucket: "b",
119
+ key: "k",
120
+ getPresignedUrl: vi.fn(),
121
+ s3Client,
122
+ })
123
+ await expect(
124
+ adapter.completeUpload("upload-123", [{ partNumber: 1, etag: "etag-1" }])
125
+ ).rejects.toBeInstanceOf(CompleteUploadError)
126
+ })
127
+ })
@@ -0,0 +1,92 @@
1
+ import { CompleteUploadError, PartUploadError, PresignedUrlError } from "@tranquilload/core/errors"
2
+ import type { CompletedPart } from "@tranquilload/core/multipart"
3
+
4
+ export const S3_MIN_PART_SIZE = 5 * 1024 * 1024 // 5 MiB
5
+
6
+ export interface S3Client {
7
+ createMultipartUpload(params: {
8
+ Bucket: string
9
+ Key: string
10
+ }): Promise<{ UploadId?: string }>
11
+
12
+ completeMultipartUpload(params: {
13
+ Bucket: string
14
+ Key: string
15
+ UploadId: string
16
+ MultipartUpload: { Parts: ReadonlyArray<{ PartNumber: number; ETag: string }> }
17
+ }): Promise<unknown>
18
+ }
19
+
20
+ export interface S3MultipartUploadOptions {
21
+ bucket: string
22
+ key: string
23
+ chunkSize?: number
24
+ getPresignedUrl: (partNumber: number, uploadId: string) => string | Promise<string>
25
+ s3Client: S3Client
26
+ }
27
+
28
+ export function s3MultipartUpload(options: S3MultipartUploadOptions): {
29
+ chunkSize: number
30
+ initiate: () => Promise<{ uploadId: string }>
31
+ uploadPart: (partNumber: number, chunk: Uint8Array) => Promise<string>
32
+ completeUpload: (uploadId: string, parts: ReadonlyArray<CompletedPart>) => Promise<void>
33
+ } {
34
+ const { bucket, key, chunkSize = S3_MIN_PART_SIZE, getPresignedUrl, s3Client } = options
35
+
36
+ if (chunkSize < S3_MIN_PART_SIZE) {
37
+ throw new Error(
38
+ `S3 requires chunkSize >= ${S3_MIN_PART_SIZE} bytes (5 MiB), received ${chunkSize} bytes`
39
+ )
40
+ }
41
+
42
+ let storedUploadId = ""
43
+
44
+ const initiate = async (): Promise<{ uploadId: string }> => {
45
+ const result = await s3Client.createMultipartUpload({ Bucket: bucket, Key: key })
46
+ if (!result.UploadId) throw new Error("S3 CreateMultipartUpload did not return an UploadId")
47
+ storedUploadId = result.UploadId
48
+ return { uploadId: storedUploadId }
49
+ }
50
+
51
+ const uploadPart = async (partNumber: number, chunk: Uint8Array): Promise<string> => {
52
+ let url: string
53
+ try {
54
+ url = await Promise.resolve(getPresignedUrl(partNumber, storedUploadId))
55
+ } catch (cause) {
56
+ throw new PresignedUrlError(cause)
57
+ }
58
+ const response = await fetch(url, { method: "PUT", body: chunk as unknown as BodyInit })
59
+ if (!response.ok) {
60
+ throw new PartUploadError(
61
+ partNumber,
62
+ 0,
63
+ new Error(`S3 PUT failed: HTTP ${response.status} ${response.statusText}`)
64
+ )
65
+ }
66
+ const rawEtag = response.headers.get("ETag")
67
+ if (!rawEtag) {
68
+ throw new PartUploadError(partNumber, 0, new Error("S3 response missing ETag header"))
69
+ }
70
+ return rawEtag.replace(/"/g, "")
71
+ }
72
+
73
+ const completeUpload = async (
74
+ uploadId: string,
75
+ parts: ReadonlyArray<CompletedPart>
76
+ ): Promise<void> => {
77
+ try {
78
+ await s3Client.completeMultipartUpload({
79
+ Bucket: bucket,
80
+ Key: key,
81
+ UploadId: uploadId,
82
+ MultipartUpload: {
83
+ Parts: parts.map((p) => ({ PartNumber: p.partNumber, ETag: p.etag })),
84
+ },
85
+ })
86
+ } catch (cause) {
87
+ throw new CompleteUploadError(cause)
88
+ }
89
+ }
90
+
91
+ return { chunkSize, initiate, uploadPart, completeUpload }
92
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest"
2
+ import { AbortError, CompleteUploadError } from "@tranquilload/core/errors"
3
+ import { simpleHttpUpload } from "./simple-http-upload.js"
4
+
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals()
7
+ })
8
+
9
+ describe("simpleHttpUpload", () => {
10
+ it("upload calls fetch with url, method, headers, and stream as body", async () => {
11
+ const stream = new ReadableStream<Uint8Array>()
12
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true })
13
+ vi.stubGlobal("fetch", fetchMock)
14
+
15
+ const adapter = simpleHttpUpload({
16
+ url: "https://example.com/upload",
17
+ method: "PUT",
18
+ headers: { "x-foo": "bar" },
19
+ })
20
+ await adapter.upload(stream)
21
+
22
+ expect(fetchMock).toHaveBeenCalledWith(
23
+ "https://example.com/upload",
24
+ expect.objectContaining({
25
+ method: "PUT",
26
+ headers: { "x-foo": "bar" },
27
+ body: stream,
28
+ })
29
+ )
30
+ })
31
+
32
+ it("method defaults to PUT when omitted", async () => {
33
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true })
34
+ vi.stubGlobal("fetch", fetchMock)
35
+
36
+ const adapter = simpleHttpUpload({ url: "https://example.com/upload" })
37
+ await adapter.upload(new ReadableStream())
38
+
39
+ expect(fetchMock).toHaveBeenCalledWith(
40
+ "https://example.com/upload",
41
+ expect.objectContaining({ method: "PUT" })
42
+ )
43
+ })
44
+
45
+ it("rejects with CompleteUploadError when response is not ok", async () => {
46
+ vi.stubGlobal(
47
+ "fetch",
48
+ vi.fn().mockResolvedValue({ ok: false, status: 403, statusText: "Forbidden" })
49
+ )
50
+
51
+ const adapter = simpleHttpUpload({ url: "https://example.com/upload" })
52
+ const error = await adapter.upload(new ReadableStream()).catch((e) => e)
53
+ expect(error).toBeInstanceOf(CompleteUploadError)
54
+ expect(error.cause).toBeInstanceOf(Error)
55
+ expect((error.cause as Error).message).toBe("HTTP 403 Forbidden")
56
+ })
57
+
58
+ it("rejects with AbortError when fetch is aborted", async () => {
59
+ const abortError = new DOMException("The operation was aborted.", "AbortError")
60
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abortError))
61
+
62
+ const adapter = simpleHttpUpload({ url: "https://example.com/upload" })
63
+ await expect(adapter.upload(new ReadableStream())).rejects.toBeInstanceOf(AbortError)
64
+ })
65
+
66
+ it("rejects with CompleteUploadError on network failure", async () => {
67
+ const networkError = new Error("Failed to fetch")
68
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(networkError))
69
+
70
+ const adapter = simpleHttpUpload({ url: "https://example.com/upload" })
71
+ const error = await adapter.upload(new ReadableStream()).catch((e) => e)
72
+ expect(error).toBeInstanceOf(CompleteUploadError)
73
+ expect(error.cause).toBe(networkError)
74
+ })
75
+ })
@@ -0,0 +1,38 @@
1
+ import { AbortError, CompleteUploadError } from "@tranquilload/core/errors"
2
+
3
+ export interface SimpleHttpUploadOptions {
4
+ url: string
5
+ method?: "PUT" | "POST"
6
+ headers?: Record<string, string>
7
+ signal?: AbortSignal
8
+ }
9
+
10
+ export function simpleHttpUpload(options: SimpleHttpUploadOptions): {
11
+ upload: (stream: ReadableStream<Uint8Array>) => Promise<void>
12
+ } {
13
+ const { url, method = "PUT", headers, signal } = options
14
+
15
+ const upload = async (stream: ReadableStream<Uint8Array>): Promise<void> => {
16
+ let response: Response
17
+ try {
18
+ response = await fetch(url, {
19
+ method,
20
+ headers,
21
+ body: stream as unknown as BodyInit,
22
+ signal,
23
+ })
24
+ } catch (cause) {
25
+ if (cause instanceof Error && cause.name === "AbortError") {
26
+ throw new AbortError()
27
+ }
28
+ throw new CompleteUploadError(cause)
29
+ }
30
+ if (!response.ok) {
31
+ throw new CompleteUploadError(
32
+ new Error(`HTTP ${response.status} ${response.statusText}`)
33
+ )
34
+ }
35
+ }
36
+
37
+ return { upload }
38
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { networkMultiplier } from './network-multiplier.js'
3
+
4
+ describe('networkMultiplier', () => {
5
+ it('returns 1.0 when no samples have been recorded', () => {
6
+ const m = networkMultiplier()
7
+ expect(m.factor()).toBe(1.0)
8
+ })
9
+
10
+ it('returns 1.0 when throughput >= target', () => {
11
+ const target = 10_000 // bytes/ms
12
+ const m = networkMultiplier({ targetBytesPerMs: target })
13
+ // Record sample at exactly target speed
14
+ m.record(10_000, 1)
15
+ expect(m.factor()).toBe(1.0)
16
+ // Record sample faster than target
17
+ m.record(20_000, 1)
18
+ expect(m.factor()).toBe(1.0)
19
+ })
20
+
21
+ it('returns 0.1 when throughput is 10% of target', () => {
22
+ const target = 10_000
23
+ const m = networkMultiplier({ targetBytesPerMs: target })
24
+ m.record(1_000, 1) // 1000 bytes/ms = 10% of target
25
+ expect(m.factor()).toBeCloseTo(0.1, 5)
26
+ })
27
+
28
+ it('clamps below 0.1 when throughput is very low', () => {
29
+ const target = 10_000
30
+ const m = networkMultiplier({ targetBytesPerMs: target })
31
+ m.record(100, 1) // 1% of target
32
+ expect(m.factor()).toBe(0.1)
33
+ })
34
+
35
+ it('rolling window evicts oldest sample', () => {
36
+ const target = 10_000
37
+ const m = networkMultiplier({ windowSize: 3, targetBytesPerMs: target })
38
+ // Fill window with slow samples (1000 bytes/ms = 0.1 factor)
39
+ m.record(1_000, 1)
40
+ m.record(1_000, 1)
41
+ m.record(1_000, 1)
42
+ expect(m.factor()).toBeCloseTo(0.1, 5)
43
+ // Now push 3 fast samples to evict all slow ones
44
+ m.record(10_000, 1)
45
+ m.record(10_000, 1)
46
+ m.record(10_000, 1)
47
+ expect(m.factor()).toBe(1.0)
48
+ })
49
+
50
+ it('skips sample when durationMs <= 0', () => {
51
+ const m = networkMultiplier()
52
+ m.record(1_000, 0)
53
+ m.record(1_000, -5)
54
+ // No valid samples → factor stays 1.0
55
+ expect(m.factor()).toBe(1.0)
56
+ })
57
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Network throughput multiplier adapter.
3
+ *
4
+ * Measures upload throughput via a sliding window and returns a factor
5
+ * in [0.1, 1.0] to scale chunk size dynamically.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ export interface NetworkMultiplierInstance {
11
+ /** Record a completed upload measurement. Skips if durationMs <= 0. */
12
+ record(bytes: number, durationMs: number): void
13
+ /** Returns current throughput factor [0.1, 1.0]. Returns 1.0 with no samples. */
14
+ factor(): number
15
+ }
16
+
17
+ export interface NetworkMultiplierOptions {
18
+ /** Number of recent samples to average. Default: 5 */
19
+ windowSize?: number
20
+ /** Throughput (bytes/ms) that maps to factor=1.0. Default: ~10 MB/s */
21
+ targetBytesPerMs?: number
22
+ }
23
+
24
+ const DEFAULT_WINDOW_SIZE = 5
25
+ const DEFAULT_TARGET_BYTES_PER_MS = (10 * 1024 * 1024) / 1000
26
+
27
+ export function networkMultiplier(
28
+ options?: NetworkMultiplierOptions
29
+ ): NetworkMultiplierInstance {
30
+ const windowSize = options?.windowSize ?? DEFAULT_WINDOW_SIZE
31
+ const targetBytesPerMs =
32
+ options?.targetBytesPerMs ?? DEFAULT_TARGET_BYTES_PER_MS
33
+ const samples: number[] = []
34
+
35
+ return {
36
+ record(bytes: number, durationMs: number): void {
37
+ if (durationMs <= 0) return
38
+ const throughput = bytes / durationMs
39
+ if (samples.length >= windowSize) {
40
+ samples.shift()
41
+ }
42
+ samples.push(throughput)
43
+ },
44
+
45
+ factor(): number {
46
+ if (samples.length === 0) return 1.0
47
+ const avg =
48
+ samples.reduce((sum, s) => sum + s, 0) / samples.length
49
+ return Math.max(0.1, Math.min(1.0, avg / targetBytesPerMs))
50
+ },
51
+ }
52
+ }