@clonde/sdk 0.0.0-stage → 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 +82 -2
- package/dist/index.d.ts +215 -0
- package/dist/index.js +196 -0
- package/package.json +16 -4
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @clonde/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CLONDE's app API for JavaScript and TypeScript. Replace your storage
|
|
4
|
+
provider's calls with these: CLONDE picks the bucket for each file by the
|
|
5
|
+
policies in your dashboard, can move it later, and you keep the same key.
|
|
6
|
+
|
|
7
|
+
File bytes never pass through CLONDE. Uploads go straight to the chosen
|
|
8
|
+
bucket with pre-signed URLs (in parts for large files), and file links
|
|
9
|
+
redirect to wherever the file is now.
|
|
10
|
+
|
|
11
|
+
Works with fetch: Node 18+, browsers, React Native, Deno, Bun. No dependencies.
|
|
12
|
+
|
|
13
|
+
## Set up
|
|
14
|
+
|
|
15
|
+
In CLONDE's dashboard: **Apps → New App**, choose the backends it may use,
|
|
16
|
+
then **New key**. Keep the key on your server as `CLONDE_APP_KEY`.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Clonde } from "@clonde/sdk";
|
|
20
|
+
|
|
21
|
+
const clonde = new Clonde({ apiKey: process.env.CLONDE_APP_KEY!, baseUrl: "https://api.clonde.io" });
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Upload from your server
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
const file = await clonde.upload("posts/8812/photo.jpg", bytes, { contentType: "image/jpeg" });
|
|
28
|
+
file.link // permanent link for <img src>; follows the file if it moves
|
|
29
|
+
file.placement // { backend: "R2 photos", provider: "s3", ... }
|
|
30
|
+
file.decidedBy // "Photos on R2" — the policy that chose it
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Upload from a phone or browser
|
|
34
|
+
|
|
35
|
+
Your server asks CLONDE where the file goes and hands the URL to the client,
|
|
36
|
+
which uploads straight to the bucket:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// server
|
|
40
|
+
const intent = await clonde.createUpload({ key, contentType, size });
|
|
41
|
+
// → intent.url / intent.headers (or intent.multipart for files over 100 MB)
|
|
42
|
+
|
|
43
|
+
// client
|
|
44
|
+
await fetch(intent.url, { method: "PUT", headers: intent.headers, body: file });
|
|
45
|
+
|
|
46
|
+
// server
|
|
47
|
+
const stored = await clonde.completeUpload({ uploadId: intent.uploadId });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For a multipart intent, get part URLs with `clonde.partUrls(uploadId)`, PUT
|
|
51
|
+
each slice of `partSize` bytes, then `completeUpload`. `uploadedParts()`
|
|
52
|
+
resumes an interrupted upload; `abortUpload()` discards it.
|
|
53
|
+
|
|
54
|
+
## Read, find and delete
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
await clonde.link(key) // permanent link
|
|
58
|
+
await clonde.signedUrl(key, 600) // short-lived direct URL (private files)
|
|
59
|
+
await clonde.file(key) // size, type, where it is now, its trail
|
|
60
|
+
await clonde.trail(key) // where it has been (also after deletion)
|
|
61
|
+
await clonde.list({ prefix: "posts/" }) // files and the backend each is on
|
|
62
|
+
await clonde.delete(key) // every copy, on every backend
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Where a file is, and where it has been
|
|
66
|
+
|
|
67
|
+
`file(key).location` lists the backends holding the file now (primary
|
|
68
|
+
first). `file(key).trail` lists every change, oldest first:
|
|
69
|
+
|
|
70
|
+
| event | meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `stored` | uploaded through CLONDE; `detail` names the rule that chose the backend |
|
|
73
|
+
| `adopted` | an existing file brought under CLONDE in place |
|
|
74
|
+
| `moved` | moved by a lifecycle rule or a provider move (`fromBackend` → `backend`) |
|
|
75
|
+
| `replaced` | re-uploaded onto a different backend |
|
|
76
|
+
| `old_copy_deleted` | the copy left behind by a move or re-upload was deleted |
|
|
77
|
+
| `deleted` | the file was deleted |
|
|
78
|
+
|
|
79
|
+
## Errors
|
|
80
|
+
|
|
81
|
+
Failures throw `ClondeError` with `status` and the server's message. A policy
|
|
82
|
+
that refuses a file (for example, a region rule) comes back as 422 with the
|
|
83
|
+
reasons in the message.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @clonde/sdk — CLONDE's app API from JavaScript and TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* Swap your storage provider's calls for these. CLONDE decides which bucket
|
|
5
|
+
* each file goes to (by the policies set in its dashboard) and may move it
|
|
6
|
+
* later; you keep using the same key. File bytes never pass through CLONDE:
|
|
7
|
+
* uploads go straight to the chosen bucket with pre-signed URLs, and file
|
|
8
|
+
* links redirect to wherever the file is now.
|
|
9
|
+
*
|
|
10
|
+
* Works anywhere with fetch: Node 18+, browsers, React Native, Deno, Bun.
|
|
11
|
+
* No dependencies.
|
|
12
|
+
*
|
|
13
|
+
* const clonde = new Clonde({ apiKey: process.env.CLONDE_APP_KEY!, baseUrl: "https://api.clonde.io" });
|
|
14
|
+
* const file = await clonde.upload("posts/8812/photo.jpg", bytes, { contentType: "image/jpeg" });
|
|
15
|
+
* file.link // put this in <img src>; it follows the file if CLONDE moves it
|
|
16
|
+
* file.placement // which backend it was stored on, and why (file.decidedBy)
|
|
17
|
+
*/
|
|
18
|
+
export interface ClondeOptions {
|
|
19
|
+
/** An app key from the dashboard (Apps → your app → New key), clk_… */
|
|
20
|
+
apiKey: string;
|
|
21
|
+
/** CLONDE's address, e.g. https://api.clonde.io. The API is at <baseUrl>/api/v1. */
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
/** For tests or custom runtimes. Defaults to the global fetch. */
|
|
24
|
+
fetch?: typeof fetch;
|
|
25
|
+
}
|
|
26
|
+
export interface Placement {
|
|
27
|
+
backendId: string;
|
|
28
|
+
backend: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
region: string | null;
|
|
31
|
+
}
|
|
32
|
+
export interface UploadIntent {
|
|
33
|
+
uploadId: string;
|
|
34
|
+
key: string;
|
|
35
|
+
/** Single upload: PUT the bytes to url with these headers. */
|
|
36
|
+
method?: string;
|
|
37
|
+
url?: string;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
expiresInSeconds?: number;
|
|
40
|
+
/** Multipart upload (large files): get part URLs with partUrls(). */
|
|
41
|
+
multipart: {
|
|
42
|
+
partSize: number;
|
|
43
|
+
partCount: number;
|
|
44
|
+
} | null;
|
|
45
|
+
placement: Placement;
|
|
46
|
+
decidedBy: string;
|
|
47
|
+
matchedPolicies: {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
}[];
|
|
51
|
+
link: string;
|
|
52
|
+
}
|
|
53
|
+
export interface StoredFile {
|
|
54
|
+
fileId: string;
|
|
55
|
+
key: string;
|
|
56
|
+
size: number;
|
|
57
|
+
contentType: string;
|
|
58
|
+
placement: Placement;
|
|
59
|
+
decidedBy: string | null;
|
|
60
|
+
link: string;
|
|
61
|
+
}
|
|
62
|
+
export interface TrailEntry {
|
|
63
|
+
event: 'stored' | 'adopted' | 'moved' | 'replaced' | 'deleted' | 'old_copy_deleted';
|
|
64
|
+
at: string;
|
|
65
|
+
backendId: string | null;
|
|
66
|
+
backend: string | null;
|
|
67
|
+
fromBackendId: string | null;
|
|
68
|
+
fromBackend: string | null;
|
|
69
|
+
detail: string | null;
|
|
70
|
+
}
|
|
71
|
+
export interface FileInfo {
|
|
72
|
+
fileId: string;
|
|
73
|
+
key: string;
|
|
74
|
+
size: number | null;
|
|
75
|
+
contentType: string | null;
|
|
76
|
+
/** Where the file is now: the primary copy first. */
|
|
77
|
+
location: {
|
|
78
|
+
backendId: string;
|
|
79
|
+
backend: string;
|
|
80
|
+
provider: string;
|
|
81
|
+
region: string | null;
|
|
82
|
+
bucket: string;
|
|
83
|
+
primary: boolean;
|
|
84
|
+
storageClass: string | null;
|
|
85
|
+
since: string | null;
|
|
86
|
+
}[];
|
|
87
|
+
/** Where it has been: stored, moved, replaced… oldest first. */
|
|
88
|
+
trail: TrailEntry[];
|
|
89
|
+
link: string;
|
|
90
|
+
}
|
|
91
|
+
export interface ListedFile {
|
|
92
|
+
key: string;
|
|
93
|
+
size: number | null;
|
|
94
|
+
contentType: string | null;
|
|
95
|
+
updatedAt: string | null;
|
|
96
|
+
backendId: string | null;
|
|
97
|
+
backend: string | null;
|
|
98
|
+
provider: string | null;
|
|
99
|
+
region: string | null;
|
|
100
|
+
copies: number;
|
|
101
|
+
}
|
|
102
|
+
export type Body = Blob | ArrayBuffer | ArrayBufferView | string;
|
|
103
|
+
export interface UploadOptions {
|
|
104
|
+
contentType?: string;
|
|
105
|
+
tags?: string[];
|
|
106
|
+
/** "hot" (default), "warm" or "cold": how often the file will be read. */
|
|
107
|
+
tier?: 'hot' | 'warm' | 'cold';
|
|
108
|
+
/** Called as bytes go up: (sent, total). */
|
|
109
|
+
onProgress?: (sent: number, total: number) => void;
|
|
110
|
+
/** Parts uploaded at once in a multipart upload (default 4). */
|
|
111
|
+
concurrency?: number;
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
}
|
|
114
|
+
export declare class ClondeError extends Error {
|
|
115
|
+
status: number;
|
|
116
|
+
body: any;
|
|
117
|
+
constructor(message: string, status: number, body?: any);
|
|
118
|
+
}
|
|
119
|
+
export declare class Clonde {
|
|
120
|
+
private readonly api;
|
|
121
|
+
private readonly base;
|
|
122
|
+
private readonly key;
|
|
123
|
+
private readonly http;
|
|
124
|
+
private linkBase;
|
|
125
|
+
constructor(options: ClondeOptions);
|
|
126
|
+
private call;
|
|
127
|
+
private q;
|
|
128
|
+
/** The app, its backends and settings. */
|
|
129
|
+
app(): Promise<{
|
|
130
|
+
app: {
|
|
131
|
+
id: string;
|
|
132
|
+
name: string;
|
|
133
|
+
slug: string;
|
|
134
|
+
};
|
|
135
|
+
linkBase: string;
|
|
136
|
+
backends: any[];
|
|
137
|
+
defaultBackendId: string | null;
|
|
138
|
+
activePolicies: number;
|
|
139
|
+
multipartThresholdBytes: number;
|
|
140
|
+
}>;
|
|
141
|
+
/**
|
|
142
|
+
* Step 1 of an upload you run yourself (e.g. hand the URL to a phone or a
|
|
143
|
+
* browser so the bytes go straight from it to the bucket).
|
|
144
|
+
*/
|
|
145
|
+
createUpload(input: {
|
|
146
|
+
key: string;
|
|
147
|
+
contentType?: string;
|
|
148
|
+
size?: number;
|
|
149
|
+
tags?: string[];
|
|
150
|
+
tier?: 'hot' | 'warm' | 'cold';
|
|
151
|
+
multipart?: boolean;
|
|
152
|
+
}, signal?: AbortSignal): Promise<UploadIntent>;
|
|
153
|
+
/** Signed URLs for parts of a multipart upload (up to 1000 at a time; all when partNumbers is omitted). */
|
|
154
|
+
partUrls(uploadId: string, partNumbers?: number[], signal?: AbortSignal): Promise<{
|
|
155
|
+
parts: {
|
|
156
|
+
partNumber: number;
|
|
157
|
+
url: string;
|
|
158
|
+
}[];
|
|
159
|
+
partSize: number;
|
|
160
|
+
partCount: number;
|
|
161
|
+
expiresInSeconds: number;
|
|
162
|
+
}>;
|
|
163
|
+
/** Parts already in the bucket, to resume an interrupted multipart upload. */
|
|
164
|
+
uploadedParts(uploadId: string): Promise<{
|
|
165
|
+
uploaded: {
|
|
166
|
+
partNumber: number;
|
|
167
|
+
etag: string;
|
|
168
|
+
size: number;
|
|
169
|
+
}[];
|
|
170
|
+
partSize: number;
|
|
171
|
+
partCount: number;
|
|
172
|
+
}>;
|
|
173
|
+
/** Step 3: the bytes are there. CLONDE checks and records the file. */
|
|
174
|
+
completeUpload(input: {
|
|
175
|
+
key?: string;
|
|
176
|
+
uploadId?: string;
|
|
177
|
+
parts?: {
|
|
178
|
+
partNumber: number;
|
|
179
|
+
etag: string;
|
|
180
|
+
}[];
|
|
181
|
+
}, signal?: AbortSignal): Promise<StoredFile>;
|
|
182
|
+
/** Give up on an upload (and discard any parts already sent). */
|
|
183
|
+
abortUpload(uploadId: string): Promise<{
|
|
184
|
+
aborted: boolean;
|
|
185
|
+
}>;
|
|
186
|
+
/**
|
|
187
|
+
* Upload a file in one call: CLONDE places it, the bytes go straight to
|
|
188
|
+
* that bucket (in parts for large files), and the stored file comes back
|
|
189
|
+
* with its link and where it was stored.
|
|
190
|
+
*/
|
|
191
|
+
upload(key: string, body: Body, options?: UploadOptions): Promise<StoredFile>;
|
|
192
|
+
/**
|
|
193
|
+
* The permanent link for a file: redirects to wherever the file is now.
|
|
194
|
+
* Fetches the app's link base once, then works offline.
|
|
195
|
+
*/
|
|
196
|
+
link(key: string): Promise<string>;
|
|
197
|
+
/** A short-lived direct URL to the file (for private files). */
|
|
198
|
+
signedUrl(key: string, expiresInSeconds?: number): Promise<string>;
|
|
199
|
+
/** One file: size, type, where it is now and where it has been. */
|
|
200
|
+
file(key: string): Promise<FileInfo>;
|
|
201
|
+
/** A file's trail, also after it has been deleted. */
|
|
202
|
+
trail(key: string): Promise<TrailEntry[]>;
|
|
203
|
+
/** The app's files, each with the backend it's on. Pass `next` back as `after` for the next page. */
|
|
204
|
+
list(options?: {
|
|
205
|
+
prefix?: string;
|
|
206
|
+
after?: string;
|
|
207
|
+
limit?: number;
|
|
208
|
+
}): Promise<{
|
|
209
|
+
files: ListedFile[];
|
|
210
|
+
next: string | null;
|
|
211
|
+
}>;
|
|
212
|
+
/** Delete a file from every backend that holds a copy. Deleting a missing file is not an error. */
|
|
213
|
+
delete(key: string): Promise<void>;
|
|
214
|
+
}
|
|
215
|
+
export default Clonde;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @clonde/sdk — CLONDE's app API from JavaScript and TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* Swap your storage provider's calls for these. CLONDE decides which bucket
|
|
5
|
+
* each file goes to (by the policies set in its dashboard) and may move it
|
|
6
|
+
* later; you keep using the same key. File bytes never pass through CLONDE:
|
|
7
|
+
* uploads go straight to the chosen bucket with pre-signed URLs, and file
|
|
8
|
+
* links redirect to wherever the file is now.
|
|
9
|
+
*
|
|
10
|
+
* Works anywhere with fetch: Node 18+, browsers, React Native, Deno, Bun.
|
|
11
|
+
* No dependencies.
|
|
12
|
+
*
|
|
13
|
+
* const clonde = new Clonde({ apiKey: process.env.CLONDE_APP_KEY!, baseUrl: "https://api.clonde.io" });
|
|
14
|
+
* const file = await clonde.upload("posts/8812/photo.jpg", bytes, { contentType: "image/jpeg" });
|
|
15
|
+
* file.link // put this in <img src>; it follows the file if CLONDE moves it
|
|
16
|
+
* file.placement // which backend it was stored on, and why (file.decidedBy)
|
|
17
|
+
*/
|
|
18
|
+
export class ClondeError extends Error {
|
|
19
|
+
constructor(message, status, body = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.body = body;
|
|
23
|
+
this.name = 'ClondeError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const sizeOf = (b) => {
|
|
27
|
+
if (typeof b === 'string')
|
|
28
|
+
return new TextEncoder().encode(b).byteLength;
|
|
29
|
+
if (typeof Blob !== 'undefined' && b instanceof Blob)
|
|
30
|
+
return b.size;
|
|
31
|
+
if (b instanceof ArrayBuffer)
|
|
32
|
+
return b.byteLength;
|
|
33
|
+
return b.byteLength;
|
|
34
|
+
};
|
|
35
|
+
const sliceOf = (b, start, end) => {
|
|
36
|
+
if (typeof b === 'string')
|
|
37
|
+
return new TextEncoder().encode(b).subarray(start, end);
|
|
38
|
+
if (typeof Blob !== 'undefined' && b instanceof Blob)
|
|
39
|
+
return b.slice(start, end);
|
|
40
|
+
if (b instanceof ArrayBuffer)
|
|
41
|
+
return b.slice(start, end);
|
|
42
|
+
const v = b;
|
|
43
|
+
return new Uint8Array(v.buffer, v.byteOffset + start, Math.min(end, v.byteLength) - start);
|
|
44
|
+
};
|
|
45
|
+
export class Clonde {
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.linkBase = null;
|
|
48
|
+
if (!options?.apiKey)
|
|
49
|
+
throw new Error('Clonde: apiKey is required');
|
|
50
|
+
if (!options?.baseUrl)
|
|
51
|
+
throw new Error('Clonde: baseUrl is required');
|
|
52
|
+
this.key = options.apiKey;
|
|
53
|
+
this.base = options.baseUrl.replace(/\/+$/, '');
|
|
54
|
+
this.api = `${this.base}/api/v1`;
|
|
55
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
56
|
+
if (!f)
|
|
57
|
+
throw new Error('Clonde: no fetch available; pass options.fetch');
|
|
58
|
+
this.http = f.bind(globalThis);
|
|
59
|
+
}
|
|
60
|
+
async call(method, path, body, signal) {
|
|
61
|
+
const res = await this.http(`${this.api}${path}`, {
|
|
62
|
+
method,
|
|
63
|
+
headers: { Authorization: `Bearer ${this.key}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
64
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
65
|
+
signal,
|
|
66
|
+
});
|
|
67
|
+
const data = await res.json().catch(() => ({}));
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const why = [data.error, ...(data.reasons ?? [])].filter(Boolean).join('; ');
|
|
70
|
+
throw new ClondeError(why || `${method} ${path} failed (${res.status})`, res.status, data);
|
|
71
|
+
}
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
q(key) {
|
|
75
|
+
return `?key=${encodeURIComponent(key)}`;
|
|
76
|
+
}
|
|
77
|
+
/** The app, its backends and settings. */
|
|
78
|
+
app() {
|
|
79
|
+
return this.call('GET', '/app');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Step 1 of an upload you run yourself (e.g. hand the URL to a phone or a
|
|
83
|
+
* browser so the bytes go straight from it to the bucket).
|
|
84
|
+
*/
|
|
85
|
+
createUpload(input, signal) {
|
|
86
|
+
return this.call('POST', '/uploads', input, signal);
|
|
87
|
+
}
|
|
88
|
+
/** Signed URLs for parts of a multipart upload (up to 1000 at a time; all when partNumbers is omitted). */
|
|
89
|
+
partUrls(uploadId, partNumbers, signal) {
|
|
90
|
+
return this.call('POST', '/uploads/parts', { uploadId, ...(partNumbers ? { partNumbers } : {}) }, signal);
|
|
91
|
+
}
|
|
92
|
+
/** Parts already in the bucket, to resume an interrupted multipart upload. */
|
|
93
|
+
uploadedParts(uploadId) {
|
|
94
|
+
return this.call('GET', `/uploads/parts?uploadId=${encodeURIComponent(uploadId)}`);
|
|
95
|
+
}
|
|
96
|
+
/** Step 3: the bytes are there. CLONDE checks and records the file. */
|
|
97
|
+
completeUpload(input, signal) {
|
|
98
|
+
return this.call('POST', '/uploads/complete', input, signal);
|
|
99
|
+
}
|
|
100
|
+
/** Give up on an upload (and discard any parts already sent). */
|
|
101
|
+
abortUpload(uploadId) {
|
|
102
|
+
return this.call('DELETE', `/uploads?uploadId=${encodeURIComponent(uploadId)}`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Upload a file in one call: CLONDE places it, the bytes go straight to
|
|
106
|
+
* that bucket (in parts for large files), and the stored file comes back
|
|
107
|
+
* with its link and where it was stored.
|
|
108
|
+
*/
|
|
109
|
+
async upload(key, body, options = {}) {
|
|
110
|
+
const size = sizeOf(body);
|
|
111
|
+
const contentType = options.contentType
|
|
112
|
+
?? (typeof Blob !== 'undefined' && body instanceof Blob && body.type ? body.type : 'application/octet-stream');
|
|
113
|
+
const intent = await this.createUpload({ key, contentType, size, tags: options.tags, tier: options.tier }, options.signal);
|
|
114
|
+
try {
|
|
115
|
+
if (!intent.multipart) {
|
|
116
|
+
const res = await this.http(intent.url, { method: intent.method || 'PUT', headers: intent.headers, body: body, signal: options.signal });
|
|
117
|
+
if (!res.ok)
|
|
118
|
+
throw new ClondeError(`Upload to ${intent.placement.backend} failed (${res.status})`, res.status);
|
|
119
|
+
options.onProgress?.(size, size);
|
|
120
|
+
return await this.completeUpload({ uploadId: intent.uploadId }, options.signal);
|
|
121
|
+
}
|
|
122
|
+
const { partSize, partCount } = intent.multipart;
|
|
123
|
+
let sent = 0;
|
|
124
|
+
for (let first = 1; first <= partCount; first += 1000) {
|
|
125
|
+
const numbers = Array.from({ length: Math.min(1000, partCount - first + 1) }, (_, i) => first + i);
|
|
126
|
+
const { parts } = await this.partUrls(intent.uploadId, numbers, options.signal);
|
|
127
|
+
const queue = [...parts];
|
|
128
|
+
const workers = Array.from({ length: Math.max(1, options.concurrency ?? 4) }, async () => {
|
|
129
|
+
for (let p = queue.shift(); p; p = queue.shift()) {
|
|
130
|
+
const start = (p.partNumber - 1) * partSize;
|
|
131
|
+
const chunk = sliceOf(body, start, Math.min(start + partSize, size));
|
|
132
|
+
const res = await this.http(p.url, { method: 'PUT', body: chunk, signal: options.signal });
|
|
133
|
+
if (!res.ok)
|
|
134
|
+
throw new ClondeError(`Part ${p.partNumber} to ${intent.placement.backend} failed (${res.status})`, res.status);
|
|
135
|
+
sent += sizeOf(chunk);
|
|
136
|
+
options.onProgress?.(sent, size);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
await Promise.all(workers);
|
|
140
|
+
}
|
|
141
|
+
// CLONDE reads the part list from the bucket, so browsers that can't see
|
|
142
|
+
// ETags work too.
|
|
143
|
+
return await this.completeUpload({ uploadId: intent.uploadId }, options.signal);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (intent.multipart)
|
|
147
|
+
await this.abortUpload(intent.uploadId).catch(() => undefined);
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The permanent link for a file: redirects to wherever the file is now.
|
|
153
|
+
* Fetches the app's link base once, then works offline.
|
|
154
|
+
*/
|
|
155
|
+
async link(key) {
|
|
156
|
+
if (!this.linkBase)
|
|
157
|
+
this.linkBase = (await this.app()).linkBase;
|
|
158
|
+
return `${this.linkBase}/${key.split('/').map(encodeURIComponent).join('/')}`;
|
|
159
|
+
}
|
|
160
|
+
/** A short-lived direct URL to the file (for private files). */
|
|
161
|
+
async signedUrl(key, expiresInSeconds = 3600) {
|
|
162
|
+
return (await this.call('GET', `/files/url${this.q(key)}&expires=${expiresInSeconds}`)).url;
|
|
163
|
+
}
|
|
164
|
+
/** One file: size, type, where it is now and where it has been. */
|
|
165
|
+
file(key) {
|
|
166
|
+
return this.call('GET', `/files${this.q(key)}`);
|
|
167
|
+
}
|
|
168
|
+
/** A file's trail, also after it has been deleted. */
|
|
169
|
+
async trail(key) {
|
|
170
|
+
return (await this.call('GET', `/files/trail${this.q(key)}`)).trail;
|
|
171
|
+
}
|
|
172
|
+
/** The app's files, each with the backend it's on. Pass `next` back as `after` for the next page. */
|
|
173
|
+
list(options = {}) {
|
|
174
|
+
const params = new URLSearchParams();
|
|
175
|
+
if (options.prefix)
|
|
176
|
+
params.set('prefix', options.prefix);
|
|
177
|
+
if (options.after)
|
|
178
|
+
params.set('after', options.after);
|
|
179
|
+
if (options.limit)
|
|
180
|
+
params.set('limit', String(options.limit));
|
|
181
|
+
const qs = params.toString();
|
|
182
|
+
return this.call('GET', `/files/list${qs ? `?${qs}` : ''}`);
|
|
183
|
+
}
|
|
184
|
+
/** Delete a file from every backend that holds a copy. Deleting a missing file is not an error. */
|
|
185
|
+
async delete(key) {
|
|
186
|
+
try {
|
|
187
|
+
await this.call('DELETE', `/files${this.q(key)}`);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
if (err instanceof ClondeError && err.status === 404)
|
|
191
|
+
return;
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export default Clonde;
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clonde/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLONDE's app API: uploads straight to the bucket CLONDE picks, permanent file links, and where each file is stored.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
+
"files": ["dist", "README.md"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json",
|
|
12
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
13
|
+
"prepare": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"engines": { "node": ">=18" },
|
|
16
|
+
"devDependencies": { "typescript": "^5.6.0" },
|
|
17
|
+
"license": "MIT"
|
|
18
|
+
}
|