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 +14 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +322 -19
- package/dist/raw.d.ts +15 -1
- package/dist/raw.js +49 -33
- package/dist/rmapi-js.esm.min.js +53 -44
- package/package.json +4 -4
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 }),
|
|
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
|
-
|
|
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
|
*
|