npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rmcommunication-ts

v0.3.0

Published

SSH-tunneled live document communication and tooling for reMarkable tablets

Readme

rmcommunication-ts

rmcommunication-ts is a Node.js library for local communication with a reMarkable tablet in developer mode. Its live client lists, downloads, and uploads documents through the tablet Web Interface over a pinned SSH tunnel on WiFi. Its separate maintenance client provides guarded SSH/SFTP bundle access, backups, mirrors, templates, rendering, and existing-page replacement.

It does not use or implement the reMarkable Cloud protocol. It exposes typed operations, not a generic HTTP proxy, shell, or unrestricted SFTP client.

The package requires Node 20.9 or newer and rmscene-ts 0.1.x.

Install

npm install rmcommunication-ts rmscene-ts

Connect with a verified host key

import { connectDevice, usbProfile } from "rmcommunication-ts";

const confirmedFingerprint = await config.get("remarkable-host-fingerprint");
const device = await connectDevice(
  usbProfile({
    authentication: {
      kind: "password",
      password: async () => await secretStore.get("remarkable-password"),
    },
    hostKey: {
      kind: "pinned",
      fingerprint: confirmedFingerprint,
    },
  }),
);

try {
  console.log(await device.identity());
} finally {
  await device.close();
}

USB uses the official developer-mode address 10.11.99.1. For WiFi, use wifiProfile({ host, ... }). WiFi SSH is enabled only by an explicit await device.enableWifiSsh() call, which runs the official rm-ssh-over-wlan on command.

A pinned fingerprint mismatch always throws ChangedHostKeyError. To implement an explicit first-use confirmation screen, use hostKey: { kind: "confirmUnknown", confirm }; rejecting the shown fingerprint throws UnknownHostKeyError. There is no silent trust-on-first-use mode.

Credentials come from a callback for the current connection. The package does not persist, return, or log them. Password and private-key authentication are supported.

Live documents over WiFi

import { connectWebInterfaceOverSsh, wifiProfile } from "rmcommunication-ts";

const ssh = wifiProfile({
  host: "192.0.2.10",
  authentication: {
    kind: "password",
    password: async () => await secretStore.get("remarkable-password"),
  },
  hostKey: { kind: "pinned", fingerprint: confirmedFingerprint },
});
const web = await connectWebInterfaceOverSsh({ ssh });

try {
  const documents = await web.listDocuments();
  const download = await web.downloadDocument({
    documentId,
    backupDirectory: "D:/remarkable-backups",
  });
  console.log(documents, download.archivePath, download.revision);
} finally {
  await web.close();
}

Each HTTP connection is an SSH direct-tcpip channel to the fixed tablet-internal destination 10.11.99.1:80. The client opens no local port and accepts no URL, bind address, or forwarding target from its caller. It rejects 10.11.99.1 as the SSH host because this client is WiFi-only.

The tablet needs a one-time Paper Pro bootstrap that keeps 10.11.99.1/27 assigned to usb1 without a cable and enables its Web Interface setting. scripts/bootstrap-paper-pro-web-interface.sh is firmware-gated, backs up every changed file under encrypted /home, writes both the active /etc overlay and its verified persistent lower filesystem, includes exact rollback, and never restarts Xochitl or reboots the tablet. The npm runtime never invokes it. It installs no proxy or WiFi listener.

The live client recursively reads /documents/, downloads /download/{id}/rmdoc, and validates the archive before publishing a local backup. Paper Pro sleep disconnects WiFi. Such transport failures after SSH setup surface as DeviceOfflineError; initial SSH setup can instead time out or return a connection failure. There is no USB, SFTP, or cloud fallback.

Explicit SSH document access

const entries = await device.listDocuments();
const page = await device.readPage(documentId, pageId);

page.bytes;     // Uint8Array containing the original .rm file
page.revision;  // SHA-256 revision for optimistic concurrency

Entries retain the original .metadata and .content JSON objects, including unknown properties. Both current cPages.pages and legacy pages layouts are recognized. Documents and folders share the same flat list with parentId and type fields.

These Device document-storage operations use a guarded offline session:

  1. verify service and recovery-timer capabilities
  2. acquire an atomic lock outside the document directory
  3. arm an on-device delayed recovery timer and renew it while work continues
  4. stop xochitl and wait for it to become inactive
  5. perform the bounded operation
  6. start xochitl in recovery code and wait for it to become active
  7. cancel the timer, then remove the lock

