nervur 0.20.2 → 0.20.4
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 +60 -15
- package/dist/cli/command.d.ts +1 -1
- package/dist/cli/command.js +30 -9
- package/dist/cli/harbor.d.ts +2 -5
- package/dist/cli/harbor.js +28 -36
- package/dist/cli/main.js +0 -0
- package/dist/edge/index.d.ts +17 -3
- package/dist/edge/index.js +40 -12
- package/dist/harbor/index.d.ts +1 -1
- package/dist/harbor/index.js +1 -1
- package/dist/harbor/package.d.ts +8 -0
- package/dist/harbor/package.js +37 -1
- package/dist/harbor/relay.js +20 -1
- package/dist/node/index.d.ts +5 -1
- package/dist/node/index.js +77 -13
- package/package.json +3 -2
- package/src/cli/command.ts +27 -9
- package/src/cli/harbor.ts +26 -35
- package/src/edge/index.ts +76 -22
- package/src/harbor/index.ts +1 -1
- package/src/harbor/package.ts +39 -1
- package/src/harbor/relay.ts +20 -1
- package/src/node/index.ts +77 -13
package/dist/node/index.js
CHANGED
|
@@ -21,10 +21,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
21
21
|
//
|
|
22
22
|
// One run holds a folder: a terrain refuses a folder another run serves.
|
|
23
23
|
import { createHash } from 'node:crypto';
|
|
24
|
-
import { chmod, rm } from 'node:fs/promises';
|
|
24
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
25
26
|
import { connect, createServer } from 'node:net';
|
|
26
27
|
import { homedir, tmpdir } from 'node:os';
|
|
27
|
-
import { join, resolve } from 'node:path';
|
|
28
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
29
|
import { pathToFileURL } from 'node:url';
|
|
29
30
|
import { Loader } from '../contract/index.js';
|
|
30
31
|
import { FolderCustody, FolderMemory } from '../folder/index.js';
|
|
@@ -47,15 +48,26 @@ export const socketOf = (dir) => {
|
|
|
47
48
|
return beside;
|
|
48
49
|
return join(tmpdir(), `nervur-${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 32)}.sock`);
|
|
49
50
|
};
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
//
|
|
51
|
+
// A specifier that names a file rather than a package.
|
|
52
|
+
export const isFile = (from) => /^(\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(from) || /\.(m?[jt]s|c[jt]s)$/.test(from);
|
|
53
|
+
// Code from a file or an installed package: a module that exports
|
|
54
|
+
// `module`, `version` and `classes`, found again by where it was found, a
|
|
55
|
+
// file by its absolute path and a package by its name, resolved from each
|
|
56
|
+
// folder of `bases` in order. What cannot be imported, or exports no
|
|
57
|
+
// module, is none.
|
|
53
58
|
export class ImportLoader extends Loader {
|
|
59
|
+
#bases;
|
|
60
|
+
constructor(bases = [process.cwd()]) {
|
|
61
|
+
super();
|
|
62
|
+
this.#bases = bases;
|
|
63
|
+
}
|
|
54
64
|
async load(from) {
|
|
55
|
-
const
|
|
65
|
+
const found = isFile(from) ? resolve(from) : this.#package(from);
|
|
66
|
+
if (found === null)
|
|
67
|
+
return null;
|
|
56
68
|
let exported;
|
|
57
69
|
try {
|
|
58
|
-
exported = (await import(__rewriteRelativeImportExtension(pathToFileURL(
|
|
70
|
+
exported = (await import(__rewriteRelativeImportExtension(pathToFileURL(found).href)));
|
|
59
71
|
}
|
|
60
72
|
catch {
|
|
61
73
|
return null;
|
|
@@ -63,20 +75,72 @@ export class ImportLoader extends Loader {
|
|
|
63
75
|
const { module, version, classes } = exported;
|
|
64
76
|
if (typeof module !== 'string' || typeof version !== 'string' || !Array.isArray(classes))
|
|
65
77
|
return null;
|
|
66
|
-
return { module, version, classes, from:
|
|
78
|
+
return { module, version, classes, from: isFile(from) ? found : from };
|
|
79
|
+
}
|
|
80
|
+
#package(name) {
|
|
81
|
+
for (const base of this.#bases) {
|
|
82
|
+
try {
|
|
83
|
+
return createRequire(join(resolve(base), 'noop.js')).resolve(name);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Not installed from here; the next base may hold it.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
67
90
|
}
|
|
68
91
|
}
|
|
69
|
-
//
|
|
92
|
+
// A module by its id, the name it declares, so the catalogue's cells name
|
|
93
|
+
// code the same way on every ground. `add` may name a module by its id, a
|
|
94
|
+
// file, or an installed package, and the ground works out which. The
|
|
95
|
+
// modules a harbor is handed are found by their id first. Where else an id
|
|
96
|
+
// is found, a file's path or a package's name, is this ground's own
|
|
97
|
+
// knowledge, kept beside the harbor in `<dir>/modules.json` and never in
|
|
98
|
+
// the sealed package, since another ground finds the same id in its own
|
|
99
|
+
// bundle. Packages resolve from where the command runs, then from the
|
|
100
|
+
// harbor's folder.
|
|
70
101
|
export class NodeLoader extends ImportLoader {
|
|
71
102
|
#held;
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
#map;
|
|
104
|
+
constructor(modules = [], dir) {
|
|
105
|
+
super(dir === undefined ? [process.cwd()] : [process.cwd(), dir]);
|
|
74
106
|
this.#held = new HeldLoader(modules);
|
|
107
|
+
this.#map = dir === undefined ? undefined : join(dir, MODULES);
|
|
75
108
|
}
|
|
76
109
|
async load(from) {
|
|
77
|
-
|
|
110
|
+
const held = await this.#held.load(from);
|
|
111
|
+
if (held)
|
|
112
|
+
return held;
|
|
113
|
+
const known = await this.#known();
|
|
114
|
+
const where = known[from];
|
|
115
|
+
if (where !== undefined) {
|
|
116
|
+
const loaded = await super.load(where);
|
|
117
|
+
return loaded?.module === from ? { ...loaded, from } : null;
|
|
118
|
+
}
|
|
119
|
+
const loaded = await super.load(from);
|
|
120
|
+
if (!loaded)
|
|
121
|
+
return null;
|
|
122
|
+
if (this.#map && known[loaded.module] !== loaded.from)
|
|
123
|
+
await this.#keep({ ...known, [loaded.module]: loaded.from });
|
|
124
|
+
return { ...loaded, from: loaded.module };
|
|
125
|
+
}
|
|
126
|
+
async #known() {
|
|
127
|
+
if (!this.#map)
|
|
128
|
+
return {};
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(await readFile(this.#map, 'utf8'));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async #keep(files) {
|
|
137
|
+
await mkdir(dirname(this.#map), { recursive: true, mode: 0o700 });
|
|
138
|
+
await writeFile(`${this.#map}.new`, `${JSON.stringify(files, null, 2)}\n`, { mode: 0o600 });
|
|
139
|
+
await rename(`${this.#map}.new`, this.#map);
|
|
78
140
|
}
|
|
79
141
|
}
|
|
142
|
+
// Where a Node harbor's module ids find their files.
|
|
143
|
+
export const MODULES = 'modules.json';
|
|
80
144
|
// One root request over a served harbor's socket, or undefined where none
|
|
81
145
|
// is served.
|
|
82
146
|
export const askServed = (dir, request) => new Promise((done, fail) => {
|
|
@@ -101,7 +165,7 @@ export class NodeTerrain extends PointerTerrain {
|
|
|
101
165
|
const entropy = new CryptoEntropy();
|
|
102
166
|
super({
|
|
103
167
|
entropy,
|
|
104
|
-
loader: new NodeLoader(parts.modules),
|
|
168
|
+
loader: new NodeLoader(parts.modules, parts.dir),
|
|
105
169
|
custody: new FolderCustody(parts.dir, entropy),
|
|
106
170
|
memory: new FolderMemory(parts.dir),
|
|
107
171
|
faculties: [tcpFaculty, webGround, webPush],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nervur",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.4",
|
|
4
4
|
"description": "Nervur's kit of Quo: an object asks another object and gets an answer, without knowing where it is.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quo",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"build": "tsc -p tsconfig.build.json",
|
|
53
53
|
"check": "tsc -p tsconfig.json && npm run build && node ../../test.mjs \"test/unit/*.test.ts\" \"test/e2e/*.test.ts\"",
|
|
54
54
|
"deep": "node ../../test.mjs \"test/terrain/*.test.ts\"",
|
|
55
|
-
"cover": "node
|
|
55
|
+
"cover": "node test/cover.mjs",
|
|
56
|
+
"mutate": "node test/mutate.mjs",
|
|
56
57
|
"prepublishOnly": "test \"$NERVUR_GATED\" = 1 || { echo 'a publish is /release, from the root, on the human'\"'\"'s word' >&2; exit 1; }"
|
|
57
58
|
},
|
|
58
59
|
"engines": {
|
package/src/cli/command.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// invitation for whoever made it
|
|
8
8
|
// nervur serve [--dir D] [--host H] [--port P] the harbor served: Quo over TCP,
|
|
9
9
|
// and the root line on its socket
|
|
10
|
-
// nervur module add <file>
|
|
10
|
+
// nervur module add <file | package | id> a module the catalogue runs, kept
|
|
11
|
+
// by the id it declares
|
|
11
12
|
// nervur module remove <module> a module it runs no more
|
|
12
13
|
// nervur modules what the catalogue runs
|
|
13
14
|
// nervur census [--ward W] the empty ask
|
|
@@ -18,6 +19,10 @@
|
|
|
18
19
|
// --with-seed the seed goes too
|
|
19
20
|
// nervur import <file> a carried harbor into an empty
|
|
20
21
|
// folder, `-` being this input
|
|
22
|
+
// nervur edge pack <file> the harbor for an edge: its seed
|
|
23
|
+
// printed, for the worker's
|
|
24
|
+
// NERVUR_SEED, and its sealed
|
|
25
|
+
// package written to the file
|
|
21
26
|
// nervur open <key> <kind> a faculty stands in the box ward
|
|
22
27
|
// nervur host <ward> [--seed S] a hosted ward stands
|
|
23
28
|
// nervur route <ward pk> <address... | none> where a ward is reached, in order
|
|
@@ -45,10 +50,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
45
50
|
import { join, resolve } from 'node:path';
|
|
46
51
|
import type { JsonObject } from '../being/index.ts';
|
|
47
52
|
import type { RootRequest } from '../harbor/index.ts';
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
53
|
+
import { WEB_OPENING } from '../line/index.ts';
|
|
54
|
+
import { harborDir, isFile } from '../node/index.ts';
|
|
55
|
+
import { readAt, webAddress } from '../quo/index.ts';
|
|
56
|
+
import { carry, isServed, land, open, pack, request, serve } from './harbor.ts';
|
|
50
57
|
|
|
51
|
-
const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import> [args] [--dir D] [--ward W] [--via V] [--seed S] [--with-seed] [--host H] [--port P]';
|
|
58
|
+
const USAGE = 'usage: nervur <init|serve|module|modules|census|sweep|open|host|route|reach|boot|unboot|public|invite|ask|own|disown|pilot|export|import|edge> [args] [--dir D] [--ward W] [--via V] [--seed S] [--with-seed] [--host H] [--port P]';
|
|
52
59
|
const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
|
|
53
60
|
// The one flag that stands alone, since what it names is a yes.
|
|
54
61
|
const ALONE = 'with-seed';
|
|
@@ -89,7 +96,7 @@ export const requestOf = (command: string, words: string[], flags: Record<string
|
|
|
89
96
|
switch (command) {
|
|
90
97
|
case 'module': {
|
|
91
98
|
const [verb, what] = need(2);
|
|
92
|
-
if (verb === 'add') return catalogue('add', { from: resolve(what!) });
|
|
99
|
+
if (verb === 'add') return catalogue('add', { from: isFile(what!) ? resolve(what!) : what! });
|
|
93
100
|
if (verb === 'remove') return catalogue('remove', { module: what! });
|
|
94
101
|
throw new Error('module is add or remove');
|
|
95
102
|
}
|
|
@@ -174,6 +181,12 @@ export const main = async (argv: string[], env: Record<string, string | undefine
|
|
|
174
181
|
const text = words[0] === '-' ? await standingIn() : await readFile(words[0]!, 'utf8');
|
|
175
182
|
return out({ landed: await land(dir, JSON.parse(text)) });
|
|
176
183
|
}
|
|
184
|
+
if (command === 'edge') {
|
|
185
|
+
if (words[0] !== 'pack' || words.length !== 2) throw new Error('edge takes pack and a file');
|
|
186
|
+
if (flags.via !== undefined) throw new Error('edge pack reads this folder, so it takes no --via');
|
|
187
|
+
if (await isServed(dir)) throw new Error(`a harbor is served from ${dir}: stop it first`);
|
|
188
|
+
return out(await pack(dir, words[1]!));
|
|
189
|
+
}
|
|
177
190
|
if (command === 'serve') {
|
|
178
191
|
if (words.length > 0) throw new Error('serve takes no argument');
|
|
179
192
|
const served = await serve(dir, flags.host, flags.port === undefined ? undefined : Number(flags.port));
|
|
@@ -187,7 +200,9 @@ export const main = async (argv: string[], env: Record<string, string | undefine
|
|
|
187
200
|
if (words.length !== 2 && words.length !== 3) throw new Error('pilot takes a name, an invitation, and an address where the invitation carries none');
|
|
188
201
|
const [name, invitation, at] = words as [string, string, string | undefined];
|
|
189
202
|
const held = JSON.parse(invitation) as JsonObject;
|
|
190
|
-
|
|
203
|
+
const census = (await request(dir, {})) as { answer?: { beings?: JsonObject } };
|
|
204
|
+
const web = census.answer?.beings !== undefined && Object.hasOwn(census.answer.beings, WEB_OPENING.key);
|
|
205
|
+
for (const req of pilotRequests(name, held, at, web)) {
|
|
191
206
|
const answer = await request(dir, req);
|
|
192
207
|
if (!failed(answer)) continue;
|
|
193
208
|
if (req.method === 'ask') await request(dir, { method: 'unboot', args: { being: name } });
|
|
@@ -200,9 +215,12 @@ export const main = async (argv: string[], env: Record<string, string | undefine
|
|
|
200
215
|
return out(farAnswer(await request(dir, via(flags.via, requestOf(command, words, flags)))));
|
|
201
216
|
};
|
|
202
217
|
|
|
203
|
-
// The root requests that hold a far ward:
|
|
204
|
-
//
|
|
205
|
-
|
|
218
|
+
// The root requests that hold a far ward: the web carrier opened, where the
|
|
219
|
+
// far ward is reached on the web and this harbor has none; its route where
|
|
220
|
+
// one is named, else the take learns it from the invitation's `at`; the
|
|
221
|
+
// pilot; the take.
|
|
222
|
+
export const pilotRequests = (name: string, invitation: JsonObject, at?: string, web = false): RootRequest[] => [
|
|
223
|
+
...(!web && readAt([...(at === undefined ? [] : [at]), ...readAt(invitation.at)]).some((a) => webAddress(a) !== null) ? [{ method: 'open', args: { key: WEB_OPENING.key, class: WEB_OPENING.kind } }] : []),
|
|
206
224
|
...(at === undefined ? [] : [{ method: 'route', args: { ward: invitation.ward ?? null, at: [at] } }]),
|
|
207
225
|
{ method: 'boot', args: { key: name, class: 'org.nervur.pilot' } },
|
|
208
226
|
{ method: 'ask', args: { being: name, method: 'hold', args: { invitation } } },
|
package/src/cli/harbor.ts
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
// the command unpacks the harbor from its folder, asks, and closes it, so
|
|
8
8
|
// one process at a time writes the folder.
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import type { JsonObject } from '../being/index.ts';
|
|
13
|
-
import { hex
|
|
13
|
+
import { hex } from '../crypto/index.ts';
|
|
14
14
|
import { FolderCustody, FolderMemory } from '../folder/index.ts';
|
|
15
|
-
import { Harbor, rootLine, type CarrierFaculty, type RootRequest } from '../harbor/index.ts';
|
|
16
|
-
import { askServed, NodeTerrain, socketOf } from '../node/index.ts';
|
|
15
|
+
import { carryPlaces, Harbor, landPlaces, readCarried, rootLine, type CarrierFaculty, type Carried, type RootRequest } from '../harbor/index.ts';
|
|
16
|
+
import { askServed, MODULES, NodeTerrain, socketOf } from '../node/index.ts';
|
|
17
17
|
import { CryptoEntropy } from '../pointer/index.ts';
|
|
18
18
|
import { TCP_OPENING } from '../tcp/index.ts';
|
|
19
19
|
|
|
@@ -35,51 +35,42 @@ export const request = async (dir: string, req: RootRequest): Promise<JsonObject
|
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) return null;
|
|
51
|
-
for (const bytes of Object.values(entries)) if (!isHex(bytes)) return null;
|
|
52
|
-
}
|
|
53
|
-
return { ...(seed === undefined ? {} : { seed }), places: places as Carried['places'] };
|
|
38
|
+
// The harbor of a folder, read as bytes alone, its seed only where the
|
|
39
|
+
// one who asks says so, since custody holds it and a package without it
|
|
40
|
+
// opens nowhere. No harbor opens, so nothing is asked and nothing is
|
|
41
|
+
// written.
|
|
42
|
+
export const carry = async (dir: string, seed: boolean): Promise<Carried> => {
|
|
43
|
+
const places = await carryPlaces(new FolderMemory(dir));
|
|
44
|
+
const modules = await readFile(join(dir, MODULES), 'utf8').then((text) => JSON.parse(text) as Record<string, string>, () => undefined);
|
|
45
|
+
return {
|
|
46
|
+
...(seed ? { seed: hex(await new FolderCustody(dir, new CryptoEntropy()).seed()) } : {}),
|
|
47
|
+
places,
|
|
48
|
+
...(modules ? { modules } : {}),
|
|
49
|
+
};
|
|
54
50
|
};
|
|
55
51
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
export const
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
if (!seed) return { places };
|
|
65
|
-
return { seed: hex(await new FolderCustody(dir, new CryptoEntropy()).seed()), places };
|
|
52
|
+
// What `nervur edge pack` hands a deploy: the seed, for the worker's
|
|
53
|
+
// `NERVUR_SEED`, and the sealed package without it, for the bundle.
|
|
54
|
+
export const pack = async (dir: string, file: string): Promise<JsonObject> => {
|
|
55
|
+
if (!existsSync(join(dir, 'seed'))) throw new Error(`no harbor stands at ${dir}`);
|
|
56
|
+
const { seed, places } = await carry(dir, true);
|
|
57
|
+
await writeFile(file, `${JSON.stringify({ places })}\n`, { mode: 0o600 });
|
|
58
|
+
return { seed: seed!, package: file, places: Object.keys(places).length };
|
|
66
59
|
};
|
|
67
60
|
|
|
68
61
|
// A carried harbor into an empty folder. A folder holding a seed or a
|
|
69
62
|
// place is left as it stands, and a package with no seed needs one there
|
|
70
63
|
// already, which a terrain that seals its custody writes its own way.
|
|
71
64
|
export const land = async (dir: string, value: unknown): Promise<string[]> => {
|
|
72
|
-
const held =
|
|
65
|
+
const held = readCarried(value);
|
|
73
66
|
if (held === null) throw new Error('that is no carried harbor');
|
|
74
67
|
const memory = new FolderMemory(dir);
|
|
75
68
|
if ((await memory.places()).length > 0 || (held.seed !== undefined && existsSync(join(dir, 'seed')))) throw new Error(`a harbor stands at ${dir}`);
|
|
76
69
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
77
70
|
if (held.seed !== undefined) await writeFile(join(dir, 'seed'), `${held.seed}\n`, { mode: 0o600, flag: 'wx' });
|
|
78
71
|
else if (!existsSync(join(dir, 'seed'))) throw new Error('the package carries no seed, and none is held here');
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
return Object.keys(held.places);
|
|
72
|
+
if (held.modules && !existsSync(join(dir, MODULES))) await writeFile(join(dir, MODULES), `${JSON.stringify(held.modules, null, 2)}\n`, { mode: 0o600 });
|
|
73
|
+
return landPlaces(memory, held);
|
|
83
74
|
};
|
|
84
75
|
|
|
85
76
|
// A served harbor: its pk, its tcp address, every address its carriers
|
package/src/edge/index.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// per harbor. The object's storage keeps the harbor's sealed memory; the
|
|
4
|
+
// object answers Quo over the web, a post or a held line, since a worker
|
|
5
|
+
// takes no inbound TCP; and it asks over TCP and the web.
|
|
6
|
+
//
|
|
7
|
+
// A harbor born on another ground is carried here: its seed is the
|
|
8
|
+
// worker's `NERVUR_SEED`, and its sealed package, bundled with the worker,
|
|
9
|
+
// lands in the object's storage the first time the object starts. Its
|
|
10
|
+
// owner holds the owner invitation minted where it was born, and pilots
|
|
11
|
+
// it over the web. A harbor born here draws its seed and seals it under
|
|
12
|
+
// the worker's `NERVUR_SECRET`.
|
|
7
13
|
//
|
|
8
14
|
// m/<place>/<name> one sealed entry of the harbor's memory
|
|
9
|
-
// c/seed
|
|
15
|
+
// c/seed a drawn seed, sealed under a key from the secret
|
|
10
16
|
//
|
|
11
17
|
// A keep is one storage transaction, whole or nothing. The storage and
|
|
12
18
|
// state types name only what the kit uses, so no Cloudflare package is a
|
|
@@ -14,12 +20,14 @@
|
|
|
14
20
|
import type { AskSpec, JsonObject } from '../being/index.ts';
|
|
15
21
|
import { Custody, Memory, type Entropy, type Module } from '../contract/index.ts';
|
|
16
22
|
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';
|
|
23
|
+
import { addGround, CarrierFaculty, Harbor, landPlaces, UNDIALED, type Carried, type Dialed, type Ground, type GroundFaculty, type GroundProbe } from '../harbor/index.ts';
|
|
18
24
|
import { answerHeld, answerPost, askFrame, FrameReader, TCP, TCP_OPENING, WEB_OPENING, webFaculty, webPush, type WebListen, type WebListener } from '../line/index.ts';
|
|
19
25
|
import { CryptoEntropy, HeldLoader, PointerTerrain } from '../pointer/index.ts';
|
|
20
26
|
import { tcpAddress, tcpAt, type TcpAddress } from '../quo/index.ts';
|
|
21
27
|
import { ownerAsk } from '../ward/index.ts';
|
|
22
28
|
|
|
29
|
+
export type { Carried } from '../harbor/index.ts';
|
|
30
|
+
|
|
23
31
|
export const EDGE = 'edge';
|
|
24
32
|
const ENTRY = 'm/';
|
|
25
33
|
const SEED = 'c/seed';
|
|
@@ -39,8 +47,10 @@ export interface EdgeState {
|
|
|
39
47
|
readonly storage: EdgeStorage;
|
|
40
48
|
blockConcurrencyWhile<T>(work: () => Promise<T>): Promise<T>;
|
|
41
49
|
}
|
|
42
|
-
export type EdgeEnv = { readonly NERVUR_SECRET?: string };
|
|
43
|
-
|
|
50
|
+
export type EdgeEnv = { readonly NERVUR_SEED?: string; readonly NERVUR_SECRET?: string };
|
|
51
|
+
// What the object hands its harbor: its state, the worker's environment,
|
|
52
|
+
// and a carried package to land where the object keeps nothing yet.
|
|
53
|
+
export type EdgeGiven = { readonly state: EdgeState; readonly env: EdgeEnv; readonly package?: Carried };
|
|
44
54
|
|
|
45
55
|
const chunks = <T>(items: readonly T[]): T[][] => Array.from({ length: Math.ceil(items.length / BATCH) }, (_, i) => items.slice(i * BATCH, (i + 1) * BATCH));
|
|
46
56
|
const bytesOf = (value: unknown): Uint8Array => new Uint8Array(value instanceof ArrayBuffer ? value : (value as Uint8Array));
|
|
@@ -89,24 +99,33 @@ export class EdgeMemory extends Memory {
|
|
|
89
99
|
}
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
// The seed
|
|
93
|
-
//
|
|
102
|
+
// The seed a harbor carried here was born with, as 64 hex, where it is
|
|
103
|
+
// named. Otherwise a seed drawn once and kept sealed under a key the
|
|
104
|
+
// secret gives, so the storage alone opens nothing. A harbor with neither
|
|
105
|
+
// does not stand.
|
|
106
|
+
export type EdgeKeys = { readonly seed?: string | undefined; readonly secret?: string | undefined };
|
|
107
|
+
|
|
94
108
|
export class EdgeCustody extends Custody {
|
|
95
109
|
readonly #storage: EdgeStorage;
|
|
96
|
-
readonly #
|
|
110
|
+
readonly #keys: EdgeKeys;
|
|
97
111
|
readonly #entropy: Entropy;
|
|
98
112
|
readonly #key: string;
|
|
99
|
-
constructor(storage: EdgeStorage,
|
|
113
|
+
constructor(storage: EdgeStorage, keys: EdgeKeys, entropy: Entropy = new CryptoEntropy(), root = '') {
|
|
100
114
|
super();
|
|
101
115
|
this.#storage = storage;
|
|
102
|
-
this.#
|
|
116
|
+
this.#keys = keys;
|
|
103
117
|
this.#entropy = entropy;
|
|
104
118
|
this.#key = `${root}${SEED}`;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
async seed(): Promise<Uint8Array> {
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
const { seed: named, secret } = this.#keys;
|
|
123
|
+
if (named !== undefined) {
|
|
124
|
+
if (!isHex(named.trim(), 32)) throw new Error('NERVUR_SEED is not 64 hex');
|
|
125
|
+
return unhex(named.trim());
|
|
126
|
+
}
|
|
127
|
+
if (!secret) throw new Error('the worker holds no NERVUR_SECRET');
|
|
128
|
+
const key = await hkdf(utf8(secret), SEAL_INFO, 32);
|
|
110
129
|
return this.#storage.transaction(async (txn) => {
|
|
111
130
|
const kept = (await txn.get(this.#key)) as { nonce: Uint8Array; sealed: Uint8Array } | undefined;
|
|
112
131
|
if (kept) {
|
|
@@ -284,7 +303,9 @@ export class EdgeTerrain extends PointerTerrain {
|
|
|
284
303
|
super({
|
|
285
304
|
entropy,
|
|
286
305
|
loader: new HeldLoader(parts.modules ?? []),
|
|
287
|
-
|
|
306
|
+
// Only a harbor carried here takes the worker's NERVUR_SEED; every
|
|
307
|
+
// other object of the worker draws its own.
|
|
308
|
+
custody: new EdgeCustody(storage, { seed: parts.given.package ? parts.given.env.NERVUR_SEED : undefined, secret: parts.given.env.NERVUR_SECRET }, entropy),
|
|
288
309
|
memory: new EdgeMemory(storage),
|
|
289
310
|
faculties: [edgeTcp, webFaculty(edgeServe), webPush],
|
|
290
311
|
opens: [TCP_OPENING, WEB_OPENING],
|
|
@@ -292,6 +313,15 @@ export class EdgeTerrain extends PointerTerrain {
|
|
|
292
313
|
this.given = parts.given;
|
|
293
314
|
}
|
|
294
315
|
|
|
316
|
+
// A carried package lands where the object keeps nothing yet, and only
|
|
317
|
+
// under the seed it was sealed with, the worker's `NERVUR_SEED`.
|
|
318
|
+
override async claim(): Promise<void> {
|
|
319
|
+
const { package: carried, env } = this.given;
|
|
320
|
+
if (!carried || (await this.memory.places()).length > 0) return;
|
|
321
|
+
if (env.NERVUR_SEED === undefined) throw new Error('a carried package opens only under NERVUR_SEED');
|
|
322
|
+
await landPlaces(this.memory, carried);
|
|
323
|
+
}
|
|
324
|
+
|
|
295
325
|
override stand(harbor: Harbor): Promise<void> {
|
|
296
326
|
this.#harbor = harbor;
|
|
297
327
|
return Promise.resolve();
|
|
@@ -328,11 +358,27 @@ export const edgeGround: GroundProbe = {
|
|
|
328
358
|
terrain: ({ given, modules }) => new EdgeTerrain({ given: given as EdgeGiven, ...(modules ? { modules } : {}) }),
|
|
329
359
|
};
|
|
330
360
|
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
361
|
+
// What a Durable Object class of harbors is made of:
|
|
362
|
+
//
|
|
363
|
+
// modules the code its harbors run
|
|
364
|
+
// package a harbor carried here, from `nervur edge pack`, landed the
|
|
365
|
+
// first time an object starts; or a function of the worker's
|
|
366
|
+
// environment giving one
|
|
367
|
+
// path where every object answers Quo, `/quo` unless named, on
|
|
368
|
+
// whatever host reaches it
|
|
369
|
+
// stood the worker's own code, the root, run once the harbor stands
|
|
370
|
+
export type HarborObjectParts = {
|
|
371
|
+
readonly modules: readonly Module[];
|
|
372
|
+
readonly package?: Carried | ((env: EdgeEnv) => Carried | undefined);
|
|
373
|
+
readonly path?: string;
|
|
374
|
+
readonly stood?: (harbor: Harbor) => Promise<void>;
|
|
375
|
+
};
|
|
334
376
|
export type HarborObjectClass = new (state: EdgeState, env: EdgeEnv) => { readonly harbor: Promise<Harbor>; fetch(request: Request): Promise<Response> };
|
|
335
|
-
|
|
377
|
+
|
|
378
|
+
// A Durable Object class whose every instance is one harbor: opened once
|
|
379
|
+
// per object, its web carrier opened, and every request it receives
|
|
380
|
+
// answered by that carrier at `path`.
|
|
381
|
+
export const harborObject = ({ modules, package: carried, path = '/quo', stood }: HarborObjectParts): HarborObjectClass =>
|
|
336
382
|
class HarborObject {
|
|
337
383
|
readonly #opening: Promise<Harbor>;
|
|
338
384
|
|
|
@@ -343,10 +389,13 @@ export const harborObject = (modules: readonly Module[], stood?: (harbor: Harbor
|
|
|
343
389
|
|
|
344
390
|
constructor(state: EdgeState, env: EdgeEnv) {
|
|
345
391
|
addGround(edgeGround);
|
|
392
|
+
const held = typeof carried === 'function' ? carried(env) : carried;
|
|
393
|
+
const given: EdgeGiven = { state, env, ...(held ? { package: held } : {}) };
|
|
346
394
|
// Opened inside the object's block, so no request reaches it half
|
|
347
395
|
// open and its storage answers the opening.
|
|
348
396
|
this.#opening = state.blockConcurrencyWhile(async () => {
|
|
349
|
-
const harbor = await Harbor.open({ modules, given
|
|
397
|
+
const harbor = await Harbor.open({ modules, given });
|
|
398
|
+
if (!harbor.box.being(WEB_OPENING.key)) await harbor.root('open', { key: WEB_OPENING.key, class: WEB_OPENING.kind });
|
|
350
399
|
await stood?.(harbor);
|
|
351
400
|
return harbor;
|
|
352
401
|
});
|
|
@@ -354,6 +403,11 @@ export const harborObject = (modules: readonly Module[], stood?: (harbor: Harbor
|
|
|
354
403
|
|
|
355
404
|
async fetch(request: Request): Promise<Response> {
|
|
356
405
|
const harbor = await this.#opening;
|
|
357
|
-
|
|
406
|
+
const terrain = harbor.terrain as EdgeTerrain;
|
|
407
|
+
if (terrain.listening?.path !== path) {
|
|
408
|
+
const url = new URL(request.url);
|
|
409
|
+
await harbor.root('ask', { being: WEB_OPENING.key, method: 'listen', args: { host: url.hostname, port: url.port === '' ? 0 : Number(url.port), path } });
|
|
410
|
+
}
|
|
411
|
+
return terrain.answer(request);
|
|
358
412
|
}
|
|
359
413
|
};
|
package/src/harbor/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { Catalogue, CATALOGUE, type Cataloguing, type ModuleRow } from './catalo
|
|
|
5
5
|
export { BOX, Dock, type Failures, type Hosted, type Hosting } from './dock.ts';
|
|
6
6
|
export { CarrierFaculty, UNDIALED, type Dialed, type Ground, type GroundFaculty } from './carrying.ts';
|
|
7
7
|
export { Harbor } from './harbor.ts';
|
|
8
|
-
export { copyPackage, Package } from './package.ts';
|
|
8
|
+
export { carryPlaces, copyPackage, landPlaces, Package, readCarried, type Carried } from './package.ts';
|
|
9
9
|
export { readRootRequest, rootLine, type RootRequest } from './root-line.ts';
|
|
10
10
|
export { addGround, grounds, probeGround, type GroundProbe, type Probing } from './probe.ts';
|
|
11
11
|
export { Registry } from './registry.ts';
|
package/src/harbor/package.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// A row is the head, under the empty name, or one being, under her key. An
|
|
14
14
|
// entry that does not open refuses the whole ward, so nothing unpacks from
|
|
15
15
|
// bytes the package did not seal.
|
|
16
|
-
import { concat, decrypt, encrypt, hex, hkdf, utf8 } from '../crypto/index.ts';
|
|
16
|
+
import { concat, decrypt, encrypt, hex, hkdf, isHex, unhex, utf8 } from '../crypto/index.ts';
|
|
17
17
|
import type { Entropy, Memory, Rows } from '../contract/index.ts';
|
|
18
18
|
import { HEAD_ROW } from '../ward/index.ts';
|
|
19
19
|
|
|
@@ -28,6 +28,44 @@ export const copyPackage = async (from: Memory, to: Memory): Promise<string[]> =
|
|
|
28
28
|
return places;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// A harbor as one JSON value, for carrying it to another ground: every
|
|
32
|
+
// place of its memory with its sealed entries as hex, its seed only where
|
|
33
|
+
// the one who carries it says so, and where the ground it left found each
|
|
34
|
+
// module id, a hint the next ground of the same kind may take.
|
|
35
|
+
export type Carried = { seed?: string; places: Record<string, Record<string, string>>; modules?: Record<string, string> };
|
|
36
|
+
|
|
37
|
+
// A carried harbor as JSON reads, or null for anything else.
|
|
38
|
+
export const readCarried = (value: unknown): Carried | null => {
|
|
39
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
40
|
+
const { seed, places, modules } = value as { seed?: unknown; places?: unknown; modules?: unknown };
|
|
41
|
+
if (seed !== undefined && !isHex(seed, 32)) return null;
|
|
42
|
+
const hints = modules === undefined || (typeof modules === 'object' && modules !== null && !Array.isArray(modules) && Object.values(modules).every((m) => typeof m === 'string'));
|
|
43
|
+
if (!hints) return null;
|
|
44
|
+
if (typeof places !== 'object' || places === null || Array.isArray(places)) return null;
|
|
45
|
+
for (const entries of Object.values(places)) {
|
|
46
|
+
if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) return null;
|
|
47
|
+
for (const bytes of Object.values(entries)) if (!isHex(bytes)) return null;
|
|
48
|
+
}
|
|
49
|
+
return { ...(seed === undefined ? {} : { seed }), places: places as Carried['places'], ...(modules === undefined ? {} : { modules: modules as Record<string, string> }) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Every place of a memory, as a carried harbor holds it.
|
|
53
|
+
export const carryPlaces = async (memory: Memory): Promise<Carried['places']> => {
|
|
54
|
+
const places: Carried['places'] = {};
|
|
55
|
+
for (const place of await memory.places()) {
|
|
56
|
+
places[place] = Object.fromEntries([...(await memory.read(place))].map(([name, bytes]) => [name, hex(bytes)]));
|
|
57
|
+
}
|
|
58
|
+
return places;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// A carried harbor's places, written into a memory, and their names.
|
|
62
|
+
export const landPlaces = async (memory: Memory, carried: Carried): Promise<string[]> => {
|
|
63
|
+
for (const [place, entries] of Object.entries(carried.places)) {
|
|
64
|
+
await memory.write(place, new Map(Object.entries(entries).map(([name, bytes]) => [name, unhex(bytes)])));
|
|
65
|
+
}
|
|
66
|
+
return Object.keys(carried.places);
|
|
67
|
+
};
|
|
68
|
+
|
|
31
69
|
const NONCE = 12;
|
|
32
70
|
const LABEL = utf8('nervur-package');
|
|
33
71
|
|
package/src/harbor/relay.ts
CHANGED
|
@@ -183,10 +183,20 @@ export const relayFaculty = (clock: Clock): BeingClass =>
|
|
|
183
183
|
if (replies === null) return { error: 'replies are a list of { n, box }' };
|
|
184
184
|
const id = asker.id!;
|
|
185
185
|
this.#rung.delete(id);
|
|
186
|
+
const replied = new Set(replies.map((r) => r.n));
|
|
186
187
|
for (const { n, box } of replies) {
|
|
187
188
|
const taken = this.#taken.get(n);
|
|
188
189
|
if (taken?.collector === id) this.#settle(taken, box === null ? null : unhex(box));
|
|
189
190
|
}
|
|
191
|
+
// A client answers every box it took in its next collect, so a box it
|
|
192
|
+
// left out never reached its doors, and waits again. A door that
|
|
193
|
+
// hears it twice answers the second as repeated.
|
|
194
|
+
for (const taken of [...this.#taken.values()]) {
|
|
195
|
+
if (taken.collector !== id || replied.has(taken.n)) continue;
|
|
196
|
+
this.#taken.delete(taken.n);
|
|
197
|
+
delete taken.collector;
|
|
198
|
+
this.#waiting.unshift(taken);
|
|
199
|
+
}
|
|
190
200
|
const wards = wardsOf(this.notes(asker));
|
|
191
201
|
if (args.wait === true && !this.#waiting.some((b) => wards.includes(b.ward))) await this.#hold(wards);
|
|
192
202
|
const boxes = this.#waiting.filter((b) => wards.includes(b.ward)).slice(0, BATCH);
|
|
@@ -271,6 +281,7 @@ export const relayClient = (ground: Ground, clock: Clock): BeingClass =>
|
|
|
271
281
|
|
|
272
282
|
#awake = false;
|
|
273
283
|
#replies: Reply[] = [];
|
|
284
|
+
#turn: Promise<unknown> = Promise.resolve();
|
|
274
285
|
// Ends every rest and schedule when the client sleeps or lets go.
|
|
275
286
|
#stop: () => void = () => {};
|
|
276
287
|
|
|
@@ -380,7 +391,15 @@ export const relayClient = (ground: Ground, clock: Clock): BeingClass =>
|
|
|
380
391
|
// One collect: the replies owed go with it, and each box it brings is
|
|
381
392
|
// handed to this harbor's doors. The number of boxes, or null where the
|
|
382
393
|
// relay was not reached.
|
|
383
|
-
|
|
394
|
+
// Collects run one after another, so each carries the replies to every
|
|
395
|
+
// box the one before it brought, as the relay counts on.
|
|
396
|
+
#collect(wait: boolean): Promise<number | null> {
|
|
397
|
+
const run = this.#turn.then(() => this.#collectNow(wait));
|
|
398
|
+
this.#turn = run.catch(() => undefined);
|
|
399
|
+
return run;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async #collectNow(wait: boolean): Promise<number | null> {
|
|
384
403
|
const standing = this.stance.standings.get(CLIENT);
|
|
385
404
|
if (!standing) return null;
|
|
386
405
|
const replies = this.#replies.splice(0);
|