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 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
- // …stream the bytes to draft.tusEndpoint using draft.files[n].uploadToken…
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
- - **Does not upload bytes.** Endpoints that accept files return a tus endpoint
41
- and per-file tokens; streaming to those is the caller's job. Keeping the byte
42
- pipeline out is what lets this stay one small file. If you want that handled
43
- for you, use [`yungle-cli`](https://www.npmjs.com/package/yungle-cli), which
44
- does it resumably.
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, WebhookEvent, WebhookEventType } from './types.js';
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
- * It does **not** upload bytes. `createTransfer` and `addCollectionFiles`
12
- * return a tus endpoint and per-file tokens, and streaming to those is the
13
- * caller's job (`yungle-cli` does it with `tus-js-client`). Keeping the byte
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
- constructor(status: number, code: string, message: string, details?: Record<string, unknown> | undefined);
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
- /** Seconds to wait, when the server said. */
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: FileInput[];
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
- addTransferFiles(id: string, files: FileInput[]): Promise<UploadTargets>;
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: (WebhookEvent & {
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
- /** Seconds to wait, when the server said. */
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
- return typeof value === "number" ? value : null;
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.2.0" : "dev";
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
- addTransferFiles(id, files) {
87
- return this.request("POST", `/transfers/${enc(id)}/files`, { files });
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
- expiresAt: Iso8601 | null;
82
- createdAt: Iso8601 | null;
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
- /** Loaded the link. Recorded from a browser, so link scanners do not count. */
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 | null;
137
- updatedAt: Iso8601 | null;
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 | null;
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
- createdAt: Iso8601 | null;
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 | null;
181
- updatedAt: Iso8601 | null;
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>;
@@ -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
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yungle-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {