rmapi-js 13.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
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The pen and color codes a stroke carries, shared by every `.rm` version.
3
+ *
4
+ * A stroke stores its pen and its color as small integers. Both sets grew with
5
+ * the firmware, so a code from a newer device can name the same pen or color
6
+ * as an older one. Reading a page checks the codes against these lists.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ import { z } from "zod";
11
+ /** a pen/tool name for a stroke */
12
+ export type RmBrush = "brush" | "pencil" | "ballpoint" | "marker" | "fineliner" | "highlighter" | "eraser" | "mechanicalPencil" | "eraseArea" | "calligraphy" | "shader";
13
+ /** a pen code that appears in a file */
14
+ export type RmBrushCode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 23;
15
+ export declare const rmBrushCode: z.ZodType<RmBrushCode>;
16
+ /**
17
+ * the reMarkable pens, by code
18
+ *
19
+ * The codes come in two firmware families, so two codes can name the same pen.
20
+ */
21
+ export declare const rmBrushes: Readonly<Record<RmBrushCode, RmBrush>>;
22
+ /** a color name for a stroke */
23
+ export type RmColor = "black" | "gray" | "white" | "yellow" | "green" | "pink" | "blue" | "red" | "grayOverlap" | "highlight" | "cyan" | "magenta";
24
+ /** a color code that appears in a file */
25
+ export type RmColorCode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
26
+ export declare const rmColorCode: z.ZodType<RmColorCode>;
27
+ /**
28
+ * the reMarkable palette, by code
29
+ *
30
+ * The palette grew when colored annotations arrived and again for the Paper
31
+ * Pro, so two codes can name the same color. `"highlight"` is a marker rather
32
+ * than a color — the stroke's real color is its `colorRgba`. The shade a name
33
+ * renders as depends on the device.
34
+ */
35
+ export declare const rmColors: Readonly<Record<RmColorCode, RmColor>>;
package/dist/codes.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The pen and color codes a stroke carries, shared by every `.rm` version.
3
+ *
4
+ * A stroke stores its pen and its color as small integers. Both sets grew with
5
+ * the firmware, so a code from a newer device can name the same pen or color
6
+ * as an older one. Reading a page checks the codes against these lists.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ import { z } from "zod";
11
+ export const rmBrushCode = z.literal([0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 15, 16, 17, 18, 21, 23], { error: "unknown pen code" });
12
+ /**
13
+ * the reMarkable pens, by code
14
+ *
15
+ * The codes come in two firmware families, so two codes can name the same pen.
16
+ */
17
+ export const rmBrushes = {
18
+ 0: "brush",
19
+ 12: "brush",
20
+ 1: "pencil",
21
+ 14: "pencil",
22
+ 2: "ballpoint",
23
+ 15: "ballpoint",
24
+ 3: "marker",
25
+ 16: "marker",
26
+ 4: "fineliner",
27
+ 17: "fineliner",
28
+ 5: "highlighter",
29
+ 18: "highlighter",
30
+ 6: "eraser",
31
+ 7: "mechanicalPencil",
32
+ 13: "mechanicalPencil",
33
+ 8: "eraseArea",
34
+ 21: "calligraphy",
35
+ 23: "shader",
36
+ };
37
+ export const rmColorCode = z.literal([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], { error: "unknown color code" });
38
+ /**
39
+ * the reMarkable palette, by code
40
+ *
41
+ * The palette grew when colored annotations arrived and again for the Paper
42
+ * Pro, so two codes can name the same color. `"highlight"` is a marker rather
43
+ * than a color — the stroke's real color is its `colorRgba`. The shade a name
44
+ * renders as depends on the device.
45
+ */
46
+ export const rmColors = {
47
+ 0: "black",
48
+ 1: "gray",
49
+ 2: "white",
50
+ 3: "yellow",
51
+ 4: "green",
52
+ 5: "pink",
53
+ 6: "blue",
54
+ 7: "red",
55
+ 8: "grayOverlap",
56
+ 9: "highlight",
57
+ 10: "green",
58
+ 11: "cyan",
59
+ 12: "magenta",
60
+ 13: "yellow",
61
+ };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { type BackgroundFilter, type CollectionContent, type Content, type DocumentContent, type Highlight, type ItemRef, type Metadata, type Orientation, type PageMetadata, RawRemarkable, type RmPage, type Tag, type TemplateContent, type TextAlignment, type ZoomMode } from "./raw.js";
2
+ export type { RmBrush, RmBrushCode, RmColor, RmColorCode, } from "./codes.js";
3
+ export { rmBrushes, rmColors } from "./codes.js";
2
4
  export { type DeviceModel, type DeviceScreen, deviceScreens, } from "./devices.js";
