yungle-client 0.2.1 → 0.3.1

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