@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 CHANGED
@@ -1,3 +1,203 @@
1
- # Temporary Holding Version
1
+ # ThailandHosting Object Storage — Node.js SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ TypeScript/JavaScript client for ThailandHosting Object Storage: buckets,
4
+ objects, large files and share links over the ThailandHosting API.
5
+
6
+ ```
7
+ npm install @thailandhosting/object-storage
8
+ ```
9
+
10
+ Node.js 18.17+. ESM with TypeScript types. No runtime dependencies: it uses
11
+ Node's built-in `fetch` (keep-alive connection pooling) and streams.
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { Client, NotFoundError } from '@thailandhosting/object-storage';
17
+
18
+ const client = new Client({
19
+ endpoint: 'https://objects.thailandhosting.com/th-bkk-1',
20
+ accessKeyId: process.env.TH_ACCESS_KEY_ID!,
21
+ secretAccessKey: process.env.TH_SECRET_ACCESS_KEY!,
22
+ });
23
+ const bucket = client.bucket('my-bucket');
24
+
25
+ // Any size: large files go up as parallel parts.
26
+ await bucket.uploadFile('backups/db.dump', '/var/backups/db.dump');
27
+
28
+ // Large objects come down as parallel ranges.
29
+ await bucket.downloadFile('backups/db.dump', '/tmp/db.dump');
30
+
31
+ // Small objects
32
+ await bucket.put('notes/hello.txt', 'สวัสดี', { metadata: { author: 'me' } });
33
+ console.log(await bucket.getText('notes/hello.txt'));
34
+
35
+ // Stream an upload of unknown length (an HTTP request, a pipe, a generated export)
36
+ await bucket.upload('exports/report.csv', someReadable);
37
+
38
+ // Stream a download
39
+ const dl = await bucket.get('videos/intro.mp4');
40
+ dl.toNodeStream().pipe(res);
41
+
42
+ // Every object under a prefix
43
+ for await (const obj of bucket.objects('backups/')) console.log(obj.key, obj.size);
44
+
45
+ // A link that works without a key for one hour
46
+ console.log((await bucket.createLink('backups/db.dump', { expiresIn: 3600 })).url);
47
+
48
+ try {
49
+ await bucket.head('missing');
50
+ } catch (e) {
51
+ if (e instanceof NotFoundError) console.log(e.code); // object_not_found
52
+ }
53
+ ```
54
+
55
+ Create access keys in the Console (Object Storage → Access keys). Reuse one
56
+ `Client`: it keeps connections open. Every call takes an optional
57
+ `AbortSignal`.
58
+
59
+ ## Authentication
60
+
61
+ Every request is signed with HTTP Message Signatures (RFC 9421,
62
+ HMAC-SHA256 keyed with the secret access key). The secret itself is never
63
+ sent: the request carries `Signature-Input` (covering the method, host, path,
64
+ query and, with a body, its `Content-Digest`) and `Signature`. Each attempt is
65
+ signed afresh, so the machine's clock must be within 5 minutes of the
66
+ service's. A client without a key sends no signature.
67
+
68
+ `signRequest` and `contentDigest` are exported for signing requests made
69
+ with your own HTTP client.
70
+
71
+ ## Using a service account
72
+
73
+ A program can use a service account's short-lived token instead of an access
74
+ key. Get it with the identity SDK and pass `tokenProvider` in place of the key:
75
+
76
+ ```ts
77
+ import { defaultCredential } from '@thailandhosting/identity';
78
+
79
+ const cred = defaultCredential();
80
+ const client = new Client({
81
+ endpoint: 'https://objects.thailandhosting.com/th-bkk-1',
82
+ tokenProvider: () => cred.getToken(),
83
+ });
84
+ ```
85
+
86
+ The account needs a grant on the bucket, set in the Console. Creating and
87
+ deleting buckets and changing public access take an access key allowed to
88
+ manage buckets.
89
+
90
+ `tokenProvider` is called before every attempt, so a refreshed token is picked
91
+ up. Give a key or a token provider, not both. If it fails, nothing is sent or
92
+ retried: the call rejects with `TokenError`. Large uploads and downloads work
93
+ as with a key. `createLink` needs an access key, because a link is signed with
94
+ the key's secret, and rejects with a token. `publicUrl` works.
95
+
96
+ ## Conditional writes
97
+
98
+ ```ts
99
+ import { PreconditionFailedError } from '@thailandhosting/object-storage';
100
+
101
+ // Create only if the key is free (also on uploadFile / upload, where the
102
+ // condition is checked when a large upload's parts are joined).
103
+ await bucket.put('locks/job-42', owner, { ifNoneMatch: '*' });
104
+
105
+ // Replace only the version you read; delete only if unchanged.
106
+ const info = await bucket.head('config.json');
107
+ try {
108
+ await bucket.put('config.json', next, { ifMatch: info.etag });
109
+ } catch (e) {
110
+ if (e instanceof PreconditionFailedError) console.log('changed meanwhile'); // code precondition_failed
111
+ }
112
+ await bucket.delete('config.json', { ifMatch: info.etag });
113
+
114
+ // Reads: NotModifiedError when the cached copy is current.
115
+ await bucket.get('logo.png', { ifNoneMatch: cachedETag });
116
+ await bucket.head('logo.png', { ifModifiedSince: cachedAt });
117
+ ```
118
+
119
+ The service checks `ifMatch` / `ifNoneMatch` atomically with the write. A
120
+ failed condition is `PreconditionFailedError` (HTTP 412) and is never retried.
121
+
122
+ ## Public buckets and folders
123
+
124
+ ```ts
125
+ // Anyone may read images/ (and nothing else) without a key; needs a key
126
+ // allowed to manage buckets. [] makes the bucket private again.
127
+ await bucket.setPublicAccess([{ prefix: 'images/' }]);
128
+ console.log(bucket.publicUrl('images/logo.png'));
129
+ // https://objects.thailandhosting.com/th-bkk-1/my-bucket/images/logo.png
130
+
131
+ // A client without a key reads (and, where allowed, lists) public objects.
132
+ const anon = new Client({ endpoint: 'https://objects.thailandhosting.com/th-bkk-1' });
133
+ const logo = await anon.bucket('my-bucket').getBytes('images/logo.png');
134
+ ```
135
+
136
+ What a key may do (buckets, folders, read / list / write / delete,
137
+ managing buckets, an expiry date) is set on the key in the Console.
138
+
139
+ ## Performance
140
+
141
+ | | |
142
+ |---|---|
143
+ | Connections | Node's `fetch` (undici): keep-alive pooling, no per-request handshake |
144
+ | Uploads | `uploadFile` reads parts with positional reads and sends them in parallel; `upload` streams any `Readable` / `ReadableStream` / async iterable, holding at most `concurrency + 1` parts in memory |
145
+ | Downloads | `downloadFile` fetches parallel ranges and writes each straight to its place in the file; `get` streams |
146
+ | Small data | one request; a stream that turns out small is one request too |
147
+ | Tuning | `partSize` (default 16 MiB), `concurrency` (default 8), `multipartThreshold` (default 32 MiB) |
148
+
149
+ ## Reliability
150
+
151
+ - Requests that are safe to repeat are retried with exponential backoff and
152
+ jitter (`maxRetries`, default 4) on 429, 500, 502, 503, 504 and network
153
+ errors, waiting at least as long as `Retry-After` asks.
154
+ - Every request body (each part, and JSON) carries a `Content-Digest`
155
+ (SHA-256); the service checks it as the bytes arrive and refuses a body
156
+ that changed on the way (`bad_digest`, not retried). Streams are cut into
157
+ parts in memory, so their digest is known before they are sent.
158
+ - Every single upload and every part is checked against the MD5 the service
159
+ reports; damaged data throws `IntegrityError` and is never accepted.
160
+ - An object uploaded in one request has a `Repr-Digest` (`info.digest`).
161
+ Whole-object downloads (`getBytes`, `getText`, `get` without a range,
162
+ `downloadFile` including its parallel ranges) are checked against it and
163
+ throw `IntegrityError` if the bytes differ. Multipart uploads have none.
164
+ - A failed multipart upload is aborted, so its parts are not billed.
165
+ - `downloadFile` writes to `path + '.download'` and renames it into place;
166
+ every range is pinned to the object's ETag, and a broken range resumes
167
+ where it stopped.
168
+
169
+ ## API
170
+
171
+ | `Client` | |
172
+ |---|---|
173
+ | `listBuckets()`, `createBucket(name)`, `getBucket(name)`, `deleteBucket(name)` | buckets |
174
+ | `bucket(name)` | a `Bucket` handle |
175
+
176
+ | `Bucket` | |
177
+ |---|---|
178
+ | `put`, `upload`, `uploadFile` | upload (`contentType`, `metadata`, `cacheControl`, `contentDisposition`, `contentEncoding`, `ifMatch`, `ifNoneMatch`, `ifNotExists`) |
179
+ | `get` (`offset`/`length`, `ifNoneMatch` / `ifModifiedSince` → `NotModifiedError`, `ifMatch` / `ifUnmodifiedSince`), `getBytes`, `getText`, `downloadFile` | download |
180
+ | `head` (same conditions as `get`), `exists`, `delete` (`ifMatch`, `ifNoneMatch`), `deleteMany`, `copy` | object operations |
181
+ | `list` (one page), `objects` (async iterator) | listing |
182
+ | `createLink(key, { expiresIn, upload })` | a link that needs no key |
183
+ | `publicAccess()`, `setPublicAccess(rules)`, `publicUrl(key)` | make the bucket or some folders readable by anyone, and their URLs |
184
+ | `createMultipartUpload`, `resumeMultipartUpload` → `uploadPart`, `parts`, `complete` (`ifMatch`, `ifNoneMatch`), `abort` | do-it-yourself multipart |
185
+
186
+ The service answers errors as Problem Details (RFC 9457,
187
+ `application/problem+json`). They throw `ObjectStorageError` with `status`,
188
+ `code` (for example `object_not_found`, `bucket_already_exists`, `busy`), the
189
+ problem's title in `message`, and `type` / `instance`. `NotFoundError` is the
190
+ 404 case and `PreconditionFailedError` the 412 one.
191
+
192
+ ## Development
193
+
194
+ ```
195
+ npm install
196
+ npm test # runs the TypeScript tests directly (Node 22.6+ type stripping)
197
+ npm run build # dist/ (ESM + .d.ts)
198
+ ```
199
+
200
+ `npm test` uses an in-memory fake of the service. Set
201
+ `TH_OBJECT_STORAGE_ENDPOINT`, `TH_ACCESS_KEY_ID` and `TH_SECRET_ACCESS_KEY`
202
+ to also run a round trip against a real endpoint (it creates and deletes a
203
+ bucket).
@@ -0,0 +1,265 @@
1
+ import { Readable } from 'node:stream';
2
+ export declare const VERSION = "1.0.0";
3
+ export interface ClientOptions {
4
+ /** The region's endpoint, e.g. https://objects.bkk.thailandhosting.com */
5
+ endpoint: string;
6
+ /** With secretAccessKey; leave both out for an anonymous client that reads only what buckets made public.
7
+ * Requests are signed with the secret (HTTP Message Signatures); the secret itself is never sent. */
8
+ accessKeyId?: string;
9
+ secretAccessKey?: string;
10
+ /** Instead of a key: gives a service account's short-lived token, sent as `Authorization: Bearer` with no
11
+ * signature. Called before every attempt, retries included, so it should cache and refresh the token.
12
+ * `createLink` needs an access key. */
13
+ tokenProvider?: () => Promise<string> | string;
14
+ /** Size of each upload part and download range (default 16 MiB, at least 5 MiB). */
15
+ partSize?: number;
16
+ /** Size above which transfers are split (default 2 × partSize). */
17
+ multipartThreshold?: number;
18
+ /** Parts or ranges in flight at once (default 8). */
19
+ concurrency?: number;
20
+ /** Retries of requests that are safe to repeat (default 4). */
21
+ maxRetries?: number;
22
+ /** Appended to the SDK's User-Agent. */
23
+ userAgent?: string;
24
+ /** A fetch implementation (default: the global fetch). */
25
+ fetch?: typeof fetch;
26
+ }
27
+ export interface BucketInfo {
28
+ name: string;
29
+ region: string;
30
+ createdAt?: Date;
31
+ /** What anyone may read; getBucket fills it, listBuckets does not. */
32
+ public?: PublicRule[];
33
+ }
34
+ /** Makes the objects under `prefix` ("" is the whole bucket) readable by anyone without a key; `list` also lets anyone list those keys. */
35
+ export interface PublicRule {
36
+ prefix: string;
37
+ list?: boolean;
38
+ }
39
+ export interface ObjectInfo {
40
+ key: string;
41
+ size: number;
42
+ etag: string;
43
+ lastModified?: Date;
44
+ contentType?: string;
45
+ metadata: Record<string, string>;
46
+ /** The object's Repr-Digest (`sha-256=:<base64>:`), from get and head, when it was uploaded in
47
+ * one request; full downloads are checked against it. Multipart uploads have none. */
48
+ digest?: string;
49
+ }
50
+ /** Conditions checked by the service atomically with a write or delete; a false one throws
51
+ * PreconditionFailedError. An ETag (quotes optional), a comma-separated list of them, or `*`. */
52
+ export interface WriteConditions {
53
+ /** Only if the object's ETag is one of these (`*`: only if it exists). */
54
+ ifMatch?: string;
55
+ /** Only if the object's ETag is none of these (`*`: only if the key is free). */
56
+ ifNoneMatch?: string;
57
+ }
58
+ export interface PutOptions extends WriteConditions {
59
+ contentType?: string;
60
+ cacheControl?: string;
61
+ contentDisposition?: string;
62
+ contentEncoding?: string;
63
+ /** Stored with the object, returned as X-Meta-<name> (2 KB in all). */
64
+ metadata?: Record<string, string>;
65
+ /** Fail with code `precondition_failed` if the key already holds an object (`ifNoneMatch: '*'`). */
66
+ ifNotExists?: boolean;
67
+ signal?: AbortSignal;
68
+ }
69
+ export interface HeadOptions {
70
+ /** Throw NotModifiedError when the object's ETag is one of these. */
71
+ ifNoneMatch?: string;
72
+ /** Throw PreconditionFailedError unless the object's ETag is one of these. */
73
+ ifMatch?: string;
74
+ /** Throw NotModifiedError unless the object changed after this. */
75
+ ifModifiedSince?: Date;
76
+ /** Throw PreconditionFailedError if the object changed after this. */
77
+ ifUnmodifiedSince?: Date;
78
+ signal?: AbortSignal;
79
+ }
80
+ export interface GetOptions extends HeadOptions {
81
+ /** Download `length` bytes from `offset` (length omitted: to the end). */
82
+ offset?: number;
83
+ length?: number;
84
+ }
85
+ export interface DeleteOptions extends WriteConditions {
86
+ signal?: AbortSignal;
87
+ }
88
+ export interface CompleteOptions extends WriteConditions {
89
+ signal?: AbortSignal;
90
+ }
91
+ export interface ListOptions {
92
+ prefix?: string;
93
+ /** Roll keys up into folders ("/" lists one level). */
94
+ delimiter?: string;
95
+ cursor?: string;
96
+ /** Page size, 1-1000 (default 1000). */
97
+ limit?: number;
98
+ signal?: AbortSignal;
99
+ }
100
+ export interface ListPage {
101
+ objects: ObjectInfo[];
102
+ folders: string[];
103
+ /** Continues the listing; undefined on the last page. */
104
+ nextCursor?: string;
105
+ }
106
+ export interface Part {
107
+ number: number;
108
+ etag: string;
109
+ size: number;
110
+ }
111
+ export interface Link {
112
+ url: string;
113
+ method: 'GET' | 'PUT';
114
+ expiresAt?: Date;
115
+ }
116
+ /** A download in progress. Read `body` (a web stream) or `toNodeStream()`. */
117
+ export interface ObjectDownload {
118
+ info: ObjectInfo;
119
+ contentRange?: string;
120
+ body: ReadableStream<Uint8Array>;
121
+ toNodeStream(): Readable;
122
+ }
123
+ type Body = string | Uint8Array;
124
+ interface RequestInit2 {
125
+ method: string;
126
+ path: string;
127
+ query?: Record<string, string | number | undefined>;
128
+ headers?: Record<string, string>;
129
+ body?: Uint8Array;
130
+ /** Retry a POST on answers that mean nothing was done (429, 503). */
131
+ retryPost?: boolean;
132
+ /** Statuses that are not errors (default: any 2xx). */
133
+ expect?: number[];
134
+ signal?: AbortSignal;
135
+ }
136
+ /** A client of one region's Object Storage endpoint. Reuse it: it keeps connections open. */
137
+ export declare class Client {
138
+ readonly partSize: number;
139
+ readonly multipartThreshold: number;
140
+ readonly concurrency: number;
141
+ readonly maxRetries: number;
142
+ private readonly base;
143
+ /** @internal */ readonly origin: string;
144
+ private readonly keyId;
145
+ private readonly secret;
146
+ /** @internal */ readonly tokenProvider?: () => Promise<string> | string;
147
+ private readonly ua;
148
+ private readonly fetch;
149
+ constructor(opts: ClientOptions);
150
+ /** @internal Sends a request with retries; the caller reads or cancels the body. */
151
+ request(r: RequestInit2): Promise<Response>;
152
+ /** @internal */
153
+ json<T = any>(method: string, path: string, payload?: unknown, opts?: {
154
+ query?: RequestInit2['query'];
155
+ retryPost?: boolean;
156
+ signal?: AbortSignal;
157
+ headers?: Record<string, string>;
158
+ }): Promise<T>;
159
+ /** The buckets this access key can use. */
160
+ listBuckets(signal?: AbortSignal): Promise<BucketInfo[]>;
161
+ /** Create a bucket (3-63 characters of a-z, 0-9, dots and hyphens). */
162
+ createBucket(name: string, signal?: AbortSignal): Promise<BucketInfo>;
163
+ getBucket(name: string, signal?: AbortSignal): Promise<BucketInfo>;
164
+ /** Delete an empty bucket. */
165
+ deleteBucket(name: string, signal?: AbortSignal): Promise<void>;
166
+ /** A handle for one bucket's objects (no request is made). */
167
+ bucket(name: string): Bucket;
168
+ /** @internal */
169
+ partSizeFor(size: number): number;
170
+ }
171
+ /** The objects of one bucket. */
172
+ export declare class Bucket {
173
+ readonly name: string;
174
+ /** @internal */ readonly c: Client;
175
+ /** @internal */ readonly path: string;
176
+ constructor(client: Client, name: string);
177
+ private obj;
178
+ /** Upload a string or bytes in one request. */
179
+ put(key: string, data: Body, opts?: PutOptions): Promise<ObjectInfo>;
180
+ /** Stream an object (or a range of it). `info.size` is the whole object's size. A whole-object
181
+ * download with a Repr-Digest is checked as it is read: the body fails with IntegrityError at
182
+ * its end if the bytes do not match. */
183
+ get(key: string, opts?: GetOptions): Promise<ObjectDownload>;
184
+ /** Download a whole object into memory, checked against its Repr-Digest when it has one. */
185
+ getBytes(key: string, signal?: AbortSignal): Promise<Uint8Array>;
186
+ /** Download a whole object as UTF-8 text. */
187
+ getText(key: string, signal?: AbortSignal): Promise<string>;
188
+ /** An object's info without its data. With `ifNoneMatch` / `ifModifiedSince` it throws
189
+ * NotModifiedError when the object is unchanged. */
190
+ head(key: string, opts?: AbortSignal | HeadOptions): Promise<ObjectInfo>;
191
+ exists(key: string, signal?: AbortSignal): Promise<boolean>;
192
+ /** Delete an object; deleting a missing one succeeds (unless `ifMatch` wanted one). */
193
+ delete(key: string, opts?: AbortSignal | DeleteOptions): Promise<void>;
194
+ /** Delete keys in parallel. */
195
+ deleteMany(keys: string[], signal?: AbortSignal): Promise<void>;
196
+ /** Copy inside the service, without downloading. */
197
+ copy(srcKey: string, dstKey: string, opts?: {
198
+ sourceBucket?: string;
199
+ metadata?: Record<string, string>;
200
+ signal?: AbortSignal;
201
+ }): Promise<ObjectInfo>;
202
+ /** One page of objects (and folders, with a delimiter). */
203
+ list(opts?: ListOptions): Promise<ListPage>;
204
+ /** Every object under prefix: `for await (const obj of bucket.objects('photos/'))`. */
205
+ objects(prefix?: string, opts?: {
206
+ pageSize?: number;
207
+ signal?: AbortSignal;
208
+ }): AsyncGenerator<ObjectInfo>;
209
+ /** A URL that downloads (or, with `upload: true`, uploads) one object without a key. Needs a client with an access key. */
210
+ createLink(key: string, opts?: {
211
+ expiresIn?: number;
212
+ upload?: boolean;
213
+ signal?: AbortSignal;
214
+ }): Promise<Link>;
215
+ /** What anyone may read in the bucket. */
216
+ publicAccess(opts?: {
217
+ signal?: AbortSignal;
218
+ }): Promise<PublicRule[]>;
219
+ /**
220
+ * Replace what anyone may read: `[]` makes the bucket private, `[{ prefix: '' }]` opens all of it,
221
+ * `[{ prefix: 'images/' }]` one folder. Needs a key allowed to manage buckets; takes effect within
222
+ * about 15 seconds. Resolves to the rules as stored.
223
+ */
224
+ setPublicAccess(rules: PublicRule[], opts?: {
225
+ signal?: AbortSignal;
226
+ }): Promise<PublicRule[]>;
227
+ /** The address anyone can open `key` at once its folder (or the bucket) is public. No request is made. */
228
+ publicUrl(key: string): string;
229
+ /** Start a multipart upload (uploadFile and upload do this for you). */
230
+ createMultipartUpload(key: string, opts?: PutOptions): Promise<MultipartUpload>;
231
+ resumeMultipartUpload(key: string, uploadId: string): MultipartUpload;
232
+ /** Upload a local file. Large files go up as parts read from the file and
233
+ * sent in parallel; a part is re-read if it must be sent again. `ifMatch` /
234
+ * `ifNoneMatch` are checked when the parts are joined. */
235
+ uploadFile(key: string, path: string, opts?: PutOptions): Promise<ObjectInfo>;
236
+ /** Upload what a stream yields (a pipe, an HTTP request, a generated
237
+ * export) without knowing its size. Parts are sent in parallel, holding at
238
+ * most `concurrency + 1` in memory; every part (or a small stream, sent in
239
+ * one request) is buffered, so its Content-Digest is known before it is sent. */
240
+ upload(key: string, source: AsyncIterable<Uint8Array> | ReadableStream<Uint8Array>, opts?: PutOptions): Promise<ObjectInfo>;
241
+ /** Save an object to `path`. Large objects come down as parallel ranges
242
+ * pinned to the object's ETag, written straight to their place in the
243
+ * file; the data goes to `path + '.download'` and is renamed into place
244
+ * only once it matches the object's Repr-Digest (when it has one). */
245
+ downloadFile(key: string, path: string, signal?: AbortSignal): Promise<ObjectInfo>;
246
+ private downloadRange;
247
+ }
248
+ /** A large upload in progress: send parts (any order, in parallel), then complete() or abort(). */
249
+ export declare class MultipartUpload {
250
+ readonly bucket: Bucket;
251
+ readonly key: string;
252
+ readonly uploadId: string;
253
+ constructor(bucket: Bucket, key: string, uploadId: string);
254
+ private get path();
255
+ /** Send part `n` (1-10000); every part but the last must be at least 5 MiB. */
256
+ uploadPart(n: number, data: Body, signal?: AbortSignal): Promise<Part>;
257
+ parts(signal?: AbortSignal): Promise<Part[]>;
258
+ /** Join the parts into the object; without `parts`, every part received, in order.
259
+ * `ifMatch` / `ifNoneMatch` are checked against the key atomically with the join. */
260
+ complete(parts?: Part[], opts?: AbortSignal | CompleteOptions): Promise<ObjectInfo>;
261
+ abort(signal?: AbortSignal): Promise<void>;
262
+ /** @internal */
263
+ abortQuietly(): Promise<void>;
264
+ }
265
+ export {};