@quo-systems/quo 0.1.0 → 0.2.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/README.md +9 -6
- package/SPEC.md +235 -137
- package/dist/being/being.d.ts +22 -0
- package/dist/being/being.js +78 -0
- package/dist/being/digest.d.ts +3 -0
- package/dist/being/digest.js +19 -0
- package/dist/being/index.d.ts +5 -0
- package/dist/being/index.js +6 -0
- package/dist/being/silence.d.ts +10 -0
- package/dist/being/silence.js +29 -0
- package/dist/being/types.d.ts +84 -0
- package/dist/being/types.js +16 -0
- package/dist/conformance/assert.d.ts +11 -0
- package/dist/conformance/assert.js +72 -0
- package/dist/conformance/beings.d.ts +114 -0
- package/dist/conformance/beings.js +126 -0
- package/dist/conformance/estate.d.ts +5 -0
- package/dist/conformance/estate.js +316 -0
- package/dist/conformance/index.d.ts +65 -0
- package/dist/conformance/index.js +448 -0
- package/dist/conformance/reach.d.ts +10 -0
- package/dist/conformance/reach.js +72 -0
- package/dist/conformance/store.d.ts +5 -0
- package/dist/conformance/store.js +97 -0
- package/dist/harbor/core.d.ts +46 -0
- package/dist/harbor/core.js +218 -0
- package/dist/harbor/dial.d.ts +8 -0
- package/dist/harbor/dial.js +45 -0
- package/dist/harbor/index.d.ts +6 -0
- package/dist/harbor/index.js +10 -0
- package/dist/harbor/memory.d.ts +24 -0
- package/dist/harbor/memory.js +68 -0
- package/dist/harbor/reach.d.ts +36 -0
- package/dist/harbor/reach.js +166 -0
- package/dist/harbor/store.d.ts +33 -0
- package/dist/harbor/store.js +41 -0
- package/dist/ward/allowance.d.ts +10 -0
- package/dist/ward/allowance.js +60 -0
- package/dist/ward/arithmetic.d.ts +26 -0
- package/dist/ward/arithmetic.js +159 -0
- package/dist/ward/cells.d.ts +3 -0
- package/dist/ward/cells.js +79 -0
- package/dist/ward/door.d.ts +16 -0
- package/dist/ward/door.js +127 -0
- package/dist/ward/ground.d.ts +15 -0
- package/dist/ward/ground.js +1 -0
- package/dist/ward/heirs.d.ts +14 -0
- package/dist/ward/heirs.js +102 -0
- package/dist/ward/index.d.ts +8 -0
- package/dist/ward/index.js +10 -0
- package/dist/ward/owner.d.ts +32 -0
- package/dist/ward/owner.js +116 -0
- package/dist/ward/partition.d.ts +57 -0
- package/dist/ward/partition.js +64 -0
- package/dist/ward/seal.d.ts +50 -0
- package/dist/ward/seal.js +109 -0
- package/dist/ward/stance.d.ts +19 -0
- package/dist/ward/stance.js +259 -0
- package/dist/ward/ward.d.ts +2 -0
- package/dist/ward/ward.js +197 -0
- package/package.json +21 -29
- package/src/being/index.ts +1 -1
- package/src/being/silence.ts +26 -8
- package/src/being/types.ts +13 -3
- package/src/conformance/beings.ts +5 -5
- package/src/conformance/estate.ts +11 -6
- package/src/conformance/index.ts +14 -12
- package/src/harbor/core.ts +56 -6
- package/src/harbor/dial.ts +1 -1
- package/src/harbor/memory.ts +6 -1
- package/src/ward/door.ts +54 -32
- package/src/ward/ground.ts +6 -2
- package/src/ward/heirs.ts +20 -5
- package/src/ward/owner.ts +4 -3
- package/src/ward/partition.ts +10 -0
- package/src/ward/seal.ts +8 -5
- package/src/ward/stance.ts +20 -14
- package/src/ward/ward.ts +9 -9
- package/vectors/framing.json +7 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { WardPointers } from '../ward/ground.ts';
|
|
2
|
+
import type { BeingClass, BeingLike } from '../being/types.ts';
|
|
3
|
+
import { type Reach } from './reach.ts';
|
|
4
|
+
import type { Kept, Store, WardRecord } from './store.ts';
|
|
5
|
+
export type Hosted = WardPointers & {
|
|
6
|
+
pk: string;
|
|
7
|
+
name: string;
|
|
8
|
+
record: WardRecord;
|
|
9
|
+
partition: Record<string, unknown>;
|
|
10
|
+
being(key: string): BeingLike | undefined;
|
|
11
|
+
save(): Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
export type Bound = {
|
|
14
|
+
reach: Reach;
|
|
15
|
+
held: boolean;
|
|
16
|
+
};
|
|
17
|
+
export type Loader = (record: WardRecord) => Promise<Record<string, BeingClass>>;
|
|
18
|
+
export declare const DEFAULT_CODE = "classes/index.ts";
|
|
19
|
+
export declare class Harbor {
|
|
20
|
+
#private;
|
|
21
|
+
readonly store: Store;
|
|
22
|
+
readonly loader: Loader;
|
|
23
|
+
readonly wards: Map<string, Hosted>;
|
|
24
|
+
readonly doors: Map<string, (bytes: Uint8Array) => Promise<{
|
|
25
|
+
bytes: Uint8Array;
|
|
26
|
+
heard: boolean;
|
|
27
|
+
}>>;
|
|
28
|
+
readonly reaches: Map<string, Bound>;
|
|
29
|
+
readonly down: Set<string>;
|
|
30
|
+
readonly refused: Map<string, number>;
|
|
31
|
+
readonly classes: Record<string, BeingClass>;
|
|
32
|
+
readonly fallbacks: Reach[];
|
|
33
|
+
constructor(store: Store, loader?: Loader);
|
|
34
|
+
boot(): Promise<void>;
|
|
35
|
+
carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
36
|
+
deliver(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
37
|
+
hint(pk: string, url: string): void;
|
|
38
|
+
remember(pk: string, url: string): Promise<void>;
|
|
39
|
+
prove(pk: string, reach: Reach): Promise<boolean>;
|
|
40
|
+
bind(pks: string[], reach: Reach, held: boolean): Promise<void>;
|
|
41
|
+
unbind(reach: Reach): void;
|
|
42
|
+
create(name: string, user?: string, code?: string): Promise<Hosted>;
|
|
43
|
+
drop(name: string): Promise<Kept | undefined>;
|
|
44
|
+
adopt(name: string, kept: Kept): Promise<Hosted>;
|
|
45
|
+
host(name: string, kept: Kept): Promise<Hosted>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
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 once the door behind the
|
|
13
|
+
// dialer's claim has proved it holds the key, and unbound at close; a hint
|
|
14
|
+
// from a link. And one fallback: a dialer with nothing in its
|
|
15
|
+
// directory for a pk sends down the socket it holds, because the listener
|
|
16
|
+
// it dialed is the rendezvous and may hold that pk on another socket. Bytes
|
|
17
|
+
// that arrive from the wire go to an own door or a held socket and never
|
|
18
|
+
// onward by request or by fallback; that one rule is the rendezvous.
|
|
19
|
+
import { Ward } from '../ward/ward.js';
|
|
20
|
+
import { request } from './reach.js';
|
|
21
|
+
import { openReply, wardSignPk } from '../ward/seal.js';
|
|
22
|
+
import { concat, sealingPair, KEY } from '../ward/arithmetic.js';
|
|
23
|
+
import { within, LATE } from '../ward/allowance.js';
|
|
24
|
+
export const DEFAULT_CODE = 'classes/index.ts';
|
|
25
|
+
// How long a probe waits for the door behind a claim. A claim that answers
|
|
26
|
+
// nothing in this time is not bound; the next announce is another chance.
|
|
27
|
+
const PROBE = 10_000;
|
|
28
|
+
export class Harbor {
|
|
29
|
+
store;
|
|
30
|
+
loader;
|
|
31
|
+
wards = new Map(); // name -> pointers
|
|
32
|
+
doors = new Map(); // ward pk -> door
|
|
33
|
+
reaches = new Map(); // the directory: foreign ward pk -> reach
|
|
34
|
+
down = new Set(); // pks this harbor will not reach right now: weather, for a test
|
|
35
|
+
refused = new Map(); // own ward pk -> arrivals its door would not admit. what a terrain does with it is its own
|
|
36
|
+
classes = {}; // classes handed in-process, beside the loader's
|
|
37
|
+
// The dialers' sockets, in the order they were dialed: where a pk nobody
|
|
38
|
+
// here knows is sent, because the listener a harbor dialed is a rendezvous
|
|
39
|
+
// and may hold that pk for someone else. It is a list because a harbor may
|
|
40
|
+
// dial more than one, and a rendezvous is a listener and nothing more, so
|
|
41
|
+
// there is never only one of them. A pk is tried down the list until one
|
|
42
|
+
// carries it: with a single slot, a harbor holding two lines answered
|
|
43
|
+
// unreached for a pk the other line could have reached.
|
|
44
|
+
fallbacks = [];
|
|
45
|
+
constructor(store, loader = async () => ({})) {
|
|
46
|
+
this.store = store;
|
|
47
|
+
this.loader = loader;
|
|
48
|
+
}
|
|
49
|
+
// Boot every ward the store keeps, and learn the hints.
|
|
50
|
+
async boot() {
|
|
51
|
+
for (const name of await this.store.list()) {
|
|
52
|
+
const kept = await this.store.load(name);
|
|
53
|
+
if (kept)
|
|
54
|
+
await this.host(name, kept);
|
|
55
|
+
}
|
|
56
|
+
for (const [pk, url] of Object.entries(await this.store.hints()))
|
|
57
|
+
this.hint(pk, url);
|
|
58
|
+
}
|
|
59
|
+
// ---- the directory
|
|
60
|
+
// Bytes to one of this harbor's own doors, and the one bit the door says
|
|
61
|
+
// beside its reply, counted.
|
|
62
|
+
async #own(pk, door, bytes) {
|
|
63
|
+
const r = await door(bytes);
|
|
64
|
+
if (!r.heard)
|
|
65
|
+
this.refused.set(pk, (this.refused.get(pk) ?? 0) + 1);
|
|
66
|
+
return new Uint8Array(r.bytes);
|
|
67
|
+
}
|
|
68
|
+
async carry(pk, bytes) {
|
|
69
|
+
if (this.down.has(pk))
|
|
70
|
+
return undefined;
|
|
71
|
+
const door = this.doors.get(pk);
|
|
72
|
+
if (door)
|
|
73
|
+
return this.#own(pk, door, bytes);
|
|
74
|
+
const bound = this.reaches.get(pk);
|
|
75
|
+
if (bound)
|
|
76
|
+
return bound.reach.carry(pk, bytes);
|
|
77
|
+
for (const reach of this.fallbacks) {
|
|
78
|
+
const back = await reach.carry(pk, bytes);
|
|
79
|
+
if (back !== undefined)
|
|
80
|
+
return back;
|
|
81
|
+
}
|
|
82
|
+
return undefined; // nobody here knows it, and no listener we dialed holds it
|
|
83
|
+
}
|
|
84
|
+
async deliver(pk, bytes) {
|
|
85
|
+
if (this.down.has(pk))
|
|
86
|
+
return undefined;
|
|
87
|
+
const door = this.doors.get(pk);
|
|
88
|
+
if (door)
|
|
89
|
+
return this.#own(pk, door, bytes);
|
|
90
|
+
const bound = this.reaches.get(pk);
|
|
91
|
+
return bound?.held ? bound.reach.carry(pk, bytes) : undefined;
|
|
92
|
+
}
|
|
93
|
+
hint(pk, url) {
|
|
94
|
+
if (this.reaches.get(pk)?.held)
|
|
95
|
+
return; // a socket in hand beats a hint
|
|
96
|
+
this.reaches.set(pk, { reach: request(url), held: false });
|
|
97
|
+
}
|
|
98
|
+
async remember(pk, url) {
|
|
99
|
+
this.hint(pk, url);
|
|
100
|
+
await this.store.hint(pk, url);
|
|
101
|
+
}
|
|
102
|
+
// An announce is a claim, and a claim binds nothing until proven: anyone
|
|
103
|
+
// who can reach a listener could otherwise name a pk that is not theirs and
|
|
104
|
+
// take its reachability. The proof is the door itself, as it already is. A
|
|
105
|
+
// lid this harbor minted and noise after it is a box that does not open,
|
|
106
|
+
// and the door answers one such box with silence sealed to the lid and
|
|
107
|
+
// signed by the ward key. Only the holder of that ward's seed can write
|
|
108
|
+
// that reply, the lid is fresh so nothing replays, and a box that does not
|
|
109
|
+
// open writes nothing at the ward. This is the one box a harbor ever opens,
|
|
110
|
+
// the one it sealed itself, and it reads nothing from it but that the
|
|
111
|
+
// signature is the claimed key's.
|
|
112
|
+
async prove(pk, reach) {
|
|
113
|
+
if (!/^[0-9a-f]{128}$/.test(pk))
|
|
114
|
+
return false;
|
|
115
|
+
try {
|
|
116
|
+
const random = (n) => globalThis.crypto.getRandomValues(new Uint8Array(n));
|
|
117
|
+
const lid = await sealingPair(random(KEY));
|
|
118
|
+
const back = await within(PROBE, reach.carry(pk, concat([lid.pk, random(KEY)])));
|
|
119
|
+
if (back === LATE || back === undefined)
|
|
120
|
+
return false;
|
|
121
|
+
const reply = await openReply(back, lid.secret, wardSignPk(pk));
|
|
122
|
+
return reply !== null && 'silence' in reply;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Bind what the far side claims, each pk proven at its door first. Both
|
|
129
|
+
// sides bind this way: the listener the dialer's claims, the dialer the
|
|
130
|
+
// listener's, since a claim is a claim whichever end made it.
|
|
131
|
+
async bind(pks, reach, held) {
|
|
132
|
+
// An announce is the whole of what that side holds now, so a pk this
|
|
133
|
+
// reach was bound for and no longer claims is unbound at once: a ward
|
|
134
|
+
// that left a dialer is not reachable through it, proof or no proof.
|
|
135
|
+
for (const [pk, b] of this.reaches)
|
|
136
|
+
if (b.reach === reach && !pks.includes(pk))
|
|
137
|
+
this.reaches.delete(pk);
|
|
138
|
+
await Promise.all(pks.map(async (pk) => {
|
|
139
|
+
if (this.doors.has(pk) || !(await this.prove(pk, reach)))
|
|
140
|
+
return;
|
|
141
|
+
this.reaches.set(pk, { reach, held });
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
unbind(reach) {
|
|
145
|
+
for (const [pk, b] of this.reaches)
|
|
146
|
+
if (b.reach === reach)
|
|
147
|
+
this.reaches.delete(pk);
|
|
148
|
+
}
|
|
149
|
+
// ---- wards
|
|
150
|
+
// Mint a seed and boot an empty ward under a name: a world born here.
|
|
151
|
+
async create(name, user = '', code = DEFAULT_CODE) {
|
|
152
|
+
const kept = { seed: globalThis.crypto.getRandomValues(new Uint8Array(32)), partition: {}, record: { pk: '', code, user } };
|
|
153
|
+
await this.store.put(name, kept);
|
|
154
|
+
const hosted = await this.host(name, kept);
|
|
155
|
+
await this.store.record(name, hosted.record);
|
|
156
|
+
return hosted;
|
|
157
|
+
}
|
|
158
|
+
// Stop serving a ward and take it out of the store: what was kept comes
|
|
159
|
+
// back, for another harbor to put. The partition is written first, because
|
|
160
|
+
// a being driven in-process changes it without passing a door.
|
|
161
|
+
async drop(name) {
|
|
162
|
+
const h = this.wards.get(name);
|
|
163
|
+
if (h) {
|
|
164
|
+
await h.save();
|
|
165
|
+
this.wards.delete(name);
|
|
166
|
+
this.doors.delete(h.pk);
|
|
167
|
+
}
|
|
168
|
+
return this.store.take(name);
|
|
169
|
+
}
|
|
170
|
+
// Put what another harbor dropped, and boot it: same seed, same pk.
|
|
171
|
+
async adopt(name, kept) {
|
|
172
|
+
await this.store.put(name, kept);
|
|
173
|
+
return this.host(name, kept);
|
|
174
|
+
}
|
|
175
|
+
// A ward from what the store keeps: the ground, once, and two pointers.
|
|
176
|
+
async host(name, kept) {
|
|
177
|
+
const { seed, partition: memory } = kept;
|
|
178
|
+
const classes = await this.loader(kept.record);
|
|
179
|
+
const objects = new Map(); // cells -> the object instantiate made. a side's hand, never the ward's
|
|
180
|
+
const ground = {
|
|
181
|
+
seed,
|
|
182
|
+
memory,
|
|
183
|
+
instantiate: (className, stance) => {
|
|
184
|
+
const C = classes[className] ?? this.classes[className];
|
|
185
|
+
if (!C)
|
|
186
|
+
return null;
|
|
187
|
+
const obj = new C(stance);
|
|
188
|
+
objects.set(stance.cells, obj);
|
|
189
|
+
return obj;
|
|
190
|
+
},
|
|
191
|
+
carry: (pk, bytes) => this.carry(pk, new Uint8Array(bytes)),
|
|
192
|
+
random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
|
|
193
|
+
};
|
|
194
|
+
const w = await Ward(ground);
|
|
195
|
+
const save = () => this.store.save(name, memory);
|
|
196
|
+
const after = (f) => async (...a) => {
|
|
197
|
+
try {
|
|
198
|
+
return await f(...a);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await save();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const door = after(w.door);
|
|
205
|
+
const ask = after(w.ask);
|
|
206
|
+
const bp = (await w.ask()); // learned by asking, as anyone learns anything
|
|
207
|
+
const beings = memory.beings;
|
|
208
|
+
const being = (key) => {
|
|
209
|
+
const cells = beings?.[key];
|
|
210
|
+
return cells ? objects.get(cells) : undefined;
|
|
211
|
+
};
|
|
212
|
+
const hosted = { door, ask, pk: bp.notes.pk, name, record: { ...kept.record, pk: bp.notes.pk }, partition: memory, being, save };
|
|
213
|
+
this.wards.set(name, hosted);
|
|
214
|
+
this.doors.set(hosted.pk, door);
|
|
215
|
+
await save();
|
|
216
|
+
return hosted;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { REFUSED, Socket } from './reach.js';
|
|
2
|
+
export function dial(harbor, url) {
|
|
3
|
+
const d = { url, socket: null, close: () => { } };
|
|
4
|
+
let stopped = false, wait = 1000, timer;
|
|
5
|
+
const connect = () => {
|
|
6
|
+
if (stopped)
|
|
7
|
+
return;
|
|
8
|
+
const line = new WebSocket(url.replace(/\/$/, '').replace(/^http/, 'ws'));
|
|
9
|
+
const s = new Socket(line, (pk, bytes) => harbor.deliver(pk, bytes), (far) => {
|
|
10
|
+
// A line that opened is not a line that works. The wait goes back to
|
|
11
|
+
// a second here, where the far side has answered and been taken, and
|
|
12
|
+
// not at the handshake: a listener that opens and then refuses every
|
|
13
|
+
// time would otherwise be dialed at a fixed two seconds for good.
|
|
14
|
+
wait = 1000;
|
|
15
|
+
void harbor.bind(far, s, false); // the listener's claims, each proven at its door: reached through this socket, not held for others
|
|
16
|
+
}, (why) => {
|
|
17
|
+
harbor.unbind(s);
|
|
18
|
+
if (d.socket === s)
|
|
19
|
+
d.socket = null;
|
|
20
|
+
const at = harbor.fallbacks.indexOf(s);
|
|
21
|
+
if (at !== -1)
|
|
22
|
+
harbor.fallbacks.splice(at, 1); // this line only: another dialer's stays
|
|
23
|
+
// A suite this listener will not speak is not a line that dropped. It
|
|
24
|
+
// will not become speakable by asking again sooner, so the wait goes
|
|
25
|
+
// straight to the ceiling and stays there until it changes its mind.
|
|
26
|
+
if (why.code === REFUSED)
|
|
27
|
+
wait = 30000;
|
|
28
|
+
if (!stopped)
|
|
29
|
+
timer = setTimeout(connect, wait);
|
|
30
|
+
wait = Math.min(wait * 2, 30000);
|
|
31
|
+
});
|
|
32
|
+
harbor.fallbacks.push(s); // from the moment it is dialed: an ask made before the handshake waits on the socket, and is not unreached
|
|
33
|
+
line.addEventListener('open', () => {
|
|
34
|
+
d.socket = s;
|
|
35
|
+
s.announce([...harbor.doors.keys()]);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
d.close = () => {
|
|
39
|
+
stopped = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
d.socket?.close();
|
|
42
|
+
};
|
|
43
|
+
connect();
|
|
44
|
+
return d;
|
|
45
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { MemoryHarbor, type Booted, type WardFactory } from './memory.ts';
|
|
2
|
+
export { Harbor, DEFAULT_CODE, type Hosted, type Bound, type Loader } from './core.ts';
|
|
3
|
+
export { dial, type Dialer } from './dial.ts';
|
|
4
|
+
export { MemoryStore, values, type Store, type Kept, type WardRecord } from './store.ts';
|
|
5
|
+
export { request, Socket, SUITE, REFUSED, type Reach, type Carry, type Line, type Announce } from './reach.ts';
|
|
6
|
+
export type { Ground, WardPointers } from '../ward/ground.ts';
|
|
@@ -0,0 +1,10 @@
|
|
|
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 } from './memory.js';
|
|
7
|
+
export { Harbor, DEFAULT_CODE } from './core.js';
|
|
8
|
+
export { dial } from './dial.js';
|
|
9
|
+
export { MemoryStore, values } from './store.js';
|
|
10
|
+
export { request, Socket, SUITE, REFUSED } from './reach.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BeingClass } from '../being/types.ts';
|
|
2
|
+
import type { Ground, WardPointers } from '../ward/ground.ts';
|
|
3
|
+
export type Booted = WardPointers & {
|
|
4
|
+
pk: string;
|
|
5
|
+
};
|
|
6
|
+
export type WardFactory = (ground: Ground) => Promise<WardPointers>;
|
|
7
|
+
export declare class MemoryHarbor {
|
|
8
|
+
readonly doors: Map<string, (bytes: Uint8Array) => Promise<{
|
|
9
|
+
bytes: Uint8Array;
|
|
10
|
+
heard: boolean;
|
|
11
|
+
}>>;
|
|
12
|
+
readonly down: Set<string>;
|
|
13
|
+
readonly wire: string[];
|
|
14
|
+
readonly refused: Map<string, number>;
|
|
15
|
+
readonly peers: Set<MemoryHarbor>;
|
|
16
|
+
cut: boolean;
|
|
17
|
+
readonly partitions: Map<string, Record<string, unknown>>;
|
|
18
|
+
readonly wards: Map<string, Booted>;
|
|
19
|
+
readonly objects: Map<object, unknown>;
|
|
20
|
+
route(farPk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
21
|
+
link(harbor: MemoryHarbor): void;
|
|
22
|
+
boot(seed: string, Ward: WardFactory, classes: Record<string, BeingClass>): Promise<Booted>;
|
|
23
|
+
reboot(seed: string, Ward: WardFactory, classes: Record<string, BeingClass>): Promise<Booted>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { hex } from '../ward/arithmetic.js';
|
|
2
|
+
export class MemoryHarbor {
|
|
3
|
+
doors = new Map(); // ward pk -> door
|
|
4
|
+
down = new Set(); // ward pks this harbor cannot reach right now
|
|
5
|
+
wire = []; // every byte string that ever crossed, as hex. for inspection
|
|
6
|
+
refused = new Map(); // ward pk -> arrivals its door would not admit: strangers, counted
|
|
7
|
+
peers = new Set();
|
|
8
|
+
cut = false;
|
|
9
|
+
partitions = new Map(); // seed -> memory. the harbor keeps it and reads nothing
|
|
10
|
+
wards = new Map(); // seed -> pointers
|
|
11
|
+
objects = new Map(); // cells -> being object. what instantiate constructed. a hand for tests, never the ward's
|
|
12
|
+
// the directory: its own doors, else a peer it is linked to. Quo says nothing about how.
|
|
13
|
+
async route(farPk, bytes) {
|
|
14
|
+
if (this.down.has(farPk))
|
|
15
|
+
return undefined;
|
|
16
|
+
const door = this.doors.get(farPk);
|
|
17
|
+
if (door) {
|
|
18
|
+
const r = await door(bytes);
|
|
19
|
+
if (!r.heard)
|
|
20
|
+
this.refused.set(farPk, (this.refused.get(farPk) ?? 0) + 1);
|
|
21
|
+
return r.bytes;
|
|
22
|
+
}
|
|
23
|
+
const peer = [...this.peers].find((h) => h.doors.has(farPk) && !h.down.has(farPk));
|
|
24
|
+
if (!peer || this.cut)
|
|
25
|
+
return undefined; // nothing. the harbor has no word silence
|
|
26
|
+
return peer.route(farPk, bytes);
|
|
27
|
+
}
|
|
28
|
+
link(harbor) {
|
|
29
|
+
this.peers.add(harbor);
|
|
30
|
+
harbor.peers.add(this);
|
|
31
|
+
}
|
|
32
|
+
// boot by seed. the harbor gets a door and an ask, and learns the pk by asking.
|
|
33
|
+
async boot(seed, Ward, classes) {
|
|
34
|
+
this.partitions.set(seed, {});
|
|
35
|
+
return this.reboot(seed, Ward, classes);
|
|
36
|
+
}
|
|
37
|
+
// a restart: same seed, same partition, fresh ward. the ward's files are the harbor's to keep.
|
|
38
|
+
async reboot(seed, Ward, classes) {
|
|
39
|
+
const memory = this.partitions.get(seed);
|
|
40
|
+
if (!memory)
|
|
41
|
+
throw new Error(`no partition for ${seed}`);
|
|
42
|
+
const ground = {
|
|
43
|
+
seed,
|
|
44
|
+
memory,
|
|
45
|
+
instantiate: (name, stance) => {
|
|
46
|
+
const C = classes[name];
|
|
47
|
+
if (!C)
|
|
48
|
+
return null;
|
|
49
|
+
const obj = new C(stance);
|
|
50
|
+
this.objects.set(stance.cells, obj);
|
|
51
|
+
return obj;
|
|
52
|
+
},
|
|
53
|
+
// bytes cross, copied, never a reference. both ways.
|
|
54
|
+
carry: async (farPk, bytes) => {
|
|
55
|
+
this.wire.push(hex(bytes));
|
|
56
|
+
const back = await this.route(farPk, new Uint8Array(bytes));
|
|
57
|
+
return back === undefined ? undefined : new Uint8Array(back);
|
|
58
|
+
},
|
|
59
|
+
random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
|
|
60
|
+
};
|
|
61
|
+
const w = await Ward(ground);
|
|
62
|
+
const bp = (await w.ask()); // the harbor learns its ward's pk the way anyone learns anything: by asking
|
|
63
|
+
const booted = { ...w, pk: bp.notes.pk };
|
|
64
|
+
this.doors.set(booted.pk, booted.door);
|
|
65
|
+
this.wards.set(seed, booted);
|
|
66
|
+
return booted;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type Carry = (pk: string, bytes: Uint8Array) => Promise<Uint8Array | undefined>;
|
|
2
|
+
export type Reach = {
|
|
3
|
+
carry: Carry;
|
|
4
|
+
close(): void;
|
|
5
|
+
};
|
|
6
|
+
export declare function request(url: string): Reach;
|
|
7
|
+
export declare const SUITE = 1;
|
|
8
|
+
export declare const REFUSED = 4001;
|
|
9
|
+
export type Line = {
|
|
10
|
+
readyState: number;
|
|
11
|
+
binaryType: string;
|
|
12
|
+
send(data: string | Uint8Array): void;
|
|
13
|
+
close(code?: number, reason?: string): void;
|
|
14
|
+
addEventListener(type: 'open' | 'error', fn: () => void): void;
|
|
15
|
+
addEventListener(type: 'close', fn: (e?: {
|
|
16
|
+
code?: number;
|
|
17
|
+
reason?: string;
|
|
18
|
+
}) => void): void;
|
|
19
|
+
addEventListener(type: 'message', fn: (e: {
|
|
20
|
+
data: unknown;
|
|
21
|
+
}) => void): void;
|
|
22
|
+
};
|
|
23
|
+
export type Announce = (pks: string[]) => void;
|
|
24
|
+
export declare class Socket implements Reach {
|
|
25
|
+
#private;
|
|
26
|
+
readonly line: Line;
|
|
27
|
+
readonly pending: Map<number, (bytes: Uint8Array | undefined) => void>;
|
|
28
|
+
far: string[];
|
|
29
|
+
constructor(line: Line, deliver: Carry, onAnnounce: Announce, onClose: (why: {
|
|
30
|
+
code?: number;
|
|
31
|
+
reason?: string;
|
|
32
|
+
}) => void);
|
|
33
|
+
announce(pks: string[]): void;
|
|
34
|
+
carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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.js';
|
|
23
|
+
const never = () => new Promise(() => { }); // sent, and no word since: the ward's bound decides
|
|
24
|
+
// A request reach to one listener.
|
|
25
|
+
export function request(url) {
|
|
26
|
+
const base = url.replace(/\/$/, '');
|
|
27
|
+
return {
|
|
28
|
+
close: () => { },
|
|
29
|
+
carry: async (pk, bytes) => {
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetch(`${base}/${pk}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'quo-suite': String(SUITE) }, body: new Uint8Array(bytes) });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined; // the connection never opened: nothing was delivered
|
|
36
|
+
}
|
|
37
|
+
if (res.status === 404)
|
|
38
|
+
return undefined; // the listener holds no reach for that pk
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
return never();
|
|
41
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// The frames on a socket. Binary, one byte of kind first.
|
|
46
|
+
// ask [0][id: 4 bytes][pk: 64 bytes][bytes]
|
|
47
|
+
// reply [1][id: 4 bytes][bytes]
|
|
48
|
+
// none [2][id: 4 bytes] nothing was delivered
|
|
49
|
+
// And one text frame, JSON: { announce: [pk, ...], suite }, the ward pks this
|
|
50
|
+
// side holds and the wire suite it speaks, sent when the socket opens and
|
|
51
|
+
// whenever the pks change.
|
|
52
|
+
const ASK = 0, REPLY = 1, NONE = 2;
|
|
53
|
+
// The wire suite: which frames these are and which four algorithms seal what
|
|
54
|
+
// they carry. One value, and it is not negotiated. It rides in the handshake
|
|
55
|
+
// and never on an ask, so an ask stays bytes nobody can tell from noise, and
|
|
56
|
+
// a line still learns before it carries anything whether the far side speaks
|
|
57
|
+
// what this side speaks. A side meeting a suite it does not know carries
|
|
58
|
+
// nothing for it and closes: the alternative is bytes that will never open,
|
|
59
|
+
// answered by a silence that says nothing about why, forever. Nothing
|
|
60
|
+
// changes this number before 1.0.0. After it, this is the one place a second
|
|
61
|
+
// suite can be told from a first without guessing.
|
|
62
|
+
export const SUITE = 1;
|
|
63
|
+
// The close a side sends when it refuses a suite, in the range a protocol
|
|
64
|
+
// may spend on itself. A line that just closes tells the far side nothing,
|
|
65
|
+
// and a dialer that learns nothing dials again on its backoff for good: the
|
|
66
|
+
// whole reason this number exists is that a kit which cannot be understood
|
|
67
|
+
// should be told so instead of meeting a silence that names no reason. So
|
|
68
|
+
// the refusal names itself on the way out, and the far side may read it.
|
|
69
|
+
export const REFUSED = 4001;
|
|
70
|
+
const OPEN = 1; // WebSocket.OPEN, on every terrain
|
|
71
|
+
// One held socket, either side of it. `deliver` is what this side does with
|
|
72
|
+
// an ask that arrives for a pk: its own door, or a socket it holds for that
|
|
73
|
+
// pk, and nothing else. `onAnnounce` learns the far side's pks; `onClose`
|
|
74
|
+
// forgets them.
|
|
75
|
+
export class Socket {
|
|
76
|
+
line;
|
|
77
|
+
pending = new Map();
|
|
78
|
+
far = []; // the pks the far side announced
|
|
79
|
+
#id = 1;
|
|
80
|
+
#open;
|
|
81
|
+
constructor(line, deliver, onAnnounce, onClose) {
|
|
82
|
+
this.line = line;
|
|
83
|
+
line.binaryType = 'arraybuffer';
|
|
84
|
+
this.#open = line.readyState === OPEN ? Promise.resolve() : new Promise((ok, no) => (line.addEventListener('open', ok), line.addEventListener('error', no)));
|
|
85
|
+
this.#open.catch(() => { }); // a handshake refused is a reach that failed, not a process that dies
|
|
86
|
+
line.addEventListener('message', (e) => {
|
|
87
|
+
if (typeof e.data === 'string') {
|
|
88
|
+
try {
|
|
89
|
+
const m = JSON.parse(e.data);
|
|
90
|
+
// Absent is this suite: a side that says nothing says one.
|
|
91
|
+
if (m.suite !== undefined && m.suite !== SUITE) {
|
|
92
|
+
this.far = [];
|
|
93
|
+
line.close(REFUSED, 'suite');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(m.announce)) {
|
|
97
|
+
this.far = m.announce.filter((x) => typeof x === 'string');
|
|
98
|
+
onAnnounce(this.far);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* not for us */
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
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;
|
|
107
|
+
if (!buf || buf.length < 5)
|
|
108
|
+
return;
|
|
109
|
+
const kind = buf[0], id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
|
|
110
|
+
if (kind === ASK) {
|
|
111
|
+
const pk = hex(buf.subarray(5, 69));
|
|
112
|
+
void deliver(pk, new Uint8Array(buf.subarray(69))).then((back) => {
|
|
113
|
+
const head = new Uint8Array(5);
|
|
114
|
+
head[0] = back === undefined ? NONE : REPLY;
|
|
115
|
+
new DataView(head.buffer).setUint32(1, id);
|
|
116
|
+
if (line.readyState === OPEN)
|
|
117
|
+
line.send(back === undefined ? head : concat(head, back));
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const waiting = this.pending.get(id);
|
|
122
|
+
if (!waiting)
|
|
123
|
+
return;
|
|
124
|
+
this.pending.delete(id);
|
|
125
|
+
waiting(kind === REPLY ? new Uint8Array(buf.subarray(5)) : undefined);
|
|
126
|
+
});
|
|
127
|
+
line.addEventListener('error', () => { });
|
|
128
|
+
line.addEventListener('close', (e) => {
|
|
129
|
+
this.pending.clear(); // in flight and no word since: nothing is answered
|
|
130
|
+
onClose({ code: e?.code, reason: e?.reason });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// Say which ward pks this side holds.
|
|
134
|
+
announce(pks) {
|
|
135
|
+
if (this.line.readyState === OPEN)
|
|
136
|
+
this.line.send(JSON.stringify({ announce: pks, suite: SUITE }));
|
|
137
|
+
}
|
|
138
|
+
async carry(pk, bytes) {
|
|
139
|
+
try {
|
|
140
|
+
await this.#open;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (this.line.readyState !== OPEN)
|
|
146
|
+
return undefined;
|
|
147
|
+
const id = this.#id++;
|
|
148
|
+
const head = new Uint8Array(69);
|
|
149
|
+
head[0] = ASK;
|
|
150
|
+
new DataView(head.buffer).setUint32(1, id);
|
|
151
|
+
head.set(unhex(pk), 5);
|
|
152
|
+
return new Promise((ok) => {
|
|
153
|
+
this.pending.set(id, ok);
|
|
154
|
+
this.line.send(concat(head, bytes));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
close() {
|
|
158
|
+
this.line.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function concat(a, b) {
|
|
162
|
+
const out = new Uint8Array(a.length + b.length);
|
|
163
|
+
out.set(a, 0);
|
|
164
|
+
out.set(b, a.length);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type WardRecord = {
|
|
2
|
+
pk: string;
|
|
3
|
+
code: string;
|
|
4
|
+
user: string;
|
|
5
|
+
};
|
|
6
|
+
export type Kept = {
|
|
7
|
+
seed: Uint8Array;
|
|
8
|
+
partition: Record<string, unknown>;
|
|
9
|
+
record: WardRecord;
|
|
10
|
+
};
|
|
11
|
+
export type Store = {
|
|
12
|
+
list(): Promise<string[]>;
|
|
13
|
+
load(name: string): Promise<Kept | undefined>;
|
|
14
|
+
put(name: string, kept: Kept): Promise<void>;
|
|
15
|
+
save(name: string, partition: Record<string, unknown>): Promise<void>;
|
|
16
|
+
record(name: string, record: WardRecord): Promise<void>;
|
|
17
|
+
take(name: string): Promise<Kept | undefined>;
|
|
18
|
+
hints(): Promise<Record<string, string>>;
|
|
19
|
+
hint(pk: string, url: string): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export declare const values: (p: Record<string, unknown>) => Record<string, unknown>;
|
|
22
|
+
export declare class MemoryStore implements Store {
|
|
23
|
+
readonly rows: Map<string, Kept>;
|
|
24
|
+
readonly reach: Map<string, string>;
|
|
25
|
+
list(): Promise<string[]>;
|
|
26
|
+
load(name: string): Promise<Kept | undefined>;
|
|
27
|
+
put(name: string, kept: Kept): Promise<void>;
|
|
28
|
+
save(name: string, partition: Record<string, unknown>): Promise<void>;
|
|
29
|
+
record(name: string, record: WardRecord): Promise<void>;
|
|
30
|
+
take(name: string): Promise<Kept | undefined>;
|
|
31
|
+
hints(): Promise<Record<string, string>>;
|
|
32
|
+
hint(pk: string, url: string): Promise<void>;
|
|
33
|
+
}
|