@byokit/pair 0.9.0 → 0.10.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.1 (2026-10-16)
6
+
7
+ - FIX: the browser pairing test opens the pairing page over the test browser's own private CDP pipe once Chromium answers there, not from the launch command line. On a slow first start Chromium's network service quits after 15 s unanswered and took the launch page's request with it, so the test silently sat out its 180 s deadline; the page now loads after Chromium restarts the service, and a page Chromium cannot open fails at once with Chromium's own navigation error.
8
+ - FIX: pairing and setup checks split "can't reach the computer" from "the computer is slow": a socket that never opens within the timeout reports `unreachable` ("Check the phone and computer are on the same network, and that no firewall is blocking the connection."), while an open socket with no answer reports the new `slow` code ("Your computer is slow to answer. Wait a moment and try again."). Consumers relay the codes unchanged.
9
+
10
+ ## 0.10.0 (2026-10-12)
11
+
12
+ - DOCS: the "Checking reachability before pairing" section now shows both sides: setup self-checks its own advertised URLs with `check()` before printing the tick, and the phone re-checks the scanned offer before consent. The probe still spends nothing, so a checked offer pairs afterwards.
13
+ - ADD: `ndjson(stream)` sends and receives whole JSON messages over a pair stream, one per `\n`-terminated line, through the existing credit window. Chunks split at any byte boundary reassemble before decoding, a line past `NDJSON_MAX_LINE` (1 MiB, configurable per stream) ends the stream with `line-too-long` on both ends, and a non-JSON line ends it with `failed`.
14
+ - FIX: every wait in the link end-to-end suite is bounded: each test carries a one-minute deadline, so a request stalled past its reconnect window (twice it parked the whole full-suite run at 0% CPU until killed) fails by its test name instead of hanging every lane and release behind the shared test lock. The file still passes in about 10 s alone; nothing about the protocol changed.
15
+
5
16
  ## 0.9.0 (2026-10-09)
6
17
 
7
18
  - FIX: a phone killed while the person at the computer decides now resumes against the pinned computer, for compact and version 1 QRs alike. `pairWithOffer` takes `onPending(grant)`, which hands over the pending grant to keep before the computer can approve this device, and `pendingGrant` accepts a compact QR given the authenticated `host` key, and `parseOffer` reads a compact QR for inspection (expiry, addresses, name, role) without one. Before, `pendingGrant` refused compact QRs, and even a version 1 pending grant never came online after a real kill. A socket that drops once the computer has taken the code now ends pairing with `unreachable` instead of trying the next address, where the spent code or ticket could only be refused as `wrong-code` or `expired`. A socket that closes while `onPending` is still saving ends pairing at once with that close; a version 1 `onPending` that never settles times out. The compact QR is unchanged: 109 characters for one relay address and the name Umer.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
- <h1 align="center">@byokit/link</h1>
1
+ <h1 align="center">@byokit/pair</h1>
2
2
 
3
3
  <p align="center">
4
- <a href="https://www.npmjs.com/package/@byokit/link"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/link?style=flat&label=npm" /></a>
4
+ <a href="https://www.npmjs.com/package/@byokit/pair"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/pair?style=flat&label=npm" /></a>
5
5
  <a href="https://github.com/umeranjum17/byokit/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/byokit/ci.yml?style=flat&branch=main" /></a>
6
6
  <a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
7
7
  <img alt="Node | browsers | React Native" src="https://img.shields.io/badge/platform-Node%20%7C%20browsers%20%7C%20React%20Native-666?style=flat" />
@@ -12,8 +12,11 @@ One encrypted link between the two. The computer (the <strong>host</strong>) kee
12
12
  only its own key and a grant, and asks the host to do things. For apps that reach a person's own computer from their
13
13
  phone or browser.</p>
14
14
 
15
+ This package was published as `@byokit/link` up to 0.8.x. `@byokit/link` still re-exports it unchanged through
16
+ 0.8.x and is removed in 0.9.0; new installs use `@byokit/pair`.
17
+
15
18
  <p align="center">
16
- <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/herdr-kit-host.png" width="420" alt="A terminal running npm start -- --herdr &quot;$(command -v herdr)&quot; --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this. If its page is already open there, type K3J8-CJZ7-SE4R instead. Codes last five minutes. Press Enter for new ones. Connecting to Herdr… Connected to Herdr. (stand-in Herdr)" />
19
+ <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/herdr-kit-host.png" width="420" alt="A terminal running npm start -- --herdr &quot;$(command -v herdr)&quot; --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this, or open http://192.168.1.144:7310/ and type K3J8-CJZ7-SE4R. Codes last five minutes. Press Enter for new ones. Connecting to Herdr… Connected to Herdr. (stand-in Herdr)" />
17
20
  <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/2-compare.png" width="240" alt="A phone page titled Agents, under Pair this phone: Check your computer shows these two words, then say yes there. The words are coast comet." />
18
21
  </p>
