rmapi-js 2.2.0 → 3.0.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/dist/index.js CHANGED
@@ -15,9 +15,8 @@
15
15
  * const token = await register(code)
16
16
  * // persist token
17
17
  * const api = await remarkable(token);
18
- * const [root] = await api.getRootHash();
19
- * const fileEntries = await api.getEntries(root);
20
- * for (const entry of fileEntries) {
18
+ * const rootEntries = await api.getEntries();
19
+ * for (const entry of rootEntries) {
21
20
  * const children = await api.getEntries(entry.hash);
22
21
  * for (const { hash, documentId } of children) {
23
22
  * if (documentId.endsWith(".metadata")) {
@@ -42,6 +41,7 @@
42
41
  * @packageDocumentation
43
42
  */
44
43
  import { fromByteArray } from "base64-js";
44
+ import stringify from "json-stable-stringify";
45
45
  import { v4 as uuid4 } from "uuid";
46
46
  import { concatBuffers, fromHex, toHex } from "./utils";
47
47
  import { validate } from "./validate";
@@ -295,15 +295,22 @@ export function parseEntry(line) {
295
295
  class Remarkable {
296
296
  #userToken;
297
297
  #fetch;
298
- #cache;
299
298
  #subtle;
300
299
  #syncHost;
301
- constructor(userToken, fetch, cache, subtle, syncHost) {
300
+ // caches
301
+ #cacheLimitBytes;
302
+ #cache = new Map();
303
+ #rootCache = null;
304
+ constructor(userToken, fetch, subtle, syncHost, cacheLimitBytes, initCache) {
302
305
  this.#userToken = userToken;
303
306
  this.#fetch = fetch;
304
- this.#cache = cache;
305
307
  this.#subtle = subtle;
306
308
  this.#syncHost = syncHost;
309
+ this.#cacheLimitBytes = cacheLimitBytes;
310
+ // set cache
311
+ for (const [hash, val] of initCache) {
312
+ this.#cache.set(hash, Promise.resolve(val));
313
+ }
307
314
  }
308
315
  /** make an authorized request to remarkable */
309
316
  async #authedFetch(url, { body, method = "POST", headers = {}, }) {
@@ -348,7 +355,7 @@ class Remarkable {
348
355
  async #getUrl(relativePath, gen, rootHash) {
349
356
  const key = gen === undefined ? "downloads" : "uploads";
350
357
  // NOTE this is done manually to serialize the bigints appropriately
351
- const body = rootHash
358
+ const body = rootHash && gen !== null && gen !== undefined
352
359
  ? `{ "http_method": "PUT", "relative_path": "${relativePath}", "root_schema": "${rootHash}", "generation": ${gen} }`
353
360
  : JSON.stringify({ http_method: "GET", relative_path: relativePath });
354
361
  const resp = await this.#authedFetch(`${this.#syncHost}/sync/v2/signed-urls/${key}`, { body });
@@ -360,14 +367,38 @@ class Remarkable {
360
367
  /**
361
368
  * get the root hash and the current generation
362
369
  */
363
- async getRootHash() {
364
- const signed = await this.#getUrl("root");
365
- const resp = await this.#signedFetch(signed);
366
- const generation = resp.headers.get(GENERATION_HEADER);
367
- if (!generation) {
368
- throw new Error("no generation header in root hash");
370
+ async getRootHash({ cache = true } = {}) {
371
+ if (cache) {
372
+ while (this.#rootCache) {
373
+ try {
374
+ const [hash, gen] = await this.#rootCache;
375
+ return [hash, gen];
376
+ }
377
+ catch {
378
+ // noop
379
+ }
380
+ }
369
381
  }
370
- return [await resp.text(), BigInt(generation)];
382
+ const prom = (async () => {
383
+ try {
384
+ const signed = await this.#getUrl("root");
385
+ const resp = await this.#signedFetch(signed);
386
+ const generation = resp.headers.get(GENERATION_HEADER);
387
+ if (!generation) {
388
+ throw new Error("no generation header in root hash");
389
+ }
390
+ else {
391
+ return [await resp.text(), BigInt(generation)];
392
+ }
393
+ }
394
+ catch (ex) {
395
+ this.#rootCache = null;
396
+ throw ex;
397
+ }
398
+ })();
399
+ this.#rootCache = prom;
400
+ const [hash, gen] = await prom;
401
+ return [hash, gen];
371
402
  }
372
403
  /**
373
404
  * write the root hash, incrementing from the current generation
@@ -382,55 +413,92 @@ class Remarkable {
382
413
  }
383
414
  catch (ex) {
384
415
  if (ex instanceof ResponseError && ex.status === 412) {
416
+ this.#rootCache = null;
385
417
  throw new GenerationError();
386
418
  }
387
419
  else {
388
420
  throw ex;
389
421
  }
390
422
  }
391
- const gen = resp.headers.get(GENERATION_HEADER);
392
- if (!gen) {
423
+ const genStr = resp.headers.get(GENERATION_HEADER);
424
+ if (!genStr) {
393
425
  throw new Error("no generation header in root hash");
394
426
  }
395
- return BigInt(gen);
427
+ const gen = BigInt(genStr);
428
+ this.#rootCache = Promise.resolve([hash, gen]);
429
+ return gen;
430
+ }
431
+ /**
432
+ * get content associated with hash
433
+ */
434
+ async #getHash(hash) {
435
+ let cached = this.#cache.get(hash);
436
+ while (cached) {
437
+ try {
438
+ const val = await cached;
439
+ if (val) {
440
+ return val;
441
+ }
442
+ else {
443
+ cached = undefined; // break
444
+ }
445
+ }
446
+ catch {
447
+ // try again if promise rejected
448
+ cached = this.#cache.get(hash);
449
+ }
450
+ }
451
+ const prom = (async () => {
452
+ const signed = await this.#getUrl(hash);
453
+ const resp = await this.#signedFetch(signed);
454
+ return await resp.arrayBuffer();
455
+ })();
456
+ // set cache with appropriate promise that cleans up on rejection
457
+ this.#cache.set(hash, prom.then((buff) => (buff.byteLength < this.#cacheLimitBytes ? buff : null), (ex) => {
458
+ this.#cache.delete(hash);
459
+ throw ex;
460
+ }));
461
+ return await prom;
396
462
  }
397
463
  /**
398
464
  * get text content associated with hash
399
465
  */
400
466
  async getBuffer(hash) {
401
- const signed = await this.#getUrl(hash);
402
- const resp = await this.#signedFetch(signed);
403
- return await resp.arrayBuffer();
467
+ const buff = await this.#getHash(hash);
468
+ // copy if it is long enough to be cached to preven mutations
469
+ return buff.byteLength < this.#cacheLimitBytes ? buff.slice(0) : buff;
404
470
  }
405
471
  /**
406
472
  * get text content associated with hash
407
473
  */
408
474
  async getText(hash) {
409
- const cached = await this.#cache?.get(hash);
410
- if (cached) {
411
- return cached;
412
- }
413
- else {
414
- const signed = await this.#getUrl(hash);
415
- const resp = await this.#signedFetch(signed);
416
- const raw = await resp.text();
417
- await this.#cache?.set(hash, raw);
418
- return raw;
419
- }
475
+ const buff = await this.#getHash(hash);
476
+ const decoder = new TextDecoder();
477
+ return decoder.decode(buff);
478
+ }
479
+ /**
480
+ * get json content associated with hash
481
+ */
482
+ async getJson(hash) {
483
+ const str = await this.getText(hash);
484
+ return JSON.parse(str);
420
485
  }
421
486
  /**
422
487
  * get metadata from hash
423
488
  */
424
489
  async getMetadata(hash) {
425
- const raw = await this.getText(hash);
426
- const parsed = JSON.parse(raw);
427
- validate(metadataSchema, parsed);
428
- return parsed;
490
+ const raw = await this.getJson(hash);
491
+ validate(metadataSchema, raw);
492
+ return raw;
429
493
  }
430
494
  /**
431
495
  * get entries from a collection hash
432
496
  */
433
497
  async getEntries(hash) {
498
+ if (hash === undefined) {
499
+ const [newHash] = await this.getRootHash({ cache: true });
500
+ hash = newHash;
501
+ }
434
502
  const raw = await this.getText(hash);
435
503
  // slice for trailing new line
436
504
  const [schema, ...lines] = raw.slice(0, -1).split("\n");
@@ -441,8 +509,31 @@ class Remarkable {
441
509
  }
442
510
  /** upload data to hash */
443
511
  async #putHash(hash, body) {
444
- const signed = await this.#getUrl(hash, null);
445
- await this.#signedFetch(signed, body);
512
+ // try cached version
513
+ const cached = this.#cache.get(hash);
514
+ if (cached) {
515
+ try {
516
+ await cached;
517
+ return;
518
+ }
519
+ catch {
520
+ // noop
521
+ }
522
+ }
523
+ // if missing or rejected then put for real
524
+ const prom = (async () => {
525
+ try {
526
+ const signed = await this.#getUrl(hash, null);
527
+ await this.#signedFetch(signed, body);
528
+ return body.byteLength < this.#cacheLimitBytes ? body : null;
529
+ }
530
+ catch (ex) {
531
+ this.#cache.delete(hash);
532
+ throw ex;
533
+ }
534
+ })();
535
+ this.#cache.set(hash, prom);
536
+ await prom;
446
537
  }
447
538
  /** put a reference to a set of entries into the cloud */
448
539
  async putEntries(documentId, entries) {
@@ -456,7 +547,6 @@ class Remarkable {
456
547
  const contents = `${SCHEMA_VERSION}\n${entryContents}`;
457
548
  const buffer = enc.encode(contents);
458
549
  await this.#putHash(hash, buffer);
459
- await this.#cache?.set(hash, contents);
460
550
  return {
461
551
  hash,
462
552
  type: "80000000",
@@ -475,19 +565,21 @@ class Remarkable {
475
565
  type: "0",
476
566
  documentId,
477
567
  subfiles: 0,
478
- size: BigInt(buffer.length),
568
+ size: BigInt(buffer.byteLength),
479
569
  };
480
570
  }
481
- /** put cached text in the cloud */
571
+ /** put text in the cloud */
482
572
  async putText(documentId, contents) {
483
573
  const enc = new TextEncoder();
484
- const entry = await this.putBuffer(documentId, enc.encode(contents));
485
- await this.#cache?.set(entry.hash, contents);
486
- return entry;
574
+ return await this.putBuffer(documentId, enc.encode(contents));
575
+ }
576
+ /** put json in the cloud */
577
+ async putJson(documentId, contents) {
578
+ return await this.putText(documentId, stringify(contents));
487
579
  }
488
580
  /** put metadata into the cloud */
489
581
  async putMetadata(documentId, metadata) {
490
- return await this.putText(`${documentId}.metadata`, JSON.stringify(metadata));
582
+ return await this.putJson(`${documentId}.metadata`, metadata);
491
583
  }
492
584
  /** put a new collection (folder) */
493
585
  async putCollection(visibleName, parent = "") {
@@ -593,32 +685,32 @@ class Remarkable {
593
685
  });
594
686
  }
595
687
  /** try to sync, always succeed */
596
- async #trySync(gen) {
597
- try {
598
- await this.syncComplete(gen);
599
- return true;
688
+ async #tryPutRootEntries(gen, entries, sync) {
689
+ const { hash } = await this.putEntries("", entries);
690
+ const nextGen = await this.putRootHash(hash, gen);
691
+ if (sync) {
692
+ try {
693
+ await this.syncComplete(nextGen);
694
+ return true;
695
+ }
696
+ catch {
697
+ return false;
698
+ }
600
699
  }
601
- catch {
700
+ else {
602
701
  return false;
603
702
  }
604
703
  }
605
704
  /** high level api to create an entry */
606
- async create(entry, sync = true) {
607
- const [root, gen] = await this.getRootHash();
705
+ async create(entry, { cache = true, sync = true } = {}) {
706
+ const [root, gen] = await this.getRootHash({ cache });
608
707
  const rootEntries = await this.getEntries(root);
609
708
  rootEntries.push(entry);
610
- const { hash } = await this.putEntries("", rootEntries);
611
- const nextGen = await this.putRootHash(hash, gen);
612
- if (sync) {
613
- return await this.#trySync(nextGen);
614
- }
615
- else {
616
- return false;
617
- }
709
+ return await this.#tryPutRootEntries(gen, rootEntries, sync);
618
710
  }
619
711
  /** high level api to move a document / collection */
620
- async move(documentId, dest, sync = true) {
621
- const [root, gen] = await this.getRootHash();
712
+ async move(documentId, dest, { cache = true, sync = true } = {}) {
713
+ const [root, gen] = await this.getRootHash({ cache });
622
714
  const rootEntries = await this.getEntries(root);
623
715
  // check if destination is a collection
624
716
  if (!dest || dest === "trash") {
@@ -667,16 +759,7 @@ class Remarkable {
667
759
  // update root entries
668
760
  const newEntry = await this.putEntries(documentId, docEnts);
669
761
  rootEntries.push(newEntry);
670
- // update generation
671
- const { hash } = await this.putEntries("", rootEntries);
672
- const nextGen = await this.putRootHash(hash, gen);
673
- // sync
674
- if (sync) {
675
- return await this.#trySync(nextGen);
676
- }
677
- else {
678
- return false;
679
- }
762
+ return await this.#tryPutRootEntries(gen, rootEntries, sync);
680
763
  }
681
764
  /** get entries and metadata for all files */
682
765
  async getEntriesMetadata() {
@@ -720,6 +803,20 @@ class Remarkable {
720
803
  // TODO why doesn't this work
721
804
  return await this.#uploadFile(visibleName, buffer, "application/pdf");
722
805
  }
806
+ async getCache() {
807
+ const promises = [];
808
+ for (const [hash, prom] of this.#cache) {
809
+ promises.push(prom.then((val) => [hash, val], () => [hash, null]));
810
+ }
811
+ const entries = await Promise.all(promises);
812
+ const cache = new Map();
813
+ for (const [hash, val] of entries) {
814
+ if (val) {
815
+ cache.set(hash, val);
816
+ }
817
+ }
818
+ return cache;
819
+ }
723
820
  }
724
821
  /**
725
822
  * create an instance of the api
@@ -731,7 +828,10 @@ class Remarkable {
731
828
  * registered. Create one with {@link register}.
732
829
  * @returns an api instance
733
830
  */
734
- export async function remarkable(deviceToken, { fetch = globalThis.fetch, cache, subtle = globalThis.crypto?.subtle, authHost = AUTH_HOST, syncHost = SYNC_HOST, } = {}) {
831
+ export async function remarkable(deviceToken, { fetch = globalThis.fetch, subtle = globalThis.crypto?.subtle, authHost = AUTH_HOST, syncHost = SYNC_HOST, cacheLimitBytes = 1048576, initCache = [], } = {}) {
832
+ if (!subtle) {
833
+ throw new Error("subtle was missing, try specifying it manually");
834
+ }
735
835
  const resp = await fetch(`${authHost}/token/json/2/user/new`, {
736
836
  method: "POST",
737
837
  headers: {
@@ -742,5 +842,5 @@ export async function remarkable(deviceToken, { fetch = globalThis.fetch, cache,
742
842
  throw new Error(`couldn't fetch auth token: ${resp.statusText}`);
743
843
  }
744
844
  const userToken = await resp.text();
745
- return new Remarkable(userToken, fetch, cache, subtle, syncHost);
845
+ return new Remarkable(userToken, fetch, subtle, syncHost, cacheLimitBytes, initCache);
746
846
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rmapi-js",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "JavaScript implementation of the reMarkable 1.5 api",
5
5
  "repository": "git@github.com:erikbrinkman/rmapi-js.git",
6
6
  "author": "Erik Brinkman <erik.brinkman@gmail.com>",
@@ -21,14 +21,17 @@
21
21
  "scripts": {
22
22
  "doc": "typedoc",
23
23
  "fmt": "prettier --cache --write 'src/*.ts' '*.json' bundle.mjs",
24
- "lint": "tsc && eslint --cache 'src/*.ts' bundle.mjs && typedoc --emit none",
24
+ "lint:es": "eslint --cache 'src/*.ts'",
25
+ "lint:doc": "typedoc --emit none",
26
+ "lint": "tsc && yarn lint:es && yarn lint:doc",
25
27
  "test": "jest --coverage",
26
- "build": "tsc -p tsconfig.build.json && yarn node bundle.mjs",
28
+ "build": "tsc -p tsconfig.build.json && node bundle.mjs",
27
29
  "prepack": "yarn lint && yarn test && yarn build"
28
30
  },
29
31
  "dependencies": {
30
32
  "ajv": "^8.11.2",
31
33
  "base64-js": "^1.5.1",
34
+ "json-stable-stringify": "^1.0.2",
32
35
  "jtd": "^0.1.1",
33
36
  "uuid": "^9.0.0"
34
37
  },
@@ -38,7 +41,8 @@
38
41
  "@babel/preset-typescript": "^7.18.6",
39
42
  "@types/babel__core": "^7.1.20",
40
43
  "@types/babel__preset-env": "^7.9.2",
41
- "@types/jest": "^29.2.4",
44
+ "@types/jest": "^29.2.5",
45
+ "@types/json-stable-stringify": "^1.0.34",
42
46
  "@types/node": "^18.11.18",
43
47
  "@types/uuid": "^9.0.0",
44
48
  "@typescript-eslint/eslint-plugin": "^5.47.1",
@@ -46,10 +50,10 @@
46
50
  "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.15",
47
51
  "babel-jest": "^29.3.1",
48
52
  "chalk": "^5.2.0",
49
- "esbuild": "^0.16.10",
50
- "eslint": "^8.30.0",
53
+ "esbuild": "^0.16.12",
54
+ "eslint": "^8.31.0",
51
55
  "eslint-config-prettier": "^8.5.0",
52
- "eslint-plugin-jest": "^27.1.7",
56
+ "eslint-plugin-jest": "^27.2.0",
53
57
  "eslint-plugin-spellcheck": "^0.0.20",
54
58
  "eslint-plugin-tsdoc": "^0.2.17",
55
59
  "jest": "^29.3.1",
@@ -88,9 +92,15 @@
88
92
  "extends": [
89
93
  "eslint:recommended",
90
94
  "plugin:@typescript-eslint/recommended",
95
+ "plugin:@typescript-eslint/recommended-requiring-type-checking",
91
96
  "plugin:jest/recommended",
92
97
  "prettier"
93
98
  ],
99
+ "parserOptions": {
100
+ "project": [
101
+ "./tsconfig.json"
102
+ ]
103
+ },
94
104
  "env": {
95
105
  "node": true
96
106
  },