yungle-client 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/dist/index.d.ts +129 -14
- package/dist/index.mjs +319 -9
- package/dist/types.d.ts +152 -9
- package/dist/upload-token.d.ts +55 -0
- package/dist/upload.d.ts +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,11 +18,21 @@ const { transfers } = await yungle.listTransfers();
|
|
|
18
18
|
const { downloads } = await yungle.transferDownloads(transfers[0].id);
|
|
19
19
|
|
|
20
20
|
// Sending works on the free plan too; collections need a paid one.
|
|
21
|
+
import { openAsBlob } from 'node:fs';
|
|
22
|
+
|
|
21
23
|
const draft = await yungle.createTransfer({
|
|
22
24
|
files: [{ name: 'final-cut.mov', size: 8_123_456_789 }],
|
|
25
|
+
// …or let Yungle fetch a file that is already online, any size:
|
|
26
|
+
imports: [{ url: 'https://my-bucket.s3.eu-central-1.amazonaws.com/render.mov?X-Amz-Signature=…' }],
|
|
23
27
|
});
|
|
24
|
-
//
|
|
28
|
+
// Resumable, retried, and its two-hour token renewed for as long as it runs.
|
|
29
|
+
await yungle.uploadFile(draft.tusEndpoint, draft.files[0], await openAsBlob('final-cut.mov'));
|
|
30
|
+
await yungle.waitForImports(draft.imports!.map((i) => i.fileId));
|
|
25
31
|
await yungle.finalizeTransfer(draft.transfer.id, { recipients: ['client@example.com'] });
|
|
32
|
+
|
|
33
|
+
// Getting files out: your own, or a link someone sent you.
|
|
34
|
+
const links = await yungle.resolveLink('https://yungle.co/t/k3v9…');
|
|
35
|
+
for (const f of links.files) console.log(f.path, f.name, f.downloadUrl, f.crc32);
|
|
26
36
|
```
|
|
27
37
|
|
|
28
38
|
Get a key from Settings → API keys. Every account can use the API: transfers and contacts on the free plan,
|
|
@@ -37,11 +47,13 @@ collections on a paid one. The first 10 GB of API uploads each month are free.
|
|
|
37
47
|
lost response to `createTransfer` gets the first draft back instead of making a second.
|
|
38
48
|
- **Pages are walked for you.** Lists take `{ limit, cursor }` and return `nextCursor`;
|
|
39
49
|
`allTransfers()` and `allCollectionFiles(id)` are async iterators over everything.
|
|
40
|
-
- **
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
- **Uploads any `Blob`, resumably.** `uploadFile` speaks tus with nothing but
|
|
51
|
+
`fetch`: it resumes from the server's last committed part after a drop, never
|
|
52
|
+
re-creates an upload in progress, and renews the two-hour upload token as it
|
|
53
|
+
goes. In Node, `fs.openAsBlob(path)` streams from disk.
|
|
54
|
+
- **Imports and downloads.** `imports` on the create calls has Yungle fetch a file
|
|
55
|
+
from a URL; `transferDownloadLinks`, `collectionDownloadLinks` and `resolveLink`
|
|
56
|
+
return signed, `Range`-resumable URLs with a CRC-32 per file.
|
|
45
57
|
- **Errors carry a stable `code`.** Branch on `err.code`, never on the message.
|
|
46
58
|
|
|
47
59
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import type { Collection, CollectionFile, CollectionSummary, Contact, ContactInput, DownloadEvent, FileInput, Folder, Guest, Me, RecipientStatus, Transfer, TransferFile, TransferSummary, UploadTargets, WebhookDelivery, WebhookEndpoint,
|
|
1
|
+
import type { Collection, CollectionFile, CollectionSummary, Contact, ContactInput, DownloadEvent, DownloadLinks, ImportInput, ImportStatus, Submission, UploadRequest, UploadRequestInput, FileInput, Folder, Guest, Me, Referral, PulledWebhookEvent, RecipientStatus, Transfer, TransferFile, TransferSummary, UploadTargets, WebhookDelivery, WebhookEndpoint, WebhookEventType } from './types.js';
|
|
2
|
+
import { type RenewOutcome as Renewal } from './upload-token.js';
|
|
3
|
+
import { type UploadFileOptions } from './upload.js';
|
|
4
|
+
export { chunkSize, uploadFile, UploadError, type UploadFileOptions } from './upload.js';
|
|
2
5
|
export * from './types.js';
|
|
6
|
+
export { createTokenKeeper, renewDelayMs, renewUploadToken, tokenExpiry, type RenewOutcome, type TokenKeeper, type TokenKeeperDeps, } from './upload-token.js';
|
|
3
7
|
/**
|
|
4
8
|
* A typed client for the Yungle API.
|
|
5
9
|
*
|
|
@@ -8,16 +12,22 @@ export * from './types.js';
|
|
|
8
12
|
* are both thin layers over this — which is the point: one place understands
|
|
9
13
|
* the wire format, so neither can drift from the other.
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* pipeline out of here is what lets this stay a few hundred lines with no
|
|
15
|
-
* dependencies.
|
|
15
|
+
* `createTransfer` and `addCollectionFiles` return a tus endpoint and
|
|
16
|
+
* per-file tokens; `client.uploadFile` streams a `Blob` to one, resumably,
|
|
17
|
+
* renewing its two-hour token as it goes.
|
|
16
18
|
*/
|
|
17
19
|
export interface YungleErrorBody {
|
|
18
20
|
code: string;
|
|
19
21
|
message: string;
|
|
20
22
|
details?: Record<string, unknown>;
|
|
23
|
+
/** A link to the explanation of `code` and what to do about it. */
|
|
24
|
+
docs?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface YungleApiErrorExtras {
|
|
27
|
+
/** `error.docs` from the body: where `code` is explained. */
|
|
28
|
+
docs?: string;
|
|
29
|
+
/** The `Retry-After` header, in seconds, when the server sent one. */
|
|
30
|
+
retryAfterHeader?: number;
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
33
|
* A failed request. Carries the machine-readable `code` — branch on that, never
|
|
@@ -27,10 +37,20 @@ export declare class YungleApiError extends Error {
|
|
|
27
37
|
readonly status: number;
|
|
28
38
|
readonly code: string;
|
|
29
39
|
readonly details?: Record<string, unknown> | undefined;
|
|
30
|
-
|
|
40
|
+
/** Where `code` is explained, with what to do about it. Absent on older servers. */
|
|
41
|
+
readonly docs?: string;
|
|
42
|
+
private readonly retryAfterHeader?;
|
|
43
|
+
constructor(status: number, code: string, message: string, details?: Record<string, unknown> | undefined, extras?: YungleApiErrorExtras);
|
|
31
44
|
/** Worth trying again: throttled, or our fault. */
|
|
32
45
|
get retryable(): boolean;
|
|
33
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Seconds to wait, when the server said — in the body's
|
|
48
|
+
* `details.retryAfterSeconds` or, failing that, the `Retry-After` header.
|
|
49
|
+
*
|
|
50
|
+
* The header matters on its own: the monthly-allowance 429 states its wait
|
|
51
|
+
* (an hour) only there, and reading the body alone sent clients back in half
|
|
52
|
+
* a second to be refused again.
|
|
53
|
+
*/
|
|
34
54
|
get retryAfterSeconds(): number | null;
|
|
35
55
|
}
|
|
36
56
|
export interface ClientOptions {
|
|
@@ -64,7 +84,30 @@ export declare class YungleClient {
|
|
|
64
84
|
private readonly maxRetries;
|
|
65
85
|
private readonly userAgent;
|
|
66
86
|
constructor(options: ClientOptions);
|
|
87
|
+
/** The site root this client talks to (`https://yungle.co`), for the few endpoints outside `/api/v1`. */
|
|
88
|
+
get origin(): string;
|
|
89
|
+
/**
|
|
90
|
+
* Trade a still-valid upload token for a fresh one (two more hours). Returns
|
|
91
|
+
* `'final'` when the token is dead or the upload finished, `'retry'` on a
|
|
92
|
+
* transient failure. `createTokenKeeper(token, { renew: (t) => client.renewUploadToken(t) })`
|
|
93
|
+
* schedules this for you.
|
|
94
|
+
*/
|
|
95
|
+
renewUploadToken(token: string): Promise<Renewal>;
|
|
96
|
+
/**
|
|
97
|
+
* Upload one file to a target from `createTransfer` / `addTransferFiles` /
|
|
98
|
+
* `addCollectionFiles`: resumable, retried, and with its token renewed for
|
|
99
|
+
* as long as it runs. In Node, pass `await fs.openAsBlob(path)` as `source`.
|
|
100
|
+
*/
|
|
101
|
+
uploadFile(tusEndpoint: string, target: {
|
|
102
|
+
id: string;
|
|
103
|
+
name: string;
|
|
104
|
+
uploadToken: string;
|
|
105
|
+
}, source: Blob, opts?: Omit<UploadFileOptions, 'endpoint' | 'target' | 'source' | 'renew' | 'fetch'>): Promise<{
|
|
106
|
+
uploadUrl: string;
|
|
107
|
+
}>;
|
|
67
108
|
me(): Promise<Me>;
|
|
109
|
+
/** Your invite code and link, and the free months it has earned. */
|
|
110
|
+
getReferral(): Promise<Referral>;
|
|
68
111
|
/**
|
|
69
112
|
* One page of transfers, newest first — 100 by default, up to 500. Pass the
|
|
70
113
|
* returned `nextCursor` back as `cursor` for the next page; it is null on the
|
|
@@ -79,10 +122,21 @@ export declare class YungleClient {
|
|
|
79
122
|
/**
|
|
80
123
|
* Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
|
|
81
124
|
*/
|
|
125
|
+
/**
|
|
126
|
+
* A draft transfer. `files` are uploaded by you (tus, to the returned
|
|
127
|
+
* targets); `imports` are fetched by Yungle from URLs. Send either or both.
|
|
128
|
+
*/
|
|
82
129
|
createTransfer(input: {
|
|
83
|
-
files
|
|
130
|
+
files?: FileInput[];
|
|
131
|
+
imports?: ImportInput[];
|
|
84
132
|
title?: string;
|
|
85
133
|
expiresInDays?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Encrypted by the client (`yungle-e2e`); the key travels only in the link's
|
|
136
|
+
* fragment. Such a transfer takes no title and cannot be emailed. File
|
|
137
|
+
* `size`s are then ciphertext lengths, and names go in `sealTransferFile`.
|
|
138
|
+
*/
|
|
139
|
+
e2ee?: boolean;
|
|
86
140
|
}): Promise<{
|
|
87
141
|
transfer: {
|
|
88
142
|
id: string;
|
|
@@ -107,11 +161,32 @@ export declare class YungleClient {
|
|
|
107
161
|
message?: string;
|
|
108
162
|
password?: string;
|
|
109
163
|
title?: string;
|
|
164
|
+
/** End-to-end encrypted transfers only: the sealed `{ message }`. */
|
|
165
|
+
e2eeMeta?: string;
|
|
110
166
|
}): Promise<{
|
|
111
167
|
transfer: Transfer;
|
|
112
168
|
notified: string[];
|
|
113
169
|
}>;
|
|
114
|
-
|
|
170
|
+
/**
|
|
171
|
+
* End-to-end encrypted transfers only: a file's sealed `{ name, path, size,
|
|
172
|
+
* type }` and/or thumbnail, from `yungle-e2e`. Called after the file exists,
|
|
173
|
+
* because each blob binds the file's id.
|
|
174
|
+
*/
|
|
175
|
+
sealTransferFile(id: string, fileId: string, sealed: {
|
|
176
|
+
meta?: string;
|
|
177
|
+
thumb?: string;
|
|
178
|
+
}): Promise<{
|
|
179
|
+
stored: true;
|
|
180
|
+
}>;
|
|
181
|
+
addTransferFiles(id: string, files: FileInput[], imports?: ImportInput[]): Promise<UploadTargets>;
|
|
182
|
+
/** Where a URL import stands: `importing` (with `receivedBytes`), `ready` or `failed`. */
|
|
183
|
+
getImport(fileId: string): Promise<ImportStatus>;
|
|
184
|
+
/**
|
|
185
|
+
* Poll `getImport` until every import is `ready` or `failed`, every
|
|
186
|
+
* `intervalMs` (default 5 s). Resolves with the final statuses; never throws
|
|
187
|
+
* for a failed import — check `status`.
|
|
188
|
+
*/
|
|
189
|
+
waitForImports(fileIds: string[], intervalMs?: number): Promise<ImportStatus[]>;
|
|
115
190
|
removeTransferFile(id: string, fileId: string): Promise<{
|
|
116
191
|
deleted: boolean;
|
|
117
192
|
}>;
|
|
@@ -125,6 +200,18 @@ export declare class YungleClient {
|
|
|
125
200
|
revokeTransfer(id: string): Promise<{
|
|
126
201
|
transfer: Transfer;
|
|
127
202
|
}>;
|
|
203
|
+
/**
|
|
204
|
+
* Signed download URLs for your own transfer. Fetching them is not a
|
|
205
|
+
* recipient download — it never appears in receipts. Throws
|
|
206
|
+
* `e2ee_unsupported` for an end-to-end encrypted transfer.
|
|
207
|
+
*/
|
|
208
|
+
transferDownloadLinks(id: string): Promise<DownloadLinks>;
|
|
209
|
+
/**
|
|
210
|
+
* A link someone shared with you (`…/t/…` or `…/c/…`), as signed download
|
|
211
|
+
* URLs — what a person opening it in a browser could download, under the same
|
|
212
|
+
* rules. Pass `password` for a protected link. Any key works, free included.
|
|
213
|
+
*/
|
|
214
|
+
resolveLink(url: string, password?: string): Promise<DownloadLinks>;
|
|
128
215
|
transferDownloads(id: string): Promise<{
|
|
129
216
|
downloads: DownloadEvent[];
|
|
130
217
|
recipients: RecipientStatus[];
|
|
@@ -139,6 +226,11 @@ export declare class YungleClient {
|
|
|
139
226
|
}): Promise<{
|
|
140
227
|
collection: Collection;
|
|
141
228
|
}>;
|
|
229
|
+
/**
|
|
230
|
+
* Signed download URLs for your own collection: every file with its folder
|
|
231
|
+
* `path`, and a whole-collection ZIP. The URLs need no key and support `Range`.
|
|
232
|
+
*/
|
|
233
|
+
collectionDownloadLinks(id: string): Promise<DownloadLinks>;
|
|
142
234
|
getCollection(id: string): Promise<{
|
|
143
235
|
collection: Collection;
|
|
144
236
|
}>;
|
|
@@ -162,7 +254,7 @@ export declare class YungleClient {
|
|
|
162
254
|
}>;
|
|
163
255
|
/** Every file in a collection, oldest first, a page at a time. */
|
|
164
256
|
allCollectionFiles(id: string, folderId?: string, pageSize?: number): AsyncGenerator<CollectionFile>;
|
|
165
|
-
addCollectionFiles(id: string, files: FileInput[], folderId?: string | null): Promise<UploadTargets>;
|
|
257
|
+
addCollectionFiles(id: string, files: FileInput[], folderId?: string | null, imports?: ImportInput[]): Promise<UploadTargets>;
|
|
166
258
|
deleteCollectionFiles(id: string, fileIds: string[]): Promise<{
|
|
167
259
|
deleted: number;
|
|
168
260
|
}>;
|
|
@@ -227,12 +319,30 @@ export declare class YungleClient {
|
|
|
227
319
|
* even when nothing is new, so keep the latest and poll with it.
|
|
228
320
|
*/
|
|
229
321
|
listWebhookEvents(id: string, page?: PageOptions): Promise<{
|
|
230
|
-
events:
|
|
231
|
-
deliveryId: string;
|
|
232
|
-
})[];
|
|
322
|
+
events: PulledWebhookEvent[];
|
|
233
323
|
nextCursor: string | null;
|
|
234
324
|
hasMore: boolean;
|
|
235
325
|
}>;
|
|
326
|
+
/** Your upload links, newest first (up to 100). */
|
|
327
|
+
listRequests(): Promise<{
|
|
328
|
+
requests: UploadRequest[];
|
|
329
|
+
}>;
|
|
330
|
+
/**
|
|
331
|
+
* A public page where anyone with the link can upload into one of your
|
|
332
|
+
* collections. Subscribe to the `request.submitted` webhook to hear about
|
|
333
|
+
* each finished submission.
|
|
334
|
+
*/
|
|
335
|
+
createRequest(input: UploadRequestInput): Promise<{
|
|
336
|
+
request: UploadRequest;
|
|
337
|
+
}>;
|
|
338
|
+
getRequest(id: string): Promise<{
|
|
339
|
+
request: UploadRequest;
|
|
340
|
+
submissions: Submission[];
|
|
341
|
+
}>;
|
|
342
|
+
/** `paused` stops uploads until set back to `active`; `closed` stops them for good. */
|
|
343
|
+
setRequestStatus(id: string, status: UploadRequest['status']): Promise<{
|
|
344
|
+
request: UploadRequest;
|
|
345
|
+
}>;
|
|
236
346
|
listContacts(): Promise<{
|
|
237
347
|
contacts: Contact[];
|
|
238
348
|
}>;
|
|
@@ -258,6 +368,11 @@ export declare class YungleClient {
|
|
|
258
368
|
*/
|
|
259
369
|
private request;
|
|
260
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* `Retry-After` is delay-seconds or an HTTP-date (RFC 9110 §10.2.3). Anything
|
|
373
|
+
* else — including a negative number or a date already past — is no statement.
|
|
374
|
+
*/
|
|
375
|
+
export declare function parseRetryAfter(value: string | null, now?: number): number | null;
|
|
261
376
|
/**
|
|
262
377
|
* Check a webhook's `Yungle-Signature` header against the RAW request body.
|
|
263
378
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -1,23 +1,215 @@
|
|
|
1
|
+
// src/upload-token.ts
|
|
2
|
+
var MIN_RENEW_DELAY_MS = 6e4;
|
|
3
|
+
var RETRY_DELAY_MS = 6e4;
|
|
4
|
+
function tokenExpiry(token) {
|
|
5
|
+
const dot = token.indexOf(".");
|
|
6
|
+
if (dot <= 0) return null;
|
|
7
|
+
try {
|
|
8
|
+
const b642 = token.slice(0, dot).replace(/-/g, "+").replace(/_/g, "/");
|
|
9
|
+
const json = atob(b642);
|
|
10
|
+
const exp = JSON.parse(json).exp;
|
|
11
|
+
return typeof exp === "number" ? exp : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function renewDelayMs(expiresAt, now) {
|
|
17
|
+
return Math.max(MIN_RENEW_DELAY_MS, Math.floor((expiresAt - now) / 2));
|
|
18
|
+
}
|
|
19
|
+
function createTokenKeeper(initial, deps) {
|
|
20
|
+
const now = deps.now ?? (() => Date.now());
|
|
21
|
+
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
22
|
+
const clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
|
|
23
|
+
let token = initial;
|
|
24
|
+
let expiresAt = tokenExpiry(initial);
|
|
25
|
+
let stopped = false;
|
|
26
|
+
let handle = null;
|
|
27
|
+
const schedule = (ms) => {
|
|
28
|
+
if (stopped) return;
|
|
29
|
+
handle = setTimer(() => void attempt(), ms);
|
|
30
|
+
};
|
|
31
|
+
const attempt = async () => {
|
|
32
|
+
if (stopped) return;
|
|
33
|
+
let outcome;
|
|
34
|
+
try {
|
|
35
|
+
outcome = await deps.renew(token);
|
|
36
|
+
} catch {
|
|
37
|
+
outcome = "retry";
|
|
38
|
+
}
|
|
39
|
+
if (stopped) return;
|
|
40
|
+
if (outcome === "final") {
|
|
41
|
+
stopped = true;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (outcome === "retry") {
|
|
45
|
+
if (expiresAt !== null && now() >= expiresAt) {
|
|
46
|
+
stopped = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
schedule(RETRY_DELAY_MS);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
token = outcome.token;
|
|
53
|
+
expiresAt = outcome.expiresAt;
|
|
54
|
+
schedule(renewDelayMs(expiresAt, now()));
|
|
55
|
+
};
|
|
56
|
+
schedule(expiresAt === null ? 60 * 6e4 : renewDelayMs(expiresAt, now()));
|
|
57
|
+
return {
|
|
58
|
+
current: () => token,
|
|
59
|
+
stop: () => {
|
|
60
|
+
stopped = true;
|
|
61
|
+
if (handle !== null) clearTimer(handle);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function renewUploadToken(origin, current, doFetch = globalThis.fetch.bind(globalThis)) {
|
|
66
|
+
const res = await doFetch(`${origin.replace(/\/+$/, "")}/api/uploads/token`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "x-yungle-upload-token": current }
|
|
69
|
+
});
|
|
70
|
+
if (res.status === 401 || res.status === 404) return "final";
|
|
71
|
+
if (!res.ok) return "retry";
|
|
72
|
+
const body = await res.json();
|
|
73
|
+
return { token: body.uploadToken, expiresAt: Date.parse(body.expiresAt) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/upload.ts
|
|
77
|
+
var MiB = 1024 * 1024;
|
|
78
|
+
function chunkSize(size) {
|
|
79
|
+
return Math.max(64 * MiB, (Math.floor(size / (9e3 * MiB)) + 2) * MiB);
|
|
80
|
+
}
|
|
81
|
+
var UploadError = class extends Error {
|
|
82
|
+
constructor(message, status) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.status = status;
|
|
85
|
+
this.name = "UploadError";
|
|
86
|
+
}
|
|
87
|
+
status;
|
|
88
|
+
};
|
|
89
|
+
function transient(status) {
|
|
90
|
+
return status === 0 || status === 408 || status === 409 || status === 423 || status === 429 || status >= 500;
|
|
91
|
+
}
|
|
92
|
+
var b64 = (s) => {
|
|
93
|
+
const bytes = new TextEncoder().encode(s);
|
|
94
|
+
let bin = "";
|
|
95
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
96
|
+
return btoa(bin);
|
|
97
|
+
};
|
|
98
|
+
async function uploadFile(o) {
|
|
99
|
+
const doFetch = o.fetch ?? globalThis.fetch.bind(globalThis);
|
|
100
|
+
const size = o.source.size;
|
|
101
|
+
const delays = o.retryDelaysMs ?? [0, 1e3, 3e3, 5e3, 1e4, 3e4];
|
|
102
|
+
const keeper = o.renew ? createTokenKeeper(o.target.uploadToken, {
|
|
103
|
+
renew: async (t) => {
|
|
104
|
+
const outcome = await o.renew(t);
|
|
105
|
+
if (typeof outcome === "object") o.onToken?.(outcome.token);
|
|
106
|
+
return outcome;
|
|
107
|
+
}
|
|
108
|
+
}) : null;
|
|
109
|
+
const auth = () => ({ "Tus-Resumable": "1.0.0", "x-yungle-upload-token": keeper?.current() ?? o.target.uploadToken });
|
|
110
|
+
const request = async (url, init) => {
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await doFetch(url, { ...init, signal: o.signal });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (o.signal?.aborted) throw err;
|
|
116
|
+
throw new UploadError(`Connection failed: ${err.message}`, 0);
|
|
117
|
+
}
|
|
118
|
+
if (!res.ok) throw new UploadError(`Upload of ${o.target.name} got HTTP ${res.status}.`, res.status);
|
|
119
|
+
return res;
|
|
120
|
+
};
|
|
121
|
+
const head = async (url) => Number((await request(url, { method: "HEAD", headers: auth() })).headers.get("upload-offset") ?? 0);
|
|
122
|
+
try {
|
|
123
|
+
let url = o.uploadUrl;
|
|
124
|
+
let offset = 0;
|
|
125
|
+
if (url) {
|
|
126
|
+
try {
|
|
127
|
+
offset = await head(url);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (!(err instanceof UploadError && err.status === 404)) throw err;
|
|
130
|
+
url = void 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!url) {
|
|
134
|
+
const created = await request(o.endpoint, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
...auth(),
|
|
138
|
+
"Upload-Length": String(size),
|
|
139
|
+
"Upload-Metadata": `fileId ${b64(o.target.id)},token ${b64(o.target.uploadToken)},filename ${b64(o.target.name)}`
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
url = new URL(created.headers.get("location") ?? "", o.endpoint).toString();
|
|
143
|
+
o.onUploadUrl?.(url);
|
|
144
|
+
}
|
|
145
|
+
o.onProgress?.(offset, size);
|
|
146
|
+
const step = chunkSize(size);
|
|
147
|
+
let failures = 0;
|
|
148
|
+
let stalls = 0;
|
|
149
|
+
while (offset < size) {
|
|
150
|
+
try {
|
|
151
|
+
const res = await request(url, {
|
|
152
|
+
method: "PATCH",
|
|
153
|
+
headers: { ...auth(), "Upload-Offset": String(offset), "Content-Type": "application/offset+octet-stream" },
|
|
154
|
+
body: o.source.slice(offset, Math.min(size, offset + step))
|
|
155
|
+
});
|
|
156
|
+
const next = Number(res.headers.get("upload-offset"));
|
|
157
|
+
if (!(next > offset)) {
|
|
158
|
+
if (++stalls > 3) throw new UploadError(`Upload of ${o.target.name} made no progress.`, res.status);
|
|
159
|
+
} else stalls = 0;
|
|
160
|
+
offset = next;
|
|
161
|
+
failures = 0;
|
|
162
|
+
o.onProgress?.(offset, size);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (o.signal?.aborted) throw err;
|
|
165
|
+
if (!(err instanceof UploadError) || !transient(err.status) || failures >= delays.length) throw err;
|
|
166
|
+
await new Promise((r) => setTimeout(r, delays[failures++]));
|
|
167
|
+
try {
|
|
168
|
+
offset = await head(url);
|
|
169
|
+
} catch (headErr) {
|
|
170
|
+
if (!(headErr instanceof UploadError) || !transient(headErr.status)) throw headErr;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { uploadUrl: url };
|
|
175
|
+
} finally {
|
|
176
|
+
keeper?.stop();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
1
180
|
// src/index.ts
|
|
2
181
|
var YungleApiError = class extends Error {
|
|
3
|
-
constructor(status, code, message, details) {
|
|
182
|
+
constructor(status, code, message, details, extras = {}) {
|
|
4
183
|
super(message);
|
|
5
184
|
this.status = status;
|
|
6
185
|
this.code = code;
|
|
7
186
|
this.details = details;
|
|
8
187
|
this.name = "YungleApiError";
|
|
188
|
+
if (extras.docs !== void 0) this.docs = extras.docs;
|
|
189
|
+
if (extras.retryAfterHeader !== void 0) this.retryAfterHeader = extras.retryAfterHeader;
|
|
9
190
|
}
|
|
10
191
|
status;
|
|
11
192
|
code;
|
|
12
193
|
details;
|
|
194
|
+
/** Where `code` is explained, with what to do about it. Absent on older servers. */
|
|
195
|
+
docs;
|
|
196
|
+
retryAfterHeader;
|
|
13
197
|
/** Worth trying again: throttled, or our fault. */
|
|
14
198
|
get retryable() {
|
|
15
199
|
return this.status === 429 || this.status >= 500;
|
|
16
200
|
}
|
|
17
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Seconds to wait, when the server said — in the body's
|
|
203
|
+
* `details.retryAfterSeconds` or, failing that, the `Retry-After` header.
|
|
204
|
+
*
|
|
205
|
+
* The header matters on its own: the monthly-allowance 429 states its wait
|
|
206
|
+
* (an hour) only there, and reading the body alone sent clients back in half
|
|
207
|
+
* a second to be refused again.
|
|
208
|
+
*/
|
|
18
209
|
get retryAfterSeconds() {
|
|
19
210
|
const value = this.details?.retryAfterSeconds;
|
|
20
|
-
|
|
211
|
+
if (typeof value === "number") return value;
|
|
212
|
+
return this.retryAfterHeader ?? null;
|
|
21
213
|
}
|
|
22
214
|
};
|
|
23
215
|
var DEFAULT_BASE = "https://yungle.co/api/v1";
|
|
@@ -28,7 +220,7 @@ function pageQuery(page, extra = {}) {
|
|
|
28
220
|
const q = params.toString();
|
|
29
221
|
return q ? `?${q}` : "";
|
|
30
222
|
}
|
|
31
|
-
var CLIENT_VERSION = true ? "0.
|
|
223
|
+
var CLIENT_VERSION = true ? "0.3.1" : "dev";
|
|
32
224
|
var YungleClient = class {
|
|
33
225
|
baseUrl;
|
|
34
226
|
apiKey;
|
|
@@ -43,10 +235,35 @@ var YungleClient = class {
|
|
|
43
235
|
this.maxRetries = options.maxRetries ?? 3;
|
|
44
236
|
this.userAgent = options.userAgent ?? `yungle-client/${CLIENT_VERSION}`;
|
|
45
237
|
}
|
|
238
|
+
/** The site root this client talks to (`https://yungle.co`), for the few endpoints outside `/api/v1`. */
|
|
239
|
+
get origin() {
|
|
240
|
+
return this.baseUrl.replace(/\/api\/v1$/, "");
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Trade a still-valid upload token for a fresh one (two more hours). Returns
|
|
244
|
+
* `'final'` when the token is dead or the upload finished, `'retry'` on a
|
|
245
|
+
* transient failure. `createTokenKeeper(token, { renew: (t) => client.renewUploadToken(t) })`
|
|
246
|
+
* schedules this for you.
|
|
247
|
+
*/
|
|
248
|
+
renewUploadToken(token) {
|
|
249
|
+
return renewUploadToken(this.origin, token, this.doFetch);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Upload one file to a target from `createTransfer` / `addTransferFiles` /
|
|
253
|
+
* `addCollectionFiles`: resumable, retried, and with its token renewed for
|
|
254
|
+
* as long as it runs. In Node, pass `await fs.openAsBlob(path)` as `source`.
|
|
255
|
+
*/
|
|
256
|
+
uploadFile(tusEndpoint, target, source, opts = {}) {
|
|
257
|
+
return uploadFile({ ...opts, endpoint: tusEndpoint, target, source, renew: (t) => this.renewUploadToken(t), fetch: this.doFetch });
|
|
258
|
+
}
|
|
46
259
|
// ── Account ───────────────────────────────────────────────────────────────
|
|
47
260
|
me() {
|
|
48
261
|
return this.request("GET", "/me");
|
|
49
262
|
}
|
|
263
|
+
/** Your invite code and link, and the free months it has earned. */
|
|
264
|
+
getReferral() {
|
|
265
|
+
return this.request("GET", "/me/referral");
|
|
266
|
+
}
|
|
50
267
|
// ── Transfers ─────────────────────────────────────────────────────────────
|
|
51
268
|
/**
|
|
52
269
|
* One page of transfers, newest first — 100 by default, up to 500. Pass the
|
|
@@ -68,6 +285,10 @@ var YungleClient = class {
|
|
|
68
285
|
/**
|
|
69
286
|
* Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
|
|
70
287
|
*/
|
|
288
|
+
/**
|
|
289
|
+
* A draft transfer. `files` are uploaded by you (tus, to the returned
|
|
290
|
+
* targets); `imports` are fetched by Yungle from URLs. Send either or both.
|
|
291
|
+
*/
|
|
71
292
|
createTransfer(input) {
|
|
72
293
|
return this.request("POST", "/transfers", input);
|
|
73
294
|
}
|
|
@@ -83,8 +304,32 @@ var YungleClient = class {
|
|
|
83
304
|
finalizeTransfer(id, input = {}) {
|
|
84
305
|
return this.request("POST", `/transfers/${enc(id)}/finalize`, input, { idempotent: true });
|
|
85
306
|
}
|
|
86
|
-
|
|
87
|
-
|
|
307
|
+
/**
|
|
308
|
+
* End-to-end encrypted transfers only: a file's sealed `{ name, path, size,
|
|
309
|
+
* type }` and/or thumbnail, from `yungle-e2e`. Called after the file exists,
|
|
310
|
+
* because each blob binds the file's id.
|
|
311
|
+
*/
|
|
312
|
+
sealTransferFile(id, fileId, sealed) {
|
|
313
|
+
return this.request("POST", `/transfers/${enc(id)}/files/${enc(fileId)}/sealed`, sealed, { idempotent: true });
|
|
314
|
+
}
|
|
315
|
+
addTransferFiles(id, files, imports) {
|
|
316
|
+
return this.request("POST", `/transfers/${enc(id)}/files`, { files, ...imports?.length ? { imports } : {} });
|
|
317
|
+
}
|
|
318
|
+
/** Where a URL import stands: `importing` (with `receivedBytes`), `ready` or `failed`. */
|
|
319
|
+
getImport(fileId) {
|
|
320
|
+
return this.request("GET", `/imports/${enc(fileId)}`);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Poll `getImport` until every import is `ready` or `failed`, every
|
|
324
|
+
* `intervalMs` (default 5 s). Resolves with the final statuses; never throws
|
|
325
|
+
* for a failed import — check `status`.
|
|
326
|
+
*/
|
|
327
|
+
async waitForImports(fileIds, intervalMs = 5e3) {
|
|
328
|
+
for (; ; ) {
|
|
329
|
+
const all = await Promise.all(fileIds.map((id) => this.getImport(id)));
|
|
330
|
+
if (all.every((s) => s.status !== "importing")) return all;
|
|
331
|
+
await sleep(intervalMs);
|
|
332
|
+
}
|
|
88
333
|
}
|
|
89
334
|
removeTransferFile(id, fileId) {
|
|
90
335
|
return this.request("DELETE", `/transfers/${enc(id)}/files/${enc(fileId)}`);
|
|
@@ -96,6 +341,22 @@ var YungleClient = class {
|
|
|
96
341
|
revokeTransfer(id) {
|
|
97
342
|
return this.request("DELETE", `/transfers/${enc(id)}`);
|
|
98
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Signed download URLs for your own transfer. Fetching them is not a
|
|
346
|
+
* recipient download — it never appears in receipts. Throws
|
|
347
|
+
* `e2ee_unsupported` for an end-to-end encrypted transfer.
|
|
348
|
+
*/
|
|
349
|
+
transferDownloadLinks(id) {
|
|
350
|
+
return this.request("GET", `/transfers/${enc(id)}/download-links`);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A link someone shared with you (`…/t/…` or `…/c/…`), as signed download
|
|
354
|
+
* URLs — what a person opening it in a browser could download, under the same
|
|
355
|
+
* rules. Pass `password` for a protected link. Any key works, free included.
|
|
356
|
+
*/
|
|
357
|
+
resolveLink(url, password) {
|
|
358
|
+
return this.request("POST", "/links/resolve", { url, ...password ? { password } : {} });
|
|
359
|
+
}
|
|
99
360
|
transferDownloads(id) {
|
|
100
361
|
return this.request("GET", `/transfers/${enc(id)}/downloads`);
|
|
101
362
|
}
|
|
@@ -106,6 +367,13 @@ var YungleClient = class {
|
|
|
106
367
|
createCollection(input) {
|
|
107
368
|
return this.request("POST", "/collections", input);
|
|
108
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Signed download URLs for your own collection: every file with its folder
|
|
372
|
+
* `path`, and a whole-collection ZIP. The URLs need no key and support `Range`.
|
|
373
|
+
*/
|
|
374
|
+
collectionDownloadLinks(id) {
|
|
375
|
+
return this.request("GET", `/collections/${enc(id)}/download-links`);
|
|
376
|
+
}
|
|
109
377
|
getCollection(id) {
|
|
110
378
|
return this.request("GET", `/collections/${enc(id)}`);
|
|
111
379
|
}
|
|
@@ -133,8 +401,8 @@ var YungleClient = class {
|
|
|
133
401
|
cursor = page.nextCursor ?? void 0;
|
|
134
402
|
} while (cursor);
|
|
135
403
|
}
|
|
136
|
-
addCollectionFiles(id, files, folderId) {
|
|
137
|
-
return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId });
|
|
404
|
+
addCollectionFiles(id, files, folderId, imports) {
|
|
405
|
+
return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId, ...imports?.length ? { imports } : {} });
|
|
138
406
|
}
|
|
139
407
|
deleteCollectionFiles(id, fileIds) {
|
|
140
408
|
return this.request("DELETE", `/collections/${enc(id)}/files`, { fileIds });
|
|
@@ -190,6 +458,26 @@ var YungleClient = class {
|
|
|
190
458
|
listWebhookEvents(id, page = {}) {
|
|
191
459
|
return this.request("GET", `/webhooks/${enc(id)}/events${pageQuery(page)}`);
|
|
192
460
|
}
|
|
461
|
+
// ── Upload requests ───────────────────────────────────────────────────────
|
|
462
|
+
/** Your upload links, newest first (up to 100). */
|
|
463
|
+
listRequests() {
|
|
464
|
+
return this.request("GET", "/requests");
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* A public page where anyone with the link can upload into one of your
|
|
468
|
+
* collections. Subscribe to the `request.submitted` webhook to hear about
|
|
469
|
+
* each finished submission.
|
|
470
|
+
*/
|
|
471
|
+
createRequest(input) {
|
|
472
|
+
return this.request("POST", "/requests", input);
|
|
473
|
+
}
|
|
474
|
+
getRequest(id) {
|
|
475
|
+
return this.request("GET", `/requests/${enc(id)}`);
|
|
476
|
+
}
|
|
477
|
+
/** `paused` stops uploads until set back to `active`; `closed` stops them for good. */
|
|
478
|
+
setRequestStatus(id, status) {
|
|
479
|
+
return this.request("PATCH", `/requests/${enc(id)}`, { status });
|
|
480
|
+
}
|
|
193
481
|
// ── Contacts ──────────────────────────────────────────────────────────────
|
|
194
482
|
listContacts() {
|
|
195
483
|
return this.request("GET", "/contacts");
|
|
@@ -250,13 +538,27 @@ async function toError(res) {
|
|
|
250
538
|
} catch {
|
|
251
539
|
}
|
|
252
540
|
const err = body?.error;
|
|
541
|
+
const retryAfterHeader = parseRetryAfter(res.headers.get("retry-after"));
|
|
253
542
|
return new YungleApiError(
|
|
254
543
|
res.status,
|
|
255
544
|
err?.code ?? "http_error",
|
|
256
545
|
err?.message ?? `Request failed with status ${res.status}.`,
|
|
257
|
-
err?.details
|
|
546
|
+
err?.details,
|
|
547
|
+
{
|
|
548
|
+
...typeof err?.docs === "string" ? { docs: err.docs } : {},
|
|
549
|
+
...retryAfterHeader === null ? {} : { retryAfterHeader }
|
|
550
|
+
}
|
|
258
551
|
);
|
|
259
552
|
}
|
|
553
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
554
|
+
if (value === null) return null;
|
|
555
|
+
const trimmed = value.trim();
|
|
556
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
557
|
+
const at = Date.parse(trimmed);
|
|
558
|
+
if (Number.isNaN(at)) return null;
|
|
559
|
+
const seconds = Math.ceil((at - now) / 1e3);
|
|
560
|
+
return seconds >= 0 ? seconds : null;
|
|
561
|
+
}
|
|
260
562
|
function backoffMs(attempt, error) {
|
|
261
563
|
const stated = error.retryAfterSeconds;
|
|
262
564
|
if (stated !== null) return stated * 1e3;
|
|
@@ -285,7 +587,15 @@ async function verifyWebhook(rawBody, signatureHeader, secret, { toleranceSecond
|
|
|
285
587
|
}
|
|
286
588
|
export {
|
|
287
589
|
CLIENT_VERSION,
|
|
590
|
+
UploadError,
|
|
288
591
|
YungleApiError,
|
|
289
592
|
YungleClient,
|
|
593
|
+
chunkSize,
|
|
594
|
+
createTokenKeeper,
|
|
595
|
+
parseRetryAfter,
|
|
596
|
+
renewDelayMs,
|
|
597
|
+
renewUploadToken,
|
|
598
|
+
tokenExpiry,
|
|
599
|
+
uploadFile,
|
|
290
600
|
verifyWebhook
|
|
291
601
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -40,6 +40,23 @@ export interface KeyInfo {
|
|
|
40
40
|
/** Whether this credential may make Yungle email anyone. Absent on older servers. */
|
|
41
41
|
canEmail?: boolean;
|
|
42
42
|
}
|
|
43
|
+
/** Your invite code and what it has earned (`GET /me/referral`). */
|
|
44
|
+
export interface Referral {
|
|
45
|
+
/** Your invite code. A friend can type it in Settings › Plan. */
|
|
46
|
+
code: string;
|
|
47
|
+
/** Your invite link: the same code, as a page that signs them up. */
|
|
48
|
+
url: string;
|
|
49
|
+
/** Free months earned so far. */
|
|
50
|
+
earnedMonths: number;
|
|
51
|
+
/** Free months still available to earn. */
|
|
52
|
+
remainingMonths: number;
|
|
53
|
+
/** The most free months one person can earn, ever. */
|
|
54
|
+
cap: number;
|
|
55
|
+
/** True once `earnedMonths` reached `cap`. The link still gives friends their month. */
|
|
56
|
+
capped: boolean;
|
|
57
|
+
/** Friends in their free month, or inside the 14 days after their first payment. */
|
|
58
|
+
pending: number;
|
|
59
|
+
}
|
|
43
60
|
export interface Me {
|
|
44
61
|
workspace: Workspace;
|
|
45
62
|
plan: PlanInfo;
|
|
@@ -59,10 +76,50 @@ export interface UploadTarget {
|
|
|
59
76
|
name: string;
|
|
60
77
|
size: number;
|
|
61
78
|
uploadToken: string;
|
|
79
|
+
/**
|
|
80
|
+
* When `uploadToken` stops working (two hours after issue). Renew before then
|
|
81
|
+
* with `renewUploadToken` — or let `createTokenKeeper` do it — for any upload
|
|
82
|
+
* that may run longer. Absent from servers older than 2026-09-29.
|
|
83
|
+
*/
|
|
84
|
+
uploadTokenExpiresAt?: string;
|
|
62
85
|
}
|
|
63
86
|
export interface UploadTargets {
|
|
64
87
|
tusEndpoint: string;
|
|
88
|
+
/** One per `files` entry, in order. */
|
|
65
89
|
files: UploadTarget[];
|
|
90
|
+
/** One per `imports` entry, in order. Absent from servers before 2026-09-29. */
|
|
91
|
+
imports?: ImportStarted[];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A file for Yungle to fetch from a URL itself, so the bytes never pass
|
|
95
|
+
* through you — a presigned S3 link, a CDN, a release asset. The source must
|
|
96
|
+
* state the size (Content-Length or a Range answer).
|
|
97
|
+
*/
|
|
98
|
+
export interface ImportInput {
|
|
99
|
+
url: string;
|
|
100
|
+
/** Defaults to the name the source gives. */
|
|
101
|
+
name?: string;
|
|
102
|
+
/** Folder to place it in. */
|
|
103
|
+
path?: string;
|
|
104
|
+
}
|
|
105
|
+
export interface ImportStarted {
|
|
106
|
+
fileId: string;
|
|
107
|
+
name: string;
|
|
108
|
+
size: number;
|
|
109
|
+
/** The source's host; never the full URL. */
|
|
110
|
+
source: string;
|
|
111
|
+
status: 'queued';
|
|
112
|
+
}
|
|
113
|
+
/** Where one import stands (`getImport`). */
|
|
114
|
+
export interface ImportStatus {
|
|
115
|
+
fileId: string;
|
|
116
|
+
name: string;
|
|
117
|
+
size: number;
|
|
118
|
+
/** Committed so far; moves in steps of one storage part. */
|
|
119
|
+
receivedBytes: number;
|
|
120
|
+
source: string | null;
|
|
121
|
+
status: 'importing' | 'ready' | 'failed';
|
|
122
|
+
error: string | null;
|
|
66
123
|
}
|
|
67
124
|
export interface Transfer {
|
|
68
125
|
id: string;
|
|
@@ -74,12 +131,18 @@ export interface Transfer {
|
|
|
74
131
|
fileCount?: number;
|
|
75
132
|
recipients: string[];
|
|
76
133
|
hasPassword: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* End-to-end encrypted: the working link carries a key Yungle never holds,
|
|
136
|
+
* so `url` alone does not open it. Absent on servers before 2026-09-29.
|
|
137
|
+
*/
|
|
138
|
+
e2ee?: boolean;
|
|
77
139
|
downloadCount: number;
|
|
78
140
|
maxDownloads: number | null;
|
|
79
141
|
/** Null while it is still a draft. A draft is not shareable. */
|
|
80
142
|
finalizedAt: Iso8601 | null;
|
|
81
|
-
|
|
82
|
-
|
|
143
|
+
/** Every transfer expires; a paid plan chooses when. */
|
|
144
|
+
expiresAt: Iso8601;
|
|
145
|
+
createdAt: Iso8601;
|
|
83
146
|
}
|
|
84
147
|
export interface TransferSummary extends Omit<Transfer, 'hasPassword' | 'fileCount'> {
|
|
85
148
|
fileCount: number;
|
|
@@ -93,6 +156,8 @@ export interface TransferFile {
|
|
|
93
156
|
path: string | null;
|
|
94
157
|
/** Null until the malware scan finishes, then `clean` or `infected`. */
|
|
95
158
|
scanResult: string | null;
|
|
159
|
+
/** CRC-32 of the bytes (IEEE, as `zlib.crc32`), 8 hex digits; null while unknown. */
|
|
160
|
+
crc32: string | null;
|
|
96
161
|
createdAt: Iso8601;
|
|
97
162
|
}
|
|
98
163
|
export interface RecipientStatus {
|
|
@@ -136,8 +201,8 @@ export interface Collection {
|
|
|
136
201
|
sizeBytes: number;
|
|
137
202
|
coverFileId: string | null;
|
|
138
203
|
expiresAt: Iso8601 | null;
|
|
139
|
-
createdAt: Iso8601
|
|
140
|
-
updatedAt: Iso8601
|
|
204
|
+
createdAt: Iso8601;
|
|
205
|
+
updatedAt: Iso8601;
|
|
141
206
|
fileCount?: number;
|
|
142
207
|
}
|
|
143
208
|
export interface CollectionSummary {
|
|
@@ -148,7 +213,7 @@ export interface CollectionSummary {
|
|
|
148
213
|
fileCount: number;
|
|
149
214
|
sizeBytes: number;
|
|
150
215
|
coverFileId: string | null;
|
|
151
|
-
updatedAt: Iso8601
|
|
216
|
+
updatedAt: Iso8601;
|
|
152
217
|
}
|
|
153
218
|
export interface CollectionFile {
|
|
154
219
|
id: string;
|
|
@@ -157,7 +222,9 @@ export interface CollectionFile {
|
|
|
157
222
|
mimeType: string;
|
|
158
223
|
folderId: string | null;
|
|
159
224
|
hasThumbnail: boolean;
|
|
160
|
-
|
|
225
|
+
/** CRC-32 of the bytes (IEEE, as `zlib.crc32`), 8 hex digits; null while unknown. */
|
|
226
|
+
crc32: string | null;
|
|
227
|
+
createdAt: Iso8601;
|
|
161
228
|
}
|
|
162
229
|
export interface Folder {
|
|
163
230
|
id: string;
|
|
@@ -180,8 +247,8 @@ export interface Contact {
|
|
|
180
247
|
email: string;
|
|
181
248
|
phone: string | null;
|
|
182
249
|
type: string;
|
|
183
|
-
createdAt: Iso8601
|
|
184
|
-
updatedAt: Iso8601
|
|
250
|
+
createdAt: Iso8601;
|
|
251
|
+
updatedAt: Iso8601;
|
|
185
252
|
}
|
|
186
253
|
export interface ContactInput {
|
|
187
254
|
firstName?: string | null;
|
|
@@ -191,7 +258,7 @@ export interface ContactInput {
|
|
|
191
258
|
phone?: string | null;
|
|
192
259
|
type?: string | null;
|
|
193
260
|
}
|
|
194
|
-
export type WebhookEventType = 'transfer.ready' | 'transfer.downloaded' | 'transfer.expiring' | 'transfer.expired' | 'collection.file_uploaded';
|
|
261
|
+
export type WebhookEventType = 'transfer.ready' | 'transfer.downloaded' | 'transfer.expiring' | 'transfer.expired' | 'collection.file_uploaded' | 'request.submitted';
|
|
195
262
|
export interface WebhookEndpoint {
|
|
196
263
|
id: string;
|
|
197
264
|
/** Null for a pull endpoint. */
|
|
@@ -224,3 +291,79 @@ export interface WebhookEvent<T = Record<string, unknown>> {
|
|
|
224
291
|
createdAt: Iso8601;
|
|
225
292
|
data: T;
|
|
226
293
|
}
|
|
294
|
+
/** An event read from `/webhooks/{id}/events`: the push payload plus the cursor to resume from. */
|
|
295
|
+
export type PulledWebhookEvent<T = Record<string, unknown>> = WebhookEvent<T> & {
|
|
296
|
+
/** Pass the last one back as `cursor`. */
|
|
297
|
+
deliveryId: string;
|
|
298
|
+
};
|
|
299
|
+
/** One file in a set of download links. */
|
|
300
|
+
export interface DownloadLink {
|
|
301
|
+
id: string;
|
|
302
|
+
/** For an end-to-end encrypted transfer, a placeholder; the real name is sealed in `e2eeMeta`. */
|
|
303
|
+
name: string;
|
|
304
|
+
size: number;
|
|
305
|
+
mimeType: string;
|
|
306
|
+
/** Folder path, `""` at the root. Join it onto your output directory. */
|
|
307
|
+
path: string;
|
|
308
|
+
/** A signed GET that needs no key. Supports `Range`. Valid until `urlsExpireAt`. */
|
|
309
|
+
downloadUrl: string;
|
|
310
|
+
e2eeMeta?: string | null;
|
|
311
|
+
/** CRC-32 of the bytes (IEEE, as `zlib.crc32`), 8 hex digits. Null while unknown. */
|
|
312
|
+
crc32?: string | null;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Signed download URLs for a transfer or collection — your own
|
|
316
|
+
* (`transferDownloadLinks`, `collectionDownloadLinks`) or a link shared with
|
|
317
|
+
* you (`resolveLink`).
|
|
318
|
+
*/
|
|
319
|
+
export interface DownloadLinks {
|
|
320
|
+
kind: 'transfer' | 'collection';
|
|
321
|
+
/** A collection's title. Always null for a transfer, whose title recipients never see. */
|
|
322
|
+
title: string | null;
|
|
323
|
+
message: string | null;
|
|
324
|
+
expiresAt: Iso8601 | null;
|
|
325
|
+
/** End-to-end encrypted: the bytes are ciphertext and the key is in the link's `#` part. */
|
|
326
|
+
e2ee: boolean;
|
|
327
|
+
e2eeMeta?: string | null;
|
|
328
|
+
/** False while a transfer is still uploading: `files` is what has arrived so far. */
|
|
329
|
+
complete: boolean;
|
|
330
|
+
zipUrl: string | null;
|
|
331
|
+
/** When the URLs stop working; ask again for fresh ones. */
|
|
332
|
+
urlsExpireAt: Iso8601;
|
|
333
|
+
files: DownloadLink[];
|
|
334
|
+
}
|
|
335
|
+
/** A public upload link that feeds one of your collections ("file request" in the dashboard). */
|
|
336
|
+
export interface UploadRequest {
|
|
337
|
+
id: string;
|
|
338
|
+
/** The page to give people. Anyone with it can upload until it is paused, closed or expires. */
|
|
339
|
+
url: string;
|
|
340
|
+
title: string;
|
|
341
|
+
message: string | null;
|
|
342
|
+
collectionId: string;
|
|
343
|
+
status: 'active' | 'paused' | 'closed';
|
|
344
|
+
hasPassword: boolean;
|
|
345
|
+
requiredItems: string[];
|
|
346
|
+
expiresAt: Iso8601 | null;
|
|
347
|
+
submissionCount: number;
|
|
348
|
+
receivedBytes: number;
|
|
349
|
+
createdAt: Iso8601;
|
|
350
|
+
}
|
|
351
|
+
export interface UploadRequestInput {
|
|
352
|
+
collectionId: string;
|
|
353
|
+
title: string;
|
|
354
|
+
message?: string;
|
|
355
|
+
password?: string;
|
|
356
|
+
/** A checklist shown on the page — guidance, not enforcement. */
|
|
357
|
+
requiredItems?: string[];
|
|
358
|
+
expiresAt?: Iso8601;
|
|
359
|
+
}
|
|
360
|
+
/** One person's upload through a request. Name, email and message are as they typed them: untrusted. */
|
|
361
|
+
export interface Submission {
|
|
362
|
+
id: string;
|
|
363
|
+
uploaderName: string | null;
|
|
364
|
+
uploaderEmail: string | null;
|
|
365
|
+
message: string | null;
|
|
366
|
+
fileCount: number;
|
|
367
|
+
sizeBytes: number;
|
|
368
|
+
createdAt: Iso8601;
|
|
369
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps an upload token fresh for as long as an upload runs.
|
|
3
|
+
*
|
|
4
|
+
* Upload tokens live two hours and every tus request presents one, so an
|
|
5
|
+
* upload that outlasts its token — a big file on a home connection — dies at
|
|
6
|
+
* the first request after expiry: the next PATCH of a chunked client, or the
|
|
7
|
+
* HEAD a dropped connection resumes with. The keeper trades the token for a
|
|
8
|
+
* fresh one (`POST /api/uploads/token`) once half its remaining life is gone,
|
|
9
|
+
* which also tells the server the upload is alive (its quota stays reserved and
|
|
10
|
+
* recipients keep seeing "still arriving").
|
|
11
|
+
*
|
|
12
|
+
* Pure apart from the injected clock, timer and `renew`, so the schedule tests
|
|
13
|
+
* on the fast rung. The same rule lives in the web app's browser uploader
|
|
14
|
+
* (`apps/web/lib/upload-token-keeper.ts` in the Yungle repo) and the Python SDK.
|
|
15
|
+
*/
|
|
16
|
+
/** A renewal attempt: the new token, `'final'` (stop — the upload is over or the token dead), or `'retry'`. */
|
|
17
|
+
export type RenewOutcome = {
|
|
18
|
+
token: string;
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
} | 'final' | 'retry';
|
|
21
|
+
export interface TokenKeeperDeps {
|
|
22
|
+
renew: (current: string) => Promise<RenewOutcome>;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
25
|
+
clearTimer?: (handle: unknown) => void;
|
|
26
|
+
}
|
|
27
|
+
/** Never renew more often than this, however short the remaining life. */
|
|
28
|
+
export declare const MIN_RENEW_DELAY_MS = 60000;
|
|
29
|
+
/** After a failed attempt (offline, 5xx), try again this soon. */
|
|
30
|
+
export declare const RETRY_DELAY_MS = 60000;
|
|
31
|
+
/** `exp` (epoch ms) from a signed token's payload, or null if it has none. */
|
|
32
|
+
export declare function tokenExpiry(token: string): number | null;
|
|
33
|
+
/**
|
|
34
|
+
* How long to wait before renewing a token that expires at `expiresAt`: half
|
|
35
|
+
* the remaining life, floored at a minute. Half, not "five minutes before",
|
|
36
|
+
* because a laptop that sleeps through the renewal time wakes up with the
|
|
37
|
+
* other half still to spend on a retry.
|
|
38
|
+
*/
|
|
39
|
+
export declare function renewDelayMs(expiresAt: number, now: number): number;
|
|
40
|
+
export interface TokenKeeper {
|
|
41
|
+
/** The token to send on the next request. */
|
|
42
|
+
current(): string;
|
|
43
|
+
/** Stop renewing (the upload finished, failed or was cancelled). */
|
|
44
|
+
stop(): void;
|
|
45
|
+
}
|
|
46
|
+
export declare function createTokenKeeper(initial: string, deps: TokenKeeperDeps): TokenKeeper;
|
|
47
|
+
/**
|
|
48
|
+
* The HTTP half of a renewal: POST the current token to
|
|
49
|
+
* `<origin>/api/uploads/token`, read the fresh one. 401/404 are final (the
|
|
50
|
+
* token is dead, or the upload is over); anything else is worth a retry.
|
|
51
|
+
*
|
|
52
|
+
* `origin` is the site root (`https://yungle.co`), not the `/api/v1` base: the
|
|
53
|
+
* credential is the upload token, not an API key, so this lives outside v1.
|
|
54
|
+
*/
|
|
55
|
+
export declare function renewUploadToken(origin: string, current: string, doFetch?: typeof globalThis.fetch): Promise<RenewOutcome>;
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type RenewOutcome } from './upload-token.js';
|
|
2
|
+
/** At least one server part per PATCH, or the committed offset never advances. */
|
|
3
|
+
export declare function chunkSize(size: number): number;
|
|
4
|
+
export interface UploadFileOptions {
|
|
5
|
+
/** `tusEndpoint` from the create response. */
|
|
6
|
+
endpoint: string;
|
|
7
|
+
/** One entry of the create response's `files`. */
|
|
8
|
+
target: {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
uploadToken: string;
|
|
12
|
+
};
|
|
13
|
+
/** The bytes. In Node: `await fs.openAsBlob(path)`. */
|
|
14
|
+
source: Blob;
|
|
15
|
+
/** Renew the two-hour token; without it an upload longer than that fails. */
|
|
16
|
+
renew?: (token: string) => Promise<RenewOutcome>;
|
|
17
|
+
/** Resume this upload rather than creating one. */
|
|
18
|
+
uploadUrl?: string;
|
|
19
|
+
/** Called once the upload exists — store it to resume after a crash. */
|
|
20
|
+
onUploadUrl?: (url: string) => void;
|
|
21
|
+
/** Called with each renewed token — store it with `uploadUrl`. */
|
|
22
|
+
onToken?: (token: string) => void;
|
|
23
|
+
onProgress?: (sent: number, total: number) => void;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
fetch?: typeof globalThis.fetch;
|
|
26
|
+
/** Waits between retries of a failed request; its length is the retry budget. */
|
|
27
|
+
retryDelaysMs?: number[];
|
|
28
|
+
}
|
|
29
|
+
export declare class UploadError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
constructor(message: string, status: number);
|
|
32
|
+
}
|
|
33
|
+
export declare function uploadFile(o: UploadFileOptions): Promise<{
|
|
34
|
+
uploadUrl: string;
|
|
35
|
+
}>;
|