Each renewal arms its replacement before cancelling the previous timer. If renewal fails, the SSH transport is destroyed so a pending remote mutation cannot finish later, while the last on-device timer remains available. Its recovery command starts xochitl and removes a stale lock. The caller's AbortSignal cannot cancel the final service recovery path.

Backups and mirror generations

const snapshot = await device.snapshotDocument(documentId, "D:/remarkable-backups");
const mirror = await device.mirrorDocuments("D:/remarkable-mirror");

A document snapshot contains every {documentId}.* sibling and every regular file below the {documentId}/ subtree. Downloads are checked with stat-before, stat-after, remote SHA-256, local SHA-256 readback, and a final stat, hash, stat verification of every selected remote file. Its manifest records paths, sizes, modes, timestamps, and hashes.

The mirror writes a complete immutable generation under generations/ and atomically updates current.json. A changed remote file is retried up to three times by default. Older generations are kept; retention is the caller's policy. Local files are flushed before publication. Parent directories are flushed after file and directory renames on systems that expose directory fsync. Node on Windows does not expose a flushable directory handle, so Windows retains atomic same-volume rename and file flush guarantees but cannot claim POSIX power-loss durability for directory entries. Snapshot manifests and both result objects report this explicitly as localDurability: "file-only"; hosts with directory fsync report "file-and-directory".

Import PDF and EPUB over WiFi

const imported = await web.importPdf({
  sourcePath: "D:/remarkable-staging/presentation.pdf",
  name: "Presentation",
  backupDirectory: "D:/remarkable-backups",
  processingTimeoutMs: 120_000,
});

console.log(imported.documentId, imported.revision, imported.pages);
console.log(imported.receipt.archivePath, imported.receipt.sourceSha256);

Use web.importEpub with the same request shape for EPUB. sourcePath must be an absolute path to a stable regular file. The hard source limit is 100,000,000 bytes. PDF validation checks the header and bounded tail without changing bytes. EPUB validation checks the ZIP directory, required uncompressed mimetype, safe paths, size and compression limits, container.xml, and its package document.

Import records current IDs, selects root immediately before upload, and streams one multipart field named file to /upload. It never retries that POST after an SSH channel has opened. It polls fresh listings for new IDs, downloads each candidate rmdoc, and accepts one only when its embedded PDF or EPUB SHA-256 exactly matches the source and its generated page model is nonempty. An EPUB bundle also contains a firmware-generated {documentId}.pdf rendition, so the source is selected by the declared document file type rather than by assuming a single candidate. The result includes the actual name and parent, pages, archive-derived revision, source receipt, and verified rmdoc backup.

request.name is a request, not the stored name. Read result.name. The firmware names an imported EPUB from its internal dc:title and ignores the uploaded filename, while a PDF keeps the multipart filename including its .pdf extension, exactly as it would if the file were dropped into the device's own web UI.

The firmware endpoint owns publication and exposes no rollback or delete operation. A lost response is reconciled by new IDs and source hashes without a second upload. No match or multiple possible matches throws AmbiguousImportError with the transaction ID, file type, and candidate IDs. The error contains no source path, document name, or source bytes.

Pass parentId to file the upload in a folder; omit it or pass null for root. The firmware API has no explicit parent parameter. It places the upload into the container listed most recently through GET /documents/…, which a live gate on firmware 3.27.3.0 confirmed with two folder rounds and two root control rounds, and which the device's own web UI relies on as well.

That selection is global device state rather than per-connection, so import validates the target folder first, issues the selecting listing as the last request before POST /upload, and accepts the result only when the reconciled document parent equals the requested one. All of it runs inside the client's exclusive queue. Folder selection is deliberately not a separate public call: any concurrent client would change the shared target between the two steps.

What the tablet has open

const open = await device.readOpenDocument();
// { documentId, name, pageId, pageNumber, pageIndex, pageCount, pageSource, observedAt }

Plain reads, so the tablet UI keeps running. documentId is null while the user is in the library list rather than inside a document.

The document comes from LastOpen in xochitl.conf. That file also holds the cloud token, the device token and the developer password, so it is not a readable root: one fixed command returns only the matching line and nothing else is transferred. The tablet writes strokes into the bundle files while the user works, so a torn read yields null page fields instead of an error.

The page is taken from the first source that answers, and pageSource says which one it was:

