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,387 @@
|
|
|
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 } from '../being/index.js';
|
|
42
|
+
import { hex, isHex, unhex } from '../crypto/index.js';
|
|
43
|
+
import { readInvitation } from '../quo/index.js';
|
|
44
|
+
import { ownerAsk } from '../ward/index.js';
|
|
45
|
+
import { CarrierFaculty, UNDIALED } from './carrying.js';
|
|
46
|
+
export const RELAY = 'org.nervur.relay';
|
|
47
|
+
export const RELAY_CLIENT = 'org.nervur.relay-client';
|
|
48
|
+
export const PUSH = 'org.nervur.push';
|
|
49
|
+
// How long a box waits for its reply: a caller's allowance, unless it
|
|
50
|
+
// asks for another.
|
|
51
|
+
export const RELAY_WAIT = 30_000;
|
|
52
|
+
// How long a collect with `wait` holds, inside the client's allowance.
|
|
53
|
+
export const RELAY_HOLD = 20_000;
|
|
54
|
+
// How long a line rests after a collect that reached nothing.
|
|
55
|
+
export const RELAY_REST = 1_000;
|
|
56
|
+
// The largest box a relay carries, so the collect answering it stays a
|
|
57
|
+
// box Quo carries.
|
|
58
|
+
export const RELAYED = 262_144;
|
|
59
|
+
// The longest push endpoint a relay keeps.
|
|
60
|
+
const ENDPOINT = 2_048;
|
|
61
|
+
// The most boxes one collect hands over.
|
|
62
|
+
const BATCH = 16;
|
|
63
|
+
const CLIENT = 'relay';
|
|
64
|
+
const PUSHER = 'push';
|
|
65
|
+
// The contract of a ring with no content: `key` answers the key a device
|
|
66
|
+
// subscribes with, and `ring { endpoint }` answers `rung`, `gone` where
|
|
67
|
+
// the push service has let the endpoint go, or an error.
|
|
68
|
+
export class Push extends Faculty {
|
|
69
|
+
static kind = PUSH;
|
|
70
|
+
}
|
|
71
|
+
const text = (v) => typeof v === 'string';
|
|
72
|
+
const wardsOf = (notes) => (Array.isArray(notes?.relay) ? notes.relay.filter(text) : []);
|
|
73
|
+
const collects = (_, notes) => wardsOf(notes).length > 0;
|
|
74
|
+
const clientAsk = (description, input = {}) => ({ description, input: { type: 'object', ...input }, for: collects });
|
|
75
|
+
const readReplies = (v) => v === undefined
|
|
76
|
+
? []
|
|
77
|
+
: Array.isArray(v) && v.every((r) => typeof r === 'object' && r !== null && Number.isInteger(r.n) && (r.box === null || isHex(r.box, (r.box ?? '').length / 2)))
|
|
78
|
+
? v
|
|
79
|
+
: null;
|
|
80
|
+
// An answer, or the error it stands for.
|
|
81
|
+
const answered = (out) => (isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: told(out) } : out);
|
|
82
|
+
export const relayFaculty = (clock) => class Relay extends CarrierFaculty {
|
|
83
|
+
static kind = RELAY;
|
|
84
|
+
static cells = { clients: {}, bells: {} };
|
|
85
|
+
static asks = {
|
|
86
|
+
invite: ownerAsk('a relay client, collecting for the ward pks named', { required: ['id', 'wards'] }),
|
|
87
|
+
release: ownerAsk('a relay client let go', { required: ['id'] }),
|
|
88
|
+
collect: clientAsk('the replies to the boxes taken last, then the boxes waiting'),
|
|
89
|
+
bell: clientAsk('the push endpoint rung when a box waits, or null', { required: ['endpoint'] }),
|
|
90
|
+
key: clientAsk('the key a device subscribes to pushes with'),
|
|
91
|
+
};
|
|
92
|
+
#n = 0;
|
|
93
|
+
#waiting = [];
|
|
94
|
+
#taken = new Map();
|
|
95
|
+
#collectors = new Set();
|
|
96
|
+
// The clients rung since they last collected.
|
|
97
|
+
#rung = new Set();
|
|
98
|
+
#lending;
|
|
99
|
+
accepts() {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
dial() {
|
|
103
|
+
return Promise.resolve(UNDIALED);
|
|
104
|
+
}
|
|
105
|
+
get #clients() {
|
|
106
|
+
return this.cells.clients;
|
|
107
|
+
}
|
|
108
|
+
get #bells() {
|
|
109
|
+
return this.cells.bells;
|
|
110
|
+
}
|
|
111
|
+
relays(pk) {
|
|
112
|
+
return Object.values(this.#clients).some((wards) => wards.includes(pk));
|
|
113
|
+
}
|
|
114
|
+
relay(pk, bytes) {
|
|
115
|
+
if (!this.relays(pk) || bytes.length > RELAYED)
|
|
116
|
+
return Promise.resolve(null);
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const wait = clock.wait(RELAY_WAIT);
|
|
119
|
+
const box = { n: ++this.#n, ward: pk, bytes, answer: resolve, cancel: wait.cancel };
|
|
120
|
+
void wait.done.then(() => this.#settle(box, null));
|
|
121
|
+
this.#waiting.push(box);
|
|
122
|
+
let woken = false;
|
|
123
|
+
for (const collector of this.#collectors) {
|
|
124
|
+
if (!collector.wards.includes(pk))
|
|
125
|
+
continue;
|
|
126
|
+
collector.wake();
|
|
127
|
+
woken = true;
|
|
128
|
+
}
|
|
129
|
+
if (woken)
|
|
130
|
+
return;
|
|
131
|
+
for (const [id, wards] of Object.entries(this.#clients)) {
|
|
132
|
+
if (!wards.includes(pk) || !Object.hasOwn(this.#bells, id) || this.#rung.has(id))
|
|
133
|
+
continue;
|
|
134
|
+
this.#rung.add(id);
|
|
135
|
+
void this.#ring(id);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Every box answered with nothing, and every collect woken.
|
|
140
|
+
sleep() {
|
|
141
|
+
for (const box of [...this.#waiting, ...this.#taken.values()])
|
|
142
|
+
this.#settle(box, null);
|
|
143
|
+
for (const collector of this.#collectors)
|
|
144
|
+
collector.wake();
|
|
145
|
+
return Promise.resolve();
|
|
146
|
+
}
|
|
147
|
+
async invite(args) {
|
|
148
|
+
const { id, wards } = args;
|
|
149
|
+
if (!text(id) || !Array.isArray(wards) || wards.length === 0 || !wards.every((w) => isHex(w, 64)))
|
|
150
|
+
return { error: 'id is text and wards a list of ward pks' };
|
|
151
|
+
if (Object.hasOwn(this.#clients, id))
|
|
152
|
+
return { error: 'that client is invited' };
|
|
153
|
+
const invitation = await this.stance.occupants.invite(id, { relay: wards });
|
|
154
|
+
if (invitation === null)
|
|
155
|
+
return { error: 'not invited' };
|
|
156
|
+
this.#clients[id] = [...wards];
|
|
157
|
+
return { invitation };
|
|
158
|
+
}
|
|
159
|
+
release(args) {
|
|
160
|
+
if (!text(args.id) || !Object.hasOwn(this.#clients, args.id))
|
|
161
|
+
return { error: 'no such client' };
|
|
162
|
+
this.stance.occupants.remove(args.id);
|
|
163
|
+
delete this.#clients[args.id];
|
|
164
|
+
delete this.#bells[args.id];
|
|
165
|
+
this.#rung.delete(args.id);
|
|
166
|
+
for (const box of [...this.#waiting, ...this.#taken.values()])
|
|
167
|
+
if (!this.relays(box.ward))
|
|
168
|
+
this.#settle(box, null);
|
|
169
|
+
return { released: args.id };
|
|
170
|
+
}
|
|
171
|
+
async collect(args, asker) {
|
|
172
|
+
const replies = readReplies(args.replies);
|
|
173
|
+
if (replies === null)
|
|
174
|
+
return { error: 'replies are a list of { n, box }' };
|
|
175
|
+
const id = asker.id;
|
|
176
|
+
this.#rung.delete(id);
|
|
177
|
+
for (const { n, box } of replies) {
|
|
178
|
+
const taken = this.#taken.get(n);
|
|
179
|
+
if (taken?.collector === id)
|
|
180
|
+
this.#settle(taken, box === null ? null : unhex(box));
|
|
181
|
+
}
|
|
182
|
+
const wards = wardsOf(this.notes(asker));
|
|
183
|
+
if (args.wait === true && !this.#waiting.some((b) => wards.includes(b.ward)))
|
|
184
|
+
await this.#hold(wards);
|
|
185
|
+
const boxes = this.#waiting.filter((b) => wards.includes(b.ward)).slice(0, BATCH);
|
|
186
|
+
for (const box of boxes) {
|
|
187
|
+
this.#waiting.splice(this.#waiting.indexOf(box), 1);
|
|
188
|
+
box.collector = id;
|
|
189
|
+
this.#taken.set(box.n, box);
|
|
190
|
+
}
|
|
191
|
+
return { boxes: boxes.map(({ n, ward, bytes }) => ({ n, ward, box: hex(bytes) })) };
|
|
192
|
+
}
|
|
193
|
+
bell(args, asker) {
|
|
194
|
+
const { endpoint } = args;
|
|
195
|
+
const id = asker.id;
|
|
196
|
+
if (endpoint === null) {
|
|
197
|
+
delete this.#bells[id];
|
|
198
|
+
return { bell: null };
|
|
199
|
+
}
|
|
200
|
+
if (!text(endpoint) || endpoint.length > ENDPOINT || !/^https?:\/\//.test(endpoint))
|
|
201
|
+
return { error: 'endpoint is a web URL, or null' };
|
|
202
|
+
this.#bells[id] = endpoint;
|
|
203
|
+
return { bell: endpoint };
|
|
204
|
+
}
|
|
205
|
+
async key() {
|
|
206
|
+
const push = await this.#push();
|
|
207
|
+
return push ? answered(await push.ask('key')) : { error: 'no push faculty is open' };
|
|
208
|
+
}
|
|
209
|
+
// The client's bell rung. An endpoint the push service calls gone is
|
|
210
|
+
// forgotten, and kept at the relay's next arrival.
|
|
211
|
+
async #ring(id) {
|
|
212
|
+
const push = await this.#push();
|
|
213
|
+
const endpoint = this.#bells[id];
|
|
214
|
+
if (!push || endpoint === undefined)
|
|
215
|
+
return;
|
|
216
|
+
const out = answered(await push.ask('ring', { endpoint }));
|
|
217
|
+
if (out.gone === true && this.#bells[id] === endpoint)
|
|
218
|
+
delete this.#bells[id];
|
|
219
|
+
}
|
|
220
|
+
async #push() {
|
|
221
|
+
if (!this.stance.standings.get(PUSHER)) {
|
|
222
|
+
this.#lending ??= this.stance.lend(Push, PUSHER).finally(() => (this.#lending = undefined));
|
|
223
|
+
await this.#lending;
|
|
224
|
+
}
|
|
225
|
+
return this.stance.standings.get(PUSHER);
|
|
226
|
+
}
|
|
227
|
+
#hold(wards) {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const wait = clock.wait(RELAY_HOLD);
|
|
230
|
+
const collector = {
|
|
231
|
+
wards,
|
|
232
|
+
wake: () => {
|
|
233
|
+
wait.cancel();
|
|
234
|
+
this.#collectors.delete(collector);
|
|
235
|
+
resolve();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
this.#collectors.add(collector);
|
|
239
|
+
void wait.done.then(collector.wake);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
#settle(box, reply) {
|
|
243
|
+
box.cancel();
|
|
244
|
+
const at = this.#waiting.indexOf(box);
|
|
245
|
+
if (at >= 0)
|
|
246
|
+
this.#waiting.splice(at, 1);
|
|
247
|
+
this.#taken.delete(box.n);
|
|
248
|
+
box.answer(reply);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
export const relayClient = (ground, clock) => class RelayClient extends CarrierFaculty {
|
|
252
|
+
static kind = RELAY_CLIENT;
|
|
253
|
+
static asks = {
|
|
254
|
+
hold: ownerAsk("a relay's invitation, collected from on a line, a schedule or a ring", { required: ['invitation'] }),
|
|
255
|
+
ring: ownerAsk('every waiting box collected and answered now'),
|
|
256
|
+
bell: ownerAsk('the push endpoint the relay rings, or null', { required: ['endpoint'] }),
|
|
257
|
+
key: ownerAsk('the key this device subscribes to pushes with'),
|
|
258
|
+
drop: ownerAsk('the relay let go'),
|
|
259
|
+
};
|
|
260
|
+
#awake = false;
|
|
261
|
+
#replies = [];
|
|
262
|
+
// Ends every rest and schedule when the client sleeps or lets go.
|
|
263
|
+
#stop = () => { };
|
|
264
|
+
accepts() {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
dial() {
|
|
268
|
+
return Promise.resolve(UNDIALED);
|
|
269
|
+
}
|
|
270
|
+
awake() {
|
|
271
|
+
if (!this.#awake) {
|
|
272
|
+
this.#awake = true;
|
|
273
|
+
this.#start();
|
|
274
|
+
}
|
|
275
|
+
return Promise.resolve();
|
|
276
|
+
}
|
|
277
|
+
sleep() {
|
|
278
|
+
this.#awake = false;
|
|
279
|
+
this.#stop();
|
|
280
|
+
return Promise.resolve();
|
|
281
|
+
}
|
|
282
|
+
async hold(args) {
|
|
283
|
+
const invitation = readInvitation(args.invitation);
|
|
284
|
+
const { line, every } = args;
|
|
285
|
+
if (invitation === null || (line !== undefined && typeof line !== 'boolean') || (every !== undefined && !(Number.isInteger(every) && every > 0))) {
|
|
286
|
+
return { error: 'an invitation, line a boolean and every a positive integer' };
|
|
287
|
+
}
|
|
288
|
+
if (this.stance.standings.get(CLIENT))
|
|
289
|
+
return { error: 'a relay is held' };
|
|
290
|
+
if ((await this.stance.standings.take(CLIENT, invitation)) === null)
|
|
291
|
+
return { error: 'not taken' };
|
|
292
|
+
this.cells.line = line === true;
|
|
293
|
+
if (every !== undefined)
|
|
294
|
+
this.cells.every = every;
|
|
295
|
+
this.#start();
|
|
296
|
+
return { held: invitation.ward };
|
|
297
|
+
}
|
|
298
|
+
async ring() {
|
|
299
|
+
if (!this.stance.standings.get(CLIENT))
|
|
300
|
+
return { error: 'no relay is held' };
|
|
301
|
+
let carried = 0;
|
|
302
|
+
for (;;) {
|
|
303
|
+
const sent = this.#replies.length;
|
|
304
|
+
const got = await this.#collect(false);
|
|
305
|
+
if (got === null)
|
|
306
|
+
return { error: 'the relay was not reached', carried };
|
|
307
|
+
carried += got;
|
|
308
|
+
if (got === 0 && sent === 0)
|
|
309
|
+
return { carried };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
bell(args) {
|
|
313
|
+
return this.#relay('bell', { endpoint: args.endpoint ?? null });
|
|
314
|
+
}
|
|
315
|
+
key() {
|
|
316
|
+
return this.#relay('key');
|
|
317
|
+
}
|
|
318
|
+
drop() {
|
|
319
|
+
if (!this.stance.standings.remove(CLIENT))
|
|
320
|
+
return { error: 'no relay is held' };
|
|
321
|
+
delete this.cells.line;
|
|
322
|
+
delete this.cells.every;
|
|
323
|
+
this.#stop();
|
|
324
|
+
return { dropped: true };
|
|
325
|
+
}
|
|
326
|
+
async #relay(method, args = {}) {
|
|
327
|
+
const standing = this.stance.standings.get(CLIENT);
|
|
328
|
+
return standing ? answered(await standing.ask(method, args)) : { error: 'no relay is held' };
|
|
329
|
+
}
|
|
330
|
+
#start() {
|
|
331
|
+
if (!this.#awake || !this.stance.standings.get(CLIENT))
|
|
332
|
+
return;
|
|
333
|
+
this.#stop();
|
|
334
|
+
let stopped = false;
|
|
335
|
+
const rests = new Set();
|
|
336
|
+
this.#stop = () => {
|
|
337
|
+
stopped = true;
|
|
338
|
+
for (const end of rests)
|
|
339
|
+
end();
|
|
340
|
+
};
|
|
341
|
+
const rest = (ms) => new Promise((resolve) => {
|
|
342
|
+
const wait = clock.wait(ms);
|
|
343
|
+
const end = () => {
|
|
344
|
+
wait.cancel();
|
|
345
|
+
rests.delete(end);
|
|
346
|
+
resolve();
|
|
347
|
+
};
|
|
348
|
+
rests.add(end);
|
|
349
|
+
void wait.done.then(end);
|
|
350
|
+
});
|
|
351
|
+
const going = () => !stopped && this.stance.standings.get(CLIENT) !== undefined;
|
|
352
|
+
if (this.cells.line === true) {
|
|
353
|
+
void (async () => {
|
|
354
|
+
while (going())
|
|
355
|
+
if ((await this.#collect(true)) === null && going())
|
|
356
|
+
await rest(RELAY_REST);
|
|
357
|
+
})();
|
|
358
|
+
}
|
|
359
|
+
const every = this.cells.every;
|
|
360
|
+
if (every !== undefined) {
|
|
361
|
+
void (async () => {
|
|
362
|
+
for (await rest(every); going(); await rest(every))
|
|
363
|
+
await this.ring();
|
|
364
|
+
})();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// One collect: the replies owed go with it, and each box it brings is
|
|
368
|
+
// handed to this harbor's doors. The number of boxes, or null where the
|
|
369
|
+
// relay was not reached.
|
|
370
|
+
async #collect(wait) {
|
|
371
|
+
const standing = this.stance.standings.get(CLIENT);
|
|
372
|
+
if (!standing)
|
|
373
|
+
return null;
|
|
374
|
+
const replies = this.#replies.splice(0);
|
|
375
|
+
const out = await standing.ask('collect', { replies, wait });
|
|
376
|
+
const boxes = !isSilence(out) && !isWord(out) && Array.isArray(out.boxes) ? out.boxes : null;
|
|
377
|
+
if (boxes === null) {
|
|
378
|
+
this.#replies.unshift(...replies);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
for (const { n, ward, box } of boxes) {
|
|
382
|
+
const reply = text(ward) && text(box) && isHex(box, box.length / 2) ? await ground.arrive(ward, unhex(box)) : null;
|
|
383
|
+
this.#replies.push({ n: n, box: reply === null ? null : hex(reply) });
|
|
384
|
+
}
|
|
385
|
+
return boxes.length;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type JsonObject } from '../being/index.ts';
|
|
2
|
+
import type { Harbor } from './harbor.ts';
|
|
3
|
+
export type RootRequest = {
|
|
4
|
+
ward?: string;
|
|
5
|
+
method?: string;
|
|
6
|
+
args?: JsonObject;
|
|
7
|
+
};
|
|
8
|
+
export declare const readRootRequest: (value: unknown) => RootRequest | null;
|
|
9
|
+
export declare const rootLine: (harbor: Harbor, value: unknown) => Promise<JsonObject>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The root line: one request, one answer, both plain JSON, for whatever
|
|
3
|
+
// carries the root's asks to a harbor in its own process. The command line
|
|
4
|
+
// carries it over a local socket, and an edge route carries it the same way.
|
|
5
|
+
//
|
|
6
|
+
// request { ward?, method?, args? } no ward is the box ward, no
|
|
7
|
+
// method the census
|
|
8
|
+
// answer { answer } what the ward-being said
|
|
9
|
+
// { silence: true } she said nothing
|
|
10
|
+
// { word } a kit word: unreached, late, ...
|
|
11
|
+
// { error } the request was not one, or the
|
|
12
|
+
// ward is none this harbor holds
|
|
13
|
+
import { isSilence, isWord, told } from '../being/index.js';
|
|
14
|
+
const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
15
|
+
export const readRootRequest = (value) => {
|
|
16
|
+
if (!object(value))
|
|
17
|
+
return null;
|
|
18
|
+
const { ward, method, args } = value;
|
|
19
|
+
if ((ward !== undefined && typeof ward !== 'string') || (method !== undefined && typeof method !== 'string') || (args !== undefined && !object(args)))
|
|
20
|
+
return null;
|
|
21
|
+
return { ...(ward === undefined ? {} : { ward }), ...(method === undefined ? {} : { method }), ...(args === undefined ? {} : { args }) };
|
|
22
|
+
};
|
|
23
|
+
export const rootLine = async (harbor, value) => {
|
|
24
|
+
const request = readRootRequest(value);
|
|
25
|
+
if (request === null)
|
|
26
|
+
return { error: 'not a root request' };
|
|
27
|
+
const ward = request.ward === undefined ? harbor.box : harbor.ward(request.ward);
|
|
28
|
+
if (ward === undefined) {
|
|
29
|
+
const failed = request.ward === undefined ? undefined : harbor.failures().wards[request.ward];
|
|
30
|
+
return failed === undefined ? { error: 'no such ward' } : { error: 'the ward did not stand', reason: failed };
|
|
31
|
+
}
|
|
32
|
+
const out = await ward.root(request.method, request.args);
|
|
33
|
+
if (isSilence(out))
|
|
34
|
+
return { silence: true };
|
|
35
|
+
if (isWord(out))
|
|
36
|
+
return { word: told(out) };
|
|
37
|
+
return { answer: out };
|
|
38
|
+
};
|
package/dist/harbor/terrain.d.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import type { Carrier, Clock, Custody, Entropy, Memory } from '../contract/index.ts';
|
|
1
|
+
import type { Carrier, Clock, Custody, Entropy, Loader, Memory } from '../contract/index.ts';
|
|
2
|
+
import type { GroundFaculty } from './carrying.ts';
|
|
3
|
+
import type { Harbor } from './harbor.ts';
|
|
4
|
+
export type Opening = {
|
|
5
|
+
readonly key: string;
|
|
6
|
+
readonly kind: string;
|
|
7
|
+
};
|
|
2
8
|
export declare abstract class Terrain {
|
|
3
9
|
abstract readonly entropy: Entropy;
|
|
4
10
|
abstract readonly clock: Clock;
|
|
5
11
|
abstract readonly custody: Custody;
|
|
6
12
|
abstract readonly memory: Memory;
|
|
13
|
+
abstract readonly loader: Loader;
|
|
7
14
|
abstract readonly carrier: Carrier;
|
|
15
|
+
readonly faculties: readonly GroundFaculty[];
|
|
16
|
+
readonly opens: readonly Opening[];
|
|
17
|
+
readonly wakes: boolean;
|
|
18
|
+
claim(): Promise<void>;
|
|
19
|
+
stand(_harbor: Harbor): Promise<void>;
|
|
20
|
+
release(): Promise<void>;
|
|
8
21
|
}
|
package/dist/harbor/terrain.js
CHANGED
|
@@ -1,2 +1,21 @@
|
|
|
1
1
|
export class Terrain {
|
|
2
|
+
// The faculty classes this ground ships, its carriers among them, and
|
|
3
|
+
// those a new harbor opens.
|
|
4
|
+
faculties = [];
|
|
5
|
+
opens = [];
|
|
6
|
+
// Whether its carriers wake and listen where their cells say. A harbor
|
|
7
|
+
// opened for one ask and closed again dials and hears nothing.
|
|
8
|
+
wakes = true;
|
|
9
|
+
// Before the harbor opens: a terrain whose ground another run holds
|
|
10
|
+
// refuses here. Once it stands: whatever the ground serves beside it.
|
|
11
|
+
// Once its carriers sleep: what that served, gone.
|
|
12
|
+
claim() {
|
|
13
|
+
return Promise.resolve();
|
|
14
|
+
}
|
|
15
|
+
stand(_harbor) {
|
|
16
|
+
return Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
release() {
|
|
19
|
+
return Promise.resolve();
|
|
20
|
+
}
|
|
2
21
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Ground, GroundFaculty } from '../harbor/index.ts';
|
|
2
|
+
import { type WebListen, type WebListener } from '../line/index.ts';
|
|
3
|
+
export declare const webServe: (ground: Ground, listen: WebListen) => Promise<WebListener>;
|
|
4
|
+
export declare const webGround: GroundFaculty;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur/http`: the web carrier's listener on Node's HTTP server, an entry
|
|
3
|
+
// that names a platform.
|
|
4
|
+
//
|
|
5
|
+
// webServe answers posts at a path, and held lines opened to that path
|
|
6
|
+
// with the subprotocol `quo`, for the ground it is handed
|
|
7
|
+
// webGround the web carrier faculty of a Node ground, able to listen
|
|
8
|
+
//
|
|
9
|
+
// A post is one ask: 200 with the reply's box, or 204 for nothing. A post
|
|
10
|
+
// to another path is 404, another method 405, a body that is no ask 400.
|
|
11
|
+
// A page of an origin the cells name, or of any origin under `*`, is
|
|
12
|
+
// answered as the CORS protocol says, its preflight included. A held line
|
|
13
|
+
// carries one frame body to a binary message; a text message, a body that
|
|
14
|
+
// is no frame, or bytes that break RFC 6455 close it, and nothing after
|
|
15
|
+
// them is read.
|
|
16
|
+
import { createServer } from 'node:http';
|
|
17
|
+
import { MAX_BODY, MAX_POST, nothingBody, readBody, readPost, replyBody, webFaculty } from '../line/index.js';
|
|
18
|
+
import { hex } from '../crypto/index.js';
|
|
19
|
+
import { tcpAt } from '../quo/index.js';
|
|
20
|
+
import { acceptKey, frameOf, isClientKey, OP, WsReader } from './websocket.js';
|
|
21
|
+
const pathOf = (url) => {
|
|
22
|
+
const at = (url ?? '/').indexOf('?');
|
|
23
|
+
return at < 0 ? (url ?? '/') : (url ?? '/').slice(0, at);
|
|
24
|
+
};
|
|
25
|
+
const originOf = (origins, origin) => {
|
|
26
|
+
if (origin === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
if (origins.includes('*'))
|
|
29
|
+
return '*';
|
|
30
|
+
return origins.includes(origin) ? origin : null;
|
|
31
|
+
};
|
|
32
|
+
// The bytes of a request, up to one more than `max`, as they came.
|
|
33
|
+
const bodyOf = (request, max) => new Promise((resolve, reject) => {
|
|
34
|
+
const parts = [];
|
|
35
|
+
let length = 0;
|
|
36
|
+
request.on('data', (chunk) => {
|
|
37
|
+
if (length > max)
|
|
38
|
+
return;
|
|
39
|
+
parts.push(chunk);
|
|
40
|
+
length += chunk.length;
|
|
41
|
+
});
|
|
42
|
+
request.on('end', () => {
|
|
43
|
+
const out = new Uint8Array(length);
|
|
44
|
+
let at = 0;
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
out.set(part, at);
|
|
47
|
+
at += part.length;
|
|
48
|
+
}
|
|
49
|
+
resolve(out);
|
|
50
|
+
});
|
|
51
|
+
request.on('error', reject);
|
|
52
|
+
});
|
|
53
|
+
const post = async (ground, listen, request, response) => {
|
|
54
|
+
const allowed = originOf(listen.origins, request.headers.origin);
|
|
55
|
+
if (allowed !== null)
|
|
56
|
+
response.setHeader('access-control-allow-origin', allowed);
|
|
57
|
+
if (pathOf(request.url) !== listen.path)
|
|
58
|
+
return void response.writeHead(404).end();
|
|
59
|
+
if (request.method === 'OPTIONS') {
|
|
60
|
+
if (allowed !== null) {
|
|
61
|
+
response.setHeader('access-control-allow-methods', 'POST');
|
|
62
|
+
response.setHeader('access-control-allow-headers', 'content-type');
|
|
63
|
+
}
|
|
64
|
+
return void response.writeHead(204).end();
|
|
65
|
+
}
|
|
66
|
+
if (request.method !== 'POST')
|
|
67
|
+
return void response.writeHead(405, { allow: 'POST, OPTIONS' }).end();
|
|
68
|
+
const ask = readPost(await bodyOf(request, MAX_POST));
|
|
69
|
+
if (ask === null)
|
|
70
|
+
return void response.writeHead(400).end();
|
|
71
|
+
let reply = null;
|
|
72
|
+
try {
|
|
73
|
+
reply = await ground.arrive(ask.pk, ask.box);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
reply = null;
|
|
77
|
+
}
|
|
78
|
+
if (reply === null || reply.length === 0)
|
|
79
|
+
return void response.writeHead(204).end();
|
|
80
|
+
response.writeHead(200, { 'content-type': 'application/octet-stream' }).end(reply);
|
|
81
|
+
};
|
|
82
|
+
const upgrade = (ground, listen, request, socket) => {
|
|
83
|
+
const key = request.headers['sec-websocket-key'];
|
|
84
|
+
const offered = String(request.headers['sec-websocket-protocol'] ?? '')
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((p) => p.trim());
|
|
87
|
+
const fine = pathOf(request.url) === listen.path && request.headers['sec-websocket-version'] === '13' && isClientKey(key) && offered.includes('quo');
|
|
88
|
+
if (!fine) {
|
|
89
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nconnection: close\r\ncontent-length: 0\r\n\r\n');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\nconnection: Upgrade\r\nsec-websocket-accept: ${acceptKey(key)}\r\nsec-websocket-protocol: quo\r\n\r\n`);
|
|
93
|
+
const reader = new WsReader(MAX_BODY);
|
|
94
|
+
const send = (opcode, payload) => {
|
|
95
|
+
if (!socket.destroyed)
|
|
96
|
+
socket.write(frameOf(opcode, payload));
|
|
97
|
+
};
|
|
98
|
+
const close = () => {
|
|
99
|
+
send(OP.close);
|
|
100
|
+
socket.end();
|
|
101
|
+
};
|
|
102
|
+
socket.on('data', (chunk) => {
|
|
103
|
+
for (const event of reader.push(chunk)) {
|
|
104
|
+
if (event.type === 'ping')
|
|
105
|
+
send(OP.pong, event.data);
|
|
106
|
+
else if (event.type !== 'binary')
|
|
107
|
+
return close();
|
|
108
|
+
else {
|
|
109
|
+
const frame = readBody(event.data);
|
|
110
|
+
if (frame === null)
|
|
111
|
+
return close();
|
|
112
|
+
if (frame.kind !== 'ask')
|
|
113
|
+
continue;
|
|
114
|
+
void ground
|
|
115
|
+
.arrive(hex(frame.pk), frame.box)
|
|
116
|
+
.catch(() => null)
|
|
117
|
+
.then((reply) => send(OP.binary, reply === null ? nothingBody(frame.id) : replyBody(frame.id, reply)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
socket.on('error', () => socket.destroy());
|
|
122
|
+
};
|
|
123
|
+
// A listener of the web carrier for one ground.
|
|
124
|
+
export const webServe = (ground, listen) => {
|
|
125
|
+
const sockets = new Set();
|
|
126
|
+
const server = createServer((request, response) => {
|
|
127
|
+
void post(ground, listen, request, response).catch(() => {
|
|
128
|
+
if (!response.headersSent)
|
|
129
|
+
response.writeHead(400);
|
|
130
|
+
response.end();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
server.on('connection', (socket) => {
|
|
134
|
+
sockets.add(socket);
|
|
135
|
+
socket.on('close', () => sockets.delete(socket));
|
|
136
|
+
});
|
|
137
|
+
server.on('upgrade', (request, socket) => upgrade(ground, listen, request, socket));
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
server.once('error', reject);
|
|
140
|
+
server.listen(listen.port, listen.host, () => {
|
|
141
|
+
const { address, port } = server.address();
|
|
142
|
+
const authority = tcpAt(address, port).slice('tcp://'.length);
|
|
143
|
+
resolve({
|
|
144
|
+
port,
|
|
145
|
+
at: [`http://${authority}${listen.path}`, `ws://${authority}${listen.path}`],
|
|
146
|
+
close: () => {
|
|
147
|
+
for (const socket of sockets)
|
|
148
|
+
socket.destroy();
|
|
149
|
+
return new Promise((done) => server.close(() => done()));
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
// The web carrier of a Node ground: it dials, and listens through `webServe`.
|
|
156
|
+
export const webGround = webFaculty(webServe);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const OP: {
|
|
2
|
+
readonly continuation: 0;
|
|
3
|
+
readonly text: 1;
|
|
4
|
+
readonly binary: 2;
|
|
5
|
+
readonly close: 8;
|
|
6
|
+
readonly ping: 9;
|
|
7
|
+
readonly pong: 10;
|
|
8
|
+
};
|
|
9
|
+
export declare const acceptKey: (key: string) => string;
|
|
10
|
+
export declare const isClientKey: (key: unknown) => key is string;
|
|
11
|
+
export declare const frameOf: (opcode: number, payload?: Uint8Array) => Uint8Array;
|
|
12
|
+
export type WsEvent = {
|
|
13
|
+
type: 'binary';
|
|
14
|
+
data: Uint8Array;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'text';
|
|
17
|
+
} | {
|
|
18
|
+
type: 'ping';
|
|
19
|
+
data: Uint8Array;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'close';
|
|
22
|
+
} | {
|
|
23
|
+
type: 'bad';
|
|
24
|
+
};
|
|
25
|
+
export declare class WsReader {
|
|
26
|
+
#private;
|
|
27
|
+
constructor(max: number);
|
|
28
|
+
push(chunk: Uint8Array): WsEvent[];
|
|
29
|
+
}
|