@byokit/pair 0.10.0 → 0.10.2

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,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.2 (2026-10-17)
6
+
7
+ - FIX: a busy host that answers a ping late no longer has its live link dropped. `DeviceLink` now counts any pong up to the latest ping, a reply to one of its own waiting requests, or a frame on one of its own streams as hearing from the host, and counts silence from its first ping rather than from the connect, so a host that answers even that first ping late stays online. A host that goes silent is still dropped after two silent ping rounds (`2 × pingMs`, checked once a round). Host events and broadcasts alone still do not keep a link up: they show only that the host can talk, not that it hears the device, so only an answer to something the device sent proves the round trip.
8
+
9
+ ## 0.10.1 (2026-10-16)
10
+
11
+ - 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.
12
+ - 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.
13
+
5
14
  ## 0.10.0 (2026-10-12)
6
15
 
7
16
  - 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.
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.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. */
package/dist/device.d.ts CHANGED
@@ -44,22 +44,22 @@ export declare const LINK_WORDS: {
44
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
+ readonly slow: 'Your computer is slow to answer. Wait a moment and try again.';
55
55
  readonly failed: "Your computer couldn't do that.";
56
- readonly busy: "Your computer is still catching up. Try again in a moment.";
57
- 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.';
58
58
  readonly 'not-supported': "The app on your computer can't do this yet. Update it there.";
59
59
  readonly ended: "This device's access has run out. Pair it again on your computer.";
60
60
  readonly 'not-allowed': "This device isn't allowed to do that.";
61
61
  readonly 'too-late': "That reached your computer too late, so it wasn't done.";
62
- readonly unsupported: "Your computer needs an update for that.";
62
+ readonly unsupported: 'Your computer needs an update for that.';
63
63
  };
64
64
  export type LinkProblem = keyof typeof LINK_WORDS;
65
65
  /** A failure with a plain `message` and a `code` for the app. `sealed` means the host said it inside the encrypted
@@ -150,6 +150,9 @@ export declare class DeviceLink {
150
150
  * A tick that fires long after it was due means this device's own timers were frozen (an app in the background,
151
151
  * a hidden tab), not that the host went quiet: no ping went out, so none came back. */
152
152
  private ping;
153
+ /** Whether `m` answers something this device sent: any pong so far (a busy host answers late), a reply to a waiting
154
+ * request, or a frame on one of its streams. Only those prove the host hears this device; its events alone do not. */
155
+ private answers;
153
156
  private sendPending;
154
157
  private settle;
155
158
  private again;
package/dist/device.js CHANGED
@@ -2,9 +2,9 @@
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
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.",
@@ -320,7 +320,7 @@ export class DeviceLink {
320
320
  let active;
321
321
  const l = await dial(url, keyPairFrom(unb64url(secret)), { key: unb64url(this.grant.host) }, { t: 'auth', session: this.session, ack: this.ack, fresh: this.fresh }, this.o, {
322
322
  message: (m) => { if (this.conn === active) {
323
- if (m.t === 'pong' && m.n === this.pingId)
323
+ if (this.answers(m))
324
324
  this.heard = Date.now();
325
325
  this.message(m);
326
326
  } },
@@ -396,8 +396,9 @@ export class DeviceLink {
396
396
  if (this.conn !== l)
397
397
  return;
398
398
  const now = Date.now();
399
- // Count the silence from now; a socket that is really dead still misses the pings that follow.
400
- if (now - due > every / 2)
399
+ // Count the silence from now; a socket that is really dead still misses the pings that follow. Before the first
400
+ // ping nothing was asked, so a host that answers it late is not silent either.
401
+ if (now - due > every / 2 || !this.pingId)
401
402
  this.heard = now;
402
403
  else if (now - this.heard > 2 * every) {
403
404
  l.close();
@@ -412,6 +413,11 @@ export class DeviceLink {
412
413
  };
413
414
  later(every, tick);
414
415
  }
416
+ /** Whether `m` answers something this device sent: any pong so far (a busy host answers late), a reply to a waiting
417
+ * request, or a frame on one of its streams. Only those prove the host hears this device; its events alone do not. */
418
+ answers(m) {
419
+ return (m.t === 'pong' && m.n <= this.pingId) || (m.t === 'res' && this.pending.has(m.id)) || !!this.streams?.has(m.s);
420
+ }
415
421
  sendPending(l, id, p) {
416
422
  try {
417
423
  l.send(p.msg);
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.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, 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";
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/pair",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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",