@thailandhosting/object-storage 0.0.0-stage → 1.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 +202 -2
- package/dist/client.d.ts +265 -0
- package/dist/client.js +711 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.js +55 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/signing.d.ts +24 -0
- package/dist/signing.js +48 -0
- package/dist/signing.js.map +1 -0
- package/package.json +43 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { open, mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname } from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { IntegrityError, NotFoundError, NotModifiedError, ObjectStorageError, PreconditionFailedError, TokenError } from "./errors.js";
|
|
6
|
+
import { contentDigest, sha256FromField, signRequest } from "./signing.js";
|
|
7
|
+
export const VERSION = '1.0.0';
|
|
8
|
+
const MiB = 1024 * 1024;
|
|
9
|
+
const MIN_PART_SIZE = 5 * MiB;
|
|
10
|
+
const MAX_PARTS = 10000;
|
|
11
|
+
const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
12
|
+
const escapeKey = (key) => key.split('/').map(encodeURIComponent).join('/');
|
|
13
|
+
const md5 = (data) => createHash('md5').update(data).digest('hex');
|
|
14
|
+
const toBytes = (b) => (typeof b === 'string' ? Buffer.from(b) : b);
|
|
15
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
16
|
+
if (signal?.aborted)
|
|
17
|
+
return reject(signal.reason);
|
|
18
|
+
const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
19
|
+
const onAbort = () => { clearTimeout(t); reject(signal.reason); };
|
|
20
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
21
|
+
});
|
|
22
|
+
const backoff = (attempt) => {
|
|
23
|
+
const d = Math.min(200 * 2 ** Math.min(attempt, 6), 10_000);
|
|
24
|
+
return d / 2 + Math.random() * (d / 2);
|
|
25
|
+
};
|
|
26
|
+
/** Retry-After (seconds or an HTTP date) in ms, at most 30 s; 0 when absent. */
|
|
27
|
+
function retryAfter(v) {
|
|
28
|
+
if (!v)
|
|
29
|
+
return 0;
|
|
30
|
+
const ms = /^\d+$/.test(v.trim()) ? Number(v) * 1000 : Date.parse(v) - Date.now();
|
|
31
|
+
return ms > 0 ? Math.min(ms, 30_000) : 0;
|
|
32
|
+
}
|
|
33
|
+
/** An If-Match / If-None-Match value: `*`, or each ETag quoted. */
|
|
34
|
+
const etagList = (v) => (v.trim() === '*' ? '*' : v.split(',').map((e) => `"${e.trim().replace(/^W\//, '').replace(/"/g, '')}"`).join(', '));
|
|
35
|
+
function conditionHeaders(o, h = {}) {
|
|
36
|
+
if (o.ifMatch)
|
|
37
|
+
h['If-Match'] = etagList(o.ifMatch);
|
|
38
|
+
if (o.ifNoneMatch)
|
|
39
|
+
h['If-None-Match'] = etagList(o.ifNoneMatch);
|
|
40
|
+
if (o.ifModifiedSince)
|
|
41
|
+
h['If-Modified-Since'] = o.ifModifiedSince.toUTCString();
|
|
42
|
+
if (o.ifUnmodifiedSince)
|
|
43
|
+
h['If-Unmodified-Since'] = o.ifUnmodifiedSince.toUTCString();
|
|
44
|
+
return h;
|
|
45
|
+
}
|
|
46
|
+
/** Calls that took a bare signal take an options object too. */
|
|
47
|
+
const optionsOf = (v) => (v === undefined ? {} : v instanceof AbortSignal ? { signal: v } : v);
|
|
48
|
+
/** The base64 SHA-256 a full download must have, when the service gave one for the bytes as sent. */
|
|
49
|
+
function expectedDigest(h) {
|
|
50
|
+
const enc = h.get('content-encoding');
|
|
51
|
+
// fetch decodes compressed bodies, so their bytes are not the representation's.
|
|
52
|
+
if (enc && enc.toLowerCase() !== 'identity')
|
|
53
|
+
return undefined;
|
|
54
|
+
return sha256FromField(h.get('repr-digest'));
|
|
55
|
+
}
|
|
56
|
+
function checkDigest(want, got, key) {
|
|
57
|
+
if (want !== got)
|
|
58
|
+
throw new IntegrityError(`${key}: the downloaded data does not match the object's Repr-Digest (sha-256 ${want}, got ${got})`);
|
|
59
|
+
}
|
|
60
|
+
/** Passes a body through, failing it at the end if its SHA-256 is not `want`. */
|
|
61
|
+
function verifying(body, want, key) {
|
|
62
|
+
const hash = createHash('sha256');
|
|
63
|
+
return body.pipeThrough(new TransformStream({
|
|
64
|
+
transform(chunk, ctrl) {
|
|
65
|
+
hash.update(chunk);
|
|
66
|
+
ctrl.enqueue(chunk);
|
|
67
|
+
},
|
|
68
|
+
flush() {
|
|
69
|
+
checkDigest(want, hash.digest('base64'), key);
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
function checkETag(etag, digest, what) {
|
|
74
|
+
const e = etag.replace(/"/g, '');
|
|
75
|
+
if (e.length === 32 && !e.includes('-') && e !== digest) {
|
|
76
|
+
throw new IntegrityError(`${what}: the stored data does not match what was sent (ETag ${e}, MD5 ${digest})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const CONTENT_TYPES = {
|
|
80
|
+
'.txt': 'text/plain', '.html': 'text/html', '.htm': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript',
|
|
81
|
+
'.json': 'application/json', '.xml': 'application/xml', '.csv': 'text/csv', '.md': 'text/markdown', '.pdf': 'application/pdf',
|
|
82
|
+
'.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar', '.7z': 'application/x-7z-compressed',
|
|
83
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
84
|
+
'.avif': 'image/avif', '.ico': 'image/x-icon', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg',
|
|
85
|
+
'.wav': 'audio/wav', '.ogg': 'audio/ogg', '.woff': 'font/woff', '.woff2': 'font/woff2', '.wasm': 'application/wasm',
|
|
86
|
+
};
|
|
87
|
+
const guessType = (name) => CONTENT_TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
88
|
+
const parseDate = (v) => (v ? new Date(v) : undefined);
|
|
89
|
+
function objectFromJSON(d) {
|
|
90
|
+
return {
|
|
91
|
+
key: d.key ?? '',
|
|
92
|
+
size: Number(d.size ?? 0),
|
|
93
|
+
etag: String(d.etag ?? '').replace(/"/g, ''),
|
|
94
|
+
lastModified: parseDate(d.lastModified),
|
|
95
|
+
contentType: d.contentType || undefined,
|
|
96
|
+
metadata: d.metadata ?? {},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function objectFromHeaders(key, h) {
|
|
100
|
+
const cr = h.get('content-range');
|
|
101
|
+
const size = cr && cr.includes('/') ? Number(cr.split('/')[1]) : Number(h.get('content-length') ?? 0);
|
|
102
|
+
const metadata = {};
|
|
103
|
+
h.forEach((v, k) => { if (k.startsWith('x-meta-'))
|
|
104
|
+
metadata[k.slice(7)] = v; });
|
|
105
|
+
return {
|
|
106
|
+
key, size, etag: (h.get('etag') ?? '').replace(/"/g, ''), lastModified: parseDate(h.get('last-modified')), contentType: h.get('content-type') ?? undefined, metadata,
|
|
107
|
+
digest: h.get('repr-digest') ?? undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** The Authorization value from a token provider; its failure is a TokenError. */
|
|
111
|
+
async function bearer(provider) {
|
|
112
|
+
let token;
|
|
113
|
+
try {
|
|
114
|
+
token = await provider();
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
throw new TokenError(`tokenProvider failed: ${err instanceof Error ? err.message : err}`, err);
|
|
118
|
+
}
|
|
119
|
+
if (!token || typeof token !== 'string')
|
|
120
|
+
throw new TokenError('tokenProvider gave no token');
|
|
121
|
+
return 'Bearer ' + token;
|
|
122
|
+
}
|
|
123
|
+
/** Runs task(0..n-1), `workers` at a time; the first failure stops new work and is thrown. */
|
|
124
|
+
async function parallel(workers, n, task) {
|
|
125
|
+
let next = 0;
|
|
126
|
+
let failed;
|
|
127
|
+
const run = async () => {
|
|
128
|
+
while (failed === undefined && next < n) {
|
|
129
|
+
const i = next++;
|
|
130
|
+
try {
|
|
131
|
+
await task(i);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
failed ??= e;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
await Promise.all(Array.from({ length: Math.min(workers, n) }, run));
|
|
139
|
+
if (failed !== undefined)
|
|
140
|
+
throw failed;
|
|
141
|
+
}
|
|
142
|
+
/** A client of one region's Object Storage endpoint. Reuse it: it keeps connections open. */
|
|
143
|
+
export class Client {
|
|
144
|
+
partSize;
|
|
145
|
+
multipartThreshold;
|
|
146
|
+
concurrency;
|
|
147
|
+
maxRetries;
|
|
148
|
+
base;
|
|
149
|
+
/** @internal */ origin;
|
|
150
|
+
keyId;
|
|
151
|
+
secret;
|
|
152
|
+
/** @internal */ tokenProvider;
|
|
153
|
+
ua;
|
|
154
|
+
fetch;
|
|
155
|
+
constructor(opts) {
|
|
156
|
+
if (!opts.endpoint || !opts.accessKeyId !== !opts.secretAccessKey) {
|
|
157
|
+
throw new Error('endpoint is required, and accessKeyId and secretAccessKey together');
|
|
158
|
+
}
|
|
159
|
+
if (opts.tokenProvider && opts.accessKeyId)
|
|
160
|
+
throw new Error('give an access key or tokenProvider, not both');
|
|
161
|
+
const u = new URL(opts.endpoint);
|
|
162
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:')
|
|
163
|
+
throw new Error(`invalid endpoint ${opts.endpoint}`);
|
|
164
|
+
const path = u.pathname.replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
165
|
+
this.origin = `${u.protocol}//${u.host}${path}`;
|
|
166
|
+
this.base = `${this.origin}/v1`;
|
|
167
|
+
this.keyId = opts.accessKeyId ?? '';
|
|
168
|
+
this.secret = opts.secretAccessKey ?? '';
|
|
169
|
+
this.tokenProvider = opts.tokenProvider;
|
|
170
|
+
this.partSize = Math.max(opts.partSize ?? 16 * MiB, MIN_PART_SIZE);
|
|
171
|
+
this.multipartThreshold = opts.multipartThreshold ?? 2 * this.partSize;
|
|
172
|
+
this.concurrency = Math.max(1, opts.concurrency ?? 8);
|
|
173
|
+
this.maxRetries = Math.max(0, opts.maxRetries ?? 4);
|
|
174
|
+
this.ua = `thailandhosting-object-storage-sdk-node/${VERSION}${opts.userAgent ? ' ' + opts.userAgent : ''}`;
|
|
175
|
+
this.fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
176
|
+
}
|
|
177
|
+
/** @internal Sends a request with retries; the caller reads or cancels the body. */
|
|
178
|
+
async request(r) {
|
|
179
|
+
let url = this.base + r.path;
|
|
180
|
+
if (r.query) {
|
|
181
|
+
const q = new URLSearchParams();
|
|
182
|
+
for (const [k, v] of Object.entries(r.query))
|
|
183
|
+
if (v !== undefined && v !== '')
|
|
184
|
+
q.set(k, String(v));
|
|
185
|
+
const s = q.toString();
|
|
186
|
+
if (s)
|
|
187
|
+
url += '?' + s;
|
|
188
|
+
}
|
|
189
|
+
const headers = { 'User-Agent': this.ua, ...r.headers };
|
|
190
|
+
// Every body carries its digest; the service checks it as the bytes arrive.
|
|
191
|
+
if (r.body)
|
|
192
|
+
headers['Content-Digest'] = contentDigest(r.body);
|
|
193
|
+
for (let attempt = 0;; attempt++) {
|
|
194
|
+
if (this.tokenProvider) {
|
|
195
|
+
// Asked for on every attempt: a retry picks up a refreshed token.
|
|
196
|
+
headers.Authorization = await bearer(this.tokenProvider);
|
|
197
|
+
}
|
|
198
|
+
else if (this.keyId) {
|
|
199
|
+
// A fresh signature each attempt: created must be close to the service's clock.
|
|
200
|
+
Object.assign(headers, signRequest({ method: r.method, url, accessKeyId: this.keyId, secretAccessKey: this.secret, contentDigest: headers['Content-Digest'] }));
|
|
201
|
+
}
|
|
202
|
+
let resp;
|
|
203
|
+
try {
|
|
204
|
+
resp = await this.fetch(url, { method: r.method, headers, body: r.body, signal: r.signal, redirect: 'manual' });
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
if (r.signal?.aborted)
|
|
208
|
+
throw e;
|
|
209
|
+
// The request may have reached the service; only a repeatable one is sent again.
|
|
210
|
+
if (attempt < this.maxRetries && (r.method !== 'POST' || r.retryPost)) {
|
|
211
|
+
await sleep(backoff(attempt), r.signal);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
const okStatus = r.expect ? r.expect.includes(resp.status) : resp.status >= 200 && resp.status < 300;
|
|
217
|
+
if (okStatus)
|
|
218
|
+
return resp;
|
|
219
|
+
const err = await readError(resp);
|
|
220
|
+
const retryable = RETRY_STATUSES.has(resp.status) && (r.method !== 'POST' || (!!r.retryPost && (resp.status === 429 || resp.status === 503)));
|
|
221
|
+
if (retryable && attempt < this.maxRetries) {
|
|
222
|
+
// Retry-After (sent with 503) is the least the service wants us to wait.
|
|
223
|
+
await sleep(Math.max(backoff(attempt), retryAfter(resp.headers.get('retry-after'))), r.signal);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** @internal */
|
|
230
|
+
async json(method, path, payload, opts = {}) {
|
|
231
|
+
const resp = await this.request({
|
|
232
|
+
method, path, query: opts.query, retryPost: opts.retryPost, signal: opts.signal,
|
|
233
|
+
headers: payload === undefined ? opts.headers : { ...opts.headers, 'Content-Type': 'application/json' },
|
|
234
|
+
body: payload === undefined ? undefined : Buffer.from(JSON.stringify(payload)),
|
|
235
|
+
});
|
|
236
|
+
const text = await resp.text();
|
|
237
|
+
return (text ? JSON.parse(text) : undefined);
|
|
238
|
+
}
|
|
239
|
+
/** The buckets this access key can use. */
|
|
240
|
+
async listBuckets(signal) {
|
|
241
|
+
const d = await this.json('GET', '/buckets', undefined, { signal });
|
|
242
|
+
return d.buckets.map((b) => ({ name: b.name, region: b.region, createdAt: parseDate(b.createdAt) }));
|
|
243
|
+
}
|
|
244
|
+
/** Create a bucket (3-63 characters of a-z, 0-9, dots and hyphens). */
|
|
245
|
+
async createBucket(name, signal) {
|
|
246
|
+
const b = await this.json('PUT', '/buckets/' + encodeURIComponent(name), undefined, { signal });
|
|
247
|
+
return { name: b?.name ?? name, region: b?.region ?? '', createdAt: parseDate(b?.createdAt), public: b?.public };
|
|
248
|
+
}
|
|
249
|
+
async getBucket(name, signal) {
|
|
250
|
+
const b = await this.json('GET', '/buckets/' + encodeURIComponent(name), undefined, { signal });
|
|
251
|
+
return { name: b.name, region: b.region, createdAt: parseDate(b.createdAt) };
|
|
252
|
+
}
|
|
253
|
+
/** Delete an empty bucket. */
|
|
254
|
+
async deleteBucket(name, signal) {
|
|
255
|
+
await this.json('DELETE', '/buckets/' + encodeURIComponent(name), undefined, { signal });
|
|
256
|
+
}
|
|
257
|
+
/** A handle for one bucket's objects (no request is made). */
|
|
258
|
+
bucket(name) {
|
|
259
|
+
return new Bucket(this, name);
|
|
260
|
+
}
|
|
261
|
+
/** @internal */
|
|
262
|
+
partSizeFor(size) {
|
|
263
|
+
let ps = this.partSize;
|
|
264
|
+
if (size > 0 && Math.ceil(size / ps) > MAX_PARTS)
|
|
265
|
+
ps = Math.ceil(Math.ceil(size / MAX_PARTS) / MiB) * MiB;
|
|
266
|
+
return ps;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** An error answer as Problem Details (RFC 9457); a HEAD answer has no body, only X-Error-Code. */
|
|
270
|
+
async function readError(resp) {
|
|
271
|
+
let code = resp.headers.get('x-error-code') ?? '';
|
|
272
|
+
let message = resp.statusText;
|
|
273
|
+
const problem = {};
|
|
274
|
+
try {
|
|
275
|
+
const doc = JSON.parse(await resp.text());
|
|
276
|
+
code = doc?.code || code;
|
|
277
|
+
message = doc?.title || message;
|
|
278
|
+
problem.type = doc?.type || undefined;
|
|
279
|
+
problem.instance = doc?.instance || undefined;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// no body, or not JSON
|
|
283
|
+
}
|
|
284
|
+
code ||= (resp.statusText || 'error').toLowerCase().replace(/ /g, '_');
|
|
285
|
+
if (resp.status === 404)
|
|
286
|
+
return new NotFoundError(code, message, problem);
|
|
287
|
+
if (resp.status === 412)
|
|
288
|
+
return new PreconditionFailedError(code, message, problem);
|
|
289
|
+
return new ObjectStorageError(resp.status, code, message, problem);
|
|
290
|
+
}
|
|
291
|
+
function putHeaders(key, o = {}) {
|
|
292
|
+
const h = { 'Content-Type': o.contentType || guessType(key) };
|
|
293
|
+
if (o.cacheControl)
|
|
294
|
+
h['Cache-Control'] = o.cacheControl;
|
|
295
|
+
if (o.contentDisposition)
|
|
296
|
+
h['Content-Disposition'] = o.contentDisposition;
|
|
297
|
+
if (o.contentEncoding)
|
|
298
|
+
h['Content-Encoding'] = o.contentEncoding;
|
|
299
|
+
for (const [k, v] of Object.entries(o.metadata ?? {}))
|
|
300
|
+
h['X-Meta-' + k] = v;
|
|
301
|
+
return conditionHeaders(writeConditions(o), h);
|
|
302
|
+
}
|
|
303
|
+
/** The write conditions of upload options (ifNotExists is ifNoneMatch: '*'). */
|
|
304
|
+
const writeConditions = (o) => ({ ifMatch: o.ifMatch, ifNoneMatch: o.ifNotExists ? '*' : o.ifNoneMatch });
|
|
305
|
+
/** The objects of one bucket. */
|
|
306
|
+
export class Bucket {
|
|
307
|
+
name;
|
|
308
|
+
/** @internal */ c;
|
|
309
|
+
/** @internal */ path;
|
|
310
|
+
constructor(client, name) {
|
|
311
|
+
this.c = client;
|
|
312
|
+
this.name = name;
|
|
313
|
+
this.path = '/buckets/' + encodeURIComponent(name);
|
|
314
|
+
}
|
|
315
|
+
obj(key) {
|
|
316
|
+
return this.path + '/objects/' + escapeKey(key);
|
|
317
|
+
}
|
|
318
|
+
/** Upload a string or bytes in one request. */
|
|
319
|
+
async put(key, data, opts = {}) {
|
|
320
|
+
const bytes = toBytes(data);
|
|
321
|
+
const resp = await this.c.request({ method: 'PUT', path: this.obj(key), headers: putHeaders(key, opts), body: bytes, signal: opts.signal });
|
|
322
|
+
const info = objectFromJSON(await resp.json());
|
|
323
|
+
checkETag(info.etag, md5(bytes), key);
|
|
324
|
+
return info;
|
|
325
|
+
}
|
|
326
|
+
/** Stream an object (or a range of it). `info.size` is the whole object's size. A whole-object
|
|
327
|
+
* download with a Repr-Digest is checked as it is read: the body fails with IntegrityError at
|
|
328
|
+
* its end if the bytes do not match. */
|
|
329
|
+
async get(key, opts = {}) {
|
|
330
|
+
const headers = conditionHeaders(opts);
|
|
331
|
+
const ranged = !!(opts.offset || opts.length);
|
|
332
|
+
if (ranged) {
|
|
333
|
+
const start = opts.offset ?? 0;
|
|
334
|
+
headers.Range = opts.length ? `bytes=${start}-${start + opts.length - 1}` : `bytes=${start}-`;
|
|
335
|
+
}
|
|
336
|
+
const resp = await this.c.request({ method: 'GET', path: this.obj(key), headers, expect: [200, 206, 304], signal: opts.signal });
|
|
337
|
+
if (resp.status === 304) {
|
|
338
|
+
await resp.body?.cancel();
|
|
339
|
+
throw new NotModifiedError(key);
|
|
340
|
+
}
|
|
341
|
+
let body = resp.body ?? new ReadableStream({ start: (c) => c.close() });
|
|
342
|
+
const want = resp.status === 200 ? expectedDigest(resp.headers) : undefined;
|
|
343
|
+
if (want)
|
|
344
|
+
body = verifying(body, want, key);
|
|
345
|
+
return {
|
|
346
|
+
info: objectFromHeaders(key, resp.headers),
|
|
347
|
+
contentRange: resp.headers.get('content-range') ?? undefined,
|
|
348
|
+
body,
|
|
349
|
+
toNodeStream: () => Readable.fromWeb(body),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/** Download a whole object into memory, checked against its Repr-Digest when it has one. */
|
|
353
|
+
async getBytes(key, signal) {
|
|
354
|
+
const resp = await this.c.request({ method: 'GET', path: this.obj(key), signal });
|
|
355
|
+
const data = new Uint8Array(await resp.arrayBuffer());
|
|
356
|
+
const want = expectedDigest(resp.headers);
|
|
357
|
+
if (want)
|
|
358
|
+
checkDigest(want, createHash('sha256').update(data).digest('base64'), key);
|
|
359
|
+
return data;
|
|
360
|
+
}
|
|
361
|
+
/** Download a whole object as UTF-8 text. */
|
|
362
|
+
async getText(key, signal) {
|
|
363
|
+
return new TextDecoder().decode(await this.getBytes(key, signal));
|
|
364
|
+
}
|
|
365
|
+
/** An object's info without its data. With `ifNoneMatch` / `ifModifiedSince` it throws
|
|
366
|
+
* NotModifiedError when the object is unchanged. */
|
|
367
|
+
async head(key, opts) {
|
|
368
|
+
const o = optionsOf(opts);
|
|
369
|
+
const resp = await this.c.request({ method: 'HEAD', path: this.obj(key), headers: conditionHeaders(o), expect: [200, 304], signal: o.signal });
|
|
370
|
+
if (resp.status === 304)
|
|
371
|
+
throw new NotModifiedError(key);
|
|
372
|
+
return objectFromHeaders(key, resp.headers);
|
|
373
|
+
}
|
|
374
|
+
async exists(key, signal) {
|
|
375
|
+
try {
|
|
376
|
+
await this.head(key, signal);
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
catch (e) {
|
|
380
|
+
if (e instanceof NotFoundError)
|
|
381
|
+
return false;
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Delete an object; deleting a missing one succeeds (unless `ifMatch` wanted one). */
|
|
386
|
+
async delete(key, opts) {
|
|
387
|
+
const o = optionsOf(opts);
|
|
388
|
+
const resp = await this.c.request({ method: 'DELETE', path: this.obj(key), headers: conditionHeaders(o), signal: o.signal });
|
|
389
|
+
await resp.body?.cancel();
|
|
390
|
+
}
|
|
391
|
+
/** Delete keys in parallel. */
|
|
392
|
+
async deleteMany(keys, signal) {
|
|
393
|
+
await parallel(this.c.concurrency, keys.length, (i) => this.delete(keys[i], signal));
|
|
394
|
+
}
|
|
395
|
+
/** Copy inside the service, without downloading. */
|
|
396
|
+
async copy(srcKey, dstKey, opts = {}) {
|
|
397
|
+
const payload = { from: { bucket: opts.sourceBucket ?? this.name, key: srcKey }, key: dstKey };
|
|
398
|
+
if (opts.metadata)
|
|
399
|
+
payload.metadata = opts.metadata;
|
|
400
|
+
return objectFromJSON(await this.c.json('POST', this.path + '/copy', payload, { retryPost: true, signal: opts.signal }));
|
|
401
|
+
}
|
|
402
|
+
/** One page of objects (and folders, with a delimiter). */
|
|
403
|
+
async list(opts = {}) {
|
|
404
|
+
const d = await this.c.json('GET', this.path + '/objects', undefined, {
|
|
405
|
+
query: { prefix: opts.prefix, delimiter: opts.delimiter, cursor: opts.cursor, limit: opts.limit }, signal: opts.signal,
|
|
406
|
+
});
|
|
407
|
+
return { objects: (d.objects ?? []).map(objectFromJSON), folders: d.folders ?? [], nextCursor: d.nextCursor || undefined };
|
|
408
|
+
}
|
|
409
|
+
/** Every object under prefix: `for await (const obj of bucket.objects('photos/'))`. */
|
|
410
|
+
async *objects(prefix = '', opts = {}) {
|
|
411
|
+
let cursor;
|
|
412
|
+
do {
|
|
413
|
+
const page = await this.list({ prefix, cursor, limit: opts.pageSize, signal: opts.signal });
|
|
414
|
+
yield* page.objects;
|
|
415
|
+
cursor = page.nextCursor;
|
|
416
|
+
} while (cursor);
|
|
417
|
+
}
|
|
418
|
+
/** A URL that downloads (or, with `upload: true`, uploads) one object without a key. Needs a client with an access key. */
|
|
419
|
+
async createLink(key, opts = {}) {
|
|
420
|
+
if (this.c.tokenProvider)
|
|
421
|
+
throw new Error("createLink needs an access key: a link is signed with the key's secret, and a service account has none");
|
|
422
|
+
const d = await this.c.json('POST', this.path + '/links', { key, expiresIn: opts.expiresIn ?? 3600, method: opts.upload ? 'PUT' : 'GET' }, { retryPost: true, signal: opts.signal });
|
|
423
|
+
return { url: d.url, method: d.method, expiresAt: parseDate(d.expiresAt) };
|
|
424
|
+
}
|
|
425
|
+
/** What anyone may read in the bucket. */
|
|
426
|
+
async publicAccess(opts = {}) {
|
|
427
|
+
const d = await this.c.json('GET', this.path + '/public', undefined, { signal: opts.signal });
|
|
428
|
+
return d.rules ?? [];
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Replace what anyone may read: `[]` makes the bucket private, `[{ prefix: '' }]` opens all of it,
|
|
432
|
+
* `[{ prefix: 'images/' }]` one folder. Needs a key allowed to manage buckets; takes effect within
|
|
433
|
+
* about 15 seconds. Resolves to the rules as stored.
|
|
434
|
+
*/
|
|
435
|
+
async setPublicAccess(rules, opts = {}) {
|
|
436
|
+
const payload = { rules: rules.map((r) => ({ prefix: r.prefix, list: !!r.list })) };
|
|
437
|
+
const d = await this.c.json('PUT', this.path + '/public', payload, { retryPost: true, signal: opts.signal });
|
|
438
|
+
return d.rules ?? [];
|
|
439
|
+
}
|
|
440
|
+
/** The address anyone can open `key` at once its folder (or the bucket) is public. No request is made. */
|
|
441
|
+
publicUrl(key) {
|
|
442
|
+
return `${this.c.origin}/${encodeURIComponent(this.name)}/${escapeKey(key)}`;
|
|
443
|
+
}
|
|
444
|
+
/** Start a multipart upload (uploadFile and upload do this for you). */
|
|
445
|
+
async createMultipartUpload(key, opts = {}) {
|
|
446
|
+
const payload = { key, contentType: opts.contentType || guessType(key) };
|
|
447
|
+
if (opts.metadata)
|
|
448
|
+
payload.metadata = opts.metadata;
|
|
449
|
+
const d = await this.c.json('POST', this.path + '/uploads', payload, { retryPost: true, signal: opts.signal });
|
|
450
|
+
return new MultipartUpload(this, key, d.uploadId);
|
|
451
|
+
}
|
|
452
|
+
resumeMultipartUpload(key, uploadId) {
|
|
453
|
+
return new MultipartUpload(this, key, uploadId);
|
|
454
|
+
}
|
|
455
|
+
/** Upload a local file. Large files go up as parts read from the file and
|
|
456
|
+
* sent in parallel; a part is re-read if it must be sent again. `ifMatch` /
|
|
457
|
+
* `ifNoneMatch` are checked when the parts are joined. */
|
|
458
|
+
async uploadFile(key, path, opts = {}) {
|
|
459
|
+
const { size } = await stat(path);
|
|
460
|
+
const options = { ...opts, contentType: opts.contentType || guessType(path) };
|
|
461
|
+
const fh = await open(path, 'r');
|
|
462
|
+
try {
|
|
463
|
+
if (size <= this.c.multipartThreshold) {
|
|
464
|
+
const buf = Buffer.allocUnsafe(size);
|
|
465
|
+
await readFully(fh, buf, 0);
|
|
466
|
+
return await this.put(key, buf, options);
|
|
467
|
+
}
|
|
468
|
+
const ps = this.c.partSizeFor(size);
|
|
469
|
+
const count = Math.ceil(size / ps);
|
|
470
|
+
const up = await this.createMultipartUpload(key, options);
|
|
471
|
+
const parts = new Array(count);
|
|
472
|
+
try {
|
|
473
|
+
await parallel(this.c.concurrency, count, async (i) => {
|
|
474
|
+
const len = Math.min(ps, size - i * ps);
|
|
475
|
+
const buf = Buffer.allocUnsafe(len);
|
|
476
|
+
await readFully(fh, buf, i * ps);
|
|
477
|
+
parts[i] = await up.uploadPart(i + 1, buf, opts.signal);
|
|
478
|
+
});
|
|
479
|
+
return await up.complete(parts, { ...writeConditions(opts), signal: opts.signal });
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
await up.abortQuietly();
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
await fh.close();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/** Upload what a stream yields (a pipe, an HTTP request, a generated
|
|
491
|
+
* export) without knowing its size. Parts are sent in parallel, holding at
|
|
492
|
+
* most `concurrency + 1` in memory; every part (or a small stream, sent in
|
|
493
|
+
* one request) is buffered, so its Content-Digest is known before it is sent. */
|
|
494
|
+
async upload(key, source, opts = {}) {
|
|
495
|
+
const ps = this.c.partSize;
|
|
496
|
+
const chunks = partChunks(source, ps);
|
|
497
|
+
const first = await chunks.next();
|
|
498
|
+
if (first.done)
|
|
499
|
+
return this.put(key, new Uint8Array(0), opts);
|
|
500
|
+
const second = first.value.length < ps ? await chunks.next() : undefined;
|
|
501
|
+
if (second?.done)
|
|
502
|
+
return this.put(key, first.value, opts);
|
|
503
|
+
const up = await this.createMultipartUpload(key, opts);
|
|
504
|
+
const parts = [];
|
|
505
|
+
const inflight = new Set();
|
|
506
|
+
let failed;
|
|
507
|
+
try {
|
|
508
|
+
let n = 1;
|
|
509
|
+
const send = (data, num) => {
|
|
510
|
+
const p = up.uploadPart(num, data, opts.signal)
|
|
511
|
+
.then((part) => { parts.push(part); }, (e) => { failed ??= e; })
|
|
512
|
+
.finally(() => inflight.delete(p));
|
|
513
|
+
inflight.add(p);
|
|
514
|
+
};
|
|
515
|
+
send(first.value, n++);
|
|
516
|
+
if (second && !second.done)
|
|
517
|
+
send(second.value, n++);
|
|
518
|
+
for await (const data of chunks) {
|
|
519
|
+
if (failed !== undefined)
|
|
520
|
+
break;
|
|
521
|
+
if (n > MAX_PARTS)
|
|
522
|
+
throw new Error(`more than ${MAX_PARTS} parts of ${ps} bytes: use a larger partSize`);
|
|
523
|
+
while (inflight.size >= this.c.concurrency)
|
|
524
|
+
await Promise.race(inflight);
|
|
525
|
+
if (failed !== undefined)
|
|
526
|
+
break;
|
|
527
|
+
send(data, n++);
|
|
528
|
+
}
|
|
529
|
+
await Promise.all(inflight);
|
|
530
|
+
if (failed !== undefined)
|
|
531
|
+
throw failed;
|
|
532
|
+
return await up.complete(parts, { ...writeConditions(opts), signal: opts.signal });
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
await Promise.allSettled(inflight);
|
|
536
|
+
await up.abortQuietly();
|
|
537
|
+
throw e;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Save an object to `path`. Large objects come down as parallel ranges
|
|
541
|
+
* pinned to the object's ETag, written straight to their place in the
|
|
542
|
+
* file; the data goes to `path + '.download'` and is renamed into place
|
|
543
|
+
* only once it matches the object's Repr-Digest (when it has one). */
|
|
544
|
+
async downloadFile(key, path, signal) {
|
|
545
|
+
const info = await this.head(key, signal);
|
|
546
|
+
await mkdir(dirname(path), { recursive: true });
|
|
547
|
+
const tmp = path + '.download';
|
|
548
|
+
const fh = await open(tmp, 'w+'); // read back to check a ranged download's digest
|
|
549
|
+
try {
|
|
550
|
+
await fh.truncate(info.size);
|
|
551
|
+
if (info.size <= this.c.multipartThreshold) {
|
|
552
|
+
const hash = createHash('md5');
|
|
553
|
+
const dl = await this.get(key, { ifMatch: info.etag, signal });
|
|
554
|
+
let pos = 0;
|
|
555
|
+
for await (const chunk of dl.body) {
|
|
556
|
+
hash.update(chunk);
|
|
557
|
+
await writeFully(fh, chunk, pos);
|
|
558
|
+
pos += chunk.length;
|
|
559
|
+
}
|
|
560
|
+
if (pos !== info.size)
|
|
561
|
+
throw new IntegrityError(`${key}: got ${pos} of ${info.size} bytes`);
|
|
562
|
+
checkETag(info.etag, hash.digest('hex'), key);
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
const ps = this.c.partSize;
|
|
566
|
+
await parallel(this.c.concurrency, Math.ceil(info.size / ps), (i) => this.downloadRange(key, info.etag, fh, i * ps, Math.min(ps, info.size - i * ps), signal));
|
|
567
|
+
// The ranges arrived out of order: hash the whole file once they are all in.
|
|
568
|
+
const want = sha256FromField(info.digest);
|
|
569
|
+
if (want)
|
|
570
|
+
checkDigest(want, await hashFile(fh, info.size), key);
|
|
571
|
+
}
|
|
572
|
+
await fh.close();
|
|
573
|
+
await rename(tmp, path);
|
|
574
|
+
return info;
|
|
575
|
+
}
|
|
576
|
+
catch (e) {
|
|
577
|
+
await fh.close().catch(() => undefined);
|
|
578
|
+
await rm(tmp, { force: true });
|
|
579
|
+
throw e;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async downloadRange(key, etag, fh, offset, length, signal) {
|
|
583
|
+
let done = 0;
|
|
584
|
+
for (let attempt = 0; done < length; attempt++) {
|
|
585
|
+
try {
|
|
586
|
+
const dl = await this.get(key, { offset: offset + done, length: length - done, ifMatch: etag, signal });
|
|
587
|
+
for await (const chunk of dl.body) {
|
|
588
|
+
await writeFully(fh, chunk, offset + done);
|
|
589
|
+
done += chunk.length;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
if (e instanceof ObjectStorageError || e instanceof TokenError || signal?.aborted || attempt >= this.c.maxRetries)
|
|
594
|
+
throw e;
|
|
595
|
+
}
|
|
596
|
+
if (done < length) {
|
|
597
|
+
// The connection broke mid-range: carry on from where it stopped.
|
|
598
|
+
if (attempt >= this.c.maxRetries)
|
|
599
|
+
throw new IntegrityError(`${key}: got ${done} of ${length} bytes at ${offset}`);
|
|
600
|
+
await sleep(backoff(attempt), signal);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/** A large upload in progress: send parts (any order, in parallel), then complete() or abort(). */
|
|
606
|
+
export class MultipartUpload {
|
|
607
|
+
bucket;
|
|
608
|
+
key;
|
|
609
|
+
uploadId;
|
|
610
|
+
constructor(bucket, key, uploadId) {
|
|
611
|
+
this.bucket = bucket;
|
|
612
|
+
this.key = key;
|
|
613
|
+
this.uploadId = uploadId;
|
|
614
|
+
}
|
|
615
|
+
get path() {
|
|
616
|
+
return this.bucket.path + '/uploads/' + this.uploadId;
|
|
617
|
+
}
|
|
618
|
+
/** Send part `n` (1-10000); every part but the last must be at least 5 MiB. */
|
|
619
|
+
async uploadPart(n, data, signal) {
|
|
620
|
+
const bytes = toBytes(data);
|
|
621
|
+
const resp = await this.bucket.c.request({
|
|
622
|
+
method: 'PUT', path: `${this.path}/parts/${n}`, headers: { 'Content-Type': 'application/octet-stream' }, body: bytes, signal,
|
|
623
|
+
});
|
|
624
|
+
const d = await resp.json();
|
|
625
|
+
checkETag(String(d.etag), md5(bytes), `part ${n}`);
|
|
626
|
+
return { number: Number(d.number), etag: String(d.etag).replace(/"/g, ''), size: bytes.length };
|
|
627
|
+
}
|
|
628
|
+
async parts(signal) {
|
|
629
|
+
const d = await this.bucket.c.json('GET', this.path, undefined, { signal });
|
|
630
|
+
return (d.parts ?? []).map((p) => ({ number: p.number, etag: p.etag, size: p.size }));
|
|
631
|
+
}
|
|
632
|
+
/** Join the parts into the object; without `parts`, every part received, in order.
|
|
633
|
+
* `ifMatch` / `ifNoneMatch` are checked against the key atomically with the join. */
|
|
634
|
+
async complete(parts, opts) {
|
|
635
|
+
const o = optionsOf(opts);
|
|
636
|
+
const payload = parts ? { parts: [...parts].sort((a, b) => a.number - b.number).map((p) => ({ number: p.number, etag: p.etag })) } : undefined;
|
|
637
|
+
try {
|
|
638
|
+
return objectFromJSON(await this.bucket.c.json('POST', this.path + '/complete', payload, { retryPost: true, signal: o.signal, headers: conditionHeaders(o) }));
|
|
639
|
+
}
|
|
640
|
+
catch (e) {
|
|
641
|
+
// A retry after a completion whose answer was lost.
|
|
642
|
+
if (e instanceof NotFoundError)
|
|
643
|
+
return this.bucket.head(this.key, o.signal);
|
|
644
|
+
throw e;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async abort(signal) {
|
|
648
|
+
try {
|
|
649
|
+
await this.bucket.c.json('DELETE', this.path, undefined, { signal });
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
if (!(e instanceof NotFoundError))
|
|
653
|
+
throw e;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/** @internal */
|
|
657
|
+
async abortQuietly() {
|
|
658
|
+
await this.abort().catch(() => undefined);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
async function readFully(fh, buf, position) {
|
|
662
|
+
let off = 0;
|
|
663
|
+
while (off < buf.length) {
|
|
664
|
+
const { bytesRead } = await fh.read(buf, off, buf.length - off, position + off);
|
|
665
|
+
if (bytesRead === 0)
|
|
666
|
+
throw new Error('file shrank while it was being uploaded');
|
|
667
|
+
off += bytesRead;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** The base64 SHA-256 of the first `size` bytes of a file. */
|
|
671
|
+
async function hashFile(fh, size) {
|
|
672
|
+
const hash = createHash('sha256');
|
|
673
|
+
const buf = Buffer.allocUnsafe(Math.min(size, 4 * MiB));
|
|
674
|
+
for (let pos = 0; pos < size;) {
|
|
675
|
+
const { bytesRead } = await fh.read(buf, 0, Math.min(buf.length, size - pos), pos);
|
|
676
|
+
if (bytesRead === 0)
|
|
677
|
+
break;
|
|
678
|
+
hash.update(buf.subarray(0, bytesRead));
|
|
679
|
+
pos += bytesRead;
|
|
680
|
+
}
|
|
681
|
+
return hash.digest('base64');
|
|
682
|
+
}
|
|
683
|
+
async function writeFully(fh, data, position) {
|
|
684
|
+
let off = 0;
|
|
685
|
+
while (off < data.length) {
|
|
686
|
+
const { bytesWritten } = await fh.write(data, off, data.length - off, position + off);
|
|
687
|
+
off += bytesWritten;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/** Regroups a stream's chunks into parts of exactly `size` bytes (the last may be shorter). */
|
|
691
|
+
async function* partChunks(source, size) {
|
|
692
|
+
let buf = Buffer.allocUnsafe(size);
|
|
693
|
+
let fill = 0;
|
|
694
|
+
for await (const chunk of source) {
|
|
695
|
+
let off = 0;
|
|
696
|
+
while (off < chunk.length) {
|
|
697
|
+
const n = Math.min(size - fill, chunk.length - off);
|
|
698
|
+
buf.set(chunk.subarray(off, off + n), fill);
|
|
699
|
+
fill += n;
|
|
700
|
+
off += n;
|
|
701
|
+
if (fill === size) {
|
|
702
|
+
yield buf;
|
|
703
|
+
buf = Buffer.allocUnsafe(size);
|
|
704
|
+
fill = 0;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (fill > 0)
|
|
709
|
+
yield buf.subarray(0, fill);
|
|
710
|
+
}
|
|
711
|
+
//# sourceMappingURL=client.js.map
|