@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.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +91 -0
- package/SPEC.md +1595 -0
- package/package.json +64 -0
- package/src/being/being.ts +92 -0
- package/src/being/digest.ts +31 -0
- package/src/being/index.ts +7 -0
- package/src/being/silence.ts +14 -0
- package/src/being/types.ts +88 -0
- package/src/conformance/assert.ts +67 -0
- package/src/conformance/beings.ts +129 -0
- package/src/conformance/estate.ts +339 -0
- package/src/conformance/index.ts +516 -0
- package/src/conformance/reach.ts +83 -0
- package/src/conformance/store.ts +109 -0
- package/src/harbor/core.ts +178 -0
- package/src/harbor/dial.ts +61 -0
- package/src/harbor/index.ts +11 -0
- package/src/harbor/memory.ts +74 -0
- package/src/harbor/reach.ts +189 -0
- package/src/harbor/store.ts +69 -0
- package/src/ward/allowance.ts +74 -0
- package/src/ward/arithmetic.ts +161 -0
- package/src/ward/cells.ts +90 -0
- package/src/ward/door.ts +102 -0
- package/src/ward/ground.ts +35 -0
- package/src/ward/heirs.ts +87 -0
- package/src/ward/index.ts +14 -0
- package/src/ward/owner.ts +122 -0
- package/src/ward/partition.ts +123 -0
- package/src/ward/seal.ts +133 -0
- package/src/ward/stance.ts +264 -0
- package/src/ward/ward.ts +209 -0
- package/vectors/arithmetic.json +110 -0
- package/vectors/framing.json +71 -0
- package/vectors/wire.json +48 -0
|
@@ -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
|
+
}
|