@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 +11 -0
- package/README.md +55 -26
- package/SECURITY.md +2 -2
- package/dist/channel.js +1 -1
- package/dist/check.d.ts +3 -3
- package/dist/check.js +5 -5
- package/dist/device.d.ts +10 -9
- package/dist/device.js +16 -9
- package/dist/host.js +5 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -8
- package/dist/migrate.js +1 -1
- package/dist/node.js +1 -1
- package/dist/pairing.js +1 -1
- package/dist/stream.d.ts +23 -0
- package/dist/stream.js +73 -0
- package/package.json +1 -1
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/
|
|
1
|
+
<h1 align="center">@byokit/pair</h1>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="https://www.npmjs.com/package/@byokit/
|
|
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 "$(command -v herdr)" --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this
|
|
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 "$(command -v herdr)" --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/
|
|
33
|
+
npm install @byokit/pair
|
|
31
34
|
```
|
|
32
35
|
|
|
33
|
-
[](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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
98
|
-
import { hostKeyFile } from '@byokit/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
431
|
+
import { check, parseOffer } from '@byokit/pair';
|
|
419
432
|
|
|
420
|
-
//
|
|
421
|
-
//
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
32
|
-
* that never answered) and `wrong-host` (something else answered) can
|
|
33
|
-
*
|
|
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
|
|
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 ? '
|
|
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
|
|
59
|
-
* that never answered) and `wrong-host` (something else answered) can
|
|
60
|
-
*
|
|
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
|
|
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:
|
|
48
|
-
readonly declined:
|
|
49
|
-
readonly full:
|
|
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:
|
|
52
|
-
readonly 'view-only':
|
|
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:
|
|
56
|
-
readonly stopped:
|
|
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:
|
|
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
|
|
6
|
-
import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, parseV1Offer, shortKey, COMPACT_TAG } from
|
|
7
|
-
import { Streams } from
|
|
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
|
|
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
|
|
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 = () =>
|
|
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('
|
|
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
|
|
5
|
-
import { bindCode, cleanName, codeKey, encodeCompactOffer, newCode, normalizeCode, offerText } from
|
|
6
|
-
import { PublicLinkError } from
|
|
7
|
-
import { LINK_PROBE, LINK_PROBE_OK } from
|
|
8
|
-
import { MAX_STREAMS, Streams } from
|
|
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
|
|
2
|
-
export { Host, } from
|
|
3
|
-
export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, } from
|
|
4
|
-
export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from
|
|
5
|
-
export { LinkStream, WINDOW } from
|
|
6
|
-
export { check, LINK_PROBE, LINK_PROBE_OK } from
|
|
7
|
-
export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores } from
|
|
8
|
-
export { migrateGrant, GrantMigrationError } from
|
|
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
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
|
|
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
|
|
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