rmapi-js 14.0.0 → 14.2.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
@@ -67,6 +67,20 @@ const [entry] = await api.listItems()
67
67
  const buffer = await api.getEpub(entry)
68
68
  ```
69
69
 
70
+ To react to changes as they happen, `listen` yields a notification each time a
71
+ device finishes syncing. It names the device, not what changed, so use it as a
72
+ cue to re-read whatever you care about. Compare against `deviceId` to skip your
73
+ own syncs. reMarkable authorizes the socket's handshake with a header, and a
74
+ browser's `WebSocket` takes only a url, so this needs node or bun.
75
+
76
+ ```ts
77
+ for await (const { attributes } of api.listen()) {
78
+ if (attributes.sourceDeviceID !== api.deviceId) {
79
+ const fileEntries = await api.listItems(true);
80
+ }
81
+ }
82
+ ```
83
+
70
84
  ### Gotchas
71
85
 
72
86
  By default, all calls try to do their best to verify that the input and output
package/dist/index.d.ts CHANGED
@@ -185,6 +185,41 @@ export interface PutOptions {
185
185
  */
186
186
  refresh?: boolean;
187
187
  }
188
+ /** what a sync notification says happened */
189
+ export interface SyncEventAttributes {
190
+ /** the kind of event, always a sync completion here */
191
+ event: "SyncComplete";
192
+ /**
193
+ * the device that synced
194
+ *
195
+ * Tablets report their serial, api clients the id they registered with, so
196
+ * compare it against {@link RemarkableApi.deviceId | `deviceId`} to spot your
197
+ * own syncs.
198
+ */
199
+ sourceDeviceID: string;
200
+ /** the account that synced, as an auth0 id */
201
+ auth0UserID: string;
202
+ /** the same value as `auth0UserID` in every event we've seen */
203
+ userID?: string;
204
+ /** the notification family, always "SyncEventNotification" so far */
205
+ eventType?: string;
206
+ /** the format version, always "1" so far */
207
+ eventVersion?: string;
208
+ /** the organization of a managed account, otherwise empty */
209
+ orgID?: string;
210
+ }
211
+ /**
212
+ * a sync notification pushed over the notification socket
213
+ *
214
+ * Events carry a little more, but nothing worth depending on, so the rest is
215
+ * passed through untyped rather than validated.
216
+ */
217
+ export interface SyncEvent {
218
+ /** what happened */
219
+ attributes: SyncEventAttributes;
220
+ /** the id of this notification */
221
+ messageid?: string;
222
+ }
188
223
  /**
189
224
  * the api for accessing remarkable functions
190
225
  *
@@ -206,6 +241,13 @@ declare class Remarkable {
206
241
  /** scoped access to the raw low-level api */
207
242
  readonly raw: RawRemarkable;
208
243
  constructor(sessionToken: string, rawHost: string, uploadHost: string, cache: Map<string, Uint8Array | null>, maxGenerationRetries: number, maxTransientRetries: number, maxCachedBytes: number);
244
+ /**
245
+ * the id this api is registered under
246
+ *
247
+ * This is the uuid passed to {@link register | `register`}, which reMarkable
248
+ * stamps on everything this client does. Tablets use their serial instead.
249
+ */
250
+ get deviceId(): string;
209
251
  /**
210
252
  * list all items
211
253
  *
@@ -625,6 +667,28 @@ declare class Remarkable {
625
667
  * @returns a reference to the deleted entry, with its new hash
626
668
  */
627
669
  delete(ref: ItemRef, refresh?: boolean): Promise<ItemRef>;
670
+ /**
671
+ * permanently delete an entry
672
+ *
673
+ * Unlike {@link delete | `delete`}, which moves an entry to the trash where
674
+ * the device can still restore it, this drops the entry from the account
675
+ * outright. Its files stay in the cloud, but nothing points at them anymore
676
+ * and nothing brings the entry back.
677
+ *
678
+ * Only the entry named goes: purging a folder leaves everything inside it
679
+ * pointing at a parent that's no longer there. Those entries stay in the
680
+ * account and {@link listItems | `listItems`} still returns them, but no
681
+ * folder holds them, so nothing browsing the tree will find them. Purge the
682
+ * contents first, or use {@link purgeTrash | `purgeTrash`}, which takes the
683
+ * whole tree.
684
+ *
685
+ * @example
686
+ * ```ts
687
+ * await api.purge(file);
688
+ * ```
689
+ * @param ref - a reference to the entry to purge
690
+ */
691
+ purge(ref: ItemRef, refresh?: boolean): Promise<void>;
628
692
  /**
629
693
  * rename an entry
630
694
  *
@@ -674,6 +738,66 @@ declare class Remarkable {
674
738
  * @returns references to the deleted entries, each with its new hash
675
739
  */
676
740
  bulkDelete(refs: readonly ItemRef[], refresh?: boolean): Promise<ItemRef[]>;
741
+ /**
742
+ * permanently delete many entries
743
+ *
744
+ * The bulk form of {@link purge | `purge`}, done in a single root write.
745
+ *
746
+ * @example
747
+ * ```ts
748
+ * await api.bulkPurge([file]);
749
+ * ```
750
+ *
751
+ * @param refs - references to the entries to purge
752
+ */
753
+ bulkPurge(refs: readonly ItemRef[], refresh?: boolean): Promise<void>;
754
+ /**
755
+ * permanently delete everything in the trash
756
+ *
757
+ * Trashing a folder doesn't touch what's inside it — those entries keep
758
+ * naming the folder as their parent, which is what lets the device restore
759
+ * them together — so the trash holds the whole tree hanging off it, not just
760
+ * the entries whose parent is "trash". This purges all of it in one root
761
+ * write.
762
+ *
763
+ * @example
764
+ * ```ts
765
+ * await api.purgeTrash();
766
+ * ```
767
+ *
768
+ * @remarks
769
+ * Finding that tree means reading every item's metadata, so this costs about
770
+ * as much as {@link listItems | `listItems`}.
771
+ *
772
+ * @param refresh - if true, refresh the root hash before purging
773
+ * @returns references to the entries that were purged
774
+ */
775
+ purgeTrash(refresh?: boolean): Promise<ItemRef[]>;
776
+ /**
777
+ * listen for sync notifications
778
+ *
779
+ * reMarkable sends one every time a device finishes syncing. It names the
780
+ * device, not what changed, so use it as a cue to re-read.
781
+ *
782
+ * The socket is reopened when the server drops it, which happens every few
783
+ * minutes. Leaving the loop closes it. Session tokens expire after a few
784
+ * hours, and this throws once reconnecting with an expired one fails.
785
+ *
786
+ * reMarkable authorizes the handshake with a header, which node and bun can
787
+ * attach but a browser's `WebSocket` can't, so this is server side only.
788
+ *
789
+ * @example
790
+ * ```ts
791
+ * for await (const { attributes } of api.listen()) {
792
+ * if (attributes.sourceDeviceID !== api.deviceId) {
793
+ * const entries = await api.listItems(true);
794
+ * }
795
+ * }
796
+ * ```
797
+ *
798
+ * @returns the notifications, in the order they arrive
799
+ */
800
+ listen(): AsyncGenerator<SyncEvent, void, undefined>;
677
801
  /**
678
802
  * get the current cache value as a string
679
803
  *
@@ -720,6 +844,10 @@ export interface RemarkableSessionOptions {
720
844
  /**
721
845
  * the base url for making upload requests
722
846
  *
847
+ * @deprecated uploads now go to the same server as everything else, so this
848
+ * will be removed and `rawHost` will cover both. Point `rawHost` at the
849
+ * backend you want instead.
850
+ *
723
851
  * @defaultValue "https://internal.cloud.remarkable.com"
724
852
  */
725
853
  uploadHost?: string;
@@ -793,6 +921,9 @@ export declare function auth(deviceToken: string, { authHost }?: AuthOptions): P
793
921
  * If requests start failing, simply recreate the api instance with a freshly
794
922
  * fetched session token.
795
923
  *
924
+ * The device id is read out of the token, so this throws if it isn't one
925
+ * reMarkable minted.
926
+ *
796
927
  * @param sessionToken - the session token used for authorization
797
928
  * @returns an api instance
798
929
  */
package/dist/index.js CHANGED
@@ -237,6 +237,45 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
237
237
  return await resp.text();
238
238
  }
239
239
  }
240
+ const tokenClaims = z
241
+ .object({ "device-id": z.string() })
242
+ .loose();
243
+ function tokenDeviceId(token) {
244
+ const [, payload] = token.split(".", 2);
245
+ if (payload === undefined) {
246
+ throw new Error("token wasn't a jwt, so it had no device id");
247
+ }
248
+ const decoded = Uint8Array.fromBase64(payload, {
249
+ alphabet: "base64url",
250
+ lastChunkHandling: "loose",
251
+ });
252
+ const json = new TextDecoder().decode(decoded);
253
+ const parsed = tokenClaims.parse(JSON.parse(json));
254
+ return parsed["device-id"];
255
+ }
256
+ const notification = z
257
+ .object({
258
+ message: z
259
+ .object({ attributes: z.object({ event: z.string() }).loose() })
260
+ .loose(),
261
+ })
262
+ .loose();
263
+ const syncEvent = z
264
+ .object({
265
+ attributes: z
266
+ .object({
267
+ event: z.literal("SyncComplete"),
268
+ sourceDeviceID: z.string(),
269
+ auth0UserID: z.string(),
270
+ userID: z.string().optional(),
271
+ eventType: z.string().optional(),
272
+ eventVersion: z.string().optional(),
273
+ orgID: z.string().optional(),
274
+ })
275
+ .loose(),
276
+ messageid: z.string().optional(),
277
+ })
278
+ .loose();
240
279
  /**
241
280
  * the api for accessing remarkable functions
242
281
  *
@@ -259,6 +298,7 @@ class Remarkable {
259
298
  #cache;
260
299
  /** scoped access to the raw low-level api */
261
300
  raw;
301
+ #deviceId;
262
302
  #maxGenerationRetries;
263
303
  #maxTransientRetries;
264
304
  #lastHashGen;
@@ -267,10 +307,24 @@ class Remarkable {
267
307
  #rootMutex = new Mutex();
268
308
  constructor(sessionToken, rawHost, uploadHost, cache, maxGenerationRetries, maxTransientRetries, maxCachedBytes) {
269
309
  this.#sessionToken = sessionToken;
310
+ this.#deviceId = tokenDeviceId(sessionToken);
270
311
  this.#cache = cache;
271
312
  this.#maxGenerationRetries = maxGenerationRetries;
272
313
  this.#maxTransientRetries = maxTransientRetries;
273
- this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), cache, rawHost, uploadHost, maxCachedBytes);
314
+ this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), (url) =>
315
+ // node and bun both take headers here, the dom types don't know about them
316
+ new WebSocket(url, {
317
+ headers: { Authorization: `Bearer ${this.#sessionToken}` },
318
+ }), cache, rawHost, uploadHost, maxCachedBytes);
319
+ }
320
+ /**
321
+ * the id this api is registered under
322
+ *
323
+ * This is the uuid passed to {@link register | `register`}, which reMarkable
324
+ * stamps on everything this client does. Tablets use their serial instead.
325
+ */
326
+ get deviceId() {
327
+ return this.#deviceId;
274
328
  }