| pageSource | Source | Freshness | |---|---|---| | content | content.cPages.lastOpened | live, rewritten on every page flip | | metadata | .metadata.lastOpenedPage | as fresh as the last save; the Web Interface CurrentPage | | only-page | the document has one visible page | certain while that document is open | | null | nothing answered | pageId, pageNumber and pageIndex are null together |

A one-page notebook needs the whole ladder: the firmware leaves its cPages.lastOpened empty and can leave lastOpenedPage at -1. pageId is never a pointer that names no page of the document.

pageNumber counts visible pages from one and is the number the tablet prints. pageIndex and DocumentPage.index are raw positions in the stored array and keep the gaps left by deleted pages; they are for addressing the array, never for showing a person.

Render templates and PNG

Render straight from a downloaded rmdoc. This is the only page source that leaves Xochitl running: Device.readPage needs a guarded offline session, which stops the tablet UI.

import { renderRmdocPage, svgToPng } from "rmcommunication-ts";

const backup = await web.downloadDocument({ documentId, backupDirectory });
const rendered = await renderRmdocPage(backup.archivePath, documentId, pageId, { viewport: "content" });
const png = await svgToPng(rendered.svg, { width: 1080 }); // { bytes, width, height }

renderRmdocPage reads the page with readRmdocPage, which applies the same archive limits and path checks as inspectRmdoc and reports the page's template name and the archive-derived revision. It does no device IO: pass a parsed template through template when you want the ruled background, and read that template separately with device.readTemplate, which is a plain SFTP read and does not interrupt the tablet.

The lower-level pieces stay available for callers that already hold a scene:

import { readTree } from "rmscene-ts";
import { renderPageSvg, svgToPng } from "rmcommunication-ts";

const template = await device.readTemplate("Lines medium");
const rendered = renderPageSvg(readTree(page.bytes), { template, viewport: "content" });
const png = await svgToPng(rendered.svg, { width: 1080 }); // { bytes, width, height }

The template parser supports the arithmetic, comparison, boolean, ternary, grouping, repeat, path, and text subset used by current stock Paper Pro templates. It uses a bounded expression parser, never eval, escapes text, validates colors, caps recursion and generated elements, and skips future item types with templateWarnings. All 78 templates mirrored from the tested device parse and render in local verification. textWidth uses a deterministic approximate metric because the proprietary Qt font measurement is not available.

renderPageSvg draws the template below the SVG produced by rmscene-ts. svgToPng uses pinned sharp, limits each output dimension to 16384, and limits total output to 40000000 pixels. The limits apply after an omitted width or height is inferred from the SVG aspect ratio.

Replace an existing page

import { readBlocks, writeBlocks } from "rmscene-ts";

const page = await device.readPage(documentId, pageId);
const blocks = readBlocks(page.bytes);

// Edit or replace blocks here.

const receipt = await device.writePage({
  documentId,
  pageId,
  expectedRevision: page.revision,
  bytes: writeBlocks(blocks),
  backupDirectory: "D:/remarkable-backups",
});

Writeback is limited to replacing an existing .rm page. The transaction fails closed unless the device proves POSIX rename, file flush, directory flush, SHA-256, service control, and delayed recovery timer support. It validates replacement bytes locally before connecting to document storage.

While xochitl is stopped, the transaction:

  1. rechecks the expected page and manifest revision
  2. creates and verifies a complete local document backup
  3. creates a durable, hash-verified remote rollback copy with the original file mode
  4. uploads a unique stage file with that mode, then flushes, hashes, reads back, and parses it
  5. rechecks the revision immediately before commit
  6. commits with [email protected], never delete then rename
  7. flushes the directory, hashes, reads back, and parses the committed page
  8. atomically restores the rollback copy on any post-commit failure
  9. returns a receipt linked to the local backup

An SSH error can be reported after a server-side rename already happened. The implementation treats that result as ambiguous, measures the target hash, and rolls back if the replacement is present.

Unit tests inject failures before and after every remote call on the successful transaction path. Every simulated result keeps either the original page or the complete validated replacement and always enters service recovery.

The earlier direct SFTP PDF gate was rejected because it interrupted the tablet. The tunneled HTTP import has not yet written to the live Paper Pro. A visible page replacement or Web import still requires explicit approval for the exact disposable input and backup destination.

Isolated device capability probe

Build the package, supply the host, pinned fingerprint, and password through environment-backed secret injection, then run:

npm run build
npm run probe:device

