@quo-systems/quo 0.1.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.
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The store conformance suite: what every store must show, written against
3
+ // the interface alone. A store is handed in as a maker of fresh ones: an
4
+ // empty folder, a database nobody opened, a view of storage under a prefix
5
+ // nobody used. Nothing here knows how a store keeps anything; it asks for
6
+ // what it put and expects it back, as the harbor will.
7
+ import { assert } from './assert.ts';
8
+ import type { Kept, Store } from '../harbor/store.ts';
9
+ import type { Runner } from './index.ts';
10
+
11
+ const seed = (b: number) => new Uint8Array(32).fill(b);
12
+ const kept = (b: number, extra: Record<string, unknown> = {}): Kept => ({ seed: seed(b), partition: { beings: {}, ...extra }, record: { pk: '', code: 'classes/index.ts', user: 'me' } });
13
+
14
+ export function conformStore(label: string, make: () => Promise<Store>, { test }: { test: Runner }) {
15
+ const t = (name: string, fn: () => Promise<void>) => test(`[${label}] ${name}`, {}, fn);
16
+
17
+ t('a fresh store keeps nothing', async () => {
18
+ const s = await make();
19
+ assert.deepEqual(await s.list(), []);
20
+ assert.equal(await s.load('main'), undefined);
21
+ assert.equal(await s.take('main'), undefined);
22
+ assert.deepEqual(await s.hints(), {});
23
+ });
24
+
25
+ t('put, then load: seed, partition and record come back as they went in', async () => {
26
+ const s = await make();
27
+ await s.put('main', kept(7, { n: 1, deep: { list: [1, 'two', null], flag: true } }));
28
+ assert.deepEqual(await s.list(), ['main']);
29
+ const back = await s.load('main');
30
+ assert.ok(back);
31
+ assert.deepEqual(back.seed, seed(7));
32
+ assert.deepEqual(back.partition, { beings: {}, n: 1, deep: { list: [1, 'two', null], flag: true } });
33
+ assert.deepEqual(back.record, { pk: '', code: 'classes/index.ts', user: 'me' });
34
+ });
35
+
36
+ t('a name already kept is refused, and what was kept stands', async () => {
37
+ const s = await make();
38
+ await s.put('main', kept(1));
39
+ let refused = false;
40
+ try {
41
+ await s.put('main', kept(2));
42
+ } catch {
43
+ refused = true;
44
+ }
45
+ assert.ok(refused, 'a second put under one name must throw');
46
+ assert.deepEqual((await s.load('main'))!.seed, seed(1));
47
+ });
48
+
49
+ t('save replaces the partition and touches nothing else', async () => {
50
+ const s = await make();
51
+ await s.put('main', kept(3));
52
+ await s.save('main', { beings: { a: { x: 1 } }, bind: {} });
53
+ const back = (await s.load('main'))!;
54
+ assert.deepEqual(back.partition, { beings: { a: { x: 1 } }, bind: {} });
55
+ assert.deepEqual(back.seed, seed(3));
56
+ assert.deepEqual(back.record, kept(3).record);
57
+ });
58
+
59
+ t('record replaces the record and touches nothing else', async () => {
60
+ const s = await make();
61
+ await s.put('main', kept(4, { n: 9 }));
62
+ await s.record('main', { pk: 'ab'.repeat(64), code: 'elsewhere.ts', user: 'her' });
63
+ const back = (await s.load('main'))!;
64
+ assert.deepEqual(back.record, { pk: 'ab'.repeat(64), code: 'elsewhere.ts', user: 'her' });
65
+ assert.deepEqual(back.partition, { beings: {}, n: 9 });
66
+ });
67
+
68
+ t('save and record on a name not kept are nothing', async () => {
69
+ const s = await make();
70
+ await s.save('ghost', { beings: {} });
71
+ await s.record('ghost', { pk: '', code: '', user: '' });
72
+ assert.deepEqual(await s.list(), []);
73
+ });
74
+
75
+ t('the partition handed back is values, not the object that was put', async () => {
76
+ const s = await make();
77
+ const k = kept(5, { n: 1 });
78
+ await s.put('main', k);
79
+ (k.partition as { n: number }).n = 2;
80
+ assert.deepEqual((await s.load('main'))!.partition, { beings: {}, n: 1 });
81
+ const a = (await s.load('main'))!;
82
+ (a.partition as { n: number }).n = 3;
83
+ assert.deepEqual((await s.load('main'))!.partition, { beings: {}, n: 1 });
84
+ });
85
+
86
+ t('take hands the ward out and forgets it; the name is free again', async () => {
87
+ const s = await make();
88
+ await s.put('main', kept(6, { n: 6 }));
89
+ await s.put('other', kept(8));
90
+ const out = await s.take('main');
91
+ assert.ok(out);
92
+ assert.deepEqual(out.seed, seed(6));
93
+ assert.deepEqual(out.partition, { beings: {}, n: 6 });
94
+ assert.equal(await s.load('main'), undefined);
95
+ assert.deepEqual(await s.list(), ['other']);
96
+ await s.put('main', kept(9));
97
+ assert.deepEqual((await s.load('main'))!.seed, seed(9));
98
+ });
99
+
100
+ t('hints are kept by pk and the last one wins', async () => {
101
+ const s = await make();
102
+ const pk = 'cd'.repeat(64);
103
+ await s.hint(pk, 'https://quo.acme.com/quo');
104
+ await s.hint('ef'.repeat(64), 'http://127.0.0.1:1/quo');
105
+ assert.deepEqual(await s.hints(), { [pk]: 'https://quo.acme.com/quo', ['ef'.repeat(64)]: 'http://127.0.0.1:1/quo' });
106
+ await s.hint(pk, 'https://quo.other.com/quo');
107
+ assert.equal((await s.hints())[pk], 'https://quo.other.com/quo');
108
+ });
109
+ }
@@ -0,0 +1,178 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The harbor core: what every harbor on a device is. It boots wards from a
3
+ // store, keeps the map of ward pk to door for its own wards and pk to reach
4
+ // for foreign ones, carries bytes to a pk, and hands bytes from the wire to
5
+ // one door. It names no terrain: the store, the class loader and the lease
6
+ // are the terrain's, handed in or laid over it. A disk harbor is this plus
7
+ // files and a pid; a browser harbor is this plus IndexedDB and a lock; an
8
+ // edge harbor is this over an object's storage. None of them is in this
9
+ // tree, and every one of them passes the conformance suite untouched.
10
+ //
11
+ // The directory is filled three ways, in this order: the harbor's own
12
+ // doors; a socket a dialer holds to it, bound at announce and unbound at
13
+ // close; a hint from a link. And one fallback: a dialer with nothing in its
14
+ // directory for a pk sends down the socket it holds, because the listener
15
+ // it dialed is the rendezvous and may hold that pk on another socket. Bytes
16
+ // that arrive from the wire go to an own door or a held socket and never
17
+ // onward by request or by fallback; that one rule is the rendezvous.
18
+ import { Ward } from '../ward/ward.ts';
19
+ import type { Ground, WardPointers } from '../ward/ground.ts';
20
+ import type { BeingClass, BeingLike } from '../being/types.ts';
21
+ import { request, type Reach } from './reach.ts';
22
+ import type { Kept, Store, WardRecord } from './store.ts';
23
+
24
+ export type Hosted = WardPointers & { pk: string; name: string; record: WardRecord; partition: Record<string, unknown>; being(key: string): BeingLike | undefined; save(): Promise<void> };
25
+ // A reach in the directory, and whether this harbor holds it as a socket a
26
+ // dialer opened: only those, and its own doors, take bytes from the wire.
27
+ export type Bound = { reach: Reach; held: boolean };
28
+ // How the code half arrives: the classes a record's `code` names, loaded by
29
+ // the terrain. The browser has a bundle and no files; a daemon has a folder.
30
+ export type Loader = (record: WardRecord) => Promise<Record<string, BeingClass>>;
31
+
32
+ export const DEFAULT_CODE = 'classes/index.ts';
33
+
34
+ export class Harbor {
35
+ readonly store: Store;
36
+ readonly loader: Loader;
37
+ readonly wards = new Map<string, Hosted>(); // name -> pointers
38
+ readonly doors = new Map<string, WardPointers['door']>(); // ward pk -> door
39
+ readonly reaches = new Map<string, Bound>(); // the directory: foreign ward pk -> reach
40
+ readonly down = new Set<string>(); // pks this harbor will not reach right now: weather, for a test
41
+ readonly classes: Record<string, BeingClass> = {}; // classes handed in-process, beside the loader's
42
+ // The dialers' sockets, in the order they were dialed: where a pk nobody
43
+ // here knows is sent, because the listener a harbor dialed is a rendezvous
44
+ // and may hold that pk for someone else. It is a list because a harbor may
45
+ // dial more than one, and a rendezvous is a listener and nothing more, so
46
+ // there is never only one of them. A pk is tried down the list until one
47
+ // carries it: with a single slot, a harbor holding two lines answered
48
+ // unreached for a pk the other line could have reached.
49
+ readonly fallbacks: Reach[] = [];
50
+
51
+ constructor(store: Store, loader: Loader = async () => ({})) {
52
+ this.store = store;
53
+ this.loader = loader;
54
+ }
55
+
56
+ // Boot every ward the store keeps, and learn the hints.
57
+ async boot(): Promise<void> {
58
+ for (const name of await this.store.list()) {
59
+ const kept = await this.store.load(name);
60
+ if (kept) await this.host(name, kept);
61
+ }
62
+ for (const [pk, url] of Object.entries(await this.store.hints())) this.hint(pk, url);
63
+ }
64
+
65
+ // ---- the directory
66
+
67
+ async carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
68
+ if (this.down.has(pk)) return undefined;
69
+ const door = this.doors.get(pk);
70
+ if (door) return new Uint8Array(await door(bytes));
71
+ const bound = this.reaches.get(pk);
72
+ if (bound) return bound.reach.carry(pk, bytes);
73
+ for (const reach of this.fallbacks) {
74
+ const back = await reach.carry(pk, bytes);
75
+ if (back !== undefined) return back;
76
+ }
77
+ return undefined; // nobody here knows it, and no listener we dialed holds it
78
+ }
79
+
80
+ async deliver(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
81
+ if (this.down.has(pk)) return undefined;
82
+ const door = this.doors.get(pk);
83
+ if (door) return new Uint8Array(await door(bytes));
84
+ const bound = this.reaches.get(pk);
85
+ return bound?.held ? bound.reach.carry(pk, bytes) : undefined;
86
+ }
87
+
88
+ hint(pk: string, url: string): void {
89
+ if (this.reaches.get(pk)?.held) return; // a socket in hand beats a hint
90
+ this.reaches.set(pk, { reach: request(url), held: false });
91
+ }
92
+
93
+ async remember(pk: string, url: string): Promise<void> {
94
+ this.hint(pk, url);
95
+ await this.store.hint(pk, url);
96
+ }
97
+
98
+ bind(pks: string[], reach: Reach, held: boolean): void {
99
+ for (const pk of pks) if (!this.doors.has(pk)) this.reaches.set(pk, { reach, held });
100
+ }
101
+ unbind(reach: Reach): void {
102
+ for (const [pk, b] of this.reaches) if (b.reach === reach) this.reaches.delete(pk);
103
+ }
104
+
105
+ // ---- wards
106
+
107
+ // Mint a seed and boot an empty ward under a name: a world born here.
108
+ async create(name: string, user = '', code = DEFAULT_CODE): Promise<Hosted> {
109
+ const kept: Kept = { seed: globalThis.crypto.getRandomValues(new Uint8Array(32)), partition: {}, record: { pk: '', code, user } };
110
+ await this.store.put(name, kept);
111
+ const hosted = await this.host(name, kept);
112
+ await this.store.record(name, hosted.record);
113
+ return hosted;
114
+ }
115
+
116
+ // Stop serving a ward and take it out of the store: what was kept comes
117
+ // back, for another harbor to put. The partition is written first, because
118
+ // a being driven in-process changes it without passing a door.
119
+ async drop(name: string): Promise<Kept | undefined> {
120
+ const h = this.wards.get(name);
121
+ if (h) {
122
+ await h.save();
123
+ this.wards.delete(name);
124
+ this.doors.delete(h.pk);
125
+ }
126
+ return this.store.take(name);
127
+ }
128
+
129
+ // Put what another harbor dropped, and boot it: same seed, same pk.
130
+ async adopt(name: string, kept: Kept): Promise<Hosted> {
131
+ await this.store.put(name, kept);
132
+ return this.host(name, kept);
133
+ }
134
+
135
+ // A ward from what the store keeps: the ground, once, and two pointers.
136
+ async host(name: string, kept: Kept): Promise<Hosted> {
137
+ const { seed, partition: memory } = kept;
138
+ const classes = await this.loader(kept.record);
139
+ const objects = new Map<object, BeingLike>(); // cells -> the object instantiate made. a side's hand, never the ward's
140
+ const ground: Ground = {
141
+ seed,
142
+ memory,
143
+ instantiate: (className, stance) => {
144
+ const C = classes[className] ?? this.classes[className];
145
+ if (!C) return null;
146
+ const obj = new C(stance);
147
+ objects.set(stance.cells, obj);
148
+ return obj;
149
+ },
150
+ carry: (pk, bytes) => this.carry(pk, new Uint8Array(bytes)),
151
+ random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
152
+ };
153
+ const w = await Ward(ground);
154
+ const save = () => this.store.save(name, memory);
155
+ const after =
156
+ <A extends unknown[], R>(f: (...a: A) => Promise<R>) =>
157
+ async (...a: A): Promise<R> => {
158
+ try {
159
+ return await f(...a);
160
+ } finally {
161
+ await save();
162
+ }
163
+ };
164
+ const door = after(w.door);
165
+ const ask = after(w.ask);
166
+ const bp = (await w.ask()) as { notes: { pk: string } }; // learned by asking, as anyone learns anything
167
+ const beings = memory.beings as Record<string, object> | undefined;
168
+ const being = (key: string) => {
169
+ const cells = beings?.[key];
170
+ return cells ? objects.get(cells) : undefined;
171
+ };
172
+ const hosted: Hosted = { door, ask, pk: bp.notes.pk, name, record: { ...kept.record, pk: bp.notes.pk }, partition: memory, being, save };
173
+ this.wards.set(name, hosted);
174
+ this.doors.set(hosted.pk, door);
175
+ await save();
176
+ return hosted;
177
+ }
178
+ }
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A harbor as a dialer: one held socket to a listener's quo. route, its own
3
+ // pks announced, the listener's bound, and a reconnect with backoff from a
4
+ // second to thirty when the line drops. Seed, partition and keys are here;
5
+ // only the reach comes and goes. While the socket is open it is also the
6
+ // harbor's fallback: a pk nobody here knows is sent to the listener, which
7
+ // is the rendezvous, and it is the fallback from the moment it is dialed,
8
+ // because a ward asks the instant it boots and a socket's carry waits for
9
+ // the handshake itself. Terrain-free: a daemon on a Mac and a tab dial alike,
10
+ // on the WebSocket every terrain has.
11
+ import type { Harbor } from './core.ts';
12
+ import { REFUSED, Socket, type Line } from './reach.ts';
13
+
14
+ export type Dialer = { url: string; socket: Socket | null; close(): void };
15
+
16
+ export function dial(harbor: Harbor, url: string): Dialer {
17
+ const d: Dialer = { url, socket: null, close: () => {} };
18
+ let stopped = false,
19
+ wait = 1000,
20
+ timer: ReturnType<typeof setTimeout> | undefined;
21
+ const connect = () => {
22
+ if (stopped) return;
23
+ const line = new WebSocket(url.replace(/\/$/, '').replace(/^http/, 'ws')) as unknown as Line;
24
+ const s: Socket = new Socket(
25
+ line,
26
+ (pk, bytes) => harbor.deliver(pk, bytes),
27
+ (far) => {
28
+ // A line that opened is not a line that works. The wait goes back to
29
+ // a second here, where the far side has answered and been taken, and
30
+ // not at the handshake: a listener that opens and then refuses every
31
+ // time would otherwise be dialed at a fixed two seconds for good.
32
+ wait = 1000;
33
+ harbor.bind(far, s, false); // the listener's pks: reached through this socket, not held for others
34
+ },
35
+ (why) => {
36
+ harbor.unbind(s);
37
+ if (d.socket === s) d.socket = null;
38
+ const at = harbor.fallbacks.indexOf(s);
39
+ if (at !== -1) harbor.fallbacks.splice(at, 1); // this line only: another dialer's stays
40
+ // A suite this listener will not speak is not a line that dropped. It
41
+ // will not become speakable by asking again sooner, so the wait goes
42
+ // straight to the ceiling and stays there until it changes its mind.
43
+ if (why.code === REFUSED) wait = 30000;
44
+ if (!stopped) timer = setTimeout(connect, wait);
45
+ wait = Math.min(wait * 2, 30000);
46
+ },
47
+ );
48
+ harbor.fallbacks.push(s); // from the moment it is dialed: an ask made before the handshake waits on the socket, and is not unreached
49
+ line.addEventListener('open', () => {
50
+ d.socket = s;
51
+ s.announce([...harbor.doors.keys()]);
52
+ });
53
+ };
54
+ d.close = () => {
55
+ stopped = true;
56
+ clearTimeout(timer);
57
+ d.socket?.close();
58
+ };
59
+ connect();
60
+ return d;
61
+ }
@@ -0,0 +1,11 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // @quo-systems/quo/harbor — the Ground contract and the harbors. The only
3
+ // place in the package that may know a wire, and it knows one on the
4
+ // standard surface alone: fetch and WebSocket. MemoryHarbor knows none: it
5
+ // is one process talking to itself, where every ward assertion is made.
6
+ export { MemoryHarbor, type Booted, type WardFactory } from './memory.ts';
7
+ export { Harbor, DEFAULT_CODE, type Hosted, type Bound, type Loader } from './core.ts';
8
+ export { dial, type Dialer } from './dial.ts';
9
+ export { MemoryStore, values, type Store, type Kept, type WardRecord } from './store.ts';
10
+ export { request, Socket, SUITE, REFUSED, type Reach, type Carry, type Line, type Announce } from './reach.ts';
11
+ export type { Ground, WardPointers } from '../ward/ground.ts';
@@ -0,0 +1,74 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // A memory harbor. One process, no wire. Owns what a device owns: the code
3
+ // half, the memory half, the routing. Boots a ward by passing ONE object and
4
+ // keeping TWO pointers. Routes one ward pk to one door. Knows no being,
5
+ // reads no partition, opens no byte. Several memory harbors may be linked,
6
+ // which stands in for a wire, and cut, which stands in for weather.
7
+ import type { BeingClass, Stance } from '../being/types.ts';
8
+ import type { Ground, WardPointers } from '../ward/ground.ts';
9
+ import { hex } from '../ward/arithmetic.ts';
10
+
11
+ export type Booted = WardPointers & { pk: string };
12
+ export type WardFactory = (ground: Ground) => Promise<WardPointers>;
13
+
14
+ export class MemoryHarbor {
15
+ readonly doors = new Map<string, WardPointers['door']>(); // ward pk -> door
16
+ readonly down = new Set<string>(); // ward pks this harbor cannot reach right now
17
+ readonly wire: string[] = []; // every byte string that ever crossed, as hex. for inspection
18
+ readonly peers = new Set<MemoryHarbor>();
19
+ cut = false;
20
+ readonly partitions = new Map<string, Record<string, unknown>>(); // seed -> memory. the harbor keeps it and reads nothing
21
+ readonly wards = new Map<string, Booted>(); // seed -> pointers
22
+ readonly objects = new Map<object, unknown>(); // cells -> being object. what instantiate constructed. a hand for tests, never the ward's
23
+
24
+ // the directory: its own doors, else a peer it is linked to. Quo says nothing about how.
25
+ async route(farPk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
26
+ if (this.down.has(farPk)) return undefined;
27
+ const door = this.doors.get(farPk);
28
+ if (door) return door(bytes);
29
+ const peer = [...this.peers].find((h) => h.doors.has(farPk) && !h.down.has(farPk));
30
+ if (!peer || this.cut) return undefined; // nothing. the harbor has no word silence
31
+ return peer.route(farPk, bytes);
32
+ }
33
+
34
+ link(harbor: MemoryHarbor): void {
35
+ this.peers.add(harbor);
36
+ harbor.peers.add(this);
37
+ }
38
+
39
+ // boot by seed. the harbor gets a door and an ask, and learns the pk by asking.
40
+ async boot(seed: string, Ward: WardFactory, classes: Record<string, BeingClass>): Promise<Booted> {
41
+ this.partitions.set(seed, {});
42
+ return this.reboot(seed, Ward, classes);
43
+ }
44
+
45
+ // a restart: same seed, same partition, fresh ward. the ward's files are the harbor's to keep.
46
+ async reboot(seed: string, Ward: WardFactory, classes: Record<string, BeingClass>): Promise<Booted> {
47
+ const memory = this.partitions.get(seed);
48
+ if (!memory) throw new Error(`no partition for ${seed}`);
49
+ const ground: Ground = {
50
+ seed,
51
+ memory,
52
+ instantiate: (name: string, stance: Stance) => {
53
+ const C = classes[name];
54
+ if (!C) return null;
55
+ const obj = new C(stance);
56
+ this.objects.set(stance.cells, obj);
57
+ return obj;
58
+ },
59
+ // bytes cross, copied, never a reference. both ways.
60
+ carry: async (farPk, bytes) => {
61
+ this.wire.push(hex(bytes));
62
+ const back = await this.route(farPk, new Uint8Array(bytes));
63
+ return back === undefined ? undefined : new Uint8Array(back);
64
+ },
65
+ random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
66
+ };
67
+ const w = await Ward(ground);
68
+ const bp = (await w.ask()) as { notes: { pk: string } }; // the harbor learns its ward's pk the way anyone learns anything: by asking
69
+ const booted: Booted = { ...w, pk: bp.notes.pk };
70
+ this.doors.set(booted.pk, booted.door);
71
+ this.wards.set(seed, booted);
72
+ return booted;
73
+ }
74
+ }
@@ -0,0 +1,189 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Reach: carrying bytes to a pk off the device. One interface, two
3
+ // implementations, as quo-harbor.md names them:
4
+ //
5
+ // request a URL, the quo. route of a world. POST the bytes to
6
+ // `<url>/<pk>`, get bytes back. Listener to listener.
7
+ // socket a held WebSocket at `<url>`, opened by whichever side can
8
+ // dial, used in both directions with a frame id. Each side
9
+ // announces the ward pks it holds when the socket opens, and the
10
+ // other binds them.
11
+ //
12
+ // Both are written on what every terrain has: fetch, and the standard
13
+ // WebSocket surface, which a browser, Node, an edge worker's pair and the
14
+ // `ws` server all offer. The listener half of a socket is the terrain's
15
+ // own and stays outside this tree; what is here is the framing, either end
16
+ // of a line, and the request. Nothing here names a runtime, and
17
+ // `src/conformance/reach.ts` is what every reach passes. A reach carries and returns; it reads
18
+ // nothing. `undefined` is a promise that nothing was delivered: no such pk
19
+ // at the far end, a connection that would not open, a socket that is gone.
20
+ // A reach that sent the bytes and lost the line afterwards answers nothing
21
+ // at all, and the ward's own bound ends the ask.
22
+ import { hex, unhex } from '../ward/arithmetic.ts';
23
+
24
+ export type Carry = (pk: string, bytes: Uint8Array) => Promise<Uint8Array | undefined>;
25
+ export type Reach = { carry: Carry; close(): void };
26
+
27
+ const never = () => new Promise<undefined>(() => {}); // sent, and no word since: the ward's bound decides
28
+
29
+ // A request reach to one listener.
30
+ export function request(url: string): Reach {
31
+ const base = url.replace(/\/$/, '');
32
+ return {
33
+ close: () => {},
34
+ carry: async (pk, bytes) => {
35
+ let res: Response;
36
+ try {
37
+ res = await fetch(`${base}/${pk}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'quo-suite': String(SUITE) }, body: new Uint8Array(bytes) as unknown as BodyInit });
38
+ } catch {
39
+ return undefined; // the connection never opened: nothing was delivered
40
+ }
41
+ if (res.status === 404) return undefined; // the listener holds no reach for that pk
42
+ if (!res.ok) return never();
43
+ return new Uint8Array(await res.arrayBuffer());
44
+ },
45
+ };
46
+ }
47
+
48
+ // The frames on a socket. Binary, one byte of kind first.
49
+ // ask [0][id: 4 bytes][pk: 64 bytes][bytes]
50
+ // reply [1][id: 4 bytes][bytes]
51
+ // none [2][id: 4 bytes] nothing was delivered
52
+ // And one text frame, JSON: { announce: [pk, ...], suite }, the ward pks this
53
+ // side holds and the wire suite it speaks, sent when the socket opens and
54
+ // whenever the pks change.
55
+ const ASK = 0,
56
+ REPLY = 1,
57
+ NONE = 2;
58
+
59
+ // The wire suite: which frames these are and which four algorithms seal what
60
+ // they carry. One value, and it is not negotiated. It rides in the handshake
61
+ // and never on an ask, so an ask stays bytes nobody can tell from noise, and
62
+ // a line still learns before it carries anything whether the far side speaks
63
+ // what this side speaks. A side meeting a suite it does not know carries
64
+ // nothing for it and closes: the alternative is bytes that will never open,
65
+ // answered by a silence that says nothing about why, forever. Nothing
66
+ // changes this number before 1.0.0. After it, this is the one place a second
67
+ // suite can be told from a first without guessing.
68
+ export const SUITE = 1;
69
+
70
+ // The close a side sends when it refuses a suite, in the range a protocol
71
+ // may spend on itself. A line that just closes tells the far side nothing,
72
+ // and a dialer that learns nothing dials again on its backoff for good: the
73
+ // whole reason this number exists is that a kit which cannot be understood
74
+ // should be told so instead of meeting a silence that names no reason. So
75
+ // the refusal names itself on the way out, and the far side may read it.
76
+ export const REFUSED = 4001;
77
+ const OPEN = 1; // WebSocket.OPEN, on every terrain
78
+
79
+ // The standard surface, and no more of it: what a browser WebSocket, Node's
80
+ // WebSocket and a `ws` server socket all have.
81
+ export type Line = {
82
+ readyState: number;
83
+ binaryType: string;
84
+ send(data: string | Uint8Array): void;
85
+ close(code?: number, reason?: string): void;
86
+ addEventListener(type: 'open' | 'error', fn: () => void): void;
87
+ // A close carries why, where the terrain gives it: the code and the reason
88
+ // the far side named. A line that just ends names neither.
89
+ addEventListener(type: 'close', fn: (e?: { code?: number; reason?: string }) => void): void;
90
+ addEventListener(type: 'message', fn: (e: { data: unknown }) => void): void;
91
+ };
92
+
93
+ export type Announce = (pks: string[]) => void;
94
+
95
+ // One held socket, either side of it. `deliver` is what this side does with
96
+ // an ask that arrives for a pk: its own door, or a socket it holds for that
97
+ // pk, and nothing else. `onAnnounce` learns the far side's pks; `onClose`
98
+ // forgets them.
99
+ export class Socket implements Reach {
100
+ readonly line: Line;
101
+ readonly pending = new Map<number, (bytes: Uint8Array | undefined) => void>();
102
+ far: string[] = []; // the pks the far side announced
103
+ #id = 1;
104
+ #open: Promise<void>;
105
+
106
+ constructor(line: Line, deliver: Carry, onAnnounce: Announce, onClose: (why: { code?: number; reason?: string }) => void) {
107
+ this.line = line;
108
+ line.binaryType = 'arraybuffer';
109
+ this.#open = line.readyState === OPEN ? Promise.resolve() : new Promise((ok, no) => (line.addEventListener('open', ok), line.addEventListener('error', no)));
110
+ this.#open.catch(() => {}); // a handshake refused is a reach that failed, not a process that dies
111
+ line.addEventListener('message', (e) => {
112
+ if (typeof e.data === 'string') {
113
+ try {
114
+ const m = JSON.parse(e.data) as { announce?: unknown; suite?: unknown };
115
+ // Absent is this suite: a side that says nothing says one.
116
+ if (m.suite !== undefined && m.suite !== SUITE) {
117
+ this.far = [];
118
+ line.close(REFUSED, 'suite');
119
+ return;
120
+ }
121
+ if (Array.isArray(m.announce)) {
122
+ this.far = m.announce.filter((x): x is string => typeof x === 'string');
123
+ onAnnounce(this.far);
124
+ }
125
+ } catch {
126
+ /* not for us */
127
+ }
128
+ return;
129
+ }
130
+ const buf = e.data instanceof ArrayBuffer ? new Uint8Array(e.data) : ArrayBuffer.isView(e.data) ? new Uint8Array(e.data.buffer, e.data.byteOffset, e.data.byteLength) : undefined;
131
+ if (!buf || buf.length < 5) return;
132
+ const kind = buf[0],
133
+ id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
134
+ if (kind === ASK) {
135
+ const pk = hex(buf.subarray(5, 69));
136
+ void deliver(pk, new Uint8Array(buf.subarray(69))).then((back) => {
137
+ const head = new Uint8Array(5);
138
+ head[0] = back === undefined ? NONE : REPLY;
139
+ new DataView(head.buffer).setUint32(1, id);
140
+ if (line.readyState === OPEN) line.send(back === undefined ? head : concat(head, back));
141
+ });
142
+ return;
143
+ }
144
+ const waiting = this.pending.get(id);
145
+ if (!waiting) return;
146
+ this.pending.delete(id);
147
+ waiting(kind === REPLY ? new Uint8Array(buf.subarray(5)) : undefined);
148
+ });
149
+ line.addEventListener('error', () => {});
150
+ line.addEventListener('close', (e) => {
151
+ this.pending.clear(); // in flight and no word since: nothing is answered
152
+ onClose({ code: e?.code, reason: e?.reason });
153
+ });
154
+ }
155
+
156
+ // Say which ward pks this side holds.
157
+ announce(pks: string[]): void {
158
+ if (this.line.readyState === OPEN) this.line.send(JSON.stringify({ announce: pks, suite: SUITE }));
159
+ }
160
+
161
+ async carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
162
+ try {
163
+ await this.#open;
164
+ } catch {
165
+ return undefined;
166
+ }
167
+ if (this.line.readyState !== OPEN) return undefined;
168
+ const id = this.#id++;
169
+ const head = new Uint8Array(69);
170
+ head[0] = ASK;
171
+ new DataView(head.buffer).setUint32(1, id);
172
+ head.set(unhex(pk), 5);
173
+ return new Promise((ok) => {
174
+ this.pending.set(id, ok);
175
+ this.line.send(concat(head, bytes));
176
+ });
177
+ }
178
+
179
+ close(): void {
180
+ this.line.close();
181
+ }
182
+ }
183
+
184
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
185
+ const out = new Uint8Array(a.length + b.length);
186
+ out.set(a, 0);
187
+ out.set(b, a.length);
188
+ return out;
189
+ }