275
329
  async #getRootHash(refresh = false) {
276
330
  if (refresh || this.#lastHashGen === undefined) {
@@ -353,26 +407,35 @@ class Remarkable {
353
407
  hash: rootHash,
354
408
  });
355
409
  entries.push(entry);
356
- let newRoot;
357
- {
358
- const env_2 = { stack: [], error: void 0, hasError: false };
359
- try {
360
- const rootEntry = __addDisposableResource(env_2, await this.raw.putEntries(ROOT_LIST, entries, 4), true);
361
- newRoot = rootEntry.hash;
362
- }
363
- catch (e_2) {
364
- env_2.error = e_2;
365
- env_2.hasError = true;
366
- }
367
- finally {
368
- const result_2 = __disposeResources(env_2);
369
- if (result_2)
370
- await result_2;
371
- }
372
- }
373
- await this.#putRootHash(newRoot, generation);
410
+ await this.#writeRoot(entries, generation);
374
411
  });
375
412
  }
413
+ /**
414
+ * upload a new root index and point the account at it
415
+ *
416
+ * Every blob the index names must already be uploaded, since the root hash
417
+ * lands as soon as the index does.
418
+ */
419
+ async #writeRoot(entries, generation) {
420
+ let newRoot;
421
+ {
422
+ const env_2 = { stack: [], error: void 0, hasError: false };
423
+ try {
424
+ const rootEntry = __addDisposableResource(env_2, await this.raw.putEntries(ROOT_LIST, entries, 4), true);
425
+ newRoot = rootEntry.hash;
426
+ }
427
+ catch (e_2) {
428
+ env_2.error = e_2;
429
+ env_2.hasError = true;
430
+ }
431
+ finally {
432
+ const result_2 = __disposeResources(env_2);
433
+ if (result_2)
434
+ await result_2;
435
+ }
436
+ }
437
+ await this.#putRootHash(newRoot, generation);
438
+ }
376
439
  async #authedFetch(url, { body, method = "POST", headers = {}, }) {
