@sequenceholdings/storage 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/README.md +91 -0
- package/dist/client.d.ts +453 -0
- package/dist/client.js +298 -0
- package/dist/contracts.d.ts +244 -0
- package/dist/contracts.js +186 -0
- package/dist/crc32c.d.ts +11 -0
- package/dist/crc32c.js +76 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +22 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +28 -0
- package/dist/scope.d.ts +15 -0
- package/dist/scope.js +42 -0
- package/dist/transfer.d.ts +34 -0
- package/dist/transfer.js +257 -0
- package/package.json +38 -0
package/dist/transfer.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StorageTransferError = void 0;
|
|
4
|
+
exports.transferStorageUpload = transferStorageUpload;
|
|
5
|
+
/** GCS sequential resumable transfer. No Atlas endpoint receives file bytes. */
|
|
6
|
+
const crc32c_js_1 = require("./crc32c.js");
|
|
7
|
+
class StorageTransferError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = 'StorageTransferError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.StorageTransferError = StorageTransferError;
|
|
16
|
+
function checkedUploadUrl(value) {
|
|
17
|
+
const url = new URL(value);
|
|
18
|
+
if (url.protocol !== 'https:' ||
|
|
19
|
+
url.hostname !== 'storage.googleapis.com' ||
|
|
20
|
+
!url.pathname.startsWith('/upload/') ||
|
|
21
|
+
!url.searchParams.has('upload_id') ||
|
|
22
|
+
url.port ||
|
|
23
|
+
url.username ||
|
|
24
|
+
url.password) {
|
|
25
|
+
throw new StorageTransferError('Invalid storage upload destination');
|
|
26
|
+
}
|
|
27
|
+
return url.href;
|
|
28
|
+
}
|
|
29
|
+
async function* bytes(source) {
|
|
30
|
+
if (source instanceof Blob || source instanceof ReadableStream) {
|
|
31
|
+
const reader = (source instanceof Blob ? source.stream() : source).getReader();
|
|
32
|
+
try {
|
|
33
|
+
for (;;) {
|
|
34
|
+
const part = await reader.read();
|
|
35
|
+
if (part.done)
|
|
36
|
+
return;
|
|
37
|
+
yield part.value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await reader.cancel().catch(() => { });
|
|
42
|
+
reader.releaseLock();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
yield* source;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Retain at most two fixed-size chunks, independent of total file size. Every
|
|
51
|
+
* source byte is hashed as it is read, including the acknowledged prefix a
|
|
52
|
+
* resume skips: the digest describes the object the source says it is, so a
|
|
53
|
+
* source that yields different bytes on re-read cannot match what GCS stored.
|
|
54
|
+
*/
|
|
55
|
+
async function* chunks(source, size, skip, hash, signal) {
|
|
56
|
+
let buffer = new Uint8Array(size);
|
|
57
|
+
let used = 0;
|
|
58
|
+
for await (const input of bytes(source)) {
|
|
59
|
+
signal?.throwIfAborted();
|
|
60
|
+
hash.update(input);
|
|
61
|
+
let position = Math.min(skip, input.byteLength);
|
|
62
|
+
skip -= position;
|
|
63
|
+
while (position < input.byteLength) {
|
|
64
|
+
const length = Math.min(size - used, input.byteLength - position);
|
|
65
|
+
buffer.set(input.subarray(position, position + length), used);
|
|
66
|
+
used += length;
|
|
67
|
+
position += length;
|
|
68
|
+
if (used === size) {
|
|
69
|
+
yield buffer;
|
|
70
|
+
buffer = new Uint8Array(size);
|
|
71
|
+
used = 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (skip)
|
|
76
|
+
throw new StorageTransferError('Upload source is shorter than the saved offset');
|
|
77
|
+
if (used)
|
|
78
|
+
yield buffer.subarray(0, used);
|
|
79
|
+
}
|
|
80
|
+
function receivedOffset(response) {
|
|
81
|
+
const range = response.headers.get('Range');
|
|
82
|
+
if (!range)
|
|
83
|
+
return 0;
|
|
84
|
+
const match = /^bytes=0-(\d+)$/.exec(range);
|
|
85
|
+
const offset = match ? Number(match[1]) + 1 : NaN;
|
|
86
|
+
if (!Number.isSafeInteger(offset) || offset < 1)
|
|
87
|
+
throw new StorageTransferError('Storage returned an invalid upload offset');
|
|
88
|
+
return offset;
|
|
89
|
+
}
|
|
90
|
+
// GCS uses 308 for upload progress. Fetch treats it as a redirect; Google's
|
|
91
|
+
// no-308 protocol preserves the acknowledgement without following a Location.
|
|
92
|
+
function incomplete(response) {
|
|
93
|
+
return (response.status === 308 ||
|
|
94
|
+
response.headers.get('X-Http-Status-Code-Override') === '308');
|
|
95
|
+
}
|
|
96
|
+
function retryable(status) {
|
|
97
|
+
return status === 408 || status === 429 || status >= 500;
|
|
98
|
+
}
|
|
99
|
+
async function delay(attempt, signal) {
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
signal?.removeEventListener('abort', abort);
|
|
104
|
+
resolve();
|
|
105
|
+
}, Math.min(250 * 2 ** attempt, 4000));
|
|
106
|
+
const abort = () => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
reject(signal?.reason ?? new Error('Upload cancelled'));
|
|
109
|
+
};
|
|
110
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async function digest(source, signal) {
|
|
114
|
+
const hash = new crc32c_js_1.Crc32c();
|
|
115
|
+
for await (const input of bytes(source)) {
|
|
116
|
+
signal?.throwIfAborted();
|
|
117
|
+
hash.update(input);
|
|
118
|
+
}
|
|
119
|
+
return hash.digest();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resume a session with the original source, starting at byte zero. The
|
|
123
|
+
* acknowledged prefix is read again (and discarded with bounded memory) so the
|
|
124
|
+
* returned CRC32C covers the whole object, not just the bytes this call sent.
|
|
125
|
+
* Session URLs are bearer credentials: never persist them in logs or telemetry.
|
|
126
|
+
*/
|
|
127
|
+
async function transferStorageUpload({ uploadUrl, source, size, maxBytes, ...options }) {
|
|
128
|
+
const url = checkedUploadUrl(uploadUrl);
|
|
129
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
130
|
+
const total = source instanceof Blob ? source.size : size;
|
|
131
|
+
if (total !== undefined &&
|
|
132
|
+
(!Number.isSafeInteger(total) || total < 0 || total > maxBytes))
|
|
133
|
+
throw new StorageTransferError('Upload exceeds its reserved size');
|
|
134
|
+
let chunkBytes = options.chunkBytes ?? 8 * 1024 * 1024;
|
|
135
|
+
if (chunkBytes < 262144 ||
|
|
136
|
+
chunkBytes > 32 * 1024 * 1024 ||
|
|
137
|
+
chunkBytes % 262144)
|
|
138
|
+
throw new StorageTransferError('Invalid upload chunk size');
|
|
139
|
+
if (options.method === 'simple' &&
|
|
140
|
+
source instanceof Blob &&
|
|
141
|
+
source.size <= 32 * 1024 * 1024)
|
|
142
|
+
chunkBytes = Math.max(262144, Math.ceil(source.size / 262144) * 262144);
|
|
143
|
+
async function probe() {
|
|
144
|
+
for (let attempt = 0;; attempt++) {
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await fetcher(url, {
|
|
148
|
+
method: 'PUT',
|
|
149
|
+
headers: {
|
|
150
|
+
'X-GUploader-No-308': 'yes',
|
|
151
|
+
'Content-Range': `bytes */${total ?? '*'}`,
|
|
152
|
+
},
|
|
153
|
+
body: new Uint8Array(),
|
|
154
|
+
signal: options.signal,
|
|
155
|
+
redirect: 'error',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
options.signal?.throwIfAborted();
|
|
160
|
+
if (attempt >= 4)
|
|
161
|
+
throw new StorageTransferError('Upload status is unavailable; resume this session later');
|
|
162
|
+
await delay(attempt, options.signal);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (response.ok && !incomplete(response))
|
|
166
|
+
return { offset: total ?? 0, complete: true };
|
|
167
|
+
if (incomplete(response))
|
|
168
|
+
return { offset: receivedOffset(response), complete: false };
|
|
169
|
+
if (!retryable(response.status) || attempt >= 4)
|
|
170
|
+
throw new StorageTransferError('Upload session is unavailable or expired', response.status);
|
|
171
|
+
await delay(attempt, options.signal);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const initial = await probe();
|
|
175
|
+
if (initial.complete) {
|
|
176
|
+
if (total !== undefined)
|
|
177
|
+
options.onProgress?.(total);
|
|
178
|
+
// An earlier call finalized the object; nothing is sent, but the checksum
|
|
179
|
+
// must still describe the source so completion can compare it.
|
|
180
|
+
return { crc32c: await digest(source, options.signal) };
|
|
181
|
+
}
|
|
182
|
+
let offset = initial.offset;
|
|
183
|
+
if (offset > maxBytes || (total !== undefined && offset > total))
|
|
184
|
+
throw new StorageTransferError('Upload offset exceeds the source size');
|
|
185
|
+
options.onProgress?.(offset);
|
|
186
|
+
const hash = new crc32c_js_1.Crc32c();
|
|
187
|
+
const iterable = chunks(source, chunkBytes, offset, hash, options.signal);
|
|
188
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
189
|
+
try {
|
|
190
|
+
let current = await iterator.next();
|
|
191
|
+
for (;;) {
|
|
192
|
+
const next = current.done ? current : await iterator.next();
|
|
193
|
+
const chunk = current.done ? new Uint8Array() : current.value;
|
|
194
|
+
const end = offset + chunk.byteLength;
|
|
195
|
+
const last = Boolean(next.done);
|
|
196
|
+
if (total === undefined && end > maxBytes)
|
|
197
|
+
throw new StorageTransferError(`Upload stream exceeds its ${maxBytes}-byte reservation; declare \`size\` when starting the upload to send a larger file`);
|
|
198
|
+
if (end > maxBytes ||
|
|
199
|
+
(total !== undefined && (end > total || (last && end !== total))))
|
|
200
|
+
throw new StorageTransferError('Upload source does not match its reserved size');
|
|
201
|
+
const length = total ?? (last ? end : '*');
|
|
202
|
+
let acknowledged = offset;
|
|
203
|
+
for (let attempt = 0;; attempt++) {
|
|
204
|
+
options.signal?.throwIfAborted();
|
|
205
|
+
let response;
|
|
206
|
+
try {
|
|
207
|
+
response = await fetcher(url, {
|
|
208
|
+
method: 'PUT',
|
|
209
|
+
signal: options.signal,
|
|
210
|
+
redirect: 'error',
|
|
211
|
+
headers: {
|
|
212
|
+
'X-GUploader-No-308': 'yes',
|
|
213
|
+
'Content-Range': chunk.byteLength
|
|
214
|
+
? `bytes ${acknowledged}-${end - 1}/${length}`
|
|
215
|
+
: `bytes */${length}`,
|
|
216
|
+
},
|
|
217
|
+
body: new Uint8Array(chunk.subarray(acknowledged - offset)),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
options.signal?.throwIfAborted();
|
|
222
|
+
}
|
|
223
|
+
if (response?.ok && !incomplete(response)) {
|
|
224
|
+
if (!last)
|
|
225
|
+
throw new StorageTransferError('Storage completed before the source ended');
|
|
226
|
+
options.onProgress?.(end);
|
|
227
|
+
return { crc32c: hash.digest() };
|
|
228
|
+
}
|
|
229
|
+
if (response && !incomplete(response) && !retryable(response.status))
|
|
230
|
+
throw new StorageTransferError('Storage rejected the upload chunk', response.status);
|
|
231
|
+
const status = response && incomplete(response)
|
|
232
|
+
? { offset: receivedOffset(response), complete: false }
|
|
233
|
+
: await probe();
|
|
234
|
+
if (status.complete) {
|
|
235
|
+
if (!last)
|
|
236
|
+
throw new StorageTransferError('Storage completed before the source ended');
|
|
237
|
+
options.onProgress?.(end);
|
|
238
|
+
return { crc32c: hash.digest() };
|
|
239
|
+
}
|
|
240
|
+
if (status.offset < acknowledged || status.offset > end)
|
|
241
|
+
throw new StorageTransferError('Storage returned an inconsistent upload offset');
|
|
242
|
+
acknowledged = status.offset;
|
|
243
|
+
options.onProgress?.(acknowledged);
|
|
244
|
+
if (acknowledged === end && !last)
|
|
245
|
+
break;
|
|
246
|
+
if (attempt >= 4)
|
|
247
|
+
throw new StorageTransferError('Upload interrupted; resume this session to continue');
|
|
248
|
+
await delay(attempt, options.signal);
|
|
249
|
+
}
|
|
250
|
+
offset = end;
|
|
251
|
+
current = next;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await iterator.return(undefined);
|
|
256
|
+
}
|
|
257
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sequenceholdings/storage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Atlas workspace storage contracts and streaming client",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"zod": "^4.1.13"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.3.3",
|
|
25
|
+
"vitest": "^4.1.11"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"type-check": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
}
|
|
38
|
+
}
|