@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.
- package/.turbo/turbo-build.log +62 -0
- package/dist/from-file.cjs +12 -0
- package/dist/from-file.cjs.map +1 -0
- package/dist/from-file.d.cts +8 -0
- package/dist/from-file.d.cts.map +1 -0
- package/dist/from-file.d.mts +8 -0
- package/dist/from-file.d.mts.map +1 -0
- package/dist/from-file.mjs +11 -0
- package/dist/from-file.mjs.map +1 -0
- package/dist/from-node-readable.cjs +10 -0
- package/dist/from-node-readable.cjs.map +1 -0
- package/dist/from-node-readable.d.cts +7 -0
- package/dist/from-node-readable.d.cts.map +1 -0
- package/dist/from-node-readable.d.mts +7 -0
- package/dist/from-node-readable.d.mts.map +1 -0
- package/dist/from-node-readable.mjs +9 -0
- package/dist/from-node-readable.mjs.map +1 -0
- package/dist/network-multiplier.cjs +26 -0
- package/dist/network-multiplier.cjs.map +1 -0
- package/dist/network-multiplier.d.cts +25 -0
- package/dist/network-multiplier.d.cts.map +1 -0
- package/dist/network-multiplier.d.mts +25 -0
- package/dist/network-multiplier.d.mts.map +1 -0
- package/dist/network-multiplier.mjs +25 -0
- package/dist/network-multiplier.mjs.map +1 -0
- package/dist/optimal-part-size.cjs +14 -0
- package/dist/optimal-part-size.cjs.map +1 -0
- package/dist/optimal-part-size.d.cts +23 -0
- package/dist/optimal-part-size.d.cts.map +1 -0
- package/dist/optimal-part-size.d.mts +23 -0
- package/dist/optimal-part-size.d.mts.map +1 -0
- package/dist/optimal-part-size.mjs +13 -0
- package/dist/optimal-part-size.mjs.map +1 -0
- package/dist/s3-multipart-upload.cjs +60 -0
- package/dist/s3-multipart-upload.cjs.map +1 -0
- package/dist/s3-multipart-upload.d.cts +41 -0
- package/dist/s3-multipart-upload.d.cts.map +1 -0
- package/dist/s3-multipart-upload.d.mts +41 -0
- package/dist/s3-multipart-upload.d.mts.map +1 -0
- package/dist/s3-multipart-upload.mjs +58 -0
- package/dist/s3-multipart-upload.mjs.map +1 -0
- package/dist/simple-http-upload.cjs +26 -0
- package/dist/simple-http-upload.cjs.map +1 -0
- package/dist/simple-http-upload.d.cts +13 -0
- package/dist/simple-http-upload.d.cts.map +1 -0
- package/dist/simple-http-upload.d.mts +13 -0
- package/dist/simple-http-upload.d.mts.map +1 -0
- package/dist/simple-http-upload.mjs +25 -0
- package/dist/simple-http-upload.mjs.map +1 -0
- package/package.json +73 -0
- package/src/protocols/s3-multipart-upload.test.ts +127 -0
- package/src/protocols/s3-multipart-upload.ts +92 -0
- package/src/protocols/simple-http-upload.test.ts +75 -0
- package/src/protocols/simple-http-upload.ts +38 -0
- package/src/resilience/network-multiplier.test.ts +57 -0
- package/src/resilience/network-multiplier.ts +52 -0
- package/src/resilience/optimal-part-size.test.ts +57 -0
- package/src/resilience/optimal-part-size.ts +34 -0
- package/src/sources/from-file.test.ts +37 -0
- package/src/sources/from-file.ts +6 -0
- package/src/sources/from-node-readable.test.ts +43 -0
- package/src/sources/from-node-readable.ts +5 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +16 -0
- 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
|
+
}
|