rmapi-js 14.0.0 → 14.1.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
  *
@@ -674,6 +716,31 @@ declare class Remarkable {
674
716
  * @returns references to the deleted entries, each with its new hash
675
717
  */
676
718
  bulkDelete(refs: readonly ItemRef[], refresh?: boolean): Promise<ItemRef[]>;
719
+ /**
720
+ * listen for sync notifications
721
+ *
722
+ * reMarkable sends one every time a device finishes syncing. It names the
723
+ * device, not what changed, so use it as a cue to re-read.
724
+ *
725
+ * The socket is reopened when the server drops it, which happens every few
726
+ * minutes. Leaving the loop closes it. Session tokens expire after a few
727
+ * hours, and this throws once reconnecting with an expired one fails.
728
+ *
729
+ * reMarkable authorizes the handshake with a header, which node and bun can
730
+ * attach but a browser's `WebSocket` can't, so this is server side only.
731
+ *
732
+ * @example
733
+ * ```ts
734
+ * for await (const { attributes } of api.listen()) {
735
+ * if (attributes.sourceDeviceID !== api.deviceId) {
736
+ * const entries = await api.listItems(true);
737
+ * }
738
+ * }
739
+ * ```
740
+ *
741
+ * @returns the notifications, in the order they arrive
742
+ */
743
+ listen(): AsyncGenerator<SyncEvent, void, undefined>;
677
744
  /**
678
745
  * get the current cache value as a string
679
746
  *
@@ -720,6 +787,10 @@ export interface RemarkableSessionOptions {
720
787
  /**
721
788
  * the base url for making upload requests
722
789
  *
790
+ * @deprecated uploads now go to the same server as everything else, so this
791
+ * will be removed and `rawHost` will cover both. Point `rawHost` at the
792
+ * backend you want instead.
793
+ *
723
794
  * @defaultValue "https://internal.cloud.remarkable.com"
724
795
  */
725
796
  uploadHost?: string;
@@ -793,6 +864,9 @@ export declare function auth(deviceToken: string, { authHost }?: AuthOptions): P
793
864
  * If requests start failing, simply recreate the api instance with a freshly
794
865
  * fetched session token.
795
866
  *
867
+ * The device id is read out of the token, so this throws if it isn't one
868
+ * reMarkable minted.
869
+ *
796
870
  * @param sessionToken - the session token used for authorization
797
871
  * @returns an api instance
798
872
  */
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) {
@@ -1577,6 +1631,107 @@ class Remarkable {
1577
1631
  async bulkDelete(refs, refresh = false) {
1578
1632
  return await this.bulkMove(refs, TRASH_ID, refresh);
1579
1633
  }
1634
+ /**
1635
+ * listen for sync notifications
1636
+ *
1637
+ * reMarkable sends one every time a device finishes syncing. It names the
1638
+ * device, not what changed, so use it as a cue to re-read.
1639
+ *
1640
+ * The socket is reopened when the server drops it, which happens every few
1641
+ * minutes. Leaving the loop closes it. Session tokens expire after a few
1642
+ * hours, and this throws once reconnecting with an expired one fails.
1643
+ *
1644
+ * reMarkable authorizes the handshake with a header, which node and bun can
1645
+ * attach but a browser's `WebSocket` can't, so this is server side only.
1646
+ *
1647
+ * @example
1648
+ * ```ts
1649
+ * for await (const { attributes } of api.listen()) {
1650
+ * if (attributes.sourceDeviceID !== api.deviceId) {
1651
+ * const entries = await api.listItems(true);
1652
+ * }
1653
+ * }
1654
+ * ```
1655
+ *
1656
+ * @returns the notifications, in the order they arrive
1657
+ */
1658
+ listen() {
1659
+ const stop = new AbortController();
1660
+ const events = this.#listen(stop.signal);
1661
+ const { return: finish } = events;
1662
+ // an async generator only acts on return once it reaches a yield, so
1663
+ // stopping has to unblock the loop and let it end on its own
1664
+ events.return = async (value) => {
1665
+ stop.abort();
1666
+ return await finish.call(events, value);
1667
+ };
1668
+ return events;
1669
+ }
1670
+ async *#listen(signal) {
1671
+ for (let failures = 0;;) {
1672
+ const socket = this.raw.notifications();
1673
+ const closeSocket = () => {
1674
+ socket.close();
1675
+ };
1676
+ signal.addEventListener("abort", closeSocket, { once: true });
1677
+ const queued = [];
1678
+ let arrived = () => { };
1679
+ // tells a failed handshake, likely an expired token, from the server's
1680
+ // routine drop of a healthy socket
1681
+ let opened = false;
1682
+ let done = false;
1683
+ socket.addEventListener("open", () => {
1684
+ opened = true;
1685
+ });
1686
+ socket.addEventListener("message", ({ data }) => {
1687
+ queued.push(data);
1688
+ arrived();
1689
+ });
1690
+ socket.addEventListener("error", () => {
1691
+ done = true;
1692
+ arrived();
1693
+ });
1694
+ socket.addEventListener("close", () => {
1695
+ done = true;
1696
+ arrived();
1697
+ });
1698
+ try {
1699
+ while (!done || queued.length) {
1700
+ const raw = queued.shift();
1701
+ if (raw === undefined) {
1702
+ await new Promise((res) => {
1703
+ arrived = res;
1704
+ });
1705
+ }
1706
+ else {
1707
+ const { message } = notification.parse(JSON.parse(raw));
1708
+ // screenshare and passcode events share the socket, and their
1709
+ // attributes are shaped differently
1710
+ if (message.attributes.event === "SyncComplete") {
1711
+ yield syncEvent.parse(message);
1712
+ }
1713
+ }
1714
+ }
1715
+ }
1716
+ finally {
1717
+ signal.removeEventListener("abort", closeSocket);
1718
+ socket.close();
1719
+ }
1720
+ if (signal.aborted) {
1721
+ return;
1722
+ }
1723
+ else if (opened) {
1724
+ failures = 0;
1725
+ }
1726
+ else if (failures < this.#maxTransientRetries) {
1727
+ failures++;
1728
+ }
1729
+ else {
1730
+ throw new Error("couldn't open the reMarkable notification socket; the session token may have expired");
1731
+ }
1732
+ await sleep(backoffMs(failures, TRANSIENT_BASE_MS));
1733
+ }
1734
+ }
1580
1735
  /**
1581
1736
  * get the current cache value as a string
1582
1737
  *
@@ -1705,6 +1860,9 @@ export async function auth(deviceToken, { authHost = AUTH_HOST } = {}) {
1705
1860
  * If requests start failing, simply recreate the api instance with a freshly
1706
1861
  * fetched session token.
1707
1862
  *
1863
+ * The device id is read out of the token, so this throws if it isn't one
1864
+ * reMarkable minted.
1865
+ *
1708
1866
  * @param sessionToken - the session token used for authorization
1709
1867
  * @returns an api instance
1710
1868
  */
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
  *
package/dist/raw.js CHANGED
@@ -66,14 +66,14 @@ const tag = z
66
66
  name: z.string(),
67
67
  timestamp: z.number(),
68
68
  })
