nervur 0.20.0 → 0.20.2
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 +229 -20
- package/dist/being/being.d.ts +1 -0
- package/dist/being/being.js +5 -1
- package/dist/being/faculty.d.ts +1 -0
- package/dist/being/faculty.js +13 -7
- package/dist/being/index.d.ts +1 -0
- package/dist/being/index.js +1 -0
- package/dist/being/kind.d.ts +4 -0
- package/dist/being/kind.js +28 -0
- package/dist/being/types.d.ts +3 -4
- package/dist/browser/index.d.ts +35 -0
- package/dist/browser/index.js +188 -0
- package/dist/browser/worker.d.ts +12 -0
- package/dist/browser/worker.js +40 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +7 -0
- package/dist/cli/command.d.ts +13 -0
- package/dist/cli/command.js +237 -0
- package/dist/cli/harbor.d.ts +19 -0
- package/dist/cli/harbor.js +99 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +14 -0
- package/dist/contract/index.d.ts +16 -2
- package/dist/contract/index.js +13 -3
- package/dist/edge/index.d.ts +57 -0
- package/dist/edge/index.js +306 -0
- package/dist/edge.d.ts +1 -0
- package/dist/edge.js +8 -0
- package/dist/folder/index.d.ts +9 -4
- package/dist/folder/index.js +89 -84
- package/dist/harbor/carrying.d.ts +17 -0
- package/dist/harbor/carrying.js +38 -0
- package/dist/harbor/catalogue.d.ts +27 -0
- package/dist/harbor/catalogue.js +101 -0
- package/dist/harbor/dock.d.ts +32 -3
- package/dist/harbor/dock.js +98 -12
- package/dist/harbor/harbor.d.ts +27 -8
- package/dist/harbor/harbor.js +217 -26
- package/dist/harbor/index.d.ts +8 -2
- package/dist/harbor/index.js +8 -1
- package/dist/harbor/package.d.ts +12 -0
- package/dist/harbor/package.js +130 -0
- package/dist/harbor/probe.d.ts +18 -0
- package/dist/harbor/probe.js +15 -0
- package/dist/harbor/registry.d.ts +6 -3
- package/dist/harbor/registry.js +83 -7
- package/dist/harbor/relay.d.ts +15 -0
- package/dist/harbor/relay.js +387 -0
- package/dist/harbor/root-line.d.ts +9 -0
- package/dist/harbor/root-line.js +38 -0
- package/dist/harbor/terrain.d.ts +14 -1
- package/dist/harbor/terrain.js +19 -0
- package/dist/http/index.d.ts +4 -0
- package/dist/http/index.js +156 -0
- package/dist/http/websocket.d.ts +29 -0
- package/dist/http/websocket.js +133 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +12 -6
- package/dist/line/answer.d.ts +8 -0
- package/dist/line/answer.js +60 -0
- package/dist/line/frame.d.ts +34 -0
- package/dist/line/frame.js +81 -0
- package/dist/line/ground.d.ts +3 -0
- package/dist/line/ground.js +9 -0
- package/dist/line/index.d.ts +5 -0
- package/dist/line/index.js +9 -0
- package/dist/line/push.d.ts +7 -0
- package/dist/line/push.js +92 -0
- package/dist/line/web.d.ts +30 -0
- package/dist/line/web.js +181 -0
- package/dist/node/index.d.ts +32 -0
- package/dist/node/index.js +164 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +7 -0
- package/dist/pointer/bodies.d.ts +11 -3
- package/dist/pointer/bodies.js +47 -18
- package/dist/pointer/index.d.ts +1 -1
- package/dist/pointer/index.js +1 -1
- package/dist/pointer/world.d.ts +12 -5
- package/dist/pointer/world.js +29 -14
- package/dist/quo/address.d.ts +19 -0
- package/dist/quo/address.js +84 -0
- package/dist/quo/door.d.ts +1 -1
- package/dist/quo/door.js +6 -4
- package/dist/quo/index.d.ts +1 -0
- package/dist/quo/index.js +1 -0
- package/dist/quo/invitation.d.ts +1 -0
- package/dist/quo/invitation.js +9 -5
- package/dist/quo/keys.js +1 -1
- package/dist/tcp/frame.d.ts +25 -0
- package/dist/tcp/frame.js +64 -0
- package/dist/tcp/index.d.ts +24 -0
- package/dist/tcp/index.js +260 -0
- package/dist/ward/index.d.ts +3 -2
- package/dist/ward/index.js +2 -1
- package/dist/ward/pilot.d.ts +8 -0
- package/dist/ward/pilot.js +50 -0
- package/dist/ward/stance.d.ts +4 -4
- package/dist/ward/stance.js +10 -7
- package/dist/ward/ward-being.d.ts +4 -1
- package/dist/ward/ward-being.js +8 -2
- package/dist/ward/ward.d.ts +11 -2
- package/dist/ward/ward.js +41 -5
- package/package.json +28 -2
- package/src/being/being.ts +5 -1
- package/src/being/faculty.ts +14 -7
- package/src/being/index.ts +1 -0
- package/src/being/kind.ts +30 -0
- package/src/being/types.ts +8 -6
- package/src/browser/index.ts +207 -0
- package/src/browser/worker.ts +66 -0
- package/src/browser.ts +9 -0
- package/src/cli/command.ts +224 -0
- package/src/cli/harbor.ts +103 -0
- package/src/cli/main.ts +13 -0
- package/src/contract/index.ts +42 -10
- package/src/edge/index.ts +359 -0
- package/src/edge.ts +10 -0
- package/src/folder/index.ts +87 -84
- package/src/harbor/carrying.ts +56 -0
- package/src/harbor/catalogue.ts +125 -0
- package/src/harbor/dock.ts +122 -15
- package/src/harbor/harbor.ts +240 -36
- package/src/harbor/index.ts +10 -3
- package/src/harbor/package.ts +147 -0
- package/src/harbor/probe.ts +34 -0
- package/src/harbor/registry.ts +73 -9
- package/src/harbor/relay.ts +399 -0
- package/src/harbor/root-line.ts +39 -0
- package/src/harbor/terrain.ts +31 -3
- package/src/http/index.ts +150 -0
- package/src/http/websocket.ts +124 -0
- package/src/index.ts +39 -6
- package/src/line/answer.ts +59 -0
- package/src/line/frame.ts +88 -0
- package/src/line/ground.ts +19 -0
- package/src/line/index.ts +9 -0
- package/src/line/push.ts +112 -0
- package/src/line/web.ts +227 -0
- package/src/node/index.ts +170 -0
- package/src/node.ts +9 -0
- package/src/pointer/bodies.ts +45 -16
- package/src/pointer/index.ts +1 -1
- package/src/pointer/world.ts +38 -19
- package/src/quo/address.ts +87 -0
- package/src/quo/door.ts +6 -4
- package/src/quo/index.ts +1 -0
- package/src/quo/invitation.ts +10 -6
- package/src/quo/keys.ts +1 -1
- package/src/stand/main.ts +1 -0
- package/src/stand/stand.ts +87 -6
- package/src/tcp/index.ts +284 -0
- package/src/ward/index.ts +3 -2
- package/src/ward/pilot.ts +50 -0
- package/src/ward/stance.ts +14 -8
- package/src/ward/ward-being.ts +9 -3
- package/src/ward/ward.ts +53 -6
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The package: a harbor's memory, sealed under the harbor's seed. The
|
|
3
|
+
// terrain keeps its bytes and reads neither a ward's name, nor a being's
|
|
4
|
+
// key, nor anything a row holds.
|
|
5
|
+
//
|
|
6
|
+
// key HKDF-SHA-256 of the seed under `nervur-package`
|
|
7
|
+
// place 16 bytes under ["place", ward], as hex
|
|
8
|
+
// entry 16 bytes under ["entry", ward, row], as hex
|
|
9
|
+
// seal 32 bytes under ["seal", ward]
|
|
10
|
+
// entry bytes nonce (12) || AES-256-GCM( JSON { row, value } ),
|
|
11
|
+
// the entry's name as additional data
|
|
12
|
+
//
|
|
13
|
+
// A row is the head, under the empty name, or one being, under her key. An
|
|
14
|
+
// entry that does not open refuses the whole ward, so nothing unpacks from
|
|
15
|
+
// bytes the package did not seal.
|
|
16
|
+
import { concat, decrypt, encrypt, hex, hkdf, utf8 } from '../crypto/index.ts';
|
|
17
|
+
import type { Entropy, Memory, Rows } from '../contract/index.ts';
|
|
18
|
+
import { HEAD_ROW } from '../ward/index.ts';
|
|
19
|
+
|
|
20
|
+
// Every place of one memory, written into another, and the places
|
|
21
|
+
// carried. The bytes are the package's own, sealed under a seed neither
|
|
22
|
+
// memory holds, so a harbor opened on the second memory under that seed
|
|
23
|
+
// stands what the first one stood. A place the second memory already
|
|
24
|
+
// holds is written over.
|
|
25
|
+
export const copyPackage = async (from: Memory, to: Memory): Promise<string[]> => {
|
|
26
|
+
const places = await from.places();
|
|
27
|
+
for (const place of places) await to.write(place, await from.read(place));
|
|
28
|
+
return places;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const NONCE = 12;
|
|
32
|
+
const LABEL = utf8('nervur-package');
|
|
33
|
+
|
|
34
|
+
type Sealed = { row: string; value: unknown };
|
|
35
|
+
|
|
36
|
+
class PackageRows implements Rows {
|
|
37
|
+
readonly rows: Record<string, unknown>;
|
|
38
|
+
readonly #ward: Ward;
|
|
39
|
+
readonly #told = new Set<string>();
|
|
40
|
+
#line: Promise<boolean> = Promise.resolve(true);
|
|
41
|
+
|
|
42
|
+
constructor(ward: Ward, rows: Record<string, unknown>) {
|
|
43
|
+
this.#ward = ward;
|
|
44
|
+
this.rows = rows;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
told(row: string): void {
|
|
48
|
+
this.#told.add(row);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Every row told since the last keep, sealed and written as one keep, one
|
|
52
|
+
// keep at a time.
|
|
53
|
+
kept(): Promise<boolean> {
|
|
54
|
+
const rows = [...this.#told];
|
|
55
|
+
this.#told.clear();
|
|
56
|
+
this.#line = this.#line.then(() => this.#write(rows)).catch(() => false);
|
|
57
|
+
return this.#line;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async #write(told: string[]): Promise<boolean> {
|
|
61
|
+
const beings = (this.rows.beings ?? {}) as Record<string, unknown>;
|
|
62
|
+
const entries = new Map<string, Uint8Array | null>();
|
|
63
|
+
for (const row of told) {
|
|
64
|
+
const value = row === HEAD_ROW ? (this.rows.ward ?? null) : Object.hasOwn(beings, row) ? beings[row] : undefined;
|
|
65
|
+
entries.set(await this.#ward.entry(row), value === undefined ? null : await this.#ward.seal(row, value));
|
|
66
|
+
}
|
|
67
|
+
await this.#ward.write(entries);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class Ward {
|
|
73
|
+
readonly #pack: Package;
|
|
74
|
+
readonly #name: string;
|
|
75
|
+
readonly #seal: Uint8Array;
|
|
76
|
+
readonly place: string;
|
|
77
|
+
|
|
78
|
+
constructor(pack: Package, name: string, seal: Uint8Array, place: string) {
|
|
79
|
+
this.#pack = pack;
|
|
80
|
+
this.#name = name;
|
|
81
|
+
this.#seal = seal;
|
|
82
|
+
this.place = place;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
entry(row: string): Promise<string> {
|
|
86
|
+
return this.#pack.derive(['entry', this.#name, row], 16).then(hex);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async seal(row: string, value: unknown): Promise<Uint8Array> {
|
|
90
|
+
const nonce = this.#pack.entropy.draw(NONCE);
|
|
91
|
+
const text = utf8(JSON.stringify({ row, value } satisfies Sealed));
|
|
92
|
+
return concat(nonce, await encrypt(this.#seal, nonce, utf8(await this.entry(row)), text));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The name is the additional data, so an entry moved under another name
|
|
96
|
+
// does not open either.
|
|
97
|
+
async open(name: string, bytes: Uint8Array): Promise<Sealed> {
|
|
98
|
+
const text = bytes.length > NONCE ? await decrypt(this.#seal, bytes.subarray(0, NONCE), utf8(name), bytes.subarray(NONCE)) : null;
|
|
99
|
+
if (text === null) throw new Error('an entry the package did not seal');
|
|
100
|
+
return JSON.parse(new TextDecoder().decode(text)) as Sealed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
write(entries: ReadonlyMap<string, Uint8Array | null>): Promise<void> {
|
|
104
|
+
return this.#pack.memory.write(this.place, entries);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class Package {
|
|
109
|
+
readonly memory: Memory;
|
|
110
|
+
readonly entropy: Entropy;
|
|
111
|
+
readonly #key: Uint8Array;
|
|
112
|
+
|
|
113
|
+
private constructor(memory: Memory, entropy: Entropy, key: Uint8Array) {
|
|
114
|
+
this.memory = memory;
|
|
115
|
+
this.entropy = entropy;
|
|
116
|
+
this.#key = key;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static async open(memory: Memory, entropy: Entropy, seed: Uint8Array): Promise<Package> {
|
|
120
|
+
return new Package(memory, entropy, await hkdf(seed, LABEL, 32));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
derive(info: string[], length: number): Promise<Uint8Array> {
|
|
124
|
+
return hkdf(this.#key, utf8(JSON.stringify(info)), length);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The place a ward's rows live in.
|
|
128
|
+
place(name: string): Promise<string> {
|
|
129
|
+
return this.derive(['place', name], 16).then(hex);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// One ward's rows, as the package kept them.
|
|
133
|
+
async rows(name: string): Promise<Rows> {
|
|
134
|
+
const ward = new Ward(this, name, await this.derive(['seal', name], 32), hex(await this.derive(['place', name], 16)));
|
|
135
|
+
const rows: Record<string, unknown> = {};
|
|
136
|
+
const beings: Record<string, unknown> = {};
|
|
137
|
+
let any = false;
|
|
138
|
+
for (const [entry, bytes] of await this.memory.read(ward.place)) {
|
|
139
|
+
const { row, value } = await ward.open(entry, bytes);
|
|
140
|
+
any = true;
|
|
141
|
+
if (row === HEAD_ROW) rows.ward = value;
|
|
142
|
+
else beings[row] = value;
|
|
143
|
+
}
|
|
144
|
+
if (any) rows.beings = beings;
|
|
145
|
+
return new PackageRows(ward, rows);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The ground probe: `Harbor.open()` with no terrain asks each ground an
|
|
3
|
+
// entry knows whether it fits here, the one added last first, and opens on
|
|
4
|
+
// the terrain the first that fits makes. The main entry adds a ground that
|
|
5
|
+
// fits everywhere; an entry that names a platform adds its own.
|
|
6
|
+
import type { Module } from '../contract/index.ts';
|
|
7
|
+
import type { Terrain } from './terrain.ts';
|
|
8
|
+
|
|
9
|
+
// What a probed harbor is told: the modules its code holds, where it keeps
|
|
10
|
+
// itself, as its ground reads a place, and what the engine handed the
|
|
11
|
+
// code, such as an edge's object state and environment.
|
|
12
|
+
export type Probing = { readonly modules?: readonly Module[]; readonly where?: string; readonly given?: unknown };
|
|
13
|
+
|
|
14
|
+
export type GroundProbe = {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
fits(probing: Probing): boolean;
|
|
17
|
+
terrain(probing: Probing): Terrain;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const known: GroundProbe[] = [];
|
|
21
|
+
|
|
22
|
+
// A ground this process can stand on, known once by its name.
|
|
23
|
+
export const addGround = (probe: GroundProbe): void => {
|
|
24
|
+
if (!known.some((k) => k.name === probe.name)) known.push(probe);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// The grounds known, in the order they are tried.
|
|
28
|
+
export const grounds = (): string[] => known.map((k) => k.name).reverse();
|
|
29
|
+
|
|
30
|
+
// The terrain of the first ground that fits, and its name.
|
|
31
|
+
export const probeGround = (probing: Probing, probes: readonly GroundProbe[] = known): { ground: string; terrain: Terrain } => {
|
|
32
|
+
for (const probe of [...probes].reverse()) if (probe.fits(probing)) return { ground: probe.name, terrain: probe.terrain(probing) };
|
|
33
|
+
throw new Error('no ground fits here');
|
|
34
|
+
};
|
package/src/harbor/registry.ts
CHANGED
|
@@ -1,20 +1,84 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The catalogue
|
|
3
|
-
//
|
|
4
|
-
|
|
2
|
+
// The catalogue's index: every kind the harbor runs, from the kit's own
|
|
3
|
+
// classes and the modules the catalogue loaded. It resolves a kind a row
|
|
4
|
+
// keeps, and decides nothing about what stands. It refuses any set of
|
|
5
|
+
// modules two readings could split:
|
|
6
|
+
//
|
|
7
|
+
// a module whose name is no kind, or is the kit's, or two of one name
|
|
8
|
+
// a class with no kind of its own, or one outside its module's domain
|
|
9
|
+
// two classes of one kind, or one kind in two modules
|
|
10
|
+
// a contract with no kind of its own, or one kind for two contracts
|
|
11
|
+
// a kind that is a class and a contract
|
|
12
|
+
//
|
|
13
|
+
// A module's domain is its name's first two segments: `com.acme.shop`
|
|
14
|
+
// stands classes under `com.acme.` alone. A contract may be any domain's,
|
|
15
|
+
// so a faculty fulfils a contract another module declares. Two harbors hold
|
|
16
|
+
// two registries, and nothing between them names a class.
|
|
17
|
+
import { Faculty, isKind, KIT, kindOf, type BeingClass } from '../being/index.ts';
|
|
18
|
+
import type { Module } from '../contract/index.ts';
|
|
5
19
|
import type { Catalogue } from '../ward/index.ts';
|
|
6
20
|
|
|
21
|
+
const domainOf = (name: string): string => `${name.split('.').slice(0, 2).join('.')}.`;
|
|
22
|
+
|
|
7
23
|
export class Registry implements Catalogue {
|
|
8
24
|
readonly #classes = new Map<string, BeingClass>();
|
|
25
|
+
readonly #modules = new Map<string, string[]>();
|
|
9
26
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
27
|
+
// `kit` holds the kit's own classes, which alone may be of its kinds.
|
|
28
|
+
constructor(kit: readonly BeingClass[], modules: readonly Module[]) {
|
|
29
|
+
const contracts = new Map<string, unknown>();
|
|
30
|
+
const owners = new Map<string, string>();
|
|
31
|
+
const add = (C: BeingClass, kind: string): void => {
|
|
32
|
+
if (this.#classes.has(kind) && this.#classes.get(kind) !== C) throw new Error(`two classes are of kind ${kind}`);
|
|
33
|
+
this.#classes.set(kind, C);
|
|
34
|
+
};
|
|
35
|
+
for (const C of kit) add(C, kindOf(C));
|
|
36
|
+
for (const m of modules) {
|
|
37
|
+
if (!isKind(m.module) || m.module.startsWith(KIT)) throw new Error(`module ${String(m.module)} is no name a module may take`);
|
|
38
|
+
if (this.#modules.has(m.module)) throw new Error(`two modules are named ${m.module}`);
|
|
39
|
+
if (typeof m.version !== 'string' || m.version === '') throw new Error(`module ${m.module} declares no version`);
|
|
40
|
+
const kinds: string[] = [];
|
|
41
|
+
for (const C of m.classes) {
|
|
42
|
+
if (typeof C !== 'function') throw new Error(`module ${m.module} holds something that is no class`);
|
|
43
|
+
const kind = kindOf(C);
|
|
44
|
+
if (!kind.startsWith(domainOf(m.module))) throw new Error(`kind ${kind} is outside module ${m.module}`);
|
|
45
|
+
const owner = owners.get(kind);
|
|
46
|
+
if (owner !== undefined && owner !== m.module) throw new Error(`kind ${kind} is in modules ${owner} and ${m.module}`);
|
|
47
|
+
owners.set(kind, m.module);
|
|
48
|
+
add(C as BeingClass, kind);
|
|
49
|
+
kinds.push(kind);
|
|
50
|
+
if (!Faculty.fulfils(C)) continue;
|
|
51
|
+
for (let c: unknown = Object.getPrototypeOf(C); c !== Faculty; c = Object.getPrototypeOf(c)) {
|
|
52
|
+
const contract = kindOf(c);
|
|
53
|
+
if (contract.startsWith(KIT)) throw new Error(`kind ${contract} is the kit's`);
|
|
54
|
+
if (contracts.has(contract) && contracts.get(contract) !== c) throw new Error(`two contracts are of kind ${contract}`);
|
|
55
|
+
contracts.set(contract, c);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
this.#modules.set(m.module, kinds);
|
|
59
|
+
}
|
|
60
|
+
for (const kind of contracts.keys()) {
|
|
61
|
+
if (this.#classes.has(kind)) throw new Error(`kind ${kind} is a class and a contract`);
|
|
14
62
|
}
|
|
15
63
|
}
|
|
16
64
|
|
|
17
|
-
|
|
18
|
-
|
|
65
|
+
// Why a registry of these modules would be refused, or null.
|
|
66
|
+
static refusal(kit: readonly BeingClass[], modules: readonly Module[]): string | null {
|
|
67
|
+
try {
|
|
68
|
+
const registry = new Registry(kit, modules);
|
|
69
|
+
void registry;
|
|
70
|
+
return null;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return (e as Error).message;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
classOf(kind: string): BeingClass | undefined {
|
|
77
|
+
return this.#classes.get(kind);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The kinds a module stands, or none.
|
|
81
|
+
kindsOf(module: string): string[] {
|
|
82
|
+
return [...(this.#modules.get(module) ?? [])];
|
|
19
83
|
}
|
|
20
84
|
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The relay: a harbor nobody dials is reached through a harbor that is,
|
|
3
|
+
// by relations alone. Both sides are kit faculties, opened by the root.
|
|
4
|
+
//
|
|
5
|
+
// On the reached harbor, `org.nervur.relay`:
|
|
6
|
+
//
|
|
7
|
+
// invite { id, wards } a relay client invited under `id`, its
|
|
8
|
+
// notes naming the ward pks it collects for
|
|
9
|
+
// release { id } that client let go, and every box waiting
|
|
10
|
+
// for its wards answered with nothing
|
|
11
|
+
// collect { replies?, wait? } asked by a client: the replies to the boxes
|
|
12
|
+
// it took last, handed to their callers, then
|
|
13
|
+
// the boxes waiting for its wards; with
|
|
14
|
+
// `wait`, it waits for one first
|
|
15
|
+
// bell { endpoint } asked by a client: the push endpoint rung
|
|
16
|
+
// when a box waits for it, or null for none
|
|
17
|
+
// key asked by a client: the key its device
|
|
18
|
+
// subscribes to pushes with
|
|
19
|
+
//
|
|
20
|
+
// A box for a ward a client collects for reaches this harbor on any
|
|
21
|
+
// carrier, and the caller's dial waits for its reply, or for nothing once
|
|
22
|
+
// `RELAY_WAIT` passes. No box is opened here. A box no waiting collect
|
|
23
|
+
// takes rings the client's bell once, through a `Push` faculty the relay
|
|
24
|
+
// lends, until the client collects again.
|
|
25
|
+
//
|
|
26
|
+
// On the harbor nobody dials, `org.nervur.relay-client`:
|
|
27
|
+
//
|
|
28
|
+
// hold { invitation, line?, every? } the relay's invitation taken; a
|
|
29
|
+
// line collects always, `every`
|
|
30
|
+
// rings on a schedule, in ms
|
|
31
|
+
// ring every waiting box collected, handed
|
|
32
|
+
// to this harbor's doors, and the
|
|
33
|
+
// replies returned: a doorbell
|
|
34
|
+
// bell { endpoint } the relay's `bell`, asked
|
|
35
|
+
// key the relay's `key`, asked
|
|
36
|
+
// drop the relay let go
|
|
37
|
+
//
|
|
38
|
+
// Its harbor's `reach` names the relay's address, so every invitation it
|
|
39
|
+
// gives sends callers there. Whatever hears a push on the device asks
|
|
40
|
+
// `ring`.
|
|
41
|
+
import { Faculty, isSilence, isWord, told, type Asker, type AskSpec, type BeingClass, type JsonObject } from '../being/index.ts';
|
|
42
|
+
import type { Clock } from '../contract/index.ts';
|
|
43
|
+
import { hex, isHex, unhex } from '../crypto/index.ts';
|
|
44
|
+
import { readInvitation } from '../quo/index.ts';
|
|
45
|
+
import { ownerAsk } from '../ward/index.ts';
|
|
46
|
+
import { CarrierFaculty, UNDIALED, type Dialed, type Ground } from './carrying.ts';
|
|
47
|
+
|
|
48
|
+
export const RELAY = 'org.nervur.relay';
|
|
49
|
+
export const RELAY_CLIENT = 'org.nervur.relay-client';
|
|
50
|
+
export const PUSH = 'org.nervur.push';
|
|
51
|
+
// How long a box waits for its reply: a caller's allowance, unless it
|
|
52
|
+
// asks for another.
|
|
53
|
+
export const RELAY_WAIT = 30_000;
|
|
54
|
+
// How long a collect with `wait` holds, inside the client's allowance.
|
|
55
|
+
export const RELAY_HOLD = 20_000;
|
|
56
|
+
// How long a line rests after a collect that reached nothing.
|
|
57
|
+
export const RELAY_REST = 1_000;
|
|
58
|
+
// The largest box a relay carries, so the collect answering it stays a
|
|
59
|
+
// box Quo carries.
|
|
60
|
+
export const RELAYED = 262_144;
|
|
61
|
+
// The longest push endpoint a relay keeps.
|
|
62
|
+
const ENDPOINT = 2_048;
|
|
63
|
+
// The most boxes one collect hands over.
|
|
64
|
+
const BATCH = 16;
|
|
65
|
+
const CLIENT = 'relay';
|
|
66
|
+
const PUSHER = 'push';
|
|
67
|
+
|
|
68
|
+
// The contract of a ring with no content: `key` answers the key a device
|
|
69
|
+
// subscribes with, and `ring { endpoint }` answers `rung`, `gone` where
|
|
70
|
+
// the push service has let the endpoint go, or an error.
|
|
71
|
+
export abstract class Push extends Faculty {
|
|
72
|
+
static override readonly kind: string = PUSH;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Waiting = { n: number; ward: string; bytes: Uint8Array; answer: (reply: Uint8Array | null) => void; cancel: () => void; collector?: string };
|
|
76
|
+
type Collector = { wards: readonly string[]; wake: () => void };
|
|
77
|
+
type Reply = { n: number; box: string | null };
|
|
78
|
+
|
|
79
|
+
const text = (v: unknown): v is string => typeof v === 'string';
|
|
80
|
+
const wardsOf = (notes: JsonObject | undefined): string[] => (Array.isArray(notes?.relay) ? notes.relay.filter(text) : []);
|
|
81
|
+
const collects: AskSpec['for'] = (_: Asker, notes) => wardsOf(notes).length > 0;
|
|
82
|
+
const clientAsk = (description: string, input: JsonObject = {}): AskSpec => ({ description, input: { type: 'object', ...input }, for: collects });
|
|
83
|
+
const readReplies = (v: unknown): Reply[] | null =>
|
|
84
|
+
v === undefined
|
|
85
|
+
? []
|
|
86
|
+
: Array.isArray(v) && v.every((r) => typeof r === 'object' && r !== null && Number.isInteger((r as Reply).n) && ((r as Reply).box === null || isHex((r as Reply).box, ((r as Reply).box ?? '').length / 2)))
|
|
87
|
+
? (v as Reply[])
|
|
88
|
+
: null;
|
|
89
|
+
// An answer, or the error it stands for.
|
|
90
|
+
const answered = (out: unknown): JsonObject => (isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: told(out) as string } : (out as JsonObject));
|
|
91
|
+
|
|
92
|
+
export const relayFaculty = (clock: Clock): BeingClass =>
|
|
93
|
+
class Relay extends CarrierFaculty {
|
|
94
|
+
static override readonly kind: string = RELAY;
|
|
95
|
+
static override cells: JsonObject = { clients: {}, bells: {} };
|
|
96
|
+
static override asks: Record<string, AskSpec> = {
|
|
97
|
+
invite: ownerAsk('a relay client, collecting for the ward pks named', { required: ['id', 'wards'] }),
|
|
98
|
+
release: ownerAsk('a relay client let go', { required: ['id'] }),
|
|
99
|
+
collect: clientAsk('the replies to the boxes taken last, then the boxes waiting'),
|
|
100
|
+
bell: clientAsk('the push endpoint rung when a box waits, or null', { required: ['endpoint'] }),
|
|
101
|
+
key: clientAsk('the key a device subscribes to pushes with'),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
#n = 0;
|
|
105
|
+
readonly #waiting: Waiting[] = [];
|
|
106
|
+
readonly #taken = new Map<number, Waiting>();
|
|
107
|
+
readonly #collectors = new Set<Collector>();
|
|
108
|
+
// The clients rung since they last collected.
|
|
109
|
+
readonly #rung = new Set<string>();
|
|
110
|
+
#lending: Promise<string | null> | undefined;
|
|
111
|
+
|
|
112
|
+
accepts(): boolean {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
dial(): Promise<Dialed> {
|
|
117
|
+
return Promise.resolve(UNDIALED);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get #clients(): Record<string, string[]> {
|
|
121
|
+
return this.cells.clients as Record<string, string[]>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get #bells(): Record<string, string> {
|
|
125
|
+
return this.cells.bells as Record<string, string>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
override relays(pk: string): boolean {
|
|
129
|
+
return Object.values(this.#clients).some((wards) => wards.includes(pk));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override relay(pk: string, bytes: Uint8Array): Promise<Uint8Array | null> {
|
|
133
|
+
if (!this.relays(pk) || bytes.length > RELAYED) return Promise.resolve(null);
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const wait = clock.wait(RELAY_WAIT);
|
|
136
|
+
const box: Waiting = { n: ++this.#n, ward: pk, bytes, answer: resolve, cancel: wait.cancel };
|
|
137
|
+
void wait.done.then(() => this.#settle(box, null));
|
|
138
|
+
this.#waiting.push(box);
|
|
139
|
+
let woken = false;
|
|
140
|
+
for (const collector of this.#collectors) {
|
|
141
|
+
if (!collector.wards.includes(pk)) continue;
|
|
142
|
+
collector.wake();
|
|
143
|
+
woken = true;
|
|
144
|
+
}
|
|
145
|
+
if (woken) return;
|
|
146
|
+
for (const [id, wards] of Object.entries(this.#clients)) {
|
|
147
|
+
if (!wards.includes(pk) || !Object.hasOwn(this.#bells, id) || this.#rung.has(id)) continue;
|
|
148
|
+
this.#rung.add(id);
|
|
149
|
+
void this.#ring(id);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Every box answered with nothing, and every collect woken.
|
|
155
|
+
override sleep(): Promise<void> {
|
|
156
|
+
for (const box of [...this.#waiting, ...this.#taken.values()]) this.#settle(box, null);
|
|
157
|
+
for (const collector of this.#collectors) collector.wake();
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async invite(args: JsonObject): Promise<JsonObject> {
|
|
162
|
+
const { id, wards } = args;
|
|
163
|
+
if (!text(id) || !Array.isArray(wards) || wards.length === 0 || !wards.every((w) => isHex(w, 64))) return { error: 'id is text and wards a list of ward pks' };
|
|
164
|
+
if (Object.hasOwn(this.#clients, id)) return { error: 'that client is invited' };
|
|
165
|
+
const invitation = await this.stance.occupants.invite(id, { relay: wards });
|
|
166
|
+
if (invitation === null) return { error: 'not invited' };
|
|
167
|
+
this.#clients[id] = [...(wards as string[])];
|
|
168
|
+
return { invitation };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
release(args: JsonObject): JsonObject {
|
|
172
|
+
if (!text(args.id) || !Object.hasOwn(this.#clients, args.id)) return { error: 'no such client' };
|
|
173
|
+
this.stance.occupants.remove(args.id);
|
|
174
|
+
delete this.#clients[args.id];
|
|
175
|
+
delete this.#bells[args.id];
|
|
176
|
+
this.#rung.delete(args.id);
|
|
177
|
+
for (const box of [...this.#waiting, ...this.#taken.values()]) if (!this.relays(box.ward)) this.#settle(box, null);
|
|
178
|
+
return { released: args.id };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async collect(args: JsonObject, asker: Asker): Promise<JsonObject> {
|
|
182
|
+
const replies = readReplies(args.replies);
|
|
183
|
+
if (replies === null) return { error: 'replies are a list of { n, box }' };
|
|
184
|
+
const id = asker.id!;
|
|
185
|
+
this.#rung.delete(id);
|
|
186
|
+
for (const { n, box } of replies) {
|
|
187
|
+
const taken = this.#taken.get(n);
|
|
188
|
+
if (taken?.collector === id) this.#settle(taken, box === null ? null : unhex(box));
|
|
189
|
+
}
|
|
190
|
+
const wards = wardsOf(this.notes(asker));
|
|
191
|
+
if (args.wait === true && !this.#waiting.some((b) => wards.includes(b.ward))) await this.#hold(wards);
|
|
192
|
+
const boxes = this.#waiting.filter((b) => wards.includes(b.ward)).slice(0, BATCH);
|
|
193
|
+
for (const box of boxes) {
|
|
194
|
+
this.#waiting.splice(this.#waiting.indexOf(box), 1);
|
|
195
|
+
box.collector = id;
|
|
196
|
+
this.#taken.set(box.n, box);
|
|
197
|
+
}
|
|
198
|
+
return { boxes: boxes.map(({ n, ward, bytes }) => ({ n, ward, box: hex(bytes) })) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
bell(args: JsonObject, asker: Asker): JsonObject {
|
|
202
|
+
const { endpoint } = args;
|
|
203
|
+
const id = asker.id!;
|
|
204
|
+
if (endpoint === null) {
|
|
205
|
+
delete this.#bells[id];
|
|
206
|
+
return { bell: null };
|
|
207
|
+
}
|
|
208
|
+
if (!text(endpoint) || endpoint.length > ENDPOINT || !/^https?:\/\//.test(endpoint)) return { error: 'endpoint is a web URL, or null' };
|
|
209
|
+
this.#bells[id] = endpoint;
|
|
210
|
+
return { bell: endpoint };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async key(): Promise<JsonObject> {
|
|
214
|
+
const push = await this.#push();
|
|
215
|
+
return push ? answered(await push.ask('key')) : { error: 'no push faculty is open' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The client's bell rung. An endpoint the push service calls gone is
|
|
219
|
+
// forgotten, and kept at the relay's next arrival.
|
|
220
|
+
async #ring(id: string): Promise<void> {
|
|
221
|
+
const push = await this.#push();
|
|
222
|
+
const endpoint = this.#bells[id];
|
|
223
|
+
if (!push || endpoint === undefined) return;
|
|
224
|
+
const out = answered(await push.ask('ring', { endpoint }));
|
|
225
|
+
if (out.gone === true && this.#bells[id] === endpoint) delete this.#bells[id];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async #push() {
|
|
229
|
+
if (!this.stance.standings.get(PUSHER)) {
|
|
230
|
+
this.#lending ??= this.stance.lend(Push, PUSHER).finally(() => (this.#lending = undefined));
|
|
231
|
+
await this.#lending;
|
|
232
|
+
}
|
|
233
|
+
return this.stance.standings.get(PUSHER);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#hold(wards: readonly string[]): Promise<void> {
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
const wait = clock.wait(RELAY_HOLD);
|
|
239
|
+
const collector: Collector = {
|
|
240
|
+
wards,
|
|
241
|
+
wake: () => {
|
|
242
|
+
wait.cancel();
|
|
243
|
+
this.#collectors.delete(collector);
|
|
244
|
+
resolve();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
this.#collectors.add(collector);
|
|
248
|
+
void wait.done.then(collector.wake);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#settle(box: Waiting, reply: Uint8Array | null): void {
|
|
253
|
+
box.cancel();
|
|
254
|
+
const at = this.#waiting.indexOf(box);
|
|
255
|
+
if (at >= 0) this.#waiting.splice(at, 1);
|
|
256
|
+
this.#taken.delete(box.n);
|
|
257
|
+
box.answer(reply);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const relayClient = (ground: Ground, clock: Clock): BeingClass =>
|
|
262
|
+
class RelayClient extends CarrierFaculty {
|
|
263
|
+
static override readonly kind: string = RELAY_CLIENT;
|
|
264
|
+
static override asks: Record<string, AskSpec> = {
|
|
265
|
+
hold: ownerAsk("a relay's invitation, collected from on a line, a schedule or a ring", { required: ['invitation'] }),
|
|
266
|
+
ring: ownerAsk('every waiting box collected and answered now'),
|
|
267
|
+
bell: ownerAsk('the push endpoint the relay rings, or null', { required: ['endpoint'] }),
|
|
268
|
+
key: ownerAsk('the key this device subscribes to pushes with'),
|
|
269
|
+
drop: ownerAsk('the relay let go'),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
#awake = false;
|
|
273
|
+
#replies: Reply[] = [];
|
|
274
|
+
// Ends every rest and schedule when the client sleeps or lets go.
|
|
275
|
+
#stop: () => void = () => {};
|
|
276
|
+
|
|
277
|
+
accepts(): boolean {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
dial(): Promise<Dialed> {
|
|
282
|
+
return Promise.resolve(UNDIALED);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
override awake(): Promise<void> {
|
|
286
|
+
if (!this.#awake) {
|
|
287
|
+
this.#awake = true;
|
|
288
|
+
this.#start();
|
|
289
|
+
}
|
|
290
|
+
return Promise.resolve();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
override sleep(): Promise<void> {
|
|
294
|
+
this.#awake = false;
|
|
295
|
+
this.#stop();
|
|
296
|
+
return Promise.resolve();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async hold(args: JsonObject): Promise<JsonObject> {
|
|
300
|
+
const invitation = readInvitation(args.invitation);
|
|
301
|
+
const { line, every } = args;
|
|
302
|
+
if (invitation === null || (line !== undefined && typeof line !== 'boolean') || (every !== undefined && !(Number.isInteger(every) && (every as number) > 0))) {
|
|
303
|
+
return { error: 'an invitation, line a boolean and every a positive integer' };
|
|
304
|
+
}
|
|
305
|
+
if (this.stance.standings.get(CLIENT)) return { error: 'a relay is held' };
|
|
306
|
+
if ((await this.stance.standings.take(CLIENT, invitation)) === null) return { error: 'not taken' };
|
|
307
|
+
this.cells.line = line === true;
|
|
308
|
+
if (every !== undefined) this.cells.every = every;
|
|
309
|
+
this.#start();
|
|
310
|
+
return { held: invitation.ward };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async ring(): Promise<JsonObject> {
|
|
314
|
+
if (!this.stance.standings.get(CLIENT)) return { error: 'no relay is held' };
|
|
315
|
+
let carried = 0;
|
|
316
|
+
for (;;) {
|
|
317
|
+
const sent = this.#replies.length;
|
|
318
|
+
const got = await this.#collect(false);
|
|
319
|
+
if (got === null) return { error: 'the relay was not reached', carried };
|
|
320
|
+
carried += got;
|
|
321
|
+
if (got === 0 && sent === 0) return { carried };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
bell(args: JsonObject): Promise<JsonObject> {
|
|
326
|
+
return this.#relay('bell', { endpoint: args.endpoint ?? null });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
key(): Promise<JsonObject> {
|
|
330
|
+
return this.#relay('key');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
drop(): JsonObject {
|
|
334
|
+
if (!this.stance.standings.remove(CLIENT)) return { error: 'no relay is held' };
|
|
335
|
+
delete this.cells.line;
|
|
336
|
+
delete this.cells.every;
|
|
337
|
+
this.#stop();
|
|
338
|
+
return { dropped: true };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async #relay(method: string, args: JsonObject = {}): Promise<JsonObject> {
|
|
342
|
+
const standing = this.stance.standings.get(CLIENT);
|
|
343
|
+
return standing ? answered(await standing.ask(method, args)) : { error: 'no relay is held' };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#start(): void {
|
|
347
|
+
if (!this.#awake || !this.stance.standings.get(CLIENT)) return;
|
|
348
|
+
this.#stop();
|
|
349
|
+
let stopped = false;
|
|
350
|
+
const rests = new Set<() => void>();
|
|
351
|
+
this.#stop = () => {
|
|
352
|
+
stopped = true;
|
|
353
|
+
for (const end of rests) end();
|
|
354
|
+
};
|
|
355
|
+
const rest = (ms: number): Promise<void> =>
|
|
356
|
+
new Promise((resolve) => {
|
|
357
|
+
const wait = clock.wait(ms);
|
|
358
|
+
const end = () => {
|
|
359
|
+
wait.cancel();
|
|
360
|
+
rests.delete(end);
|
|
361
|
+
resolve();
|
|
362
|
+
};
|
|
363
|
+
rests.add(end);
|
|
364
|
+
void wait.done.then(end);
|
|
365
|
+
});
|
|
366
|
+
const going = () => !stopped && this.stance.standings.get(CLIENT) !== undefined;
|
|
367
|
+
if (this.cells.line === true) {
|
|
368
|
+
void (async () => {
|
|
369
|
+
while (going()) if ((await this.#collect(true)) === null && going()) await rest(RELAY_REST);
|
|
370
|
+
})();
|
|
371
|
+
}
|
|
372
|
+
const every = this.cells.every as number | undefined;
|
|
373
|
+
if (every !== undefined) {
|
|
374
|
+
void (async () => {
|
|
375
|
+
for (await rest(every); going(); await rest(every)) await this.ring();
|
|
376
|
+
})();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// One collect: the replies owed go with it, and each box it brings is
|
|
381
|
+
// handed to this harbor's doors. The number of boxes, or null where the
|
|
382
|
+
// relay was not reached.
|
|
383
|
+
async #collect(wait: boolean): Promise<number | null> {
|
|
384
|
+
const standing = this.stance.standings.get(CLIENT);
|
|
385
|
+
if (!standing) return null;
|
|
386
|
+
const replies = this.#replies.splice(0);
|
|
387
|
+
const out = await standing.ask('collect', { replies, wait });
|
|
388
|
+
const boxes = !isSilence(out) && !isWord(out) && Array.isArray((out as JsonObject).boxes) ? ((out as JsonObject).boxes as JsonObject[]) : null;
|
|
389
|
+
if (boxes === null) {
|
|
390
|
+
this.#replies.unshift(...replies);
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
for (const { n, ward, box } of boxes) {
|
|
394
|
+
const reply = text(ward) && text(box) && isHex(box, box.length / 2) ? await ground.arrive(ward, unhex(box)) : null;
|
|
395
|
+
this.#replies.push({ n: n as number, box: reply === null ? null : hex(reply) });
|
|
396
|
+
}
|
|
397
|
+
return boxes.length;
|
|
398
|
+
}
|
|
399
|
+
};
|