377
440
  // the root PUT is a compare-and-set; retrying a lost-but-applied response
378
441
  // would resurface as a false generation conflict and be double-applied by
@@ -1463,6 +1526,30 @@ class Remarkable {
1463
1526
  async delete(ref, refresh = false) {
1464
1527
  return await this.move(ref, TRASH_ID, refresh);
1465
1528
  }
1529
+ /**
1530
+ * permanently delete an entry
1531
+ *
1532
+ * Unlike {@link delete | `delete`}, which moves an entry to the trash where
1533
+ * the device can still restore it, this drops the entry from the account
1534
+ * outright. Its files stay in the cloud, but nothing points at them anymore
1535
+ * and nothing brings the entry back.
1536
+ *
1537
+ * Only the entry named goes: purging a folder leaves everything inside it
1538
+ * pointing at a parent that's no longer there. Those entries stay in the
1539
+ * account and {@link listItems | `listItems`} still returns them, but no
1540
+ * folder holds them, so nothing browsing the tree will find them. Purge the
1541
+ * contents first, or use {@link purgeTrash | `purgeTrash`}, which takes the
1542
+ * whole tree.
1543
+ *
1544
+ * @example
1545
+ * ```ts
1546
+ * await api.purge(file);
1547
+ * ```
1548
+ * @param ref - a reference to the entry to purge
1549
+ */
1550
+ async purge(ref, refresh = false) {
1551
+ await this.bulkPurge([ref], refresh);
1552
+ }
1466
1553
  /**
1467
1554
  * rename an entry
1468
1555
  *
@@ -1577,6 +1664,219 @@ class Remarkable {
1577
1664
  async bulkDelete(refs, refresh = false) {
1578
1665
  return await this.bulkMove(refs, TRASH_ID, refresh);
1579
1666
  }
1667
+ /**
1668
+ * permanently delete many entries
1669
+ *
1670
+ * The bulk form of {@link purge | `purge`}, done in a single root write.
1671
+ *
1672
+ * @example
1673
+ * ```ts
1674
+ * await api.bulkPurge([file]);
1675
+ * ```
1676
+ *
1677
+ * @param refs - references to the entries to purge
1678
+ */
1679
+ async bulkPurge(refs, refresh = false) {
1680
+ if (!refs.length) {
1681
+ return;
1682
+ }
1683
+ await this.#withRetry(async () => {
1684
+ const [rootHash, generation] = await this.#getRootHash(refresh);
1685
+ const { entries } = await this.raw.getEntries({
1686
+ id: ROOT_LIST,
1687
+ hash: rootHash,
1688
+ });
1689
+ const wanted = new Set(refs.map((ref) => `${ref.id}\0${ref.hash}`));
1690
+ const found = new Set();
1691
+ const newEntries = [];
1692
+ for (const entry of entries) {
1693
+ const key = `${entry.id}\0${entry.hash}`;
1694
+ if (wanted.has(key)) {
1695
+ found.add(key);
1696
+ }
1697
+ else {
1698
+ newEntries.push(entry);
1699
+ }
1700
+ }
1701
+ for (const ref of refs) {
1702
+ if (!found.has(`${ref.id}\0${ref.hash}`)) {
1703
+ throw new HashNotFoundError(ref.hash);
1704
+ }
1705
+ }
1706
+ await this.#writeRoot(newEntries, generation);
1707
+ });
1708
+ }
1709
+ /**
1710
+ * permanently delete everything in the trash
1711
+ *
1712
+ * Trashing a folder doesn't touch what's inside it — those entries keep
1713
+ * naming the folder as their parent, which is what lets the device restore
1714
+ * them together — so the trash holds the whole tree hanging off it, not just
1715
+ * the entries whose parent is "trash". This purges all of it in one root
1716
+ * write.
1717
+ *
1718
+ * @example
1719
+ * ```ts
1720
+ * await api.purgeTrash();
1721
+ * ```
1722
+ *
1723
+ * @remarks
1724
+ * Finding that tree means reading every item's metadata, so this costs about
1725
+ * as much as {@link listItems | `listItems`}.
1726
+ *
1727
+ * @param refresh - if true, refresh the root hash before purging
1728
+ * @returns references to the entries that were purged
1729
+ */
1730
+ async purgeTrash(refresh = false) {
1731
+ return await this.#withRetry(async () => {
1732
+ const [rootHash, generation] = await this.#getRootHash(refresh);
1733
+ const { entries } = await this.raw.getEntries({
1734
+ id: ROOT_LIST,
1735
+ hash: rootHash,
1736
+ });
1737
+ const parents = await Promise.all(entries.map(async (entry) => (await this.getMetadata(entry)).parent));
1738
+ const children = new Map();
1739
+ for (const [ind, entry] of entries.entries()) {
1740
+ const siblings = children.get(parents[ind]);
1741
+ if (siblings === undefined) {
1742
+ children.set(parents[ind], [entry]);
1743
+ }
1744
+ else {
1745
+ siblings.push(entry);
1746
+ }
1747
+ }
1748
+ const trashed = new Set();
1749
+ let frontier = children.get(TRASH_ID) ?? [];
1750
+ while (frontier.length) {
1751
+ const next = [];
1752
+ for (const { id } of frontier) {
1753
+ // skipping what we've already seen also ends a parent cycle, which
1754
+ // the account shouldn't have but which would loop here forever
1755
+ if (!trashed.has(id)) {
1756
+ trashed.add(id);
1757
+ next.push(...(children.get(id) ?? []));
1758
+ }
1759
+ }
1760
+ frontier = next;
1761
+ }
1762
+ if (!trashed.size) {
1763
+ return [];
1764
+ }
1765
+ const purged = [];
1766
+ const newEntries = [];
1767
+ for (const entry of entries) {
1768
+ if (trashed.has(entry.id)) {
1769
+ purged.push({ id: entry.id, hash: entry.hash });
1770
+ }
1771
+ else {
1772
+ newEntries.push(entry);
1773
+ }
1774
+ }
1775
+ await this.#writeRoot(newEntries, generation);
1776
+ return purged;
1777
+ });
1778
+ }
1779
+ /**
1780
+ * listen for sync notifications
1781
+ *
1782
+ * reMarkable sends one every time a device finishes syncing. It names the
1783
+ * device, not what changed, so use it as a cue to re-read.
1784
+ *
1785
+ * The socket is reopened when the server drops it, which happens every few
1786
+ * minutes. Leaving the loop closes it. Session tokens expire after a few
1787
+ * hours, and this throws once reconnecting with an expired one fails.
1788
+ *
1789
+ * reMarkable authorizes the handshake with a header, which node and bun can
1790
+ * attach but a browser's `WebSocket` can't, so this is server side only.
1791
+ *
1792
+ * @example
1793
+ * ```ts
1794
+ * for await (const { attributes } of api.listen()) {
1795
+ * if (attributes.sourceDeviceID !== api.deviceId) {
1796
+ * const entries = await api.listItems(true);
1797
+ * }
1798
+ * }
1799
+ * ```
1800
+ *
1801
+ * @returns the notifications, in the order they arrive
1802
+ */
1803
+ listen() {
1804
+ const stop = new AbortController();
1805
+ const events = this.#listen(stop.signal);
1806
+ const { return: finish } = events;
1807
+ // an async generator only acts on return once it reaches a yield, so
1808
+ // stopping has to unblock the loop and let it end on its own
1809
+ events.return = async (value) => {
1810
+ stop.abort();
1811
+ return await finish.call(events, value);
1812
+ };
1813
+ return events;
1814
+ }
1815
+ async *#listen(signal) {
1816
+ for (let failures = 0;;) {
1817
+ const socket = this.raw.notifications();
1818
+ const closeSocket = () => {
1819
+ socket.close();
1820
+ };
1821
+ signal.addEventListener("abort", closeSocket, { once: true });
1822
+ const queued = [];
1823
+ let arrived = () => { };
1824
+ // tells a failed handshake, likely an expired token, from the server's
1825
+ // routine drop of a healthy socket
1826
+ let opened = false;
1827
+ let done = false;
1828
+ socket.addEventListener("open", () => {
1829
+ opened = true;
1830
+ });
1831
+ socket.addEventListener("message", ({ data }) => {
1832
+ queued.push(data);
1833
+ arrived();
1834
+ });
1835
+ socket.addEventListener("error", () => {
1836
+ done = true;
1837
+ arrived();
1838
+ });
1839
+ socket.addEventListener("close", () => {
1840
+ done = true;
1841
+ arrived();
1842
+ });
1843
+ try {
1844
+ while (!done || queued.length) {
1845
+ const raw = queued.shift();
1846
+ if (raw === undefined) {
1847
+ await new Promise((res) => {
1848
+ arrived = res;
1849
+ });
1850
+ }
1851
+ else {
1852
+ const { message } = notification.parse(JSON.parse(raw));
1853
+ // screenshare and passcode events share the socket, and their
1854
+ // attributes are shaped differently
1855
+ if (message.attributes.event === "SyncComplete") {
1856
+ yield syncEvent.parse(message);
1857
+ }
1858
+ }
1859
+ }
1860
+ }
1861
+ finally {
1862
+ signal.removeEventListener("abort", closeSocket);
1863
+ socket.close();
1864
+ }
1865
+ if (signal.aborted) {
1866
+ return;
1867
+ }
1868
+ else if (opened) {
1869
+ failures = 0;
1870
+ }
1871
+ else if (failures < this.#maxTransientRetries) {
1872
+ failures++;
1873
+ }
1874
+ else {
1875
+ throw new Error("couldn't open the reMarkable notification socket; the session token may have expired");
1876
+ }
1877
+ await sleep(backoffMs(failures, TRANSIENT_BASE_MS));
1878
+ }
1879
+ }
1580
1880
  /**
1581
1881
  * get the current cache value as a string
1582
1882
  *
@@ -1705,6 +2005,9 @@ export async function auth(deviceToken, { authHost = AUTH_HOST } = {}) {
1705
2005
  * If requests start failing, simply recreate the api instance with a freshly
1706
2006
  * fetched session token.
1707
2007
  *
2008
+ * The device id is read out of the token, so this throws if it isn't one
2009
+ * reMarkable minted.
2010
+ *
1708
2011
  * @param sessionToken - the session token used for authorization
1709
2012
  * @returns an api instance
1710
2013
  */
