@teepin/sdk 0.2.0 → 0.3.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +181 -396
- package/dist/cjs/client.js +42 -4
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/direct/bytes.js +50 -0
- package/dist/cjs/direct/bytes.js.map +1 -0
- package/dist/cjs/direct/common.js +167 -0
- package/dist/cjs/direct/common.js.map +1 -0
- package/dist/cjs/direct/downloads-plan.js +127 -0
- package/dist/cjs/direct/downloads-plan.js.map +1 -0
- package/dist/cjs/direct/engine.js +405 -0
- package/dist/cjs/direct/engine.js.map +1 -0
- package/dist/cjs/direct/formats.js +44 -0
- package/dist/cjs/direct/formats.js.map +1 -0
- package/dist/cjs/direct/hpke.js +275 -0
- package/dist/cjs/direct/hpke.js.map +1 -0
- package/dist/cjs/direct/journal.js +80 -0
- package/dist/cjs/direct/journal.js.map +1 -0
- package/dist/cjs/direct/large.js +850 -0
- package/dist/cjs/direct/large.js.map +1 -0
- package/dist/cjs/direct/reads.js +1314 -0
- package/dist/cjs/direct/reads.js.map +1 -0
- package/dist/cjs/direct/sha256.js +122 -0
- package/dist/cjs/direct/sha256.js.map +1 -0
- package/dist/cjs/direct/tws1.js +480 -0
- package/dist/cjs/direct/tws1.js.map +1 -0
- package/dist/cjs/direct/uploads.js +1706 -0
- package/dist/cjs/direct/uploads.js.map +1 -0
- package/dist/cjs/errors.js +70 -13
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/links.js +195 -0
- package/dist/cjs/links.js.map +1 -0
- package/dist/cjs/node/downloads.js +835 -0
- package/dist/cjs/node/downloads.js.map +1 -0
- package/dist/cjs/node/journal.js +841 -0
- package/dist/cjs/node/journal.js.map +1 -0
- package/dist/cjs/node/uploads.js +219 -0
- package/dist/cjs/node/uploads.js.map +1 -0
- package/dist/cjs/node.js +26 -39
- package/dist/cjs/node.js.map +1 -1
- package/dist/cjs/storage.js +214 -184
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/transfer.js +18 -351
- package/dist/cjs/transfer.js.map +1 -1
- package/dist/esm/client.js +42 -4
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/direct/bytes.js +42 -0
- package/dist/esm/direct/bytes.js.map +1 -0
- package/dist/esm/direct/common.js +155 -0
- package/dist/esm/direct/common.js.map +1 -0
- package/dist/esm/direct/downloads-plan.js +117 -0
- package/dist/esm/direct/downloads-plan.js.map +1 -0
- package/dist/esm/direct/engine.js +385 -0
- package/dist/esm/direct/engine.js.map +1 -0
- package/dist/esm/direct/formats.js +40 -0
- package/dist/esm/direct/formats.js.map +1 -0
- package/dist/esm/direct/hpke.js +262 -0
- package/dist/esm/direct/hpke.js.map +1 -0
- package/dist/esm/direct/journal.js +75 -0
- package/dist/esm/direct/journal.js.map +1 -0
- package/dist/esm/direct/large.js +816 -0
- package/dist/esm/direct/large.js.map +1 -0
- package/dist/esm/direct/reads.js +1275 -0
- package/dist/esm/direct/reads.js.map +1 -0
- package/dist/esm/direct/sha256.js +118 -0
- package/dist/esm/direct/sha256.js.map +1 -0
- package/dist/esm/direct/tws1.js +460 -0
- package/dist/esm/direct/tws1.js.map +1 -0
- package/dist/esm/direct/uploads.js +1660 -0
- package/dist/esm/direct/uploads.js.map +1 -0
- package/dist/esm/errors.js +64 -11
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/links.js +189 -0
- package/dist/esm/links.js.map +1 -0
- package/dist/esm/node/downloads.js +829 -0
- package/dist/esm/node/downloads.js.map +1 -0
- package/dist/esm/node/journal.js +824 -0
- package/dist/esm/node/journal.js.map +1 -0
- package/dist/esm/node/uploads.js +187 -0
- package/dist/esm/node/uploads.js.map +1 -0
- package/dist/esm/node.js +17 -14
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/storage.js +214 -184
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/transfer.js +19 -349
- package/dist/esm/transfer.js.map +1 -1
- package/dist/types/client.d.ts +45 -4
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/direct/bytes.d.ts +11 -0
- package/dist/types/direct/bytes.d.ts.map +1 -0
- package/dist/types/direct/common.d.ts +47 -0
- package/dist/types/direct/common.d.ts.map +1 -0
- package/dist/types/direct/downloads-plan.d.ts +75 -0
- package/dist/types/direct/downloads-plan.d.ts.map +1 -0
- package/dist/types/direct/engine.d.ts +191 -0
- package/dist/types/direct/engine.d.ts.map +1 -0
- package/dist/types/direct/formats.d.ts +9 -0
- package/dist/types/direct/formats.d.ts.map +1 -0
- package/dist/types/direct/hpke.d.ts +55 -0
- package/dist/types/direct/hpke.d.ts.map +1 -0
- package/dist/types/direct/journal.d.ts +55 -0
- package/dist/types/direct/journal.d.ts.map +1 -0
- package/dist/types/direct/large.d.ts +170 -0
- package/dist/types/direct/large.d.ts.map +1 -0
- package/dist/types/direct/reads.d.ts +306 -0
- package/dist/types/direct/reads.d.ts.map +1 -0
- package/dist/types/direct/sha256.d.ts +16 -0
- package/dist/types/direct/sha256.d.ts.map +1 -0
- package/dist/types/direct/tws1.d.ts +157 -0
- package/dist/types/direct/tws1.d.ts.map +1 -0
- package/dist/types/direct/uploads.d.ts +461 -0
- package/dist/types/direct/uploads.d.ts.map +1 -0
- package/dist/types/errors.d.ts +47 -8
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/links.d.ts +86 -0
- package/dist/types/links.d.ts.map +1 -0
- package/dist/types/node/downloads.d.ts +73 -0
- package/dist/types/node/downloads.d.ts.map +1 -0
- package/dist/types/node/journal.d.ts +162 -0
- package/dist/types/node/journal.d.ts.map +1 -0
- package/dist/types/node/uploads.d.ts +86 -0
- package/dist/types/node/uploads.d.ts.map +1 -0
- package/dist/types/node.d.ts +4 -2
- package/dist/types/node.d.ts.map +1 -1
- package/dist/types/storage.d.ts +107 -64
- package/dist/types/storage.d.ts.map +1 -1
- package/dist/types/transfer.d.ts +14 -23
- package/dist/types/transfer.d.ts.map +1 -1
- package/package.json +8 -5
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
// Copyright 2026 TEEPIN Project
|
|
2
|
+
// Licensed under the Apache License, Version 2.0
|
|
3
|
+
// Large objects, uploaded directly in parts (TEEPIN-STORAGE-DESIGN.md sections 21.1 D1, 21.2 and 22.4), a port of
|
|
4
|
+
// teepin-python's _large.py with the same rules.
|
|
5
|
+
//
|
|
6
|
+
// One TWS1 stream per object, cut into parts at segment boundaries. The control plane starts the storage network's
|
|
7
|
+
// upload, signs each part (its first sign pins the part's MD5 and SHA-256), and completes it with the wrapped data key.
|
|
8
|
+
//
|
|
9
|
+
// An upload goes through these states, each journaled before its action:
|
|
10
|
+
//
|
|
11
|
+
// new → sending → completing → completed
|
|
12
|
+
//
|
|
13
|
+
// - new: its idempotency key is journaled, so a lost create answer gets the same upload again.
|
|
14
|
+
// - sending: the upload exists, and its data key (E3: a file of its own, 0600, in the run's directory), stream header
|
|
15
|
+
// and wrap are journaled, so a crash resumes the remaining parts.
|
|
16
|
+
// - Each part's plaintext SHA-256 is journaled before its first byte leaves (D1 condition a): all of them at create,
|
|
17
|
+
// taken in the same pass as the whole object's. A part sealed again is read and hashed again first (condition b): a
|
|
18
|
+
// changed file fails the upload, never mixes two versions.
|
|
19
|
+
// - completing: every part is sent. Complete is idempotent, so a lost answer is asked for again.
|
|
20
|
+
//
|
|
21
|
+
// An upload the storage network or control plane ended (expired, mismatched) starts again under a new create with a
|
|
22
|
+
// fresh key. A final failure cancels it, so its parts aren't kept (and billed, D5) for nothing.
|
|
23
|
+
//
|
|
24
|
+
// A stream whose size isn't known (section 22.1, E2 b) is created without its size and SHA-256, read one part ahead
|
|
25
|
+
// (TWS1's last flag needs to know which part is last), its last part declared at its sign, and the size and SHA-256
|
|
26
|
+
// brought by complete. Nothing can read it again, so it isn't journaled: any failure cancels it, and the caller uploads
|
|
27
|
+
// it again. Memory: each part in flight holds only its ciphertext, and the reader holds the part it hands over, the
|
|
28
|
+
// next, and one more while it seals a part: at most (concurrency + 3) parts.
|
|
29
|
+
import { APIError, DataIntegrityError, FileChangedError, ForbiddenError, PaymentRequiredError, StorageUnavailableError, TeepinError, TooLargeError, UnauthorizedError, } from '../errors.js';
|
|
30
|
+
import { concat, fromBase64 } from './bytes.js';
|
|
31
|
+
import { abortable, joinCapped, linked, objectFrom, Slots, toBase64, uuid7 } from './common.js';
|
|
32
|
+
import { MAX_REBUILDS, storageOutcome, tooSlow } from './engine.js';
|
|
33
|
+
import * as tws1 from './tws1.js';
|
|
34
|
+
const str = (v) => (v === undefined || v === null ? '' : String(v));
|
|
35
|
+
const int = (v) => Math.trunc(Number(v || 0)) || 0;
|
|
36
|
+
const uploadsPath = (bucket) => `/v1/storage/buckets/${encodeURIComponent(bucket)}/uploads`;
|
|
37
|
+
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
38
|
+
const EMPTY = new Uint8Array(0);
|
|
39
|
+
/** The upload can't go on: start again under a new create, with a fresh key. */
|
|
40
|
+
export class Restart extends Error {
|
|
41
|
+
constructor(why) {
|
|
42
|
+
super(why);
|
|
43
|
+
this.name = 'Restart';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Errors after which an upload is kept, journaled, for resumeUploads. */
|
|
47
|
+
function keep(err) {
|
|
48
|
+
return err instanceof StorageUnavailableError || err instanceof UnauthorizedError || err instanceof ForbiddenError || err instanceof PaymentRequiredError;
|
|
49
|
+
}
|
|
50
|
+
/** One large upload's state, journaled when it has a store. The methods tests replace are save and savePart. */
|
|
51
|
+
export class Large {
|
|
52
|
+
bucket;
|
|
53
|
+
key;
|
|
54
|
+
size;
|
|
55
|
+
sha256;
|
|
56
|
+
readAt;
|
|
57
|
+
id = uuid7();
|
|
58
|
+
idem = uuid7();
|
|
59
|
+
uploadId = '';
|
|
60
|
+
state = 'new';
|
|
61
|
+
dek;
|
|
62
|
+
header = EMPTY;
|
|
63
|
+
stream;
|
|
64
|
+
wrapped = '';
|
|
65
|
+
keyVersion = 0;
|
|
66
|
+
/** Part number → its plaintext's SHA-256 (D1 a), taken in the same pass as the whole object's. */
|
|
67
|
+
plain;
|
|
68
|
+
resumed = false;
|
|
69
|
+
restarts = 0;
|
|
70
|
+
waited = 0;
|
|
71
|
+
throttled = false;
|
|
72
|
+
lastError = '';
|
|
73
|
+
contentType;
|
|
74
|
+
metadata;
|
|
75
|
+
ifNoneMatch;
|
|
76
|
+
ifMatch;
|
|
77
|
+
partSize;
|
|
78
|
+
partCount;
|
|
79
|
+
store;
|
|
80
|
+
fileId;
|
|
81
|
+
path;
|
|
82
|
+
/** Whether the data key's file is written (and so must be deleted). */
|
|
83
|
+
keyOnDisk = false;
|
|
84
|
+
constructor(bucket, key, size, sha256, readAt, opts) {
|
|
85
|
+
this.bucket = bucket;
|
|
86
|
+
this.key = key;
|
|
87
|
+
this.size = size;
|
|
88
|
+
this.sha256 = sha256;
|
|
89
|
+
this.readAt = readAt;
|
|
90
|
+
this.contentType = opts.contentType || '';
|
|
91
|
+
this.metadata = { ...(opts.metadata ?? {}) };
|
|
92
|
+
this.ifNoneMatch = Boolean(opts.ifNoneMatch);
|
|
93
|
+
this.ifMatch = opts.ifMatch || '';
|
|
94
|
+
this.partSize = opts.partSize;
|
|
95
|
+
this.partCount = tws1.partCount(size, opts.partSize);
|
|
96
|
+
this.store = opts.store;
|
|
97
|
+
this.fileId = opts.fileId;
|
|
98
|
+
this.path = opts.path;
|
|
99
|
+
this.plain = new Map(opts.partHashes ?? []);
|
|
100
|
+
}
|
|
101
|
+
/** Whether its journal survives the process (a resumable upload). */
|
|
102
|
+
get durable() {
|
|
103
|
+
return Boolean(this.store?.journal.durable);
|
|
104
|
+
}
|
|
105
|
+
row() {
|
|
106
|
+
return {
|
|
107
|
+
table: 'uploads',
|
|
108
|
+
id: this.id,
|
|
109
|
+
row: {
|
|
110
|
+
_id: this.id,
|
|
111
|
+
file_id: this.fileId ?? null,
|
|
112
|
+
bucket: this.bucket,
|
|
113
|
+
key: this.key,
|
|
114
|
+
idempotency_key: this.idem,
|
|
115
|
+
upload_id: this.uploadId,
|
|
116
|
+
state: this.state,
|
|
117
|
+
size: this.size,
|
|
118
|
+
part_size: this.partSize,
|
|
119
|
+
sha256: this.sha256,
|
|
120
|
+
header: toBase64(this.header),
|
|
121
|
+
wrapped_key: this.wrapped,
|
|
122
|
+
key_version: this.keyVersion,
|
|
123
|
+
last_error: this.lastError,
|
|
124
|
+
updated: Date.now(),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
partRow(number, plain, md5, sha256, state) {
|
|
129
|
+
const id = `${this.id}:${number}`;
|
|
130
|
+
return { table: 'parts', id, row: { _id: id, upload: this.id, number, plain_sha256: plain, md5, sha256, state } };
|
|
131
|
+
}
|
|
132
|
+
/** The upload's state, journaled (with its data key first, on disk, while it has one). */
|
|
133
|
+
async save() {
|
|
134
|
+
if (!this.store)
|
|
135
|
+
return;
|
|
136
|
+
await this.saveKey();
|
|
137
|
+
await this.store.journal.write([this.row()]);
|
|
138
|
+
}
|
|
139
|
+
async savePart(number, plain, md5 = '', sha256 = '', state = 'hashed') {
|
|
140
|
+
if (!this.store)
|
|
141
|
+
return;
|
|
142
|
+
await this.store.journal.write([this.partRow(number, plain, md5, sha256, state)]);
|
|
143
|
+
}
|
|
144
|
+
/** D1 (a): every part's hash before any byte leaves; then the data key, header and wrap, before any part is signed. */
|
|
145
|
+
async saveCreated() {
|
|
146
|
+
if (!this.store)
|
|
147
|
+
return;
|
|
148
|
+
await this.saveKey();
|
|
149
|
+
const entries = [...this.plain.entries()].sort(([a], [b]) => a - b).map(([n, h]) => this.partRow(n, h, '', '', 'hashed'));
|
|
150
|
+
await this.store.journal.write([...entries, this.row()]);
|
|
151
|
+
}
|
|
152
|
+
async saveKey() {
|
|
153
|
+
const place = this.store?.place;
|
|
154
|
+
if (!place || !this.dek || this.keyOnDisk)
|
|
155
|
+
return;
|
|
156
|
+
await place.writeKey(this.id, this.dek);
|
|
157
|
+
this.keyOnDisk = true;
|
|
158
|
+
}
|
|
159
|
+
/** Zeroes the data key and deletes its file (E3: promptly). */
|
|
160
|
+
async forgetKey() {
|
|
161
|
+
this.dek?.fill(0);
|
|
162
|
+
this.dek = undefined;
|
|
163
|
+
this.stream = undefined;
|
|
164
|
+
const place = this.store?.place;
|
|
165
|
+
if (place && this.keyOnDisk) {
|
|
166
|
+
this.keyOnDisk = false;
|
|
167
|
+
await place.removeKey(this.id).catch(() => undefined);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** An upload a crashed run left in its journal. */
|
|
171
|
+
static async fromRow(row, readAt, opts) {
|
|
172
|
+
const u = new Large(str(row.bucket), str(row.key), int(row.size), str(row.sha256), readAt, { ...opts, partSize: int(row.part_size) });
|
|
173
|
+
u.id = str(row._id);
|
|
174
|
+
u.idem = str(row.idempotency_key);
|
|
175
|
+
u.uploadId = str(row.upload_id);
|
|
176
|
+
u.state = str(row.state);
|
|
177
|
+
const key = await opts.store?.place?.readKey(u.id).catch(() => undefined);
|
|
178
|
+
if (key && key.length === tws1.KEY_SIZE) {
|
|
179
|
+
u.dek = key;
|
|
180
|
+
u.keyOnDisk = true;
|
|
181
|
+
}
|
|
182
|
+
u.header = row.header ? fromBase64(str(row.header)) : EMPTY;
|
|
183
|
+
u.wrapped = str(row.wrapped_key);
|
|
184
|
+
u.keyVersion = int(row.key_version);
|
|
185
|
+
for (const p of opts.store?.journal.rows('parts') ?? [])
|
|
186
|
+
if (p.upload === u.id)
|
|
187
|
+
u.plain.set(int(p.number), str(p.plain_sha256));
|
|
188
|
+
u.resumed = true;
|
|
189
|
+
return u;
|
|
190
|
+
}
|
|
191
|
+
/** A new create and a fresh key. The source's part hashes stay: they still catch a file that changed. */
|
|
192
|
+
async reset() {
|
|
193
|
+
await this.forgetKey();
|
|
194
|
+
this.idem = uuid7();
|
|
195
|
+
this.uploadId = '';
|
|
196
|
+
this.header = EMPTY;
|
|
197
|
+
this.wrapped = '';
|
|
198
|
+
this.keyVersion = 0;
|
|
199
|
+
this.resumed = false;
|
|
200
|
+
this.state = 'new';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A stream's upload: created without its size, its last part declared at its sign, and its size and SHA-256 brought by
|
|
205
|
+
* complete. Held in memory only: nothing could read the stream again to resume it.
|
|
206
|
+
*/
|
|
207
|
+
export class StreamUpload {
|
|
208
|
+
bucket;
|
|
209
|
+
key;
|
|
210
|
+
partSize;
|
|
211
|
+
idem = uuid7();
|
|
212
|
+
uploadId = '';
|
|
213
|
+
stream;
|
|
214
|
+
wrapped = '';
|
|
215
|
+
keyVersion = 0;
|
|
216
|
+
path = undefined;
|
|
217
|
+
/** The measured speed when it shrank the part size (section 22.7), else undefined. */
|
|
218
|
+
slow;
|
|
219
|
+
waited = 0;
|
|
220
|
+
throttled = false;
|
|
221
|
+
lastError = '';
|
|
222
|
+
contentType;
|
|
223
|
+
metadata;
|
|
224
|
+
ifNoneMatch;
|
|
225
|
+
ifMatch;
|
|
226
|
+
constructor(bucket, key, partSize, opts = {}) {
|
|
227
|
+
this.bucket = bucket;
|
|
228
|
+
this.key = key;
|
|
229
|
+
this.partSize = partSize;
|
|
230
|
+
this.contentType = opts.contentType || '';
|
|
231
|
+
this.metadata = { ...(opts.metadata ?? {}) };
|
|
232
|
+
this.ifNoneMatch = Boolean(opts.ifNoneMatch);
|
|
233
|
+
this.ifMatch = opts.ifMatch || '';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** The most telling of a step's task errors: an abort's reason, then the kinds named, in order, then the first. */
|
|
237
|
+
function telling(errors, kinds, signal) {
|
|
238
|
+
if (signal?.aborted)
|
|
239
|
+
return signal.reason;
|
|
240
|
+
for (const kind of kinds) {
|
|
241
|
+
const e = errors.find((x) => x instanceof kind);
|
|
242
|
+
if (e !== undefined)
|
|
243
|
+
return e;
|
|
244
|
+
}
|
|
245
|
+
return errors[0];
|
|
246
|
+
}
|
|
247
|
+
/** Large uploads and streams for one DirectUploads. The methods tests replace are sendSealed and completeStream. */
|
|
248
|
+
export class Parts {
|
|
249
|
+
up;
|
|
250
|
+
constructor(up) {
|
|
251
|
+
this.up = up;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Takes a large upload to its object. A final failure cancels it; an error after which it may resume (storage
|
|
255
|
+
* unavailable, a revoked key) keeps it, in its journal, when it has one on disk. An abort leaves a journaled upload
|
|
256
|
+
* for resumeUploads, and cancels one in memory, which nothing could resume.
|
|
257
|
+
*/
|
|
258
|
+
async run(u, prog, signal) {
|
|
259
|
+
try {
|
|
260
|
+
for (;;) {
|
|
261
|
+
try {
|
|
262
|
+
return await this.attempt(u, prog, signal);
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
if (!(err instanceof Restart) || signal?.aborted)
|
|
266
|
+
throw err;
|
|
267
|
+
u.restarts++;
|
|
268
|
+
u.lastError = err.message;
|
|
269
|
+
await this.abort(u);
|
|
270
|
+
if (u.restarts > MAX_REBUILDS)
|
|
271
|
+
throw new TeepinError(`${u.key}: the upload was started again ${u.restarts} times (${err.message})`);
|
|
272
|
+
await u.reset();
|
|
273
|
+
await u.save();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
if (signal?.aborted) {
|
|
279
|
+
if (!u.durable) {
|
|
280
|
+
await this.abort(u, true);
|
|
281
|
+
await u.forgetKey();
|
|
282
|
+
}
|
|
283
|
+
throw signal.reason;
|
|
284
|
+
}
|
|
285
|
+
if (keep(err)) {
|
|
286
|
+
if (!u.durable) {
|
|
287
|
+
await this.abort(u); // nothing could resume it
|
|
288
|
+
await u.forgetKey();
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
// Final: cancelled, so its parts aren't kept (or billed) for nothing.
|
|
293
|
+
await this.abort(u);
|
|
294
|
+
await u.forgetKey();
|
|
295
|
+
u.state = 'failed';
|
|
296
|
+
u.lastError = message(err);
|
|
297
|
+
await u.save();
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Cancels the upload at the storage network: one call, best effort, never the control plane's rounds of waiting. */
|
|
302
|
+
async abort(u, stopping = false) {
|
|
303
|
+
if (u.uploadId)
|
|
304
|
+
await this.up.abortOne(u.bucket, u.uploadId, stopping);
|
|
305
|
+
}
|
|
306
|
+
async attempt(u, prog, signal) {
|
|
307
|
+
if (u.state === 'new')
|
|
308
|
+
await this.create(u, signal);
|
|
309
|
+
if (u.state === 'sending') {
|
|
310
|
+
if (!u.stream) {
|
|
311
|
+
if (!u.dek || !u.header.length)
|
|
312
|
+
throw new Restart('the data key is gone');
|
|
313
|
+
u.stream = await tws1.Stream.resume(u.dek, u.header, u.size, u.partSize);
|
|
314
|
+
}
|
|
315
|
+
await this.sendParts(u, prog, signal);
|
|
316
|
+
u.state = 'completing';
|
|
317
|
+
await u.save();
|
|
318
|
+
}
|
|
319
|
+
return this.complete(u, signal);
|
|
320
|
+
}
|
|
321
|
+
async create(u, signal) {
|
|
322
|
+
await u.save(); // the idempotency key, before the call
|
|
323
|
+
const spec = { direct: true, idempotency_key: u.idem, key: u.key, size: u.size, part_size: u.partSize, sha256: u.sha256 };
|
|
324
|
+
if (u.contentType)
|
|
325
|
+
spec.content_type = u.contentType;
|
|
326
|
+
if (Object.keys(u.metadata).length)
|
|
327
|
+
spec.metadata = u.metadata;
|
|
328
|
+
if (u.ifNoneMatch)
|
|
329
|
+
spec.if_none_match = true;
|
|
330
|
+
if (u.ifMatch)
|
|
331
|
+
spec.if_match = u.ifMatch;
|
|
332
|
+
let res;
|
|
333
|
+
try {
|
|
334
|
+
res = (await this.up.control('POST', uploadsPath(u.bucket), spec, u, signal)).data;
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if (err instanceof APIError && err.code === 'upload_completed') {
|
|
338
|
+
u.uploadId = str(err.details.upload_id);
|
|
339
|
+
u.state = 'completing';
|
|
340
|
+
await u.save();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (err instanceof APIError && err.code === 'reservation_abandoned')
|
|
344
|
+
throw new Restart(err.code);
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
const key = await this.up.checkKey(res.account_key);
|
|
348
|
+
if (int(res.part_size) !== u.partSize || int(res.size) !== u.size)
|
|
349
|
+
throw new DataIntegrityError(`${u.key}: the upload was created with another layout`, 'layout');
|
|
350
|
+
u.uploadId = str(res.upload_id);
|
|
351
|
+
const dek = this.up.newDataKey();
|
|
352
|
+
u.dek = dek;
|
|
353
|
+
u.stream = await tws1.Stream.create(dek, u.size, u.partSize);
|
|
354
|
+
u.header = u.stream.header;
|
|
355
|
+
u.wrapped = toBase64(await this.up.wrapKey(key.publicKey, key.account, tws1.objectIdBytes(u.uploadId), dek));
|
|
356
|
+
u.keyVersion = key.version;
|
|
357
|
+
u.state = 'sending';
|
|
358
|
+
await u.saveCreated();
|
|
359
|
+
}
|
|
360
|
+
async sendParts(u, prog, signal) {
|
|
361
|
+
const landed = new Set();
|
|
362
|
+
if (u.resumed) {
|
|
363
|
+
// After a crash: what storage holds, exactly as signed. Every part's source is still checked.
|
|
364
|
+
const { data } = await this.up.control('GET', `${uploadsPath(u.bucket)}/${u.uploadId}/parts`, undefined, u, signal);
|
|
365
|
+
for (const p of data.parts ?? [])
|
|
366
|
+
if (p.landed)
|
|
367
|
+
landed.add(int(p.part_number));
|
|
368
|
+
}
|
|
369
|
+
for (const n of landed) {
|
|
370
|
+
await this.checkSource(u, n);
|
|
371
|
+
prog?.add(tws1.partOf(u.size, u.partSize, n).plainSize);
|
|
372
|
+
}
|
|
373
|
+
const todo = [];
|
|
374
|
+
for (let n = 1; n <= u.partCount; n++)
|
|
375
|
+
if (!landed.has(n))
|
|
376
|
+
todo.push(n);
|
|
377
|
+
const errors = [];
|
|
378
|
+
let stop = false;
|
|
379
|
+
const { ctl, unlink } = linked(signal);
|
|
380
|
+
const task = async (n) => {
|
|
381
|
+
let outcome = 'failed';
|
|
382
|
+
try {
|
|
383
|
+
if (stop)
|
|
384
|
+
return;
|
|
385
|
+
await this.sendPart(u, n, ctl.signal);
|
|
386
|
+
outcome = u.throttled ? 'throttled' : 'ok';
|
|
387
|
+
prog?.add(tws1.partOf(u.size, u.partSize, n).plainSize);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
errors.push(err); // the coordinator decides
|
|
391
|
+
stop = true;
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
this.up.release(outcome);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const tasks = [];
|
|
398
|
+
try {
|
|
399
|
+
for (const n of todo) {
|
|
400
|
+
if (stop)
|
|
401
|
+
break;
|
|
402
|
+
await this.up.limit.acquire(ctl.signal);
|
|
403
|
+
tasks.push(task(n));
|
|
404
|
+
}
|
|
405
|
+
await Promise.all(tasks);
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
// Interrupted: each part task is stopped and waited for (section 22.13).
|
|
409
|
+
stop = true;
|
|
410
|
+
ctl.abort(err);
|
|
411
|
+
await joinCapped(tasks, this.up.cleanupMs);
|
|
412
|
+
unlink();
|
|
413
|
+
throw signal?.aborted ? signal.reason : err;
|
|
414
|
+
}
|
|
415
|
+
unlink();
|
|
416
|
+
// The most telling error: the abort, a changed file or a restart, before a passing failure.
|
|
417
|
+
if (errors.length)
|
|
418
|
+
throw telling(errors, [FileChangedError, Restart, DataIntegrityError], signal);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Part n's plaintext, read and hashed: journaled before its first byte leaves (D1 a), and the same as journaled on
|
|
422
|
+
* every later read (D1 b).
|
|
423
|
+
*/
|
|
424
|
+
async checkSource(u, n) {
|
|
425
|
+
const p = tws1.partOf(u.size, u.partSize, n);
|
|
426
|
+
let data;
|
|
427
|
+
try {
|
|
428
|
+
data = await u.readAt(p.plainFirst, p.plainSize);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
throw new FileChangedError(u.path ?? u.key, message(err));
|
|
432
|
+
}
|
|
433
|
+
if (data.length !== p.plainSize)
|
|
434
|
+
throw new FileChangedError(u.path ?? u.key, `part ${n} is ${data.length} bytes, not ${p.plainSize}`);
|
|
435
|
+
const h = await this.up.digests.sha256(data);
|
|
436
|
+
const known = u.plain.get(n);
|
|
437
|
+
if (known === undefined) {
|
|
438
|
+
u.plain.set(n, h);
|
|
439
|
+
await u.savePart(n, h);
|
|
440
|
+
}
|
|
441
|
+
else if (known !== h) {
|
|
442
|
+
throw new FileChangedError(u.path ?? u.key, `part ${n} changed`);
|
|
443
|
+
}
|
|
444
|
+
return data;
|
|
445
|
+
}
|
|
446
|
+
async sign(u, n, md5, sha, extra, signal) {
|
|
447
|
+
try {
|
|
448
|
+
return (await this.up.control('POST', `${uploadsPath(u.bucket)}/${u.uploadId}/parts/${n}/sign`, { md5, sha256: sha, ...(extra ?? {}) }, u, signal)).data;
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (err instanceof APIError && (err.code === 'upload_not_found' || err.code === 'reservation_abandoned'))
|
|
452
|
+
throw new Restart(err.code);
|
|
453
|
+
// The source hashes the same, so the bytes can only differ by key: never sent under another.
|
|
454
|
+
if (err instanceof APIError && err.code === 'part_mismatch')
|
|
455
|
+
throw new DataIntegrityError(`${u.key}: part ${n} was signed with other digests`, 'part');
|
|
456
|
+
throw err;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async sendPart(u, n, signal) {
|
|
460
|
+
if (!u.stream)
|
|
461
|
+
throw new Restart('the stream is gone');
|
|
462
|
+
let plain = await this.checkSource(u, n);
|
|
463
|
+
const sealed = await u.stream.sealPart(n, plain);
|
|
464
|
+
plain = undefined;
|
|
465
|
+
const md5 = this.up.digests.md5(sealed);
|
|
466
|
+
const sha = await this.up.digests.sha256(sealed);
|
|
467
|
+
await this.sendSealed(u, n, sealed, md5, sha, undefined, () => this.checkSource(u, n), signal);
|
|
468
|
+
await u.savePart(n, u.plain.get(n) ?? '', md5, sha, 'sent');
|
|
469
|
+
}
|
|
470
|
+
/** Signs part n and sends it until storage holds it as signed; recheck reads the source again when storage holds other bytes. Replaced in tests. */
|
|
471
|
+
async sendSealed(u, n, sealed, md5, sha, extra, recheck, signal) {
|
|
472
|
+
let signed = await this.sign(u, n, md5, sha, extra, signal);
|
|
473
|
+
let attempts = 0;
|
|
474
|
+
let resigns = 0;
|
|
475
|
+
for (;;) {
|
|
476
|
+
if (this.up.expiresSoon(signed))
|
|
477
|
+
signed = await this.sign(u, n, md5, sha, extra, signal);
|
|
478
|
+
let err;
|
|
479
|
+
let etag = '';
|
|
480
|
+
this.up.budget.onRequest();
|
|
481
|
+
try {
|
|
482
|
+
etag = await this.up.putMeasured(signed, sealed, signal);
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
if (signal?.aborted)
|
|
486
|
+
throw signal.reason;
|
|
487
|
+
if (!(e instanceof TeepinError))
|
|
488
|
+
throw e;
|
|
489
|
+
err = e;
|
|
490
|
+
}
|
|
491
|
+
let outcome = storageOutcome(err);
|
|
492
|
+
// A browser can't read the ETag (the storage network doesn't expose it): an empty one is accepted.
|
|
493
|
+
if (outcome === 'sent' && err === undefined && etag !== '' && etag !== md5)
|
|
494
|
+
outcome = 'bad';
|
|
495
|
+
switch (outcome) {
|
|
496
|
+
case 'sent':
|
|
497
|
+
return;
|
|
498
|
+
case 'resign':
|
|
499
|
+
if (++resigns > 3)
|
|
500
|
+
throw err;
|
|
501
|
+
signed = await this.sign(u, n, md5, sha, extra, signal);
|
|
502
|
+
break;
|
|
503
|
+
case 'bad':
|
|
504
|
+
// Not the bytes signed: read and hash the source again (D1 b); the same source means a bug.
|
|
505
|
+
if (recheck)
|
|
506
|
+
await recheck();
|
|
507
|
+
throw new DataIntegrityError(`${u.key}: part ${n} wasn't stored as signed`, 'part');
|
|
508
|
+
case 'unknown':
|
|
509
|
+
case 'unsent':
|
|
510
|
+
// A part may simply be sent again: the same number, the same bytes.
|
|
511
|
+
if (!this.up.budget.tryRetry())
|
|
512
|
+
await this.up.wait(u, attempts, err, signal);
|
|
513
|
+
attempts++;
|
|
514
|
+
break;
|
|
515
|
+
case 'busy':
|
|
516
|
+
await this.up.wait(u, attempts, err, signal);
|
|
517
|
+
attempts++;
|
|
518
|
+
break;
|
|
519
|
+
default:
|
|
520
|
+
if (err instanceof APIError && err.statusCode === 404)
|
|
521
|
+
throw new Restart('the storage network no longer has the upload');
|
|
522
|
+
throw err;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async complete(u, signal) {
|
|
527
|
+
let raw;
|
|
528
|
+
try {
|
|
529
|
+
raw = (await this.up.control('POST', `${uploadsPath(u.bucket)}/${u.uploadId}/complete`, { wrapped_key: u.wrapped, key_version: u.keyVersion }, u, signal, 70000)).data;
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
if (err instanceof APIError && (err.code === 'upload_mismatch' || err.code === 'reservation_abandoned'))
|
|
533
|
+
throw new Restart(err.code);
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
const out = objectFrom(raw);
|
|
537
|
+
if (out.checksumSha256 && out.checksumSha256 !== u.sha256)
|
|
538
|
+
throw new DataIntegrityError(`${u.key}: the object's checksum isn't the file's`, 'checksum');
|
|
539
|
+
u.state = 'completed';
|
|
540
|
+
await u.forgetKey();
|
|
541
|
+
await u.save();
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
// --- streams of unknown size (section 22.1, E2 b) ----------------------------------------------------------------
|
|
545
|
+
/**
|
|
546
|
+
* Uploads everything source yields, sealing and sending up to concurrency parts at once. Any failure, or an abort,
|
|
547
|
+
* cancels the upload (one call): a stream can't be read again.
|
|
548
|
+
*/
|
|
549
|
+
async runStream(s, source, concurrency, prog, signal) {
|
|
550
|
+
try {
|
|
551
|
+
try {
|
|
552
|
+
return await this.streamAttempt(s, source, Math.max(1, concurrency), prog, signal);
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
if (err instanceof Restart && !signal?.aborted) {
|
|
556
|
+
throw new TeepinError(`${s.key}: the upload was ended (${err.message}); a stream can't be read again, so upload it again`, { cause: err });
|
|
557
|
+
}
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
s.lastError = message(err);
|
|
563
|
+
await this.abort(s, Boolean(signal?.aborted));
|
|
564
|
+
throw signal?.aborted ? signal.reason : err;
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
s.stream = undefined;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async streamAttempt(s, source, concurrency, prog, signal) {
|
|
571
|
+
await this.createStream(s, signal);
|
|
572
|
+
const stream = s.stream;
|
|
573
|
+
if (!stream)
|
|
574
|
+
throw new Restart('the stream is gone');
|
|
575
|
+
const psize = s.partSize;
|
|
576
|
+
/** The next part's plaintext, read straight into one buffer (a part never exists twice while it is read): psize bytes, fewer only at the end. */
|
|
577
|
+
const take = async () => {
|
|
578
|
+
const buf = new Uint8Array(psize);
|
|
579
|
+
let got = 0;
|
|
580
|
+
while (got < psize) {
|
|
581
|
+
const b = await source.pull(psize - got, signal);
|
|
582
|
+
if (!b.length)
|
|
583
|
+
break;
|
|
584
|
+
buf.set(b, got);
|
|
585
|
+
got += b.length;
|
|
586
|
+
}
|
|
587
|
+
if (got === 0)
|
|
588
|
+
return EMPTY;
|
|
589
|
+
return got < psize ? buf.subarray(0, got) : buf;
|
|
590
|
+
};
|
|
591
|
+
const errors = [];
|
|
592
|
+
let stop = false;
|
|
593
|
+
const slots = new Slots(concurrency);
|
|
594
|
+
const { ctl, unlink } = linked(signal);
|
|
595
|
+
/** Signs and sends part n, sealed from size plaintext bytes; its sealed bytes come in a box it empties when it ends. */
|
|
596
|
+
const task = async (n, box, size, last) => {
|
|
597
|
+
let outcome = 'failed';
|
|
598
|
+
try {
|
|
599
|
+
if (stop || !box.sealed)
|
|
600
|
+
return;
|
|
601
|
+
const extra = last ? { last: true, plain_size: size } : undefined;
|
|
602
|
+
const md5 = this.up.digests.md5(box.sealed);
|
|
603
|
+
const sha = await this.up.digests.sha256(box.sealed);
|
|
604
|
+
await this.sendSealed(s, n, box.sealed, md5, sha, extra, undefined, ctl.signal);
|
|
605
|
+
outcome = s.throttled ? 'throttled' : 'ok';
|
|
606
|
+
prog?.add(size);
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
errors.push(err); // the reader decides
|
|
610
|
+
stop = true;
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
box.sealed = undefined; // its part is let go before its slot is given back
|
|
614
|
+
this.up.release(outcome);
|
|
615
|
+
slots.release();
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
const whole = this.up.digests.incremental();
|
|
619
|
+
let total = 0;
|
|
620
|
+
const tasks = [];
|
|
621
|
+
try {
|
|
622
|
+
let cur = await take();
|
|
623
|
+
if (!cur.length)
|
|
624
|
+
throw new TeepinError(`${s.key}: the stream is empty`);
|
|
625
|
+
for (let n = 1; !stop; n++) {
|
|
626
|
+
// One part ahead: a part is the last only when nothing follows it.
|
|
627
|
+
const nxt = await take();
|
|
628
|
+
const last = nxt.length === 0;
|
|
629
|
+
if (!last && n >= this.up.sizes.maxParts) {
|
|
630
|
+
if (s.slow)
|
|
631
|
+
throw tooSlow(s.key, 0, s.slow, psize, this.up.sizes);
|
|
632
|
+
throw new TooLargeError({
|
|
633
|
+
statusCode: 413,
|
|
634
|
+
message: `${s.key}: the stream is over ${this.up.sizes.maxParts} parts of ${psize} bytes; pass expectedSize (or a larger partSize) so its parts are large enough`,
|
|
635
|
+
code: 'too_many_parts',
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const size = cur.length;
|
|
639
|
+
total += size;
|
|
640
|
+
whole.update(cur);
|
|
641
|
+
let pieces = await stream.sealSegments(n, cur, last);
|
|
642
|
+
cur = nxt; // the plaintext sealed is dropped before its pieces are joined
|
|
643
|
+
let sealed = concat(pieces);
|
|
644
|
+
pieces = undefined;
|
|
645
|
+
await slots.acquire(ctl.signal);
|
|
646
|
+
try {
|
|
647
|
+
await this.up.limit.acquire(ctl.signal);
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
slots.release();
|
|
651
|
+
throw err;
|
|
652
|
+
}
|
|
653
|
+
tasks.push(task(n, { sealed }, size, last));
|
|
654
|
+
sealed = undefined;
|
|
655
|
+
if (last)
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
await Promise.all(tasks);
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
// Interrupted, or the read failed: each part task is stopped and waited for (section 22.13).
|
|
662
|
+
stop = true;
|
|
663
|
+
ctl.abort(err);
|
|
664
|
+
await joinCapped(tasks, this.up.cleanupMs);
|
|
665
|
+
unlink();
|
|
666
|
+
throw signal?.aborted ? signal.reason : err;
|
|
667
|
+
}
|
|
668
|
+
unlink();
|
|
669
|
+
if (errors.length)
|
|
670
|
+
throw telling(errors, [Restart, DataIntegrityError], signal);
|
|
671
|
+
return this.completeStream(s, total, whole.hex(), signal);
|
|
672
|
+
}
|
|
673
|
+
async createStream(s, signal) {
|
|
674
|
+
const spec = { direct: true, idempotency_key: s.idem, key: s.key, part_size: s.partSize };
|
|
675
|
+
if (s.contentType)
|
|
676
|
+
spec.content_type = s.contentType;
|
|
677
|
+
if (Object.keys(s.metadata).length)
|
|
678
|
+
spec.metadata = s.metadata;
|
|
679
|
+
if (s.ifNoneMatch)
|
|
680
|
+
spec.if_none_match = true;
|
|
681
|
+
if (s.ifMatch)
|
|
682
|
+
spec.if_match = s.ifMatch;
|
|
683
|
+
const { data: res } = await this.up.control('POST', uploadsPath(s.bucket), spec, s, signal);
|
|
684
|
+
const key = await this.up.checkKey(res.account_key);
|
|
685
|
+
if (int(res.part_size) !== s.partSize || (res.size !== undefined && res.size !== null)) {
|
|
686
|
+
throw new DataIntegrityError(`${s.key}: the upload was created with another layout`, 'layout');
|
|
687
|
+
}
|
|
688
|
+
s.uploadId = str(res.upload_id);
|
|
689
|
+
const dek = this.up.newDataKey();
|
|
690
|
+
let wrapped;
|
|
691
|
+
try {
|
|
692
|
+
s.stream = await tws1.OpenStream.create(dek, s.partSize);
|
|
693
|
+
wrapped = await this.up.wrapKey(key.publicKey, key.account, tws1.objectIdBytes(s.uploadId), dek);
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
dek.fill(0); // the stream holds its derived keys; nothing journals this one
|
|
697
|
+
}
|
|
698
|
+
s.wrapped = toBase64(wrapped);
|
|
699
|
+
s.keyVersion = key.version;
|
|
700
|
+
}
|
|
701
|
+
/** Completes a stream's upload with the size and SHA-256 read. Replaced in tests. */
|
|
702
|
+
async completeStream(s, size, sha256, signal) {
|
|
703
|
+
let raw;
|
|
704
|
+
try {
|
|
705
|
+
raw = (await this.up.control('POST', `${uploadsPath(s.bucket)}/${s.uploadId}/complete`, { wrapped_key: s.wrapped, key_version: s.keyVersion, size, sha256 }, s, signal, 70000)).data;
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
if (err instanceof APIError && (err.code === 'upload_mismatch' || err.code === 'reservation_abandoned'))
|
|
709
|
+
throw new Restart(err.code);
|
|
710
|
+
throw err;
|
|
711
|
+
}
|
|
712
|
+
const out = objectFrom(raw);
|
|
713
|
+
if ((out.checksumSha256 && out.checksumSha256 !== sha256) || out.size !== size) {
|
|
714
|
+
throw new DataIntegrityError(`${s.key}: the object isn't the stream that was read`, 'checksum');
|
|
715
|
+
}
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// --- reading what a caller hands over ----------------------------------------------------------------------------
|
|
720
|
+
/** Whether body is a stream (a ReadableStream or an async iterable) rather than data that can be read at any offset. */
|
|
721
|
+
export function isStreamBody(body) {
|
|
722
|
+
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream)
|
|
723
|
+
return true;
|
|
724
|
+
return typeof body === 'object' && body !== null && Symbol.asyncIterator in body && !('read' in body && 'size' in body);
|
|
725
|
+
}
|
|
726
|
+
/** A ReadableStream or an async iterable, read a piece at a time: no piece is copied before it is taken. */
|
|
727
|
+
export function streamSource(body) {
|
|
728
|
+
let rest = EMPTY;
|
|
729
|
+
let ended = false;
|
|
730
|
+
let next;
|
|
731
|
+
let release;
|
|
732
|
+
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
|
|
733
|
+
const reader = body.getReader();
|
|
734
|
+
next = async (signal) => {
|
|
735
|
+
const r = await abortable(reader.read(), signal);
|
|
736
|
+
return r.done ? null : (r.value ?? EMPTY);
|
|
737
|
+
};
|
|
738
|
+
release = async (reason) => {
|
|
739
|
+
// Never waits: a read still pending (a source that stalled) would hold it up.
|
|
740
|
+
if (!ended)
|
|
741
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
const it = body[Symbol.asyncIterator]();
|
|
746
|
+
next = async (signal) => {
|
|
747
|
+
const r = await abortable(it.next(), signal);
|
|
748
|
+
return r.done ? null : (r.value ?? EMPTY);
|
|
749
|
+
};
|
|
750
|
+
release = async () => {
|
|
751
|
+
// Never waits: a generator's return waits for the next() it is in, which a stalled source never ends.
|
|
752
|
+
if (!ended)
|
|
753
|
+
void Promise.resolve(it.return?.(undefined)).catch(() => undefined);
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
async pull(max, signal) {
|
|
758
|
+
while (!rest.length) {
|
|
759
|
+
if (ended)
|
|
760
|
+
return EMPTY;
|
|
761
|
+
const c = await next(signal);
|
|
762
|
+
if (c === null) {
|
|
763
|
+
ended = true;
|
|
764
|
+
return EMPTY;
|
|
765
|
+
}
|
|
766
|
+
rest = c;
|
|
767
|
+
}
|
|
768
|
+
const out = rest.length > max ? rest.subarray(0, max) : rest;
|
|
769
|
+
rest = rest.subarray(out.length);
|
|
770
|
+
return out;
|
|
771
|
+
},
|
|
772
|
+
async release(reason) {
|
|
773
|
+
rest = EMPTY;
|
|
774
|
+
await release(reason);
|
|
775
|
+
ended = true;
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* head (what was read first, to choose the way), then the rest of inner. head is handed on and dropped as soon as its
|
|
781
|
+
* bytes are taken, not held for the upload.
|
|
782
|
+
*/
|
|
783
|
+
export function prefixed(head, inner) {
|
|
784
|
+
let h = head.length ? head : undefined;
|
|
785
|
+
return {
|
|
786
|
+
get holding() {
|
|
787
|
+
return h !== undefined;
|
|
788
|
+
},
|
|
789
|
+
async pull(max, signal) {
|
|
790
|
+
if (h) {
|
|
791
|
+
const out = h.length > max ? h.subarray(0, max) : h;
|
|
792
|
+
h = h.length > max ? h.subarray(max) : undefined;
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
return inner.pull(max, signal);
|
|
796
|
+
},
|
|
797
|
+
release: (reason) => {
|
|
798
|
+
h = undefined;
|
|
799
|
+
return inner.release(reason);
|
|
800
|
+
},
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
/** Up to n bytes from source, into one buffer (shorter only at the source's end). */
|
|
804
|
+
export async function readUpTo(source, n, signal) {
|
|
805
|
+
const parts = [];
|
|
806
|
+
let got = 0;
|
|
807
|
+
while (got < n) {
|
|
808
|
+
const b = await source.pull(n - got, signal);
|
|
809
|
+
if (!b.length)
|
|
810
|
+
break;
|
|
811
|
+
parts.push(b);
|
|
812
|
+
got += b.length;
|
|
813
|
+
}
|
|
814
|
+
return parts.length === 1 ? parts[0] : concat(parts);
|
|
815
|
+
}
|
|
816
|
+
//# sourceMappingURL=large.js.map
|