yungle-client 0.2.0 → 0.3.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 +18 -6
- package/dist/index.d.ts +127 -14
- package/dist/index.mjs +315 -9
- package/dist/types.d.ts +139 -10
- 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, 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,6 +84,27 @@ 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>;
|
|
68
109
|
/**
|
|
69
110
|
* One page of transfers, newest first — 100 by default, up to 500. Pass the
|
|
@@ -79,10 +120,21 @@ export declare class YungleClient {
|
|
|
79
120
|
/**
|
|
80
121
|
* Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
|
|
81
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* A draft transfer. `files` are uploaded by you (tus, to the returned
|
|
125
|
+
* targets); `imports` are fetched by Yungle from URLs. Send either or both.
|
|
126
|
+
*/
|
|
82
127
|
createTransfer(input: {
|
|
83
|
-
files
|
|
128
|
+
files?: FileInput[];
|
|
129
|
+
imports?: ImportInput[];
|
|
84
130
|
title?: string;
|
|
85
131
|
expiresInDays?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Encrypted by the client (`yungle-e2e`); the key travels only in the link's
|
|
134
|
+
* fragment. Such a transfer takes no title and cannot be emailed. File
|
|
135
|
+
* `size`s are then ciphertext lengths, and names go in `sealTransferFile`.
|
|
136
|
+
*/
|
|
137
|
+
e2ee?: boolean;
|
|
86
138
|
}): Promise<{
|
|
87
139
|
transfer: {
|
|
88
140
|
id: string;
|
|
@@ -107,11 +159,32 @@ export declare class YungleClient {
|
|
|
107
159
|
message?: string;
|
|
108
160
|
password?: string;
|
|
109
161
|
title?: string;
|
|
162
|
+
/** End-to-end encrypted transfers only: the sealed `{ message }`. */
|
|
163
|
+
e2eeMeta?: string;
|
|
110
164
|
}): Promise<{
|
|
111
165
|
transfer: Transfer;
|
|
112
166
|
notified: string[];
|
|
113
167
|
}>;
|
|
114
|
-
|
|
168
|
+
/**
|
|
169
|
+
* End-to-end encrypted transfers only: a file's sealed `{ name, path, size,
|
|
170
|
+
* type }` and/or thumbnail, from `yungle-e2e`. Called after the file exists,
|
|
171
|
+
* because each blob binds the file's id.
|
|
172
|
+
*/
|
|
173
|
+
sealTransferFile(id: string, fileId: string, sealed: {
|
|
174
|
+
meta?: string;
|
|
175
|
+
thumb?: string;
|
|
176
|
+
}): Promise<{
|
|
177
|
+
stored: true;
|
|
178
|
+
}>;
|
|
179
|
+
addTransferFiles(id: string, files: FileInput[], imports?: ImportInput[]): Promise<UploadTargets>;
|
|
180
|
+
/** Where a URL import stands: `importing` (with `receivedBytes`), `ready` or `failed`. */
|
|
181
|
+
getImport(fileId: string): Promise<ImportStatus>;
|
|
182
|
+
/**
|
|
183
|
+
* Poll `getImport` until every import is `ready` or `failed`, every
|
|
184
|
+
* `intervalMs` (default 5 s). Resolves with the final statuses; never throws
|
|
185
|
+
* for a failed import — check `status`.
|
|
186
|
+
*/
|
|
187
|
+
waitForImports(fileIds: string[], intervalMs?: number): Promise<ImportStatus[]>;
|
|
115
188
|
removeTransferFile(id: string, fileId: string): Promise<{
|
|
116
189
|
deleted: boolean;
|
|
117
190
|
}>;
|
|
@@ -125,6 +198,18 @@ export declare class YungleClient {
|
|
|
125
198
|
revokeTransfer(id: string): Promise<{
|
|
126
199
|
transfer: Transfer;
|
|
127
200
|
}>;
|
|
201
|
+
/**
|
|
202
|
+
* Signed download URLs for your own transfer. Fetching them is not a
|
|
203
|
+
* recipient download — it never appears in receipts. Throws
|
|
204
|
+
* `e2ee_unsupported` for an end-to-end encrypted transfer.
|
|
205
|
+
*/
|
|
206
|
+
transferDownloadLinks(id: string): Promise<DownloadLinks>;
|
|
207
|
+
/**
|
|
208
|
+
* A link someone shared with you (`…/t/…` or `…/c/…`), as signed download
|
|
209
|
+
* URLs — what a person opening it in a browser could download, under the same
|
|
210
|
+
* rules. Pass `password` for a protected link. Any key works, free included.
|
|
211
|
+
*/
|
|
212
|
+
resolveLink(url: string, password?: string): Promise<DownloadLinks>;
|
|
128
213
|
transferDownloads(id: string): Promise<{
|
|
129
214
|
downloads: DownloadEvent[];
|
|
130
215
|
recipients: RecipientStatus[];
|
|
@@ -139,6 +224,11 @@ export declare class YungleClient {
|
|
|
139
224
|
}): Promise<{
|
|
140
225
|
collection: Collection;
|
|
141
226
|
}>;
|
|
227
|
+
/**
|
|
228
|
+
* Signed download URLs for your own collection: every file with its folder
|
|
229
|
+
* `path`, and a whole-collection ZIP. The URLs need no key and support `Range`.
|
|
230
|
+
*/
|
|
231
|
+
collectionDownloadLinks(id: string): Promise<DownloadLinks>;
|
|
142
232
|
getCollection(id: string): Promise<{
|
|
143
233
|
collection: Collection;
|
|
144
234
|
}>;
|
|
@@ -162,7 +252,7 @@ export declare class YungleClient {
|
|
|
162
252
|
}>;
|
|
163
253
|
/** Every file in a collection, oldest first, a page at a time. */
|
|
164
254
|
allCollectionFiles(id: string, folderId?: string, pageSize?: number): AsyncGenerator<CollectionFile>;
|
|
165
|
-
addCollectionFiles(id: string, files: FileInput[], folderId?: string | null): Promise<UploadTargets>;
|
|
255
|
+
addCollectionFiles(id: string, files: FileInput[], folderId?: string | null, imports?: ImportInput[]): Promise<UploadTargets>;
|
|
166
256
|
deleteCollectionFiles(id: string, fileIds: string[]): Promise<{
|
|
167
257
|
deleted: number;
|
|
168
258
|
}>;
|
|
@@ -227,12 +317,30 @@ export declare class YungleClient {
|
|
|
227
317
|
* even when nothing is new, so keep the latest and poll with it.
|
|
228
318
|
*/
|
|
229
319
|
listWebhookEvents(id: string, page?: PageOptions): Promise<{
|
|
230
|
-
events:
|
|
231
|
-
deliveryId: string;
|
|
232
|
-
})[];
|
|
320
|
+
events: PulledWebhookEvent[];
|
|
233
321
|
nextCursor: string | null;
|
|
234
322
|
hasMore: boolean;
|
|
235
323
|
}>;
|
|
324
|
+
/** Your upload links, newest first (up to 100). */
|
|
325
|
+
listRequests(): Promise<{
|
|
326
|
+
requests: UploadRequest[];
|
|
327
|
+
}>;
|
|
328
|
+
/**
|
|
329
|
+
* A public page where anyone with the link can upload into one of your
|
|
330
|
+
* collections. Subscribe to the `request.submitted` webhook to hear about
|
|
331
|
+
* each finished submission.
|
|
332
|
+
*/
|
|
333
|
+
createRequest(input: UploadRequestInput): Promise<{
|
|
334
|
+
request: UploadRequest;
|
|
335
|
+
}>;
|
|
336
|
+
getRequest(id: string): Promise<{
|
|
337
|
+
request: UploadRequest;
|
|
338
|
+
submissions: Submission[];
|
|
339
|
+
}>;
|
|
340
|
+
/** `paused` stops uploads until set back to `active`; `closed` stops them for good. */
|
|
341
|
+
setRequestStatus(id: string, status: UploadRequest['status']): Promise<{
|
|
342
|
+
request: UploadRequest;
|
|
343
|
+
}>;
|
|
236
344
|
listContacts(): Promise<{
|
|
237
345
|
contacts: Contact[];
|
|
238
346
|
}>;
|
|
@@ -258,6 +366,11 @@ export declare class YungleClient {
|
|
|
258
366
|
*/
|
|
259
367
|
private request;
|
|
260
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* `Retry-After` is delay-seconds or an HTTP-date (RFC 9110 §10.2.3). Anything
|
|
371
|
+
* else — including a negative number or a date already past — is no statement.
|
|
372
|
+
*/
|
|
373
|
+
export declare function parseRetryAfter(value: string | null, now?: number): number | null;
|
|
261
374
|
/**
|
|
262
375
|
* Check a webhook's `Yungle-Signature` header against the RAW request body.
|
|
263
376
|
*
|
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.0" : "dev";
|
|
32
224
|
var YungleClient = class {
|
|
33
225
|
baseUrl;
|
|
34
226
|
apiKey;
|
|
@@ -43,6 +235,27 @@ 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");
|
|
@@ -68,6 +281,10 @@ var YungleClient = class {
|
|
|
68
281
|
/**
|
|
69
282
|
* Create a draft. Nothing is live and nobody is emailed until `finalizeTransfer`.
|
|
70
283
|
*/
|
|
284
|
+
/**
|
|
285
|
+
* A draft transfer. `files` are uploaded by you (tus, to the returned
|
|
286
|
+
* targets); `imports` are fetched by Yungle from URLs. Send either or both.
|
|
287
|
+
*/
|
|
71
288
|
createTransfer(input) {
|
|
72
289
|
return this.request("POST", "/transfers", input);
|
|
73
290
|
}
|
|
@@ -83,8 +300,32 @@ var YungleClient = class {
|
|
|
83
300
|
finalizeTransfer(id, input = {}) {
|
|
84
301
|
return this.request("POST", `/transfers/${enc(id)}/finalize`, input, { idempotent: true });
|
|
85
302
|
}
|
|
86
|
-
|
|
87
|
-
|
|
303
|
+
/**
|
|
304
|
+
* End-to-end encrypted transfers only: a file's sealed `{ name, path, size,
|
|
305
|
+
* type }` and/or thumbnail, from `yungle-e2e`. Called after the file exists,
|
|
306
|
+
* because each blob binds the file's id.
|
|
307
|
+
*/
|
|
308
|
+
sealTransferFile(id, fileId, sealed) {
|
|
309
|
+
return this.request("POST", `/transfers/${enc(id)}/files/${enc(fileId)}/sealed`, sealed, { idempotent: true });
|
|
310
|
+
}
|
|
311
|
+
addTransferFiles(id, files, imports) {
|
|
312
|
+
return this.request("POST", `/transfers/${enc(id)}/files`, { files, ...imports?.length ? { imports } : {} });
|
|
313
|
+
}
|
|
314
|
+
/** Where a URL import stands: `importing` (with `receivedBytes`), `ready` or `failed`. */
|
|
315
|
+
getImport(fileId) {
|
|
316
|
+
return this.request("GET", `/imports/${enc(fileId)}`);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Poll `getImport` until every import is `ready` or `failed`, every
|
|
320
|
+
* `intervalMs` (default 5 s). Resolves with the final statuses; never throws
|
|
321
|
+
* for a failed import — check `status`.
|
|
322
|
+
*/
|
|
323
|
+
async waitForImports(fileIds, intervalMs = 5e3) {
|
|
324
|
+
for (; ; ) {
|
|
325
|
+
const all = await Promise.all(fileIds.map((id) => this.getImport(id)));
|
|
326
|
+
if (all.every((s) => s.status !== "importing")) return all;
|
|
327
|
+
await sleep(intervalMs);
|
|
328
|
+
}
|
|
88
329
|
}
|
|
89
330
|
removeTransferFile(id, fileId) {
|
|
90
331
|
return this.request("DELETE", `/transfers/${enc(id)}/files/${enc(fileId)}`);
|
|
@@ -96,6 +337,22 @@ var YungleClient = class {
|
|
|
96
337
|
revokeTransfer(id) {
|
|
97
338
|
return this.request("DELETE", `/transfers/${enc(id)}`);
|
|
98
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Signed download URLs for your own transfer. Fetching them is not a
|
|
342
|
+
* recipient download — it never appears in receipts. Throws
|
|
343
|
+
* `e2ee_unsupported` for an end-to-end encrypted transfer.
|
|
344
|
+
*/
|
|
345
|
+
transferDownloadLinks(id) {
|
|
346
|
+
return this.request("GET", `/transfers/${enc(id)}/download-links`);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* A link someone shared with you (`…/t/…` or `…/c/…`), as signed download
|
|
350
|
+
* URLs — what a person opening it in a browser could download, under the same
|
|
351
|
+
* rules. Pass `password` for a protected link. Any key works, free included.
|
|
352
|
+
*/
|
|
353
|
+
resolveLink(url, password) {
|
|
354
|
+
return this.request("POST", "/links/resolve", { url, ...password ? { password } : {} });
|
|
355
|
+
}
|
|
99
356
|
transferDownloads(id) {
|
|
100
357
|
return this.request("GET", `/transfers/${enc(id)}/downloads`);
|
|
101
358
|
}
|
|
@@ -106,6 +363,13 @@ var YungleClient = class {
|
|
|
106
363
|
createCollection(input) {
|
|
107
364
|
return this.request("POST", "/collections", input);
|
|
108
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Signed download URLs for your own collection: every file with its folder
|
|
368
|
+
* `path`, and a whole-collection ZIP. The URLs need no key and support `Range`.
|
|
369
|
+
*/
|
|
370
|
+
collectionDownloadLinks(id) {
|
|
371
|
+
return this.request("GET", `/collections/${enc(id)}/download-links`);
|
|
372
|
+
}
|
|
109
373
|
getCollection(id) {
|
|
110
374
|
return this.request("GET", `/collections/${enc(id)}`);
|
|
111
375
|
}
|
|
@@ -133,8 +397,8 @@ var YungleClient = class {
|
|
|
133
397
|
cursor = page.nextCursor ?? void 0;
|
|
134
398
|
} while (cursor);
|
|
135
399
|
}
|
|
136
|
-
addCollectionFiles(id, files, folderId) {
|
|
137
|
-
return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId });
|
|
400
|
+
addCollectionFiles(id, files, folderId, imports) {
|
|
401
|
+
return this.request("POST", `/collections/${enc(id)}/files`, { files, folderId, ...imports?.length ? { imports } : {} });
|
|
138
402
|
}
|
|
139
403
|
deleteCollectionFiles(id, fileIds) {
|
|
140
404
|
return this.request("DELETE", `/collections/${enc(id)}/files`, { fileIds });
|
|
@@ -190,6 +454,26 @@ var YungleClient = class {
|
|
|
190
454
|
listWebhookEvents(id, page = {}) {
|
|
191
455
|
return this.request("GET", `/webhooks/${enc(id)}/events${pageQuery(page)}`);
|
|
192
456
|
}
|
|
457
|
+
// ── Upload requests ───────────────────────────────────────────────────────
|
|
458
|
+
/** Your upload links, newest first (up to 100). */
|
|
459
|
+
listRequests() {
|
|
460
|
+
return this.request("GET", "/requests");
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* A public page where anyone with the link can upload into one of your
|
|
464
|
+
* collections. Subscribe to the `request.submitted` webhook to hear about
|
|
465
|
+
* each finished submission.
|
|
466
|
+
*/
|
|
467
|
+
createRequest(input) {
|
|
468
|
+
return this.request("POST", "/requests", input);
|
|
469
|
+
}
|
|
470
|
+
getRequest(id) {
|
|
471
|
+
return this.request("GET", `/requests/${enc(id)}`);
|
|
472
|
+
}
|
|
473
|
+
/** `paused` stops uploads until set back to `active`; `closed` stops them for good. */
|
|
474
|
+
setRequestStatus(id, status) {
|
|
475
|
+
return this.request("PATCH", `/requests/${enc(id)}`, { status });
|
|
476
|
+
}
|
|
193
477
|
// ── Contacts ──────────────────────────────────────────────────────────────
|
|
194
478
|
listContacts() {
|
|
195
479
|
return this.request("GET", "/contacts");
|
|
@@ -250,13 +534,27 @@ async function toError(res) {
|
|
|
250
534
|
} catch {
|
|
251
535
|
}
|
|
252
536
|
const err = body?.error;
|
|
537
|
+
const retryAfterHeader = parseRetryAfter(res.headers.get("retry-after"));
|
|
253
538
|
return new YungleApiError(
|
|
254
539
|
res.status,
|
|
255
540
|
err?.code ?? "http_error",
|
|
256
541
|
err?.message ?? `Request failed with status ${res.status}.`,
|
|
257
|
-
err?.details
|
|
542
|
+
err?.details,
|
|
543
|
+
{
|
|
544
|
+
...typeof err?.docs === "string" ? { docs: err.docs } : {},
|
|
545
|
+
...retryAfterHeader === null ? {} : { retryAfterHeader }
|
|
546
|
+
}
|
|
258
547
|
);
|
|
259
548
|
}
|
|
549
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
550
|
+
if (value === null) return null;
|
|
551
|
+
const trimmed = value.trim();
|
|
552
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
553
|
+
const at = Date.parse(trimmed);
|
|
554
|
+
if (Number.isNaN(at)) return null;
|
|
555
|
+
const seconds = Math.ceil((at - now) / 1e3);
|
|
556
|
+
return seconds >= 0 ? seconds : null;
|
|
557
|
+
}
|
|
260
558
|
function backoffMs(attempt, error) {
|
|
261
559
|
const stated = error.retryAfterSeconds;
|
|
262
560
|
if (stated !== null) return stated * 1e3;
|
|
@@ -285,7 +583,15 @@ async function verifyWebhook(rawBody, signatureHeader, secret, { toleranceSecond
|
|
|
285
583
|
}
|
|
286
584
|
export {
|
|
287
585
|
CLIENT_VERSION,
|
|
586
|
+
UploadError,
|
|
288
587
|
YungleApiError,
|
|
289
588
|
YungleClient,
|
|
589
|
+
chunkSize,
|
|
590
|
+
createTokenKeeper,
|
|
591
|
+
parseRetryAfter,
|
|
592
|
+
renewDelayMs,
|
|
593
|
+
renewUploadToken,
|
|
594
|
+
tokenExpiry,
|
|
595
|
+
uploadFile,
|
|
290
596
|
verifyWebhook
|
|
291
597
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -59,10 +59,50 @@ export interface UploadTarget {
|
|
|
59
59
|
name: string;
|
|
60
60
|
size: number;
|
|
61
61
|
uploadToken: string;
|
|
62
|
+
/**
|
|
63
|
+
* When `uploadToken` stops working (two hours after issue). Renew before then
|
|
64
|
+
* with `renewUploadToken` — or let `createTokenKeeper` do it — for any upload
|
|
65
|
+
* that may run longer. Absent from servers older than 2026-09-29.
|
|
66
|
+
*/
|
|
67
|
+
uploadTokenExpiresAt?: string;
|
|
62
68
|
}
|
|
63
69
|
export interface UploadTargets {
|
|
64
70
|
tusEndpoint: string;
|
|
71
|
+
/** One per `files` entry, in order. */
|
|
65
72
|
files: UploadTarget[];
|
|
73
|
+
/** One per `imports` entry, in order. Absent from servers before 2026-09-29. */
|
|
74
|
+
imports?: ImportStarted[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A file for Yungle to fetch from a URL itself, so the bytes never pass
|
|
78
|
+
* through you — a presigned S3 link, a CDN, a release asset. The source must
|
|
79
|
+
* state the size (Content-Length or a Range answer).
|
|
80
|
+
*/
|
|
81
|
+
export interface ImportInput {
|
|
82
|
+
url: string;
|
|
83
|
+
/** Defaults to the name the source gives. */
|
|
84
|
+
name?: string;
|
|
85
|
+
/** Folder to place it in. */
|
|
86
|
+
path?: string;
|
|
87
|
+
}
|
|
88
|
+
export interface ImportStarted {
|
|
89
|
+
fileId: string;
|
|
90
|
+
name: string;
|
|
91
|
+
size: number;
|
|
92
|
+
/** The source's host; never the full URL. */
|
|
93
|
+
source: string;
|
|
94
|
+
status: 'queued';
|
|
95
|
+
}
|
|
96
|
+
/** Where one import stands (`getImport`). */
|
|
97
|
+
export interface ImportStatus {
|
|
98
|
+
fileId: string;
|
|
99
|
+
name: string;
|
|
100
|
+
size: number;
|
|
101
|
+
/** Committed so far; moves in steps of one storage part. */
|
|
102
|
+
receivedBytes: number;
|
|
103
|
+
source: string | null;
|
|
104
|
+
status: 'importing' | 'ready' | 'failed';
|
|
105
|
+
error: string | null;
|
|
66
106
|
}
|
|
67
107
|
export interface Transfer {
|
|
68
108
|
id: string;
|
|
@@ -74,12 +114,18 @@ export interface Transfer {
|
|
|
74
114
|
fileCount?: number;
|
|
75
115
|
recipients: string[];
|
|
76
116
|
hasPassword: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* End-to-end encrypted: the working link carries a key Yungle never holds,
|
|
119
|
+
* so `url` alone does not open it. Absent on servers before 2026-09-29.
|
|
120
|
+
*/
|
|
121
|
+
e2ee?: boolean;
|
|
77
122
|
downloadCount: number;
|
|
78
123
|
maxDownloads: number | null;
|
|
79
124
|
/** Null while it is still a draft. A draft is not shareable. */
|
|
80
125
|
finalizedAt: Iso8601 | null;
|
|
81
|
-
|
|
82
|
-
|
|
126
|
+
/** Every transfer expires; a paid plan chooses when. */
|
|
127
|
+
expiresAt: Iso8601;
|
|
128
|
+
createdAt: Iso8601;
|
|
83
129
|
}
|
|
84
130
|
export interface TransferSummary extends Omit<Transfer, 'hasPassword' | 'fileCount'> {
|
|
85
131
|
fileCount: number;
|
|
@@ -93,6 +139,8 @@ export interface TransferFile {
|
|
|
93
139
|
path: string | null;
|
|
94
140
|
/** Null until the malware scan finishes, then `clean` or `infected`. */
|
|
95
141
|
scanResult: string | null;
|
|
142
|
+
/** CRC-32 of the bytes (IEEE, as `zlib.crc32`), 8 hex digits; null while unknown. */
|
|
143
|
+
crc32: string | null;
|
|
96
144
|
createdAt: Iso8601;
|
|
97
145
|
}
|
|
98
146
|
export interface RecipientStatus {
|
|
@@ -100,7 +148,10 @@ export interface RecipientStatus {
|
|
|
100
148
|
email: string;
|
|
101
149
|
notifiedAt: Iso8601 | null;
|
|
102
150
|
downloaded: boolean;
|
|
103
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* @deprecated Always false since 2026-09-26: Yungle no longer records whether
|
|
153
|
+
* a recipient opened the page — only whether they downloaded.
|
|
154
|
+
*/
|
|
104
155
|
opened: boolean;
|
|
105
156
|
/**
|
|
106
157
|
* Set when mail to this address permanently failed. Additive since 2026-09;
|
|
@@ -133,8 +184,8 @@ export interface Collection {
|
|
|
133
184
|
sizeBytes: number;
|
|
134
185
|
coverFileId: string | null;
|
|
135
186
|
expiresAt: Iso8601 | null;
|
|
136
|
-
createdAt: Iso8601
|
|
137
|
-
updatedAt: Iso8601
|
|
187
|
+
createdAt: Iso8601;
|
|
188
|
+
updatedAt: Iso8601;
|
|
138
189
|
fileCount?: number;
|
|
139
190
|
}
|
|
140
191
|
export interface CollectionSummary {
|
|
@@ -145,7 +196,7 @@ export interface CollectionSummary {
|
|
|
145
196
|
fileCount: number;
|
|
146
197
|
sizeBytes: number;
|
|
147
198
|
coverFileId: string | null;
|
|
148
|
-
updatedAt: Iso8601
|
|
199
|
+
updatedAt: Iso8601;
|
|
149
200
|
}
|
|
150
201
|
export interface CollectionFile {
|
|
151
202
|
id: string;
|
|
@@ -154,7 +205,9 @@ export interface CollectionFile {
|
|
|
154
205
|
mimeType: string;
|
|
155
206
|
folderId: string | null;
|
|
156
207
|
hasThumbnail: boolean;
|
|
157
|
-
|
|
208
|
+
/** CRC-32 of the bytes (IEEE, as `zlib.crc32`), 8 hex digits; null while unknown. */
|
|
209
|
+
crc32: string | null;
|
|
210
|
+
createdAt: Iso8601;
|
|
158
211
|
}
|
|
159
212
|
export interface Folder {
|
|
160
213
|
id: string;
|
|
@@ -177,8 +230,8 @@ export interface Contact {
|
|
|
177
230
|
email: string;
|
|
178
231
|
phone: string | null;
|
|
179
232
|
type: string;
|
|
180
|
-
createdAt: Iso8601
|
|
181
|
-
updatedAt: Iso8601
|
|
233
|
+
createdAt: Iso8601;
|
|
234
|
+
updatedAt: Iso8601;
|
|
182
235
|
}
|
|
183
236
|
export interface ContactInput {
|
|
184
237
|
firstName?: string | null;
|
|
@@ -188,7 +241,7 @@ export interface ContactInput {
|
|
|
188
241
|
phone?: string | null;
|
|
189
242
|
type?: string | null;
|
|
190
243
|
}
|
|
191
|
-
export type WebhookEventType = 'transfer.ready' | 'transfer.downloaded' | 'transfer.expiring' | 'transfer.expired' | 'collection.file_uploaded';
|
|
244
|
+
export type WebhookEventType = 'transfer.ready' | 'transfer.downloaded' | 'transfer.expiring' | 'transfer.expired' | 'collection.file_uploaded' | 'request.submitted';
|
|
192
245
|
export interface WebhookEndpoint {
|
|
193
246
|
id: string;
|
|
194
247
|
/** Null for a pull endpoint. */
|
|
@@ -221,3 +274,79 @@ export interface WebhookEvent<T = Record<string, unknown>> {
|
|
|
221
274
|
createdAt: Iso8601;
|
|
222
275
|
data: T;
|
|
223
276
|
}
|
|
277
|
+
/** An event read from `/webhooks/{id}/events`: the push payload plus the cursor to resume from. */
|
|
278
|
+
export type PulledWebhookEvent<T = Record<string, unknown>> = WebhookEvent<T> & {
|
|
279
|
+
/** Pass the last one back as `cursor`. */
|
|
280
|
+
deliveryId: string;
|
|
281
|
+
};
|
|
282
|
+
/** One file in a set of download links. */
|
|
283
|
+
export interface DownloadLink {
|
|
284
|
+
id: string;
|
|
285
|
+
/** For an end-to-end encrypted transfer, a placeholder; the real name is sealed in `e2eeMeta`. */
|
|
286
|
+
name: string;
|
|
287
|
+
size: number;
|
|
288
|
+
mimeType: string;
|
|
289
|
+
/** Folder path, `""` at the root. Join it onto your output directory. */
|
|
290
|
+
path: string;
|
|
291
|
+
/** A signed GET that needs no key. Supports `Range`. Valid until `urlsExpireAt`. */
|
|
292
|
+
downloadUrl: string;
|
|
293
|
+
e2eeMeta?: string | null;
|
|
294
|
+
/** CRC-32 of the bytes (IEEE, as `zlib.crc32`), 8 hex digits. Null while unknown. */
|
|
295
|
+
crc32?: string | null;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Signed download URLs for a transfer or collection — your own
|
|
299
|
+
* (`transferDownloadLinks`, `collectionDownloadLinks`) or a link shared with
|
|
300
|
+
* you (`resolveLink`).
|
|
301
|
+
*/
|
|
302
|
+
export interface DownloadLinks {
|
|
303
|
+
kind: 'transfer' | 'collection';
|
|
304
|
+
/** A collection's title. Always null for a transfer, whose title recipients never see. */
|
|
305
|
+
title: string | null;
|
|
306
|
+
message: string | null;
|
|
307
|
+
expiresAt: Iso8601 | null;
|
|
308
|
+
/** End-to-end encrypted: the bytes are ciphertext and the key is in the link's `#` part. */
|
|
309
|
+
e2ee: boolean;
|
|
310
|
+
e2eeMeta?: string | null;
|
|
311
|
+
/** False while a transfer is still uploading: `files` is what has arrived so far. */
|
|
312
|
+
complete: boolean;
|
|
313
|
+
zipUrl: string | null;
|
|
314
|
+
/** When the URLs stop working; ask again for fresh ones. */
|
|
315
|
+
urlsExpireAt: Iso8601;
|
|
316
|
+
files: DownloadLink[];
|
|
317
|
+
}
|
|
318
|
+
/** A public upload link that feeds one of your collections ("file request" in the dashboard). */
|
|
319
|
+
export interface UploadRequest {
|
|
320
|
+
id: string;
|
|
321
|
+
/** The page to give people. Anyone with it can upload until it is paused, closed or expires. */
|
|
322
|
+
url: string;
|
|
323
|
+
title: string;
|
|
324
|
+
message: string | null;
|
|
325
|
+
collectionId: string;
|
|
326
|
+
status: 'active' | 'paused' | 'closed';
|
|
327
|
+
hasPassword: boolean;
|
|
328
|
+
requiredItems: string[];
|
|
329
|
+
expiresAt: Iso8601 | null;
|
|
330
|
+
submissionCount: number;
|
|
331
|
+
receivedBytes: number;
|
|
332
|
+
createdAt: Iso8601;
|
|
333
|
+
}
|
|
334
|
+
export interface UploadRequestInput {
|
|
335
|
+
collectionId: string;
|
|
336
|
+
title: string;
|
|
337
|
+
message?: string;
|
|
338
|
+
password?: string;
|
|
339
|
+
/** A checklist shown on the page — guidance, not enforcement. */
|
|
340
|
+
requiredItems?: string[];
|
|
341
|
+
expiresAt?: Iso8601;
|
|
342
|
+
}
|
|
343
|
+
/** One person's upload through a request. Name, email and message are as they typed them: untrusted. */
|
|
344
|
+
export interface Submission {
|
|
345
|
+
id: string;
|
|
346
|
+
uploaderName: string | null;
|
|
347
|
+
uploaderEmail: string | null;
|
|
348
|
+
message: string | null;
|
|
349
|
+
fileCount: number;
|
|
350
|
+
sizeBytes: number;
|
|
351
|
+
createdAt: Iso8601;
|
|
352
|
+
}
|
|
@@ -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
|
+
}>;
|