The script requires RMCOMM_HOST, RMCOMM_FINGERPRINT, and RMCOMM_PASSWORD. It writes only below a unique /tmp/rmcommunication-probe-* directory, checks SFTP and service capabilities, arms and cancels a recovery timer, and removes the temporary directory. It does not read or write document storage. Do not store these values in the repository.

WiFi Web Interface probes

npm run probe:wifi-web-interface is read-only. It lists live documents through the SSH tunnel and checks tablet uptime and Xochitl PID before and after the request.

Run npm run probe:web-import only after approving the exact disposable source and backup directory. It refuses to write unless RMCOMM_IMPORT_APPROVAL is exactly write-disposable-pdf-or-epub-over-wifi-no-restart. It also requires RMCOMM_HOST, RMCOMM_FINGERPRINT, RMCOMM_PASSWORD, RMCOMM_IMPORT_TYPE, RMCOMM_IMPORT_SOURCE, RMCOMM_IMPORT_NAME, and RMCOMM_BACKUP_DIRECTORY. The source path must be absolute.

The import probe fails if Xochitl PID changes or tablet uptime resets. It leaves the document on the tablet for manual inspection and offers no delete operation. Do not store credentials or approval values in the repository.

Keep a local mirror while the tablet is in use

const result = await device.syncMirror("./mirror");
result.downloaded;          // relative paths replaced this run
result.skippedUnstable;     // written by the tablet mid-read, old copy kept, retried next run
result.changedDocumentIds;  // what a consumer has to re-derive

The run reads only: nothing is stopped, nothing is written to the tablet, so it is safe while someone is drawing on it. <mirrorRoot>/xochitl becomes a byte copy of the tablet's storage, templates a copy of its templates, and state.json records the open document and the run's counters.

Deletions propagate. A listing with no .metadata file, or one that would delete every mirrored file, fails with MirrorGuardError instead, because the mirror is also the only continuous backup; pass acceptWipedDevice when the tablet really was wiped.

This is not mirrorDocuments, which stops Xochitl to take a verified point-in-time generation. Both exist on purpose and docs/architecture/live-mirror.md puts them side by side.

Explicit exclusions

  • reMarkable Cloud authentication, download, upload, sync, or conflict protocols
  • a public arbitrary-shell or unrestricted remote-filesystem API
  • native notebook creation, document deletion or movement, and appending to published documents
  • Web Interface upload to a selected folder before its separate live gate
  • root-filesystem remounts and custom template installation
  • PDF or EPUB base-page rasterization
  • exact proprietary brush and font rendering

Verification and development

Until rmscene-ts 0.1.0 is published, source development uses coordinated sibling checkouts:

workspace/
  rmscene-ts/
  rmcommunication-ts/

Build rmscene-ts first, then run npm ci in rmcommunication-ts. The local file: entry is only a pre-release development bootstrap. rmscene-ts must be published first, after which the development entry is changed to the same semver range as the peer dependency and the lockfile is refreshed. A release guard prevents publishing rmcommunication-ts while the local entry remains.

npm ci
npm run check
npm audit
npm pack --dry-run

With a local Paper Pro template dump, run npm run verify:paper-pro-templates or pass its directory to node scripts/verify-paper-pro-templates.mjs <directory> after building.

The suite covers connection policy, credential redaction, fixed SSH forwarding, transport cancellation, recursive live listing, rmdoc limits and verification, streamed backup and mirror consistency, PDF and EPUB validation, upload reconciliation without POST retry, template parsing, SVG and bounded PNG output, renewed offline recovery, conflicts, rollback, ESM, CommonJS, declarations, and package linting.

The four packages

| Package | What it does | | --- | --- | | rmscene-ts (npm) | Reads, writes and renders .rm version 6 scene files. No filesystem, no network, browser-safe. | | rmcommunication-ts (npm) | Talks to the tablet over pinned SSH: listings, verified rmdoc backups, page rendering, templates, PNG, PDF and EPUB import. | | rmindex-ts (npm) | Turns a local mirror into a catalog: SQLite index, FTS5 full-text search and a page-image cache. Reads the mirror only. | | remarkable-cli (npm) | The rmcli command line over these libraries. |

None of them implements the reMarkable Cloud protocol.

License and credits

MIT.

Scene file reading, writing, and SVG rendering come from rmscene-ts, which is a TypeScript rewrite of rmscene by Rick Lupton. This package adds the device side: SSH transport, document bundles, backups, templates, PNG output, and PDF or EPUB import.