3
5
  export { HashNotFoundError, ValidationError } from "./error.js";
4
6
  export type { BackgroundFilter, CollectionContent, Content, CPageNumberValue, CPagePage, CPageStringValue, CPages, CPageUUID, DocumentContent, DocumentMetadata, Entries, EntryType, FileType, Highlight, HighlightRect, ItemRef, KeyboardMetadata, LegacyCollectionContent, LegacyDocumentContent, Metadata, Orientation, PageLayer, PageMetadata, PageTag, PendingEntry, RawEntry, RawRemarkableApi, RmPage, SchemaVersion, Tag, TemplateContent, TextAlignment, UploadMimeType, ZoomMode, } from "./raw.js";
5
- export type { RmBrush, RmLayer, RmLine, RmPageV5, RmPoint, RmVersion, } from "./rm5.js";
6
- export { decodeBrush, rmColors } from "./rm5.js";
7
+ export type { RmLayer, RmLine, RmPageV5, RmPoint, RmVersion, } from "./rm5.js";
7
8
  export type { AuthorIdsBlock, CrdtId, GlyphRange, LwwValue, MigrationInfoBlock, PageInfoBlock, Rectangle, RmBlock, RmScene, RmSceneItem, RmSceneLayer, RmV6Line, RmV6Point, RmV6Text, RmV6TextValue, RootTextBlock, SceneGlyphItemBlock, SceneGroupItemBlock, SceneInfoBlock, SceneItem, SceneLineItemBlock, SceneTextItemBlock, SceneTombstoneItemBlock, SceneTreeBlock, TreeNodeAnchor, TreeNodeBlock, UnknownBlock, } from "./rm6.js";
8
9
  export { crdtKey } from "./rm6.js";
9
10
  /** common properties shared by collections and documents */
@@ -184,6 +185,41 @@ export interface PutOptions {
184
185
  */
185
186
  refresh?: boolean;
186
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
+ }
187
223
  /**
188
224
  * the api for accessing remarkable functions
189
225
  *
@@ -205,6 +241,13 @@ declare class Remarkable {
205
241
  /** scoped access to the raw low-level api */
206
242
  readonly raw: RawRemarkable;
207
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;
208
251
  /**
209
252
  * list all items
210
253
  *
@@ -673,6 +716,31 @@ declare class Remarkable {
673
716
  * @returns references to the deleted entries, each with its new hash
674
717
  */
675
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>;
676
744
  /**
677
745
  * get the current cache value as a string
678
746
  *
@@ -719,6 +787,10 @@ export interface RemarkableSessionOptions {
719
787
  /**
720
788
  * the base url for making upload requests
721
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
+ *
722
794
  * @defaultValue "https://internal.cloud.remarkable.com"
723
795
  */
724
796
  uploadHost?: string;
@@ -792,6 +864,9 @@ export declare function auth(deviceToken: string, { authHost }?: AuthOptions): P
792
864
  * If requests start failing, simply recreate the api instance with a freshly
793
865
  * fetched session token.
794
866
  *
867
+ * The device id is read out of the token, so this throws if it isn't one
868
+ * reMarkable minted.
869
+ *
795
870
  * @param sessionToken - the session token used for authorization
796
871
  * @returns an api instance
797
872
  */
package/dist/index.js CHANGED
@@ -110,9 +110,9 @@ import { z } from "zod";
110
110
  import { HashNotFoundError, ValidationError } from "./error.js";
111
111
  import { LruCache } from "./lru.js";
112
112
  import { BYTES_PREFIX, CACHE_VERSION, parseMetadata, RawRemarkable, TEXT_PREFIX, } from "./raw.js";
113
+ export { rmBrushes, rmColors } from "./codes.js";
113
114
  export { deviceScreens, } from "./devices.js";
114
115
  export { HashNotFoundError, ValidationError } from "./error.js";
115
- export { decodeBrush, rmColors } from "./rm5.js";
116
116
  export { crdtKey } from "./rm6.js";
117
117
  const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
118
118
  const RAW_HOST = "https://eu.tectonic.remarkable.com";
@@ -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
  *