package/dist/raw.d.ts CHANGED
@@ -511,6 +511,7 @@ type AuthedFetch = (method: RequestMethod, url: string, init?: {
511
511
  body?: string | Uint8Array;
512
512
  headers?: Record<string, string>;
513
513
  }) => Promise<Response>;
514
+ type AuthedSocket = (url: string) => WebSocket;
514
515
  /**
515
516
  * access to the low-level reMarkable api
516
517
  *
@@ -576,7 +577,7 @@ type AuthedFetch = (method: RequestMethod, url: string, init?: {
576
577
  */
577
578
  export declare class RawRemarkable {
578
579
  #private;
579
- constructor(authedFetch: AuthedFetch, cache: Map<string, Uint8Array | null>, rawHost: string, uploadHost: string, maxCachedBytes: number);
580
+ constructor(authedFetch: AuthedFetch, authedSocket: AuthedSocket, cache: Map<string, Uint8Array | null>, rawHost: string, uploadHost: string, maxCachedBytes: number);
580
581
  /**
581
582
  * gets the root hash and the current generation
582
583
  *
@@ -778,6 +779,19 @@ export declare class RawRemarkable {
778
779
  * @returns a simple entry with the id and hash of the uploaded file
779
780
  */
780
781
  uploadFile(visibleName: string, bytes: Uint8Array, mime: UploadMimeType): Promise<ItemRef>;
782
+ /**
783
+ * open the websocket reMarkable pushes notifications to
784
+ *
785
+ * The server drops the socket every few minutes, so anything long lived has
786
+ * to reopen it. {@link RemarkableApi.listen | `listen`} does that, and parses
787
+ * the sync notifications out of what arrives.
788
+ *
789
+ * reMarkable authorizes the handshake with a header, which node and bun can
790
+ * attach but a browser's `WebSocket` can't, so this is server side only.
791
+ *
792
+ * @returns the connecting socket
793
+ */
794
+ notifications(): WebSocket;
781
795
  /**
782
796
  * dump the current cache to a string to preserve between session
783
797
  *