69
- .passthrough();
69
+ .loose();
70
70
  const pageTag = z
71
71
  .object({
72
72
  name: z.string(),
73
73
  pageId: z.string(),
74
74
  timestamp: z.number(),
75
75
  })
76
- .passthrough();
76
+ .loose();
77
77
  const documentMetadata = z
78
78
  .object({
79
79
  authors: z.array(z.string()).optional(),
@@ -81,53 +81,51 @@ const documentMetadata = z
81
81
  publicationDate: z.string().optional(),
82
82
  publisher: z.string().optional(),
83
83
  })
84
- .passthrough();
84
+ .loose();
85
85
  const cPagePage = z
86
86
  .object({
87
87
  id: z.string(),
88
- idx: z.object({ timestamp: z.string(), value: z.string() }).passthrough(),
88
+ idx: z.object({ timestamp: z.string(), value: z.string() }).loose(),
89
89
  template: z
90
90
  .object({ timestamp: z.string(), value: z.string() })
91
- .passthrough()
91
+ .loose()
92
92
  .optional(),
93
93
  redir: z
94
94
  .object({ timestamp: z.string(), value: z.number().int() })
95
- .passthrough()
95
+ .loose()
96
96
  .optional(),
97
97
  scrollTime: z
98
98
  .object({
99
99
  timestamp: z.string(),
100
- value: z.string().datetime({ offset: true }),
100
+ value: z.iso.datetime({ offset: true }),
101
101
  })
102
- .passthrough()
102
+ .loose()
103
103
  .optional(),
104
104
  verticalScroll: z
105
105
  .object({ timestamp: z.string(), value: z.number() })
106
- .passthrough()
106
+ .loose()
107
107
  .optional(),
108
108
  deleted: z
109
109
  .object({ timestamp: z.string(), value: z.number().int() })
110
- .passthrough()
110
+ .loose()
111
111
  .optional(),
112
112
  modifed: z.string().optional(),
113
113
  })
