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,359 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur/edge`: the edge ground, Cloudflare Workers with a Durable Object
|
|
3
|
+
// per harbor. The object's storage keeps the harbor's sealed memory and
|
|
4
|
+
// its seed, sealed under the worker's `NERVUR_SECRET`; the object answers
|
|
5
|
+
// Quo over the web, a post or a held line, since a worker takes no
|
|
6
|
+
// inbound TCP; and it asks over TCP and the web.
|
|
7
|
+
//
|
|
8
|
+
// m/<place>/<name> one sealed entry of the harbor's memory
|
|
9
|
+
// c/seed the seed, sealed under a key from the secret
|
|
10
|
+
//
|
|
11
|
+
// A keep is one storage transaction, whole or nothing. The storage and
|
|
12
|
+
// state types name only what the kit uses, so no Cloudflare package is a
|
|
13
|
+
// dependency.
|
|
14
|
+
import type { AskSpec, JsonObject } from '../being/index.ts';
|
|
15
|
+
import { Custody, Memory, type Entropy, type Module } from '../contract/index.ts';
|
|
16
|
+
import { decrypt, encrypt, hkdf, isHex, unhex, utf8 } from '../crypto/index.ts';
|
|
17
|
+
import { addGround, CarrierFaculty, Harbor, UNDIALED, type Dialed, type Ground, type GroundFaculty, type GroundProbe } from '../harbor/index.ts';
|
|
18
|
+
import { answerHeld, answerPost, askFrame, FrameReader, TCP, TCP_OPENING, WEB_OPENING, webFaculty, webPush, type WebListen, type WebListener } from '../line/index.ts';
|
|
19
|
+
import { CryptoEntropy, HeldLoader, PointerTerrain } from '../pointer/index.ts';
|
|
20
|
+
import { tcpAddress, tcpAt, type TcpAddress } from '../quo/index.ts';
|
|
21
|
+
import { ownerAsk } from '../ward/index.ts';
|
|
22
|
+
|
|
23
|
+
export const EDGE = 'edge';
|
|
24
|
+
const ENTRY = 'm/';
|
|
25
|
+
const SEED = 'c/seed';
|
|
26
|
+
// The most keys one storage call takes.
|
|
27
|
+
const BATCH = 128;
|
|
28
|
+
const SEAL_INFO = utf8('nervur edge custody');
|
|
29
|
+
const MAX_ID = 0xffff_ffff;
|
|
30
|
+
|
|
31
|
+
export interface EdgeStorage {
|
|
32
|
+
get(key: string): Promise<unknown>;
|
|
33
|
+
put(entries: Record<string, unknown>): Promise<void>;
|
|
34
|
+
delete(keys: string[]): Promise<number>;
|
|
35
|
+
list(options: { prefix: string }): Promise<Map<string, unknown>>;
|
|
36
|
+
transaction<T>(work: (txn: EdgeStorage) => Promise<T>): Promise<T>;
|
|
37
|
+
}
|
|
38
|
+
export interface EdgeState {
|
|
39
|
+
readonly storage: EdgeStorage;
|
|
40
|
+
blockConcurrencyWhile<T>(work: () => Promise<T>): Promise<T>;
|
|
41
|
+
}
|
|
42
|
+
export type EdgeEnv = { readonly NERVUR_SECRET?: string };
|
|
43
|
+
export type EdgeGiven = { readonly state: EdgeState; readonly env: EdgeEnv };
|
|
44
|
+
|
|
45
|
+
const chunks = <T>(items: readonly T[]): T[][] => Array.from({ length: Math.ceil(items.length / BATCH) }, (_, i) => items.slice(i * BATCH, (i + 1) * BATCH));
|
|
46
|
+
const bytesOf = (value: unknown): Uint8Array => new Uint8Array(value instanceof ArrayBuffer ? value : (value as Uint8Array));
|
|
47
|
+
|
|
48
|
+
// A body keeps its keys under `root`, so one object may hold more than one
|
|
49
|
+
// keeping.
|
|
50
|
+
export class EdgeMemory extends Memory {
|
|
51
|
+
readonly #storage: EdgeStorage;
|
|
52
|
+
readonly #entry: string;
|
|
53
|
+
constructor(storage: EdgeStorage, root = '') {
|
|
54
|
+
super();
|
|
55
|
+
this.#storage = storage;
|
|
56
|
+
this.#entry = `${root}${ENTRY}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async read(place: string): Promise<Map<string, Uint8Array>> {
|
|
60
|
+
const prefix = `${this.#entry}${place}/`;
|
|
61
|
+
const listed = await this.#storage.list({ prefix });
|
|
62
|
+
return new Map([...listed].map(([key, value]) => [key.slice(prefix.length), bytesOf(value).slice()]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
write(place: string, entries: ReadonlyMap<string, Uint8Array | null>): Promise<void> {
|
|
66
|
+
const put: [string, Uint8Array][] = [];
|
|
67
|
+
const gone: string[] = [];
|
|
68
|
+
for (const [name, bytes] of entries) {
|
|
69
|
+
if (bytes === null) gone.push(`${this.#entry}${place}/${name}`);
|
|
70
|
+
else put.push([`${this.#entry}${place}/${name}`, bytes.slice()]);
|
|
71
|
+
}
|
|
72
|
+
return this.#storage.transaction(async (txn) => {
|
|
73
|
+
for (const batch of chunks(put)) await txn.put(Object.fromEntries(batch));
|
|
74
|
+
for (const batch of chunks(gone)) await txn.delete(batch);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async places(): Promise<string[]> {
|
|
79
|
+
const entry = this.#entry;
|
|
80
|
+
const keys = [...(await this.#storage.list({ prefix: entry })).keys()];
|
|
81
|
+
return [...new Set(keys.map((key) => key.slice(entry.length, key.indexOf('/', entry.length))))];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async forget(place: string): Promise<void> {
|
|
85
|
+
const keys = [...(await this.#storage.list({ prefix: `${this.#entry}${place}/` })).keys()];
|
|
86
|
+
await this.#storage.transaction(async (txn) => {
|
|
87
|
+
for (const batch of chunks(keys)) await txn.delete(batch);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The seed, drawn once and kept sealed under a key the secret gives, so
|
|
93
|
+
// the storage alone opens nothing. A harbor with no secret does not stand.
|
|
94
|
+
export class EdgeCustody extends Custody {
|
|
95
|
+
readonly #storage: EdgeStorage;
|
|
96
|
+
readonly #secret: string | undefined;
|
|
97
|
+
readonly #entropy: Entropy;
|
|
98
|
+
readonly #key: string;
|
|
99
|
+
constructor(storage: EdgeStorage, secret: string | undefined, entropy: Entropy = new CryptoEntropy(), root = '') {
|
|
100
|
+
super();
|
|
101
|
+
this.#storage = storage;
|
|
102
|
+
this.#secret = secret;
|
|
103
|
+
this.#entropy = entropy;
|
|
104
|
+
this.#key = `${root}${SEED}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async seed(): Promise<Uint8Array> {
|
|
108
|
+
if (!this.#secret) throw new Error('the worker holds no NERVUR_SECRET');
|
|
109
|
+
const key = await hkdf(utf8(this.#secret), SEAL_INFO, 32);
|
|
110
|
+
return this.#storage.transaction(async (txn) => {
|
|
111
|
+
const kept = (await txn.get(this.#key)) as { nonce: Uint8Array; sealed: Uint8Array } | undefined;
|
|
112
|
+
if (kept) {
|
|
113
|
+
const seed = await decrypt(key, bytesOf(kept.nonce), new Uint8Array(0), bytesOf(kept.sealed));
|
|
114
|
+
if (seed === null) throw new Error('the seed does not open under this NERVUR_SECRET');
|
|
115
|
+
return seed;
|
|
116
|
+
}
|
|
117
|
+
const seed = this.#entropy.draw(32);
|
|
118
|
+
const nonce = this.#entropy.draw(12);
|
|
119
|
+
await txn.put({ [this.#key]: { nonce, sealed: await encrypt(key, nonce, new Uint8Array(0), seed) } });
|
|
120
|
+
return seed.slice();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// One TCP line opened by the worker's `connect`, many asks on it.
|
|
126
|
+
type EdgeSocket = {
|
|
127
|
+
readonly opened: Promise<unknown>;
|
|
128
|
+
readonly closed: Promise<unknown>;
|
|
129
|
+
readonly readable: ReadableStream<Uint8Array>;
|
|
130
|
+
readonly writable: WritableStream<Uint8Array>;
|
|
131
|
+
close(): Promise<void>;
|
|
132
|
+
};
|
|
133
|
+
type Connect = (address: { hostname: string; port: number }) => EdgeSocket;
|
|
134
|
+
|
|
135
|
+
class EdgeLine {
|
|
136
|
+
readonly #socket: EdgeSocket;
|
|
137
|
+
readonly #writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
138
|
+
readonly #waiting = new Map<number, (box: Uint8Array | null) => void>();
|
|
139
|
+
#next = 1;
|
|
140
|
+
#closed = false;
|
|
141
|
+
|
|
142
|
+
constructor(socket: EdgeSocket, onClose: () => void) {
|
|
143
|
+
this.#socket = socket;
|
|
144
|
+
this.#writer = socket.writable.getWriter();
|
|
145
|
+
const reader = new FrameReader();
|
|
146
|
+
void (async () => {
|
|
147
|
+
try {
|
|
148
|
+
for await (const chunk of socket.readable) {
|
|
149
|
+
for (const frame of reader.push(new Uint8Array(chunk))) {
|
|
150
|
+
if (frame.kind === 'ask') continue;
|
|
151
|
+
const done = this.#waiting.get(frame.id);
|
|
152
|
+
this.#waiting.delete(frame.id);
|
|
153
|
+
done?.(frame.kind === 'reply' ? frame.box : null);
|
|
154
|
+
}
|
|
155
|
+
if (reader.bad) break;
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// A line that broke answers nothing more.
|
|
159
|
+
}
|
|
160
|
+
this.#closed = true;
|
|
161
|
+
onClose();
|
|
162
|
+
for (const done of this.#waiting.values()) done(null);
|
|
163
|
+
this.#waiting.clear();
|
|
164
|
+
await socket.close().catch(() => undefined);
|
|
165
|
+
})();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
ask(pk: Uint8Array, box: Uint8Array): Promise<Uint8Array | null> {
|
|
169
|
+
if (this.#closed) return Promise.resolve(null);
|
|
170
|
+
const id = this.#next;
|
|
171
|
+
this.#next = this.#next === MAX_ID ? 1 : this.#next + 1;
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
this.#waiting.set(id, resolve);
|
|
174
|
+
this.#writer.write(askFrame(id, pk, box)).catch(() => resolve(null));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
close(): Promise<void> {
|
|
179
|
+
this.#closed = true;
|
|
180
|
+
return this.#socket.close().catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sockets = 'cloudflare:sockets';
|
|
185
|
+
const connector = async (): Promise<Connect | null> => {
|
|
186
|
+
try {
|
|
187
|
+
return ((await import(/* @vite-ignore */ sockets)) as { connect: Connect }).connect;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Quo over TCP from a worker: it dials, and takes no inbound TCP.
|
|
194
|
+
export const edgeTcp: GroundFaculty = () =>
|
|
195
|
+
class EdgeTcp extends CarrierFaculty {
|
|
196
|
+
static override readonly kind: string = TCP;
|
|
197
|
+
static override asks: Record<string, AskSpec> = {
|
|
198
|
+
listen: ownerAsk('an edge takes no inbound TCP'),
|
|
199
|
+
deaf: ownerAsk('listen nowhere'),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
readonly #lines = new Map<string, Promise<EdgeLine | null>>();
|
|
203
|
+
|
|
204
|
+
accepts(address: string): boolean {
|
|
205
|
+
return tcpAddress(address) !== null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async dial(address: string, pk: string, bytes: Uint8Array): Promise<Dialed> {
|
|
209
|
+
const at = tcpAddress(address);
|
|
210
|
+
if (at === null || !isHex(pk, 64)) return UNDIALED;
|
|
211
|
+
const line = await this.#line(at);
|
|
212
|
+
return line ? line.ask(unhex(pk), bytes) : UNDIALED;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#line({ host, port }: TcpAddress): Promise<EdgeLine | null> {
|
|
216
|
+
const at = tcpAt(host, port);
|
|
217
|
+
let line = this.#lines.get(at);
|
|
218
|
+
if (!line) {
|
|
219
|
+
line = (async () => {
|
|
220
|
+
const connect = await connector();
|
|
221
|
+
if (!connect) return null;
|
|
222
|
+
try {
|
|
223
|
+
const socket = connect({ hostname: host, port });
|
|
224
|
+
await socket.opened;
|
|
225
|
+
return new EdgeLine(socket, () => this.#lines.delete(at));
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
this.#lines.set(at, line);
|
|
231
|
+
void line.then((open) => open ?? this.#lines.delete(at));
|
|
232
|
+
}
|
|
233
|
+
return line;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
override async sleep(): Promise<void> {
|
|
237
|
+
const lines = [...this.#lines.values()];
|
|
238
|
+
this.#lines.clear();
|
|
239
|
+
for (const line of await Promise.all(lines)) await line?.close();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
listen(): JsonObject {
|
|
243
|
+
return { error: 'an edge takes no inbound TCP' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
deaf(): JsonObject {
|
|
247
|
+
return { deaf: true };
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Where the edge's web carrier listens: the object's own requests, at the
|
|
252
|
+
// host and path its cells name. A port of 0 is the edge's own https.
|
|
253
|
+
const edgeServe = (ground: Ground, listen: WebListen): Promise<WebListener> => {
|
|
254
|
+
const terrain = (ground as unknown as { terrain?: unknown }).terrain;
|
|
255
|
+
if (!(terrain instanceof EdgeTerrain)) return Promise.reject(new Error('this harbor stands on no edge'));
|
|
256
|
+
terrain.listening = listen;
|
|
257
|
+
const authority = listen.port === 0 ? listen.host : `${tcpAt(listen.host, listen.port).slice('tcp://'.length)}`;
|
|
258
|
+
const [post, held] = listen.port === 0 ? ['https', 'wss'] : ['http', 'ws'];
|
|
259
|
+
return Promise.resolve({
|
|
260
|
+
port: listen.port,
|
|
261
|
+
at: [`${post}://${authority}${listen.path}`, `${held}://${authority}${listen.path}`],
|
|
262
|
+
close: () => {
|
|
263
|
+
if (terrain.listening === listen) terrain.listening = undefined;
|
|
264
|
+
return Promise.resolve();
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// What a worker hands a WebSocket upgrade.
|
|
270
|
+
type EdgePair = { 0: EdgeWebSocket; 1: EdgeWebSocket };
|
|
271
|
+
type EdgeWebSocket = WebSocket & { accept(): void };
|
|
272
|
+
|
|
273
|
+
export type EdgeParts = { readonly given: EdgeGiven; readonly modules?: readonly Module[] };
|
|
274
|
+
|
|
275
|
+
export class EdgeTerrain extends PointerTerrain {
|
|
276
|
+
readonly given: EdgeGiven;
|
|
277
|
+
// Where the web carrier listens now, as the object answers requests.
|
|
278
|
+
listening: WebListen | undefined;
|
|
279
|
+
#harbor: Harbor | undefined;
|
|
280
|
+
|
|
281
|
+
constructor(parts: EdgeParts) {
|
|
282
|
+
const entropy = new CryptoEntropy();
|
|
283
|
+
const { storage } = parts.given.state;
|
|
284
|
+
super({
|
|
285
|
+
entropy,
|
|
286
|
+
loader: new HeldLoader(parts.modules ?? []),
|
|
287
|
+
custody: new EdgeCustody(storage, parts.given.env.NERVUR_SECRET, entropy),
|
|
288
|
+
memory: new EdgeMemory(storage),
|
|
289
|
+
faculties: [edgeTcp, webFaculty(edgeServe), webPush],
|
|
290
|
+
opens: [TCP_OPENING, WEB_OPENING],
|
|
291
|
+
});
|
|
292
|
+
this.given = parts.given;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
override stand(harbor: Harbor): Promise<void> {
|
|
296
|
+
this.#harbor = harbor;
|
|
297
|
+
return Promise.resolve();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// One request to the object: a post, or a held line opened, where the
|
|
301
|
+
// web carrier listens.
|
|
302
|
+
answer(request: Request): Promise<Response> {
|
|
303
|
+
const listen = this.listening;
|
|
304
|
+
const harbor = this.#harbor;
|
|
305
|
+
if (!listen || !harbor) return Promise.resolve(new Response(null, { status: 404 }));
|
|
306
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') return answerPost(harbor, listen, request);
|
|
307
|
+
const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map((p) => p.trim());
|
|
308
|
+
if (new URL(request.url).pathname !== listen.path || !offered.includes('quo')) return Promise.resolve(new Response(null, { status: 400 }));
|
|
309
|
+
const Pair = (globalThis as unknown as { WebSocketPair: new () => EdgePair }).WebSocketPair;
|
|
310
|
+
const pair = new Pair();
|
|
311
|
+
const [client, server] = [pair[0], pair[1]];
|
|
312
|
+
server.accept();
|
|
313
|
+
const side = { send: (body: Uint8Array) => server.send(new Uint8Array(body)), close: () => server.close() };
|
|
314
|
+
server.addEventListener('message', (event: MessageEvent) => answerHeld(harbor, side, typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer)));
|
|
315
|
+
return Promise.resolve(new Response(null, { status: 101, webSocket: client, headers: { 'sec-websocket-protocol': 'quo' } } as ResponseInit));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const isGiven = (value: unknown): value is EdgeGiven => {
|
|
320
|
+
const given = value as Partial<EdgeGiven> | null;
|
|
321
|
+
return typeof given?.state?.storage?.transaction === 'function' && typeof given.env === 'object';
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// A harbor handed a Durable Object's state and environment.
|
|
325
|
+
export const edgeGround: GroundProbe = {
|
|
326
|
+
name: EDGE,
|
|
327
|
+
fits: ({ given }) => isGiven(given),
|
|
328
|
+
terrain: ({ given, modules }) => new EdgeTerrain({ given: given as EdgeGiven, ...(modules ? { modules } : {}) }),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// A Durable Object class whose every instance is one harbor: opened once
|
|
332
|
+
// per object, with `stood` run on it as the worker's own code, and every
|
|
333
|
+
// request it receives answered by the harbor's web carrier.
|
|
334
|
+
export type HarborObjectClass = new (state: EdgeState, env: EdgeEnv) => { readonly harbor: Promise<Harbor>; fetch(request: Request): Promise<Response> };
|
|
335
|
+
export const harborObject = (modules: readonly Module[], stood?: (harbor: Harbor) => Promise<void>): HarborObjectClass =>
|
|
336
|
+
class HarborObject {
|
|
337
|
+
readonly #opening: Promise<Harbor>;
|
|
338
|
+
|
|
339
|
+
// The object's harbor, once it stands.
|
|
340
|
+
get harbor(): Promise<Harbor> {
|
|
341
|
+
return this.#opening;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
constructor(state: EdgeState, env: EdgeEnv) {
|
|
345
|
+
addGround(edgeGround);
|
|
346
|
+
// Opened inside the object's block, so no request reaches it half
|
|
347
|
+
// open and its storage answers the opening.
|
|
348
|
+
this.#opening = state.blockConcurrencyWhile(async () => {
|
|
349
|
+
const harbor = await Harbor.open({ modules, given: { state, env } });
|
|
350
|
+
await stood?.(harbor);
|
|
351
|
+
return harbor;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async fetch(request: Request): Promise<Response> {
|
|
356
|
+
const harbor = await this.#opening;
|
|
357
|
+
return (harbor.terrain as EdgeTerrain).answer(request);
|
|
358
|
+
}
|
|
359
|
+
};
|
package/src/edge.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur` as a Workers bundle reads it: the main entry, with the edge
|
|
3
|
+
// ground added, so `Harbor.open({ given: { state, env } })` in a Durable
|
|
4
|
+
// Object keeps a harbor in its storage.
|
|
5
|
+
import { edgeGround } from './edge/index.ts';
|
|
6
|
+
import { addGround } from './harbor/index.ts';
|
|
7
|
+
|
|
8
|
+
export * from './index.ts';
|
|
9
|
+
|
|
10
|
+
addGround(edgeGround);
|
package/src/folder/index.ts
CHANGED
|
@@ -1,109 +1,106 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// A harbor on a disk, for local proof: `nervur/folder`,
|
|
2
|
+
// A harbor on a disk, for local proof: `nervur/folder`, an entry that
|
|
3
3
|
// names a platform.
|
|
4
4
|
//
|
|
5
|
-
// <root
|
|
6
|
-
// <root>/<
|
|
7
|
-
// <root>/<
|
|
5
|
+
// <root>/seed the harbor's seed, as hex
|
|
6
|
+
// <root>/<place>/<entry> one sealed entry of the harbor's memory
|
|
7
|
+
// <root>/<place>/keep a keep of that place, until it is applied
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Places and entries are the hex names the kit chose, so no name reaches
|
|
10
|
+
// outside its folder, and the disk shows no ward's name. A file is written
|
|
11
|
+
// whole, beside itself and then renamed over.
|
|
12
|
+
//
|
|
13
|
+
// A keep is whole, by its own file: the whole keep is written under `keep`
|
|
14
|
+
// and renamed over, then its entries are written and removed one by one,
|
|
15
|
+
// then `keep` goes. A read applies a `keep` it finds first, and the same
|
|
16
|
+
// bytes written twice leave the same place, so a keep that a crash cut
|
|
17
|
+
// short finishes at the next read. A place read before its `keep` landed
|
|
18
|
+
// has none of it. `keep` is no hex name, so it is never an entry.
|
|
19
|
+
//
|
|
20
|
+
// The seed is a plain file: custody that seals it is a terrain's.
|
|
13
21
|
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
14
22
|
import { join } from 'node:path';
|
|
15
23
|
import { hex, isHex, unhex } from '../crypto/index.ts';
|
|
16
|
-
import { Custody, Memory, type Entropy
|
|
17
|
-
import { HEAD_ROW } from '../ward/index.ts';
|
|
18
|
-
|
|
19
|
-
const HEAD = 'ward.json';
|
|
20
|
-
const BEING = 'being.json';
|
|
21
|
-
|
|
22
|
-
export const folderName = (name: string): string => encodeURIComponent(name).replaceAll('.', '%2E').replaceAll('*', '%2A') || '%';
|
|
23
|
-
const nameOf = (folder: string): string => (folder === '%' ? '' : decodeURIComponent(folder));
|
|
24
|
+
import { Custody, Memory, type Entropy } from '../contract/index.ts';
|
|
24
25
|
|
|
25
26
|
const missing = (e: unknown): boolean => (e as { code?: string }).code === 'ENOENT';
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
28
|
+
// One keep as a place's `keep` file holds it: every entry's bytes as hex,
|
|
29
|
+
// or null where it goes.
|
|
30
|
+
export type Keep = Record<string, string | null>;
|
|
31
|
+
const KEEP = 'keep';
|
|
32
|
+
|
|
33
|
+
const named = (name: string): string => {
|
|
34
|
+
if (!isHex(name) || name === '') throw new Error(`${name} is not a name the kit chose`);
|
|
35
|
+
return name;
|
|
34
36
|
};
|
|
35
37
|
|
|
36
|
-
const writeWhole = async (path: string,
|
|
37
|
-
await writeFile(`${path}.next`,
|
|
38
|
+
const writeWhole = async (path: string, bytes: Uint8Array | string): Promise<void> => {
|
|
39
|
+
await writeFile(`${path}.next`, bytes, { mode: 0o600 });
|
|
38
40
|
await rename(`${path}.next`, path);
|
|
39
41
|
};
|
|
40
42
|
|
|
41
|
-
class
|
|
42
|
-
readonly
|
|
43
|
-
|
|
44
|
-
readonly #told = new Set<string>();
|
|
45
|
-
#line: Promise<boolean> = Promise.resolve(true);
|
|
46
|
-
|
|
47
|
-
constructor(dir: string, rows: Record<string, unknown>) {
|
|
48
|
-
this.#dir = dir;
|
|
49
|
-
this.rows = rows;
|
|
50
|
-
}
|
|
43
|
+
export class FolderMemory extends Memory {
|
|
44
|
+
readonly root: string;
|
|
45
|
+
#line: Promise<unknown> = Promise.resolve();
|
|
51
46
|
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
constructor(root: string) {
|
|
48
|
+
super();
|
|
49
|
+
this.root = root;
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
// The entries as the disk holds them now, a keep left unapplied first.
|
|
53
|
+
// A file that is not an entry, one still being written among them, is
|
|
54
|
+
// not read.
|
|
55
|
+
async read(place: string): Promise<Map<string, Uint8Array>> {
|
|
56
|
+
const dir = join(this.root, named(place));
|
|
57
|
+
await this.apply(dir);
|
|
58
|
+
const out = new Map<string, Uint8Array>();
|
|
59
|
+
const files = await readdir(dir, { withFileTypes: true }).catch((e: unknown) => (missing(e) ? [] : Promise.reject(e)));
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
if (!file.isFile() || !isHex(file.name) || file.name === '') continue;
|
|
62
|
+
out.set(file.name, new Uint8Array(await readFile(join(dir, file.name))));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const dir = join(this.#dir, folderName(row));
|
|
73
|
-
if (!Object.hasOwn(beings, row)) {
|
|
74
|
-
await rm(dir, { recursive: true, force: true });
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
67
|
+
// One keep at a time, written whole under `keep` before any entry moves.
|
|
68
|
+
async write(place: string, entries: ReadonlyMap<string, Uint8Array | null>): Promise<void> {
|
|
69
|
+
const dir = join(this.root, named(place));
|
|
70
|
+
const keep: Keep = {};
|
|
71
|
+
for (const [name, bytes] of entries) keep[named(name)] = bytes === null ? null : hex(bytes);
|
|
72
|
+
const kept = this.#line.then(async () => {
|
|
77
73
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
78
|
-
await writeWhole(join(dir,
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
await writeWhole(join(dir, KEEP), JSON.stringify(keep));
|
|
75
|
+
await this.apply(dir, keep);
|
|
76
|
+
});
|
|
77
|
+
this.#line = kept.catch(() => undefined);
|
|
78
|
+
await kept;
|
|
81
79
|
}
|
|
82
|
-
}
|
|
83
80
|
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
// Every folder of the root that is a place.
|
|
82
|
+
async places(): Promise<string[]> {
|
|
83
|
+
const files = await readdir(this.root, { withFileTypes: true }).catch((e: unknown) => (missing(e) ? [] : Promise.reject(e)));
|
|
84
|
+
return files.filter((f) => f.isDirectory() && isHex(f.name) && f.name !== '').map((f) => f.name);
|
|
85
|
+
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
this.root = root;
|
|
87
|
+
forget(place: string): Promise<void> {
|
|
88
|
+
return rm(join(this.root, named(place)), { recursive: true, force: true });
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
// The
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!entry.isDirectory()) continue;
|
|
102
|
-
const row = await readJson(join(dir, entry.name, BEING));
|
|
103
|
-
if (row !== undefined) beings[nameOf(entry.name)] = row;
|
|
91
|
+
// The keep a folder holds, applied and then gone. Writing the same bytes
|
|
92
|
+
// again leaves the same place, so applying twice is applying once.
|
|
93
|
+
protected async apply(dir: string, keep?: Keep): Promise<void> {
|
|
94
|
+
const found = keep ?? (await readFile(join(dir, KEEP), 'utf8').then((text) => JSON.parse(text) as Keep, () => null));
|
|
95
|
+
if (found === null) return;
|
|
96
|
+
for (const [name, written] of Object.entries(found)) {
|
|
97
|
+
const path = join(dir, named(name));
|
|
98
|
+
if (written === null) await rm(path, { force: true });
|
|
99
|
+
else await writeWhole(path, unhex(written));
|
|
104
100
|
}
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
await rm(join(dir, KEEP), { force: true });
|
|
102
|
+
const left = await readdir(dir).catch((e: unknown) => (missing(e) ? [] : Promise.reject(e)));
|
|
103
|
+
if (left.length === 0) await rm(dir, { recursive: true, force: true });
|
|
107
104
|
}
|
|
108
105
|
}
|
|
109
106
|
|
|
@@ -117,9 +114,10 @@ export class FolderCustody extends Custody {
|
|
|
117
114
|
this.#entropy = entropy;
|
|
118
115
|
}
|
|
119
116
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
// The seed the folder holds, or a drawn one written there the first time.
|
|
118
|
+
// Two first asks at once keep the one written first.
|
|
119
|
+
async seed(): Promise<Uint8Array> {
|
|
120
|
+
const path = join(this.root, 'seed');
|
|
123
121
|
const kept = await readFile(path, 'utf8').catch((e: unknown) => (missing(e) ? undefined : Promise.reject(e)));
|
|
124
122
|
if (kept !== undefined) {
|
|
125
123
|
const text = kept.trim();
|
|
@@ -127,8 +125,13 @@ export class FolderCustody extends Custody {
|
|
|
127
125
|
return unhex(text);
|
|
128
126
|
}
|
|
129
127
|
const seed = this.#entropy.draw(32);
|
|
130
|
-
await mkdir(
|
|
131
|
-
|
|
128
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
129
|
+
try {
|
|
130
|
+
await writeFile(path, `${hex(seed)}\n`, { mode: 0o600, flag: 'wx' });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if ((e as { code?: string }).code === 'EEXIST') return this.seed();
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
132
135
|
return seed;
|
|
133
136
|
}
|
|
134
137
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A carrier is a faculty. It speaks the schemes of the addresses it dials,
|
|
3
|
+
// dials an address when the harbor carries a box there, and listens where
|
|
4
|
+
// its cells say, handing each box it hears to the harbor. The harbor keeps
|
|
5
|
+
// every route and walks it in order, handing each address to the carrier
|
|
6
|
+
// that speaks its scheme.
|
|
7
|
+
//
|
|
8
|
+
// A ground ships its carriers as classes the terrain names, each made for
|
|
9
|
+
// one harbor, so the class holds the harbor it hands boxes to. They are
|
|
10
|
+
// the kit's, of kinds under `org.nervur.`, and the dock opens them as
|
|
11
|
+
// faculties, at genesis where the terrain says, and on the root's `open`.
|
|
12
|
+
import { Faculty, type BeingClass } from '../being/index.ts';
|
|
13
|
+
|
|
14
|
+
// What a carrier hands a box it heard to.
|
|
15
|
+
export interface Ground {
|
|
16
|
+
arrive(pk: string, bytes: Uint8Array): Promise<Uint8Array | null>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A carrier class of a ground, made for one harbor.
|
|
20
|
+
export type GroundFaculty = (ground: Ground) => BeingClass;
|
|
21
|
+
|
|
22
|
+
// Beside a reply and nothing, a dial answers `undialed` when the address
|
|
23
|
+
// could not be reached at all, so the harbor tries the next one. A box a
|
|
24
|
+
// line took may have been heard, so after it no other address is tried.
|
|
25
|
+
export const UNDIALED = Symbol('undialed');
|
|
26
|
+
export type Dialed = Uint8Array | null | typeof UNDIALED;
|
|
27
|
+
|
|
28
|
+
export abstract class CarrierFaculty extends Faculty {
|
|
29
|
+
static override readonly kind: string = 'org.nervur.carrier';
|
|
30
|
+
|
|
31
|
+
// Whether this carrier dials an address, written as its carrier writes it.
|
|
32
|
+
abstract accepts(address: string): boolean;
|
|
33
|
+
abstract dial(address: string, pk: string, bytes: Uint8Array): Promise<Dialed>;
|
|
34
|
+
|
|
35
|
+
// The addresses it listens at now.
|
|
36
|
+
listening(): string[] {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether it answers for a ward pk no door of this harbor holds, as a
|
|
41
|
+
// relay does, and its answer.
|
|
42
|
+
relays(_pk: string): boolean {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
relay(_pk: string, _bytes: Uint8Array): Promise<Uint8Array | null> {
|
|
46
|
+
return Promise.resolve(null);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Once the harbor has unpacked, and once before it closes.
|
|
50
|
+
awake(): Promise<void> {
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
sleep(): Promise<void> {
|
|
54
|
+
return Promise.resolve();
|
|
55
|
+
}
|
|
56
|
+
}
|