@byokit/pair 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +202 -0
- package/README.md +516 -2
- package/SECURITY.md +205 -0
- package/dist/channel.d.ts +76 -0
- package/dist/channel.js +182 -0
- package/dist/check.d.ts +34 -0
- package/dist/check.js +72 -0
- package/dist/confirm-words.d.ts +1 -0
- package/dist/confirm-words.js +26 -0
- package/dist/device.d.ts +168 -0
- package/dist/device.js +546 -0
- package/dist/host.d.ts +214 -0
- package/dist/host.js +861 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.js +62 -0
- package/dist/node.d.ts +17 -0
- package/dist/node.js +210 -0
- package/dist/pairing.d.ts +68 -0
- package/dist/pairing.js +426 -0
- package/dist/stores.d.ts +30 -0
- package/dist/stores.js +184 -0
- package/dist/stream.d.ts +73 -0
- package/dist/stream.js +235 -0
- package/package.json +42 -4
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Bytes each side may have in flight on one stream before the other has taken them. */
|
|
2
|
+
export declare const WINDOW: number;
|
|
3
|
+
export declare const MAX_STREAMS = 64;
|
|
4
|
+
/** How a stream reaches the socket. `reason` turns an error thrown in `onData` into what the other side is told. */
|
|
5
|
+
export type StreamWire = {
|
|
6
|
+
send(m: object): void;
|
|
7
|
+
data(s: number, bytes: Uint8Array): void;
|
|
8
|
+
reason?: (e: unknown) => string;
|
|
9
|
+
report?: (e: unknown) => void;
|
|
10
|
+
};
|
|
11
|
+
export declare class LinkStream {
|
|
12
|
+
readonly id: number;
|
|
13
|
+
/** What the device asked for, e.g. `terminal` with `{ pane }`. */
|
|
14
|
+
readonly op: string;
|
|
15
|
+
readonly args: unknown;
|
|
16
|
+
private wire;
|
|
17
|
+
private gone;
|
|
18
|
+
private credit;
|
|
19
|
+
private owed;
|
|
20
|
+
private allowance;
|
|
21
|
+
private inbox;
|
|
22
|
+
private reader?;
|
|
23
|
+
private ender?;
|
|
24
|
+
private pumping;
|
|
25
|
+
private wake?;
|
|
26
|
+
private tail;
|
|
27
|
+
private ending;
|
|
28
|
+
private closed;
|
|
29
|
+
private settle?;
|
|
30
|
+
constructor(id: number, op: string, args: unknown, wire: StreamWire, gone: () => void);
|
|
31
|
+
/** Each chunk that arrives, in order. Return a promise to hold the other side back until it settles. Chunks that
|
|
32
|
+
* arrive before this is set wait for it. */
|
|
33
|
+
set onData(fn: (chunk: Uint8Array) => void | Promise<void>);
|
|
34
|
+
/** Once, when the stream is over: `error` is unset when either side ended it cleanly. */
|
|
35
|
+
set onEnd(fn: (error?: string) => void | Promise<void>);
|
|
36
|
+
/** Sends bytes (a string goes as UTF-8). Resolves once they're on the socket, which waits while the other side's
|
|
37
|
+
* window is full: await each write to move bulk data at the reader's pace. Rejects if the stream ends first. */
|
|
38
|
+
write(chunk: Uint8Array | string): Promise<void>;
|
|
39
|
+
/** Ends the stream both ways. Without an error it goes after the writes already made; with one, at once. */
|
|
40
|
+
end(error?: string): void;
|
|
41
|
+
private push;
|
|
42
|
+
private finish;
|
|
43
|
+
private notifyEnd;
|
|
44
|
+
private deliverEnd;
|
|
45
|
+
private report;
|
|
46
|
+
private pump;
|
|
47
|
+
private took;
|
|
48
|
+
/** The host's side: tells the device the stream is open, with `credit` (the device's window) to send into. */
|
|
49
|
+
accept(credit: number): void;
|
|
50
|
+
opened(credit: number): void;
|
|
51
|
+
granted(n: number): void;
|
|
52
|
+
received(bytes: Uint8Array): void;
|
|
53
|
+
ended(error?: string): void;
|
|
54
|
+
waitOpened(): Promise<LinkStream>;
|
|
55
|
+
}
|
|
56
|
+
/** One connection's streams. */
|
|
57
|
+
export declare class Streams {
|
|
58
|
+
private all;
|
|
59
|
+
private wire;
|
|
60
|
+
private next;
|
|
61
|
+
constructor(wire: StreamWire);
|
|
62
|
+
get size(): number;
|
|
63
|
+
has(id: number): boolean;
|
|
64
|
+
/** Registers a stream under `id` (a device picks its own ids; the host takes the device's). */
|
|
65
|
+
add(id: number, op: string, args: unknown): LinkStream;
|
|
66
|
+
/** The device's side: asks the host for a stream; rejects with the host's reason if it says no. */
|
|
67
|
+
open(op: string, args: unknown): Promise<LinkStream>;
|
|
68
|
+
/** Handles `m` if it belongs to a stream (false if not). Throws on one that breaks the rules, so the caller drops
|
|
69
|
+
* the socket. Messages for a stream already ended here are late, not wrong, and are dropped. */
|
|
70
|
+
message(m: any): boolean;
|
|
71
|
+
/** The connection is gone: every stream ends with `error`. */
|
|
72
|
+
closeAll(error: string): void;
|
|
73
|
+
}
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Duplex named streams inside one link connection: a terminal pane, a preview tunnel's TCP connection, a voice call.
|
|
2
|
+
// Each direction has a window of bytes: a side sends only what the other has granted, and grants more as its app takes
|
|
3
|
+
// what arrived, so a slow reader slows the writer instead of filling memory at either end.
|
|
4
|
+
//
|
|
5
|
+
// Inner messages, sealed like every other: the device sends {t:'open', s, op, args, credit}; the host answers
|
|
6
|
+
// {t:'opened', s, credit} or {t:'end', s, error}. Then {t:'credit', s, n} grants n more bytes, {t:'end', s, error?}
|
|
7
|
+
// from either side closes the stream both ways, and the bytes themselves travel as binary inner messages
|
|
8
|
+
// (`Channel.sealData`). Streams live and die with their connection: after a reconnect the device opens them again.
|
|
9
|
+
/** Bytes each side may have in flight on one stream before the other has taken them. */
|
|
10
|
+
export const WINDOW = 256 * 1024;
|
|
11
|
+
const PIECE = 32 * 1024; // the most one data message carries
|
|
12
|
+
export const MAX_STREAMS = 64; // open at once on one connection
|
|
13
|
+
const text = new TextEncoder();
|
|
14
|
+
export class LinkStream {
|
|
15
|
+
id;
|
|
16
|
+
/** What the device asked for, e.g. `terminal` with `{ pane }`. */
|
|
17
|
+
op;
|
|
18
|
+
args;
|
|
19
|
+
wire;
|
|
20
|
+
gone;
|
|
21
|
+
credit = 0; // bytes this side may still send
|
|
22
|
+
owed = 0; // bytes our app has taken that the other side hasn't been told about
|
|
23
|
+
allowance = WINDOW; // bytes the other side may still send us
|
|
24
|
+
inbox = [];
|
|
25
|
+
reader;
|
|
26
|
+
ender;
|
|
27
|
+
pumping = false;
|
|
28
|
+
wake;
|
|
29
|
+
tail = Promise.resolve();
|
|
30
|
+
ending = false;
|
|
31
|
+
closed = false;
|
|
32
|
+
settle;
|
|
33
|
+
constructor(id, op, args, wire, gone) {
|
|
34
|
+
this.id = id;
|
|
35
|
+
this.op = op;
|
|
36
|
+
this.args = args;
|
|
37
|
+
this.wire = wire;
|
|
38
|
+
this.gone = gone;
|
|
39
|
+
}
|
|
40
|
+
/** Each chunk that arrives, in order. Return a promise to hold the other side back until it settles. Chunks that
|
|
41
|
+
* arrive before this is set wait for it. */
|
|
42
|
+
set onData(fn) { this.reader = fn; void this.pump(); }
|
|
43
|
+
/** Once, when the stream is over: `error` is unset when either side ended it cleanly. */
|
|
44
|
+
set onEnd(fn) { this.ender = fn; this.notifyEnd(); void this.pump(); }
|
|
45
|
+
/** Sends bytes (a string goes as UTF-8). Resolves once they're on the socket, which waits while the other side's
|
|
46
|
+
* window is full: await each write to move bulk data at the reader's pace. Rejects if the stream ends first. */
|
|
47
|
+
write(chunk) {
|
|
48
|
+
if (this.closed || this.ending)
|
|
49
|
+
return Promise.reject(new Error('stream ended'));
|
|
50
|
+
const bytes = typeof chunk === 'string' ? text.encode(chunk) : chunk;
|
|
51
|
+
const p = this.tail.then(() => this.push(bytes));
|
|
52
|
+
this.tail = p.catch(() => { });
|
|
53
|
+
return p;
|
|
54
|
+
}
|
|
55
|
+
/** Ends the stream both ways. Without an error it goes after the writes already made; with one, at once. */
|
|
56
|
+
end(error) {
|
|
57
|
+
if (error !== undefined)
|
|
58
|
+
return this.finish(error, true);
|
|
59
|
+
this.ending = true;
|
|
60
|
+
void this.tail.then(() => this.finish(undefined, true));
|
|
61
|
+
}
|
|
62
|
+
async push(bytes) {
|
|
63
|
+
for (let at = 0; at < bytes.byteLength;) {
|
|
64
|
+
if (this.closed)
|
|
65
|
+
throw new Error('stream ended');
|
|
66
|
+
if (!this.credit) {
|
|
67
|
+
await new Promise((r) => { this.wake = r; });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const n = Math.min(this.credit, PIECE, bytes.byteLength - at);
|
|
71
|
+
this.credit -= n;
|
|
72
|
+
this.wire.data(this.id, bytes.subarray(at, at + n));
|
|
73
|
+
at += n;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finish(error, tell) {
|
|
77
|
+
if (this.closed)
|
|
78
|
+
return;
|
|
79
|
+
this.closed = true;
|
|
80
|
+
this.gone();
|
|
81
|
+
this.wake?.();
|
|
82
|
+
if (tell)
|
|
83
|
+
this.wire.send({ t: 'end', s: this.id, ...(error === undefined ? {} : { error }) });
|
|
84
|
+
if (tell || error !== undefined)
|
|
85
|
+
this.inbox = [];
|
|
86
|
+
this.settle?.reject(error ?? 'ended');
|
|
87
|
+
this.inbox.push({ end: error });
|
|
88
|
+
this.notifyEnd();
|
|
89
|
+
void this.pump();
|
|
90
|
+
}
|
|
91
|
+
notifyEnd() {
|
|
92
|
+
const next = this.inbox.at(-1);
|
|
93
|
+
if (!this.ender || !next || next instanceof Uint8Array || next.end === undefined)
|
|
94
|
+
return;
|
|
95
|
+
this.inbox = [];
|
|
96
|
+
const ender = this.ender;
|
|
97
|
+
void Promise.resolve().then(() => this.deliverEnd(ender, next.end));
|
|
98
|
+
}
|
|
99
|
+
async deliverEnd(ender, error) {
|
|
100
|
+
try {
|
|
101
|
+
await ender(error);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
this.report(e);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
report(e) { try {
|
|
108
|
+
this.wire.report?.(e);
|
|
109
|
+
}
|
|
110
|
+
catch { } }
|
|
111
|
+
async pump() {
|
|
112
|
+
if (this.pumping)
|
|
113
|
+
return;
|
|
114
|
+
this.pumping = true;
|
|
115
|
+
while (this.inbox.length) {
|
|
116
|
+
const next = this.inbox[0];
|
|
117
|
+
if (!(next instanceof Uint8Array)) {
|
|
118
|
+
if (!this.ender)
|
|
119
|
+
break;
|
|
120
|
+
this.inbox.shift();
|
|
121
|
+
void this.deliverEnd(this.ender, next.end);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!this.reader) {
|
|
125
|
+
if (!this.closed || !this.ender)
|
|
126
|
+
break;
|
|
127
|
+
this.inbox.shift();
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
this.inbox.shift();
|
|
131
|
+
try {
|
|
132
|
+
await this.reader(next);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
if (this.closed)
|
|
136
|
+
this.report(e);
|
|
137
|
+
else
|
|
138
|
+
this.end(this.wire.reason?.(e) ?? 'failed');
|
|
139
|
+
}
|
|
140
|
+
this.took(next.byteLength);
|
|
141
|
+
}
|
|
142
|
+
this.pumping = false;
|
|
143
|
+
}
|
|
144
|
+
took(n) {
|
|
145
|
+
if (this.closed)
|
|
146
|
+
return;
|
|
147
|
+
this.owed += n;
|
|
148
|
+
if (this.owed < WINDOW / 4)
|
|
149
|
+
return;
|
|
150
|
+
this.wire.send({ t: 'credit', s: this.id, n: this.owed });
|
|
151
|
+
this.allowance += this.owed;
|
|
152
|
+
this.owed = 0;
|
|
153
|
+
}
|
|
154
|
+
/** The host's side: tells the device the stream is open, with `credit` (the device's window) to send into. */
|
|
155
|
+
accept(credit) {
|
|
156
|
+
this.wire.send({ t: 'opened', s: this.id, credit: WINDOW });
|
|
157
|
+
this.opened(credit);
|
|
158
|
+
}
|
|
159
|
+
// From the other side, through `Streams`.
|
|
160
|
+
opened(credit) {
|
|
161
|
+
this.credit = credit;
|
|
162
|
+
this.settle?.resolve(this);
|
|
163
|
+
this.settle = undefined;
|
|
164
|
+
this.wake?.();
|
|
165
|
+
}
|
|
166
|
+
granted(n) { this.credit += n; this.wake?.(); }
|
|
167
|
+
received(bytes) {
|
|
168
|
+
if (bytes.byteLength > this.allowance)
|
|
169
|
+
throw new Error('stream went past its window');
|
|
170
|
+
this.allowance -= bytes.byteLength;
|
|
171
|
+
if (this.closed)
|
|
172
|
+
return;
|
|
173
|
+
this.inbox.push(bytes);
|
|
174
|
+
void this.pump();
|
|
175
|
+
}
|
|
176
|
+
ended(error) { this.finish(error, false); }
|
|
177
|
+
waitOpened() {
|
|
178
|
+
return new Promise((resolve, reject) => { this.settle = { resolve, reject: (e) => reject(e) }; });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const count = (n, max) => {
|
|
182
|
+
if (!Number.isInteger(n) || n < 0 || n > max)
|
|
183
|
+
throw new Error('bad stream message');
|
|
184
|
+
return n;
|
|
185
|
+
};
|
|
186
|
+
/** One connection's streams. */
|
|
187
|
+
export class Streams {
|
|
188
|
+
all = new Map();
|
|
189
|
+
wire;
|
|
190
|
+
next = 1;
|
|
191
|
+
constructor(wire) { this.wire = wire; }
|
|
192
|
+
get size() { return this.all.size; }
|
|
193
|
+
has(id) { return this.all.has(id); }
|
|
194
|
+
/** Registers a stream under `id` (a device picks its own ids; the host takes the device's). */
|
|
195
|
+
add(id, op, args) {
|
|
196
|
+
const s = new LinkStream(id, op, args, this.wire, () => this.all.delete(id));
|
|
197
|
+
this.all.set(id, s);
|
|
198
|
+
return s;
|
|
199
|
+
}
|
|
200
|
+
/** The device's side: asks the host for a stream; rejects with the host's reason if it says no. */
|
|
201
|
+
open(op, args) {
|
|
202
|
+
const s = this.add(this.next++, op, args);
|
|
203
|
+
const opened = s.waitOpened();
|
|
204
|
+
try {
|
|
205
|
+
this.wire.send({ t: 'open', s: s.id, op, args, credit: WINDOW });
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
s.ended('failed');
|
|
209
|
+
return opened.catch(() => { throw e; });
|
|
210
|
+
}
|
|
211
|
+
return opened;
|
|
212
|
+
}
|
|
213
|
+
/** Handles `m` if it belongs to a stream (false if not). Throws on one that breaks the rules, so the caller drops
|
|
214
|
+
* the socket. Messages for a stream already ended here are late, not wrong, and are dropped. */
|
|
215
|
+
message(m) {
|
|
216
|
+
if (!['data', 'credit', 'end', 'opened'].includes(m?.t))
|
|
217
|
+
return false;
|
|
218
|
+
const s = this.all.get(count(m.s, 2 ** 32 - 1));
|
|
219
|
+
if (m.t === 'data') {
|
|
220
|
+
if (!(m.d instanceof Uint8Array))
|
|
221
|
+
throw new Error('bad stream message');
|
|
222
|
+
s?.received(m.d);
|
|
223
|
+
}
|
|
224
|
+
else if (m.t === 'credit')
|
|
225
|
+
s?.granted(count(m.n, 2 ** 31));
|
|
226
|
+
else if (m.t === 'opened')
|
|
227
|
+
s?.opened(count(m.credit, 2 ** 31));
|
|
228
|
+
else
|
|
229
|
+
s?.ended(typeof m.error === 'string' ? m.error.slice(0, 200) : undefined);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
/** The connection is gone: every stream ends with `error`. */
|
|
233
|
+
closeAll(error) { for (const s of [...this.all.values()])
|
|
234
|
+
s.ended(error); }
|
|
235
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/pair",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Scan a code to pair a phone or browser with the home computer over one encrypted link (Noise IK over WebSocket).",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/umeranjum17/byokit.git",
|
|
10
|
+
"directory": "packages/pair"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.18"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./node": {
|
|
21
|
+
"types": "./dist/node.d.ts",
|
|
22
|
+
"default": "./dist/node.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"SECURITY.md",
|
|
28
|
+
"CHANGELOG.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"prepack": "tsc -b"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@noble/ciphers": "2.4.0",
|
|
35
|
+
"b4a": "1.9.0",
|
|
36
|
+
"noise-handshake": "4.2.0",
|
|
37
|
+
"sodium-javascript": "0.8.0",
|
|
38
|
+
"sodium-native": "5.1.0",
|
|
39
|
+
"sodium-universal": "5.0.1"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|