114
- .passthrough();
114
+ .loose();
115
115
  const cPages = z
116
116
  .object({
117
- lastOpened: z
118
- .object({ timestamp: z.string(), value: z.string() })
119
- .passthrough(),
117
+ lastOpened: z.object({ timestamp: z.string(), value: z.string() }).loose(),
120
118
  original: z
121
119
  .object({ timestamp: z.string(), value: z.number().int() })
122
- .passthrough(),
120
+ .loose(),
123
121
  pages: z.array(cPagePage),
124
122
  uuids: z
125
123
  .array(z
126
124
  .object({ first: z.string(), second: z.number().int().nonnegative() })
127
- .passthrough())
125
+ .loose())
128
126
  .nullable(),
129
127
  })
130
- .passthrough();
128
+ .loose();
131
129
  const collectionContent = z
132
130
  .object({
133
131
  tags: z.array(tag).optional(),
@@ -162,7 +160,7 @@ const documentContentOptional = {
162
160
  formatVersion: z.number().int().nonnegative().optional(),
163
161
  keyboardMetadata: z
164
162
  .object({ count: z.number().int().nonnegative(), timestamp: z.number() })
165
- .passthrough()
163
+ .loose()
166
164
  .optional(),
167
165
  lastOpenedPage: z.number().int().optional(),
168
166
  margins: z.number().int().nonnegative().optional(),
@@ -183,7 +181,7 @@ const documentContentOptional = {
183
181
  m32: z.number().optional(),
184
182
  m33: z.number().optional(),
185
183
  })
186
- .passthrough()
184
+ .loose()
187
185
  .optional(),
188
186
  viewBackgroundFilter: z.enum(["off", "fullpage"]).optional(),
189
187
  zoomMode: z
@@ -192,13 +190,13 @@ const documentContentOptional = {
192
190
  };
193
191
  const commonDocumentContent = z
194
192
  .object({ ...documentContentRequired, ...documentContentOptional })
195
- .passthrough();
193
+ .loose();
196
194
  const documentContent = commonDocumentContent
197
195
  .extend({ tags: z.array(tag).optional() })
198
- .passthrough();
196
+ .loose();
199
197
  const legacyDocumentContent = commonDocumentContent
200
198
  .extend({ tags: z.array(z.string()).optional() })
201
- .passthrough();
199
+ .loose();
202
200
  const templateContent = z
203
201
  .object({
204
202
  id: z.string().optional(),
@@ -216,7 +214,7 @@ const templateContent = z
216
214
  items: z.array(z.unknown()),
217
215
  formatVersion: z.number().int().nonnegative().optional(),
218
216
  })
219
- .passthrough();
217
+ .loose();
220
218
  // content payloads aren't discriminable (legacy/modern differ only by tags
221
219
  // element type), so this is an ordered union: the first matching variant wins
222
220
  const content = z.union([
@@ -242,17 +240,17 @@ const highlightsFile = z
242
240
  width: z.number(),
243
241
  height: z.number(),
244
242
  })
245
- .passthrough()),
243
+ .loose()),
246
244
  })