19
22
  <p align="center"><sub>Left: the host terminal of <a href="https://github.com/umeranjum17/byokit/tree/main/examples/herdr-kit">examples/herdr-kit</a> (<code>npm start -- --herdr "$(command -v herdr)" --via lan --name 'Kitchen computer'</code>, pictured against the kit's stand-in Herdr, <code>BYOKIT_EXAMPLE_FAKE=1</code>) showing the QR and the typed code. Right: the phone in the same example showing the two words to compare.</sub></p>
@@ -27,26 +30,26 @@ Host policy and streams are available; relay routing is in [`@byokit/relay`](../
27
30
  ## Install
28
31
 
29
32
  ```sh
30
- npm install @byokit/link
33
+ npm install @byokit/pair
31
34
  ```
32
35
 
33
- [![npm](https://img.shields.io/npm/v/@byokit/link?style=flat&label=)](https://www.npmjs.com/package/@byokit/link) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=link-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
36
+ [![npm](https://img.shields.io/npm/v/@byokit/pair?style=flat&label=)](https://www.npmjs.com/package/@byokit/pair) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=pair-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
34
37
 
35
38
  ## Quickstart
36
39
 
37
40
  ```sh
38
- npm install @byokit/link ws
41
+ npm install @byokit/pair ws
39
42
  ```
40
43
 
41
44
  `ws` is only the WebSocket server for this example; any server works. A host and a device in one file, on loopback:
42
45
 
43
46
  ```ts
44
47
  import { WebSocketServer } from 'ws';
45
- import { DeviceLink, Host, keyPair, pairWithOffer } from '@byokit/link';
48
+ import { DeviceLink, Host, keyPair, pairWithOffer } from '@byokit/pair';
46
49
 
47
50
  // The computer: it keeps the credentials and answers requests.
48
51
  const host = await Host.open({
49
- keys: keyPair(), // keep it with hostKeyFile() from '@byokit/link/node' in a real app
52
+ keys: keyPair(), // keep it with hostKeyFile() from '@byokit/pair/node' in a real app
50
53
  name: 'Kitchen computer',
51
54
  confirm: ({ name, words }) => { console.log(`host: pair ${name}? it shows "${words}"`); return true; },
52
55
  handle: (req, device) => ({ echo: req.args, from: device.name }),
@@ -89,13 +92,13 @@ answer: { echo: { text: 'hi' }, from: 'Pixel 9' }
89
92
  | `LinkError`, `PublicLinkError`, `LINK_WORDS` | Failures with a plain `message` and a `code`; a handler error safe to show; the plain message for each failure code |
90
93
  | `secureDeviceStore`, `browserDeviceStore` | Where a device keeps its grant on a phone or in a browser |
91
94
  | `keyPair`, `keyPairFrom`, `hostId`, `b64url`, `unb64url`, `normalizeCode` | Keys, the host id, base64url and typed-code helpers |
92
- | `hostKeyFile`, `fileDeviceStore` (`@byokit/link/node`) | Node only: the host's key file, and a computer's device store |
95
+ | `hostKeyFile`, `fileDeviceStore` (`@byokit/pair/node`) | Node only: the host's key file, and a computer's device store |
93
96
 
94
97
  ## Host
95
98
 
96
99
  ```ts
97
- import { Host } from '@byokit/link';
98
- import { hostKeyFile } from '@byokit/link/node';
100
+ import { Host } from '@byokit/pair';
101
+ import { hostKeyFile } from '@byokit/pair/node';
99
102
 
100
103
  const host = await Host.open({
101
104
  keys: hostKeyFile('./link-secret/host.key'), // made once in its own 0700 folder; or use your keychain
@@ -112,7 +115,7 @@ const { code } = host.code({ role: 'view' }); // or a code to type: "7KQ4-M2
112
115
  host.devices(); await host.revoke(id); host.broadcast(event);
113
116
  ```
114
117
 
115
- `hostKeyFile(path)` from `@byokit/link/node` wants a path in its own private (`0700`) folder; it refuses an
118
+ `hostKeyFile(path)` from `@byokit/pair/node` wants a path in its own private (`0700`) folder; it refuses an
116
119
  existing folder with another mode rather than changing its permissions.
117
120
 
118
121
  More host policy, all optional:
@@ -127,6 +130,9 @@ More host policy, all optional:
127
130
  handlers with transactional effects can store it alongside the effect to avoid repeating work after a crash between
128
131
  the handler and `answers.put`.
129
132
  - `handshakes: { perMinute, perPeer }` limits new handshakes; pass `host.accept(ws, { peer: ip })` to count per source.
133
+ - `onGrantRemoved(grant, why)` runs at the one boundary where a grant is durably gone — `revoked`, `expired` or
134
+ `changed` — so an app can delete its copy. Access is already withdrawn when it runs, a socket drop does not
135
+ trigger it, and a failing cleanup never restores access.
130
136
 
131
137
  `compactOffer` is the QR text: the same secret a typed code carries (same single use, life, words and
132
138
  approval) plus the addresses to try, packed binary, so a standard offer fits a QR version 4 or lower.
@@ -186,7 +192,7 @@ old connections, and changed expiry reschedules access. A failed load rejects an
186
192
  A `GrantStore` may provide `subscribe(changed): unsubscribe`; the host reloads automatically on notification,
187
193
  reports reload failures through `onError`, and unsubscribes on `close()`.
188
194
 
189
- Node apps can use `fileGrantStore(path)` from `@byokit/link/node`. It touches the app's chosen file, temporary
195
+ Node apps can use `fileGrantStore(path)` from `@byokit/pair/node`. It touches the app's chosen file, temporary
190
196
  files and a sibling `.lock` only, writes atomically at 0600 in a 0700 folder, rejects symlinks/nonprivate files on read, and polls
191
197
  for cross-process replace/remove (default 100 ms; second argument changes the interval). Cooperating writers
192
198
  use an exclusive lock and reject a stale loaded snapshot instead of overwriting an external revoke. Load again
@@ -207,7 +213,7 @@ The **handle** step authorizes the already-paired inviter, **offer** records the
207
213
  then **confirm** checks that metadata before approving the new peer:
208
214
 
209
215
  ```ts
210
- import { Host, keyPair, PublicLinkError } from '@byokit/link';
216
+ import { Host, keyPair, PublicLinkError } from '@byokit/pair';
211
217
 
212
218
  // Supply the app's human approval UI.
213
219
  declare const ui: { ask(message: string): Promise<boolean> };
@@ -235,7 +241,7 @@ const host: Host = await Host.open({
235
241
  });
236
242
  ```
237
243
 
238
- Import `Host`, `keyPair` and `PublicLinkError` from `@byokit/link`; `ui.ask` is the app's approval UI. The app
244
+ Import `Host`, `keyPair` and `PublicLinkError` from `@byokit/pair`; `ui.ask` is the app's approval UI. The app
239
245
  chooses metadata on the host, never from an untrusted invitee. This composition is delegated pairing:
240
246
  the approved control device requests a constrained invitation; a peer cannot widen its terms. Check the
241
247
  inviter's current grant, including expiry, in both `handle` and `confirm`. Apply capability scope in `allow`
@@ -247,7 +253,7 @@ long-lived or host-offline signed invitation.
247
253
  ## Device
248
254
 
249
255
  To keep several paired computers, use `secureDeviceStores(secureStore, prefix?)` or
250
- `browserDeviceStores(databaseName?)` from `@byokit/link`. Both offer `list()` (entry names), `load(name)`,
256
+ `browserDeviceStores(databaseName?)` from `@byokit/pair`. Both offer `list()` (entry names), `load(name)`,
251
257
  `save(name, grant)`, `remove(name)`, and `store(name)` (the one-entry adapter for `DeviceLink`). Names use
252
258
  letters, digits, dots, underscores and dashes, up to 120 characters. A secure collection uses
253
259
  `<prefix>.index` and `<prefix>.grant.<name>`; existing single-entry stores stay independent. Calls sharing
@@ -257,7 +263,7 @@ The browser collection enumerates existing `browserDeviceStore` entries in its d
257
263
  non-extractable wrapping keys and forgotten-grant protection; removed tombstones stay out of `list()`.
258
264
 
259
265
  ```ts
260
- import { DeviceLink, secureDeviceStores, type DeviceGrant, type SecureStoreLike } from '@byokit/link';
266
+ import { DeviceLink, secureDeviceStores, type DeviceGrant, type SecureStoreLike } from '@byokit/pair';
261
267
 
262
268
  // Supply the platform secure-storage module and a grant returned by pairing.
263
269
  declare const SecureStore: SecureStoreLike;
@@ -271,7 +277,7 @@ await computers.remove('kitchen');
271
277
  ```
272
278
 
273
279
  ```ts
274
- import { DeviceLink, pairWithOffer, pairWithCode } from '@byokit/link';
280
+ import { DeviceLink, pairWithOffer, pairWithCode } from '@byokit/pair';
275
281
 
276
282
  const grant = await pairWithOffer(scanned, { name: 'Pixel 9', onWords: (w) => show(w) });
277
283
  // Or pairWithCode(url, typed, { name: 'Pixel 9', onWords: (w) => show(w) });
@@ -317,7 +323,7 @@ One store per paired computer, each with `load()` for the next start:
317
323
  |---|---|---|
318
324
  | Phone | `secureDeviceStore(SecureStore, 'byokit.link.home')` with `expo-secure-store` | Keychain on iOS, Keystore on Android; the grant is about 300 bytes, one value. |
319
325
  | Browser or PWA | `browserDeviceStore('home')` | IndexedDB, sealed with AES-GCM by a non-extractable browser key. The stored record alone opens nothing, but scripts running on the same origin can still use the key to decrypt it; keep untrusted scripts off the page. |
320
- | Computer (Node, Electron's main process) | `fileDeviceStore(path, safeStorage?)` from `@byokit/link/node` | A 0600 file in a newly created 0700 folder (an existing folder keeps its permissions), sealed with Electron's `safeStorage` when given. Without `safeStorage`, the file contains the grant in plaintext. |
326
+ | Computer (Node, Electron's main process) | `fileDeviceStore(path, safeStorage?)` from `@byokit/pair/node` | A 0600 file in a newly created 0700 folder (an existing folder keeps its permissions), sealed with Electron's `safeStorage` when given. Without `safeStorage`, the file contains the grant in plaintext. |
321
327
 
322
328
  ```ts
323
329
  const store = secureDeviceStore(SecureStore, 'byokit.link.home');
@@ -329,7 +335,7 @@ const link = new DeviceLink(grant, { store, onStatus });
329
335
  ### React Native
330
336
 
331
337
  Install a `crypto.getRandomValues` polyfill such as `react-native-get-random-values` (or use `expo-crypto`) and
332
- import it **before** `@byokit/link`. Metro resolves `sodium-universal` to `sodium-javascript` through its browser
338
+ import it **before** `@byokit/pair`. Metro resolves `sodium-universal` to `sodium-javascript` through its browser
333
339
  field. The device uses the platform's WebSocket and needs no Node globals. See [`examples/expo`](../../examples/expo).
334
340
 
335
341
  ## Streams
@@ -357,6 +363,13 @@ s.end();
357
363
  View-only devices open only the streams `allow` permits (or `canView` when `allow` is absent). A host without `stream`
358
364
  tells devices so (`LinkError` `not-supported`), and so does a host older than streams.
359
365
 
366
+ For whole JSON messages instead of bytes, `ndjson(stream)` sends one object per `\n`-terminated line and delivers
367
+ each complete message in order: `const j = ndjson(s); await j.send({ text: 'hi' }); j.onMessage((m) => …)`.
368
+ Messages split at any byte boundary arrive whole, including a multi-byte character split across two chunks, and a
369
+ returned handler promise holds the sender back through the same credit window. A line past `NDJSON_MAX_LINE`
370
+ (1 MiB, `ndjson(s, { maxLine })` changes it) ends the stream with `'line-too-long'` on both ends and is never
371
+ delivered, so nothing is buffered without bound; a line that is not JSON ends it with `failed`.
372
+
360
373
  On a direct socket, stream bytes go as binary WebSocket messages (without base64 overhead); through a relay, whose
361
374
  host wrapper is text, the same frames go as base64 text. The host says which in `ready`; everything else stays text.
362
375
 
@@ -376,7 +389,7 @@ Frames are the same bytes on every path; a relay only routes them and cannot rea
376
389
  - The relay authenticates the host's socket and limits abuse its own way. Link routing needs no device id: the host
377
390
  checks devices itself on every handshake.
378
391
 
379
- `@byokit/link` does not include a relay server; use [`@byokit/relay`](../relay) for the standalone server,
392
+ `@byokit/pair` does not include a relay server; use [`@byokit/relay`](../relay) for the standalone server,
380
393
  reconnecting host client and device code lookup.
381
394
 
382
395
  ## Migrating pre-kit phone pairings
@@ -415,10 +428,10 @@ share one key across machines. Muxr parity beyond this migration is tracked sepa
415
428
  ## Checking reachability before pairing
416
429
 
417
430
  ```ts
418
- import { check, parseOffer } from '@byokit/link';
431
+ import { check, parseOffer } from '@byokit/pair';
419
432
 
420
- // Before asking the person to approve, the phone probes the offer's URLs (the host probes its own advertised
421
- // URLs the same way, as a self-check only). Results come back in input order.
433
+ // Phone: before asking the person to approve, probe the scanned offer's URLs.
434
+ // Results come back in input order.
422
435
  export async function precheck(scanned: string): Promise<void> {
423
436
  const { urls } = parseOffer(scanned);
424
437
  const results = await check(urls, { timeoutMs: 5000 });
@@ -428,6 +441,22 @@ export async function precheck(scanned: string): Promise<void> {
428
441
  }
429
442
  ```
430
443
 
444
+ Setup runs the same probe on its own advertised URLs before printing the tick, so a dead
445
+ address never becomes a QR. The phone runs it again after scanning, before consent:
446
+
447
+ ```ts
448
+ import { check } from '@byokit/pair';
449
+
450
+ // Setup: self-check the offer's URLs first; only print the tick when one answers.
451
+ export async function selfcheck(urls: string[]): Promise<boolean> {
452
+ const results = await check(urls, { timeoutMs: 5000 });
453
+ for (const r of results) {
454
+ if (!r.ok) console.log(`${r.url}: ${r.message}`);
455
+ }
456
+ return results.some((r) => r.ok);
457
+ }
458
+ ```
459
+
431
460
  Probed four at a time by default (`concurrency` changes that). The probe answers read-only: it sends no ticket,
432
461
  so a checked offer still pairs afterwards, and the reply carries nothing but the fact a link host is there. It
433
462
  works in Node, browsers and React Native, and through a relay with no relay change (devices speak bare frames
@@ -479,13 +508,13 @@ untrusted address, never a trusted machine key. A malicious lookup pointing at a
479
508
  fails the commitment check. QR offers and legacy `host.code()` remain compatible; callers of
480
509
  the latter do not get this additional machine-key pinning.
481
510
 
482
- Runnable loopback example (Node 22.18+, `npm install @byokit/link ws`; save as `pair.ts`, run
511
+ Runnable loopback example (Node 22.18+, `npm install @byokit/pair ws`; save as `pair.ts`, run
483
512
  `node pair.ts`). In an app, show the code on the computer and let the phone type it:
484
513
 
485
514
  ```ts
486
515
  import { createInterface } from 'node:readline/promises';
487
516
  import { WebSocketServer } from 'ws';
488
- import { DeviceLink, Host, keyPair, pairWithCode } from '@byokit/link';
517
+ import { DeviceLink, Host, keyPair, pairWithCode } from '@byokit/pair';
489
518
 
490
519
  const terminal = createInterface({ input: process.stdin, output: process.stdout });
491
520
  const host = await Host.open({
package/SECURITY.md CHANGED
@@ -1,4 +1,4 @@
1
- # @byokit/link: threat model and review checklist
1
+ # @byokit/pair: threat model and review checklist
2
2
 
3
3
  Muxr parity is tracked separately.
4
4
 
@@ -132,7 +132,7 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
132
132
  authenticates with it.
133
133
  - [x] The handshake limiter runs before any Diffie-Hellman.
134
134
  - [x] Nothing in the main entry reads or writes files, environment variables or other programs.
135
- `@byokit/link/node` `hostKeyFile(path)` touches only that path: 0600 in 0700, atomic, never replaces a file it
135
+ `@byokit/pair/node` `hostKeyFile(path)` touches only that path: 0600 in 0700, atomic, never replaces a file it
136
136
  can't read, refuses symlinks.
137
137
 
138
138
  - [ ] Presence fires once on first/last sockets, including failed/successful unpair, reload, expiry and close.
package/dist/channel.js CHANGED
@@ -14,7 +14,7 @@ import Noise from 'noise-handshake';
14
14
  import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
15
15
  import dh from 'noise-handshake/dh.js';
16
16
  import sodium from 'sodium-universal';
17
- import { CONFIRM_WORDS } from "./confirm-words.js";
17
+ import { CONFIRM_WORDS } from './confirm-words.js';
18
18
  /** Mixed into every handshake, so a v2 of this protocol can never be confused with v1. */
19
19
  export const PROLOGUE = 'byokit-link-v1';
20
20
  const CHUNK = 60_000; // a Noise message is at most 65535 bytes; larger payloads go in pieces
package/dist/check.d.ts CHANGED
@@ -28,7 +28,7 @@ export type CheckOptions = {
28
28
  onerror: unknown;
29
29
  };
30
30
  };
31
- /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there), `timeout` (something there
32
- * that never answered) and `wrong-host` (something else answered) can come back, each with its LINK_WORDS sentence;
33
- * no new codes were needed. Never sends a ticket, so offers stay usable. */
31
+ /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there: same network or firewall),
32
+ * `slow` (something there that never answered: wait and try again) and `wrong-host` (something else answered) can
33
+ * come back, each with its LINK_WORDS sentence. Never sends a ticket, so offers stay usable. */
34
34
  export declare function check(urls: string[], o?: CheckOptions): Promise<CheckResult[]>;
package/dist/check.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // A per-URL reachability probe for onboarding: before pairing, the phone checks the offer's URLs (and the host
2
2
  // checks its own advertised URL) without claiming or burning the one-time offer. No platform APIs: the same code
3
3
  // runs in Node, browsers and React Native on the platform WebSocket, like `dial` in device.ts.
4
- import { LINK_WORDS } from "./device.js";
4
+ import { LINK_WORDS } from './device.js';
5
5
  /** The first frame a probe sends. The host answers with LINK_PROBE_OK and closes; it touches no ticket, code,
6
6
  // grant or counter, so a checked offer still pairs afterwards. Relayed devices speak bare frames, so the same
7
7
  // exchange works through a relay with no relay change. */
@@ -24,7 +24,7 @@ function probe(url, o, timeoutMs) {
24
24
  catch { }
25
25
  resolve(r);
26
26
  };
27
- const timer = setTimeout(() => finish(fail(url, opened ? 'timeout' : 'unreachable')), timeoutMs);
27
+ const timer = setTimeout(() => finish(fail(url, opened ? 'slow' : 'unreachable')), timeoutMs);
28
28
  timer.unref?.();
29
29
  try {
30
30
  const WS = o.WebSocket ?? globalThis.WebSocket;
@@ -55,9 +55,9 @@ function probe(url, o, timeoutMs) {
55
55
  };
56
56
  });
57
57
  }
58
- /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there), `timeout` (something there
59
- * that never answered) and `wrong-host` (something else answered) can come back, each with its LINK_WORDS sentence;
60
- * no new codes were needed. Never sends a ticket, so offers stay usable. */
58
+ /** Probe each URL for a link host, in input order. Only `unreachable` (nothing there: same network or firewall),
59
+ * `slow` (something there that never answered: wait and try again) and `wrong-host` (something else answered) can
60
+ * come back, each with its LINK_WORDS sentence. Never sends a ticket, so offers stay usable. */
61
61
  export async function check(urls, o = {}) {
62
62
  const timeoutMs = o.timeoutMs ?? 8000;
63
63
  const out = new Array(urls.length);
package/dist/device.d.ts CHANGED
@@ -41,24 +41,25 @@ export type Dial = {
41
41
  };
42
42
  /** Every way the link can fail, in words a person can act on. */
43
43
  export declare const LINK_WORDS: {
44
- readonly unreachable: "Can't reach your computer. Check it's on and connected.";
44
+ readonly unreachable: "Can't reach your computer. Check the phone and computer are on the same network, and that no firewall is blocking the connection.";
45
45
  readonly 'wrong-host': "Something other than your computer answered, so this device didn't connect.";
46
46
  readonly 'wrong-code': "That code didn't match. Check it, or show a new one on your computer.";
47
- readonly expired: "That pairing code has run out. Show a new one on your computer.";
48
- readonly declined: "Your computer said no to this device.";
49
- readonly full: "Your computer has all the devices it allows. Remove one there first.";
47
+ readonly expired: 'That pairing code has run out. Show a new one on your computer.';
48
+ readonly declined: 'Your computer said no to this device.';
49
+ readonly full: 'Your computer has all the devices it allows. Remove one there first.';
50
50
  readonly 'not-paired': "Your computer doesn't know this device any more. Pair it again.";
51
- readonly removed: "This device was removed on your computer.";
52
- readonly 'view-only': "This device can watch but not make changes.";
51
+ readonly removed: 'This device was removed on your computer.';
52
+ readonly 'view-only': 'This device can watch but not make changes.';
53
53
  readonly timeout: "Your computer didn't answer in time.";
54
+ readonly slow: 'Your computer is slow to answer. Wait a moment and try again.';
54
55
  readonly failed: "Your computer couldn't do that.";
55
- readonly busy: "Your computer is still catching up. Try again in a moment.";
56
- readonly stopped: "This link is stopped. Try connecting again.";
56
+ readonly busy: 'Your computer is still catching up. Try again in a moment.';
57
+ readonly stopped: 'This link is stopped. Try connecting again.';
57
58
  readonly 'not-supported': "The app on your computer can't do this yet. Update it there.";
58
59
  readonly ended: "This device's access has run out. Pair it again on your computer.";
59
60
  readonly 'not-allowed': "This device isn't allowed to do that.";
60
61
  readonly 'too-late': "That reached your computer too late, so it wasn't done.";
61
- readonly unsupported: "Your computer needs an update for that.";
62
+ readonly unsupported: 'Your computer needs an update for that.';
62
63
  };
63
64
  export type LinkProblem = keyof typeof LINK_WORDS;
64
65
  /** A failure with a plain `message` and a `code` for the app. `sealed` means the host said it inside the encrypted
package/dist/device.js CHANGED
@@ -2,12 +2,12 @@
2
2
  // the host's credentials. It pairs once (scan or typed code), then keeps one socket open, reconnecting on its own, and
3
3
  // its requests survive a reconnect: each carries a key, so a retried tap runs once. Uses only the platform's
4
4
  // WebSocket, so the same code runs in browsers, React Native and Node.
5
- import { Handshake, b64, b64url, keyPair, keyPairFrom, random, unb64url } from "./channel.js";
6
- import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, parseV1Offer, shortKey, COMPACT_TAG } from "./pairing.js";
7
- import { Streams } from "./stream.js";
5
+ import { Handshake, b64, b64url, keyPair, keyPairFrom, random, unb64url } from './channel.js';
6
+ import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, parseV1Offer, shortKey, COMPACT_TAG } from './pairing.js';
7
+ import { Streams } from './stream.js';
8
8
  /** Every way the link can fail, in words a person can act on. */
9
9
  export const LINK_WORDS = {
10
- unreachable: "Can't reach your computer. Check it's on and connected.",
10
+ unreachable: "Can't reach your computer. Check the phone and computer are on the same network, and that no firewall is blocking the connection.",
11
11
  'wrong-host': "Something other than your computer answered, so this device didn't connect.",
12
12
  'wrong-code': "That code didn't match. Check it, or show a new one on your computer.",
13
13
  expired: 'That pairing code has run out. Show a new one on your computer.',
@@ -17,6 +17,7 @@ export const LINK_WORDS = {
17
17
  removed: 'This device was removed on your computer.',
18
18
  'view-only': 'This device can watch but not make changes.',
19
19
  timeout: "Your computer didn't answer in time.",
20
+ slow: 'Your computer is slow to answer. Wait a moment and try again.',
20
21
  failed: "Your computer couldn't do that.",
21
22
  busy: 'Your computer is still catching up. Try again in a moment.',
22
23
  stopped: 'This link is stopped. Try connecting again.',
@@ -79,7 +80,8 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
79
80
  }
80
81
  catch { }
81
82
  };
82
- let timer = later(o.timeoutMs ?? 8000, () => fail(new LinkError('timeout')));
83
+ let opened = false;
84
+ let timer = later(o.timeoutMs ?? 8000, () => fail(new LinkError(opened ? 'slow' : 'unreachable')));
83
85
  try {
84
86
  ws = new WS(target);
85
87
  ws.binaryType = 'arraybuffer';
@@ -92,7 +94,12 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
92
94
  ws.send(f); };
93
95
  const data = (s, d) => { for (const f of ch.sealData(s, d))
94
96
  ws.send(binary ? f : b64(f)); };
95
- ws.onopen = () => ws.send(hs.write(mode === 'ik' ? { v: 1 } : {}));
97
+ ws.onopen = () => { opened = true; try {
98
+ ws.send(hs.write(mode === 'ik' ? { v: 1 } : {}));
99
+ }
100
+ catch {
101
+ fail(new LinkError('unreachable'));
102
+ } };
96
103
  ws.onerror = () => fail(new LinkError('unreachable'));
97
104
  // Before the handshake finishes nothing is authenticated, so these only choose what to say, never what to forget.
98
105
  ws.onclose = (e) => fail(new LinkError(ch ? 'unreachable' : e?.code === 4401 && mode === 'code' ? 'wrong-code' : e?.code === 4403 ? 'wrong-host' : 'unreachable'));
@@ -120,7 +127,7 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
120
127
  if (pairing) { // now a person at the host decides; give them time
121
128
  o.onWords?.(hs.words);
122
129
  clearTimeout(timer);
123
- timer = later(6 * 60_000, () => fail(new LinkError('timeout')));
130
+ timer = later(6 * 60_000, () => fail(new LinkError('slow')));
124
131
  }
125
132
  };
126
133
  if (!host.pin)
@@ -213,7 +220,7 @@ export async function pairWithOffer(scanned, o) {
213
220
  if (!(e instanceof LinkError))
214
221
  throw e; // the app's own onPending failure, as it said it
215
222
  last = e;
216
- if (taken || (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host'))
223
+ if (taken || (last.code !== 'unreachable' && last.code !== 'slow' && last.code !== 'timeout' && last.code !== 'wrong-host'))
217
224
  break;
218
225
  }
219
226
  }
@@ -232,7 +239,7 @@ export async function pairWithOffer(scanned, o) {
232
239
  }
233
240
  catch (e) {
234
241
  last = e instanceof LinkError ? e : new LinkError('unreachable');
235
- if (taken || (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host'))
242
+ if (taken || (last.code !== 'unreachable' && last.code !== 'slow' && last.code !== 'timeout' && last.code !== 'wrong-host'))
236
243
  break;
237
244
  }
238
245
  }
package/dist/host.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // The host: the computer that owns the credentials. It pairs devices (scanned or typed code, then a person says yes),
2
2
  // keeps one durable grant per device, answers their requests, and removes them. It never listens on anything itself:
3
3
  // the app hands it WebSockets (`accept`), or one relay socket that carries many devices (`relay`).
4
- import { Handshake, b64, b64url, firstFrame, hostId, messageBytes, random, unb64url } from "./channel.js";
5
- import { bindCode, cleanName, codeKey, encodeCompactOffer, newCode, normalizeCode, offerText } from "./pairing.js";
6
- import { PublicLinkError } from "./device.js";
7
- import { LINK_PROBE, LINK_PROBE_OK } from "./check.js";
8
- import { MAX_STREAMS, Streams } from "./stream.js";
4
+ import { Handshake, b64, b64url, firstFrame, hostId, messageBytes, random, unb64url } from './channel.js';
5
+ import { bindCode, cleanName, codeKey, encodeCompactOffer, newCode, normalizeCode, offerText } from './pairing.js';
6
+ import { PublicLinkError } from './device.js';
7
+ import { LINK_PROBE, LINK_PROBE_OK } from './check.js';
8
+ import { MAX_STREAMS, Streams } from './stream.js';
9
9
  const HANDSHAKE_MS = 15_000;
10
10
  const MAX_TRIES = 5; // wrong codes before every open code is withdrawn
11
11
  const MAX_ANSWERS = 1000;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { hostId, keyPair, keyPairFrom, b64url, unb64url, type KeyPair } from './
2
2
  export { Host, type AnswerStore, type Grant, type GrantStore, type GrantTerms, type HostOptions, type LinkRequest, type PairRequest, type Role, type Socket, } from './host.ts';
3
3
  export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, type DeviceGrant, type DeviceStore, type Dial, type LinkOptions, type LinkProblem, type LinkStatus, type RequestOptions, } from './device.ts';
4
4
  export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from './pairing.ts';
5
- export { LinkStream, WINDOW } from './stream.ts';
5
+ export { LinkStream, NDJSON_LINE_TOO_LONG, NDJSON_MAX_LINE, WINDOW, ndjson, type NdjsonOptions, type NdjsonStream } from './stream.ts';
6
6
  export { check, LINK_PROBE, LINK_PROBE_OK, type CheckOptions, type CheckResult } from './check.ts';
7
7
  export type { CompactOffer, PairOffer } from './pairing.ts';
8
8
  export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores, type DeviceStores, type KeptDevice, type SecureStoreLike } from './stores.ts';
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- export { hostId, keyPair, keyPairFrom, b64url, unb64url } from "./channel.js";
2
- export { Host, } from "./host.js";
3
- export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, } from "./device.js";
4
- export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from "./pairing.js";
5
- export { LinkStream, WINDOW } from "./stream.js";
6
- export { check, LINK_PROBE, LINK_PROBE_OK } from "./check.js";
7
- export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores } from "./stores.js";
8
- export { migrateGrant, GrantMigrationError } from "./migrate.js";
1
+ export { hostId, keyPair, keyPairFrom, b64url, unb64url } from './channel.js';
2
+ export { Host, } from './host.js';
3
+ export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, } from './device.js';
4
+ export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from './pairing.js';
5
+ export { LinkStream, NDJSON_LINE_TOO_LONG, NDJSON_MAX_LINE, WINDOW, ndjson } from './stream.js';
6
+ export { check, LINK_PROBE, LINK_PROBE_OK } from './check.js';
7
+ export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores } from './stores.js';
8
+ export { migrateGrant, GrantMigrationError } from './migrate.js';
package/dist/migrate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { b64, b64url, hash, unb64 } from "./channel.js";
1
+ import { b64, b64url, hash, unb64 } from './channel.js';
2
2
  /** A migration failure. Neither the message nor the error retains the input or its keys. */
3
3
  export class GrantMigrationError extends Error {
4
4
  code;
package/dist/node.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Node only (`@byokit/pair/node`): host key and device grant files. Kept out of the main entry for browsers and React Native.
2
2
  import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, unwatchFile, watchFile, writeFileSync, writeSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
- import { b64url, keyPair, keyPairFrom, random, unb64url } from "./channel.js";
4
+ import { b64url, keyPair, keyPairFrom, random, unb64url } from './channel.js';
5
5
  /** The host's key pair, kept in `path` (0600, in a 0700 folder, written atomically). The first call makes it.
6
6
  * A file that exists but can't be read as a key is never replaced: a new key would silently cut off every paired
7
7
  * device, so it throws and a person decides. */
package/dist/pairing.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // What pairing puts in front of a person: the QR's text (or a link that carries it), and the typed code.
2
2
  import b4a from 'b4a';
3
- import { b64url, hash, random, unb64url } from "./channel.js";
3
+ import { b64url, hash, random, unb64url } from './channel.js';
4
4
  const TAG = 'byokit-link:1:';
5
5
  const UNSAFE = /[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu; // control and direction-flipping characters
6
6
  /** A device or host name as a person should see it: no hidden characters, at most 60 of them. */
package/dist/stream.d.ts CHANGED
@@ -53,6 +53,29 @@ export declare class LinkStream {
53
53
  ended(error?: string): void;
54
54
  waitOpened(): Promise<LinkStream>;
55
55
  }
56
+ /** The longest one JSON line `ndjson` buffers, in bytes (a line is its UTF-8 bytes up to `\n`). */
57
+ export declare const NDJSON_MAX_LINE: number;
58
+ /** The stream error an `ndjson` reader ends its stream with when a line runs past its cap. Both sides see it. */
59
+ export declare const NDJSON_LINE_TOO_LONG = "line-too-long";
60
+ export type NdjsonOptions = {
61
+ /** The longest one line buffered before the stream ends with `NDJSON_LINE_TOO_LONG`. Defaults to `NDJSON_MAX_LINE`. */
62
+ maxLine?: number;
63
+ };
64
+ export type NdjsonStream = {
65
+ /** The byte stream underneath, for `onEnd`, `end` and anything else not about JSON. */
66
+ readonly stream: LinkStream;
67
+ /** Sends one JSON message (`JSON.stringify` plus `\n`) through the stream's credit window. */
68
+ send(message: unknown): Promise<void>;
69
+ /** Each whole JSON message, in order. Returning a promise holds the sender back until it settles. */
70
+ onMessage(fn: (message: unknown) => void | Promise<void>): void;
71
+ /** Ends the stream both ways: after the sends already made, or at once with an error. */
72
+ end(error?: string): void;
73
+ };
74
+ /** Whole JSON messages over a byte `LinkStream`, one `\n`-terminated line each. Chunks split at any byte
75
+ * boundary reassemble before decoding, so a multi-byte character split across two chunks still arrives whole.
76
+ * A line past the cap ends the stream with `NDJSON_LINE_TOO_LONG` and is never delivered, so nothing is buffered
77
+ * without bound; a line that is not JSON ends it with `failed` through the stream's usual handler-error path. */
78
+ export declare function ndjson(stream: LinkStream, opts?: NdjsonOptions): NdjsonStream;
56
79
  /** One connection's streams. */
57
80
  export declare class Streams {
58
81
  private all;
package/dist/stream.js CHANGED
@@ -178,6 +178,79 @@ export class LinkStream {
178
178
  return new Promise((resolve, reject) => { this.settle = { resolve, reject: (e) => reject(e) }; });
179
179
  }
180
180
  }
181
+ /** The longest one JSON line `ndjson` buffers, in bytes (a line is its UTF-8 bytes up to `\n`). */
182
+ export const NDJSON_MAX_LINE = 1024 * 1024;
183
+ /** The stream error an `ndjson` reader ends its stream with when a line runs past its cap. Both sides see it. */
184
+ export const NDJSON_LINE_TOO_LONG = 'line-too-long';
185
+ /** Whole JSON messages over a byte `LinkStream`, one `\n`-terminated line each. Chunks split at any byte
186
+ * boundary reassemble before decoding, so a multi-byte character split across two chunks still arrives whole.
187
+ * A line past the cap ends the stream with `NDJSON_LINE_TOO_LONG` and is never delivered, so nothing is buffered
188
+ * without bound; a line that is not JSON ends it with `failed` through the stream's usual handler-error path. */
189
+ export function ndjson(stream, opts = {}) {
190
+ const maxLine = opts.maxLine ?? NDJSON_MAX_LINE;
191
+ if (!Number.isInteger(maxLine) || maxLine < 1)
192
+ throw new Error('bad ndjson maxLine');
193
+ const decode = new TextDecoder();
194
+ let parts = [];
195
+ let buffered = 0;
196
+ let failed = false;
197
+ const untilNewline = () => {
198
+ let at = 0;
199
+ for (const p of parts) {
200
+ const i = p.indexOf(10);
201
+ if (i !== -1)
202
+ return at + i;
203
+ at += p.byteLength;
204
+ }
205
+ return -1;
206
+ };
207
+ const take = (n) => {
208
+ const out = new Uint8Array(n);
209
+ for (let at = 0; at < n;) {
210
+ const p = parts[0];
211
+ const k = Math.min(p.byteLength, n - at);
212
+ out.set(p.subarray(0, k), at);
213
+ at += k;
214
+ parts = k === p.byteLength ? parts.slice(1) : [p.subarray(k), ...parts.slice(1)];
215
+ }
216
+ return out;
217
+ };
218
+ return {
219
+ stream,
220
+ send(message) {
221
+ const line = JSON.stringify(message);
222
+ if (line === undefined)
223
+ throw new Error('ndjson cannot send undefined');
224
+ return stream.write(`${line}\n`);
225
+ },
226
+ onMessage(fn) {
227
+ stream.onData = async (chunk) => {
228
+ if (failed)
229
+ return;
230
+ parts.push(chunk);
231
+ buffered += chunk.byteLength;
232
+ for (;;) {
233
+ const at = untilNewline();
234
+ if (at === -1) {
235
+ if (buffered > maxLine) {
236
+ failed = true;
237
+ parts = [];
238
+ buffered = 0;
239
+ stream.end(NDJSON_LINE_TOO_LONG);
240
+ }
241
+ return;
242
+ }
243
+ const line = decode.decode(take(at));
244
+ take(1); // the '\n'
245
+ buffered -= at + 1;
246
+ if (line.length > 0)
247
+ await fn(JSON.parse(line));
248
+ }
249
+ };
250
+ },
251
+ end(error) { stream.end(error); },
252
+ };
253
+ }
181
254
  const count = (n, max) => {
182
255
  if (!Number.isInteger(n) || n < 0 || n > max)
183
256
  throw new Error('bad stream message');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/pair",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Scan a code to pair a phone or browser with the home computer over one encrypted link (Noise IK over WebSocket).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",