247
- .passthrough())),
245
+ .loose())),
248
246
  })
249
- .passthrough();
247
+ .loose();
250
248
  const pageMetadataReg = /\/[^/]+-metadata\.json$/;
251
249
  const pageMetadata = z
252
250
  .object({
253
- layers: z.array(z.object({ name: z.string() }).passthrough()),
251
+ layers: z.array(z.object({ name: z.string() }).loose()),
254
252
  })
255
- .passthrough();
253
+ .loose();
256
254
  const metadata = z
257
255
  .object({
258
256
  lastModified: z.string().optional(),
@@ -271,7 +269,7 @@ const metadata = z
271
269
  new: z.boolean().optional(),
272
270
  source: z.string().optional(),
273
271
  })
274
- .passthrough();
272
+ .loose();
275
273
  /** parse and validate the json text of a `.metadata` file */
276
274
  export function parseMetadata(text) {
277
275
  const loaded = JSON.parse(text);
@@ -282,20 +280,20 @@ const updatedRootHash = z
282
280
  hash: z.string(),
283
281
  generation: z.number(),
284
282
  })
285
- .passthrough();
283
+ .loose();
286
284
  const rootHash = z
287
285
  .object({
288
286
  hash: z.string(),
289
287
  generation: z.number(),
290
288
  schemaVersion: z.number().int().nonnegative(),
291
289
  })
292
- .passthrough();
290
+ .loose();
293
291
  const nativeItemRef = z
294
292
  .object({
295
293
  docID: z.string(),
296
294
  hash: z.string(),
297
295
  })
298
- .passthrough();
296
+ .loose();
299
297
  async function digest(buff) {
300
298
  const digest = await crypto.subtle.digest("SHA-256",
301
299
  // NOTE this is type hinted wrong, but it does work correctly on a uint8 view
@@ -389,6 +387,7 @@ function parseRawEntryLine(line) {
389
387
  */
390
388
  export class RawRemarkable {
391
389
  #authedFetch;
390
+ #authedSocket;
392
391
  #rawHost;
393
392
  #uploadHost;
394
393
  /**
@@ -402,8 +401,9 @@ export class RawRemarkable {
402
401
  */
403
402
  #cache;
404
403
  #maxCachedBytes;
405
- constructor(authedFetch, cache, rawHost, uploadHost, maxCachedBytes) {
404
+ constructor(authedFetch, authedSocket, cache, rawHost, uploadHost, maxCachedBytes) {
406
405
  this.#authedFetch = authedFetch;
406
+ this.#authedSocket = authedSocket;
407
407
  this.#cache = cache;
408
408
  this.#rawHost = rawHost;
409
409
  this.#uploadHost = uploadHost;
@@ -867,6 +867,22 @@ export class RawRemarkable {
867
867
  const { docID, hash } = nativeItemRef.parse(loaded);
868
868
  return { id: docID, hash };
869
869
  }
870
+ /**
871
+ * open the websocket reMarkable pushes notifications to
872
+ *
873
+ * The server drops the socket every few minutes, so anything long lived has
874
+ * to reopen it. {@link RemarkableApi.listen | `listen`} does that, and parses
875
+ * the sync notifications out of what arrives.
876
+ *
877
+ * reMarkable authorizes the handshake with a header, which node and bun can
878
+ * attach but a browser's `WebSocket` can't, so this is server side only.
879
+ *
880
+ * @returns the connecting socket
881
+ */
882
+ notifications() {
883
+ const host = this.#rawHost.replace(/^http/, "ws");
884
+ return this.#authedSocket(`${host}/notifications/ws/json/1`);
885
+ }
870
886
  /**
871
887
  * dump the current cache to a string to preserve between session
872
888
  *