nervur 0.20.0 → 0.20.1
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 +107 -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/cli/command.d.ts +13 -0
- package/dist/cli/command.js +194 -0
- package/dist/cli/harbor.d.ts +21 -0
- package/dist/cli/harbor.js +141 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +14 -0
- package/dist/contract/index.d.ts +15 -2
- package/dist/contract/index.js +16 -2
- package/dist/folder/index.d.ts +5 -4
- package/dist/folder/index.js +45 -83
- package/dist/harbor/catalogue.d.ts +25 -0
- package/dist/harbor/catalogue.js +93 -0
- package/dist/harbor/dock.d.ts +12 -2
- package/dist/harbor/dock.js +39 -9
- package/dist/harbor/harbor.d.ts +15 -6
- package/dist/harbor/harbor.js +83 -21
- package/dist/harbor/index.d.ts +4 -1
- package/dist/harbor/index.js +5 -1
- package/dist/harbor/package.d.ts +10 -0
- package/dist/harbor/package.js +115 -0
- package/dist/harbor/registry.d.ts +5 -3
- package/dist/harbor/registry.js +72 -7
- package/dist/harbor/root-line.d.ts +9 -0
- package/dist/harbor/root-line.js +36 -0
- package/dist/harbor/terrain.d.ts +2 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -5
- package/dist/pointer/bodies.d.ts +9 -3
- package/dist/pointer/bodies.js +37 -18
- package/dist/pointer/index.d.ts +1 -1
- package/dist/pointer/index.js +1 -1
- package/dist/pointer/world.d.ts +5 -4
- package/dist/pointer/world.js +23 -14
- package/dist/quo/door.js +2 -2
- package/dist/quo/keys.js +1 -1
- package/dist/tcp/frame.d.ts +29 -0
- package/dist/tcp/frame.js +74 -0
- package/dist/tcp/index.d.ts +18 -0
- package/dist/tcp/index.js +164 -0
- package/dist/ward/index.d.ts +1 -0
- package/dist/ward/index.js +1 -0
- package/dist/ward/pilot.d.ts +8 -0
- package/dist/ward/pilot.js +50 -0
- package/dist/ward/stance.d.ts +2 -4
- package/dist/ward/stance.js +7 -6
- package/dist/ward/ward-being.d.ts +2 -0
- package/dist/ward/ward-being.js +7 -1
- package/dist/ward/ward.d.ts +4 -2
- package/dist/ward/ward.js +35 -5
- package/package.json +8 -1
- 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/cli/command.ts +188 -0
- package/src/cli/harbor.ts +137 -0
- package/src/cli/main.ts +13 -0
- package/src/contract/index.ts +38 -9
- package/src/folder/index.ts +44 -86
- package/src/harbor/catalogue.ts +110 -0
- package/src/harbor/dock.ts +48 -12
- package/src/harbor/harbor.ts +104 -29
- package/src/harbor/index.ts +6 -2
- package/src/harbor/package.ts +131 -0
- package/src/harbor/registry.ts +62 -9
- package/src/harbor/root-line.ts +36 -0
- package/src/harbor/terrain.ts +3 -1
- package/src/index.ts +5 -5
- package/src/pointer/bodies.ts +34 -16
- package/src/pointer/index.ts +1 -1
- package/src/pointer/world.ts +21 -18
- package/src/quo/door.ts +2 -2
- package/src/quo/keys.ts +1 -1
- package/src/stand/main.ts +1 -0
- package/src/stand/stand.ts +46 -4
- package/src/tcp/frame.ts +78 -0
- package/src/tcp/index.ts +173 -0
- package/src/ward/index.ts +1 -0
- package/src/ward/pilot.ts +50 -0
- package/src/ward/stance.ts +8 -7
- package/src/ward/ward-being.ts +7 -1
- package/src/ward/ward.ts +37 -6
package/dist/ward/ward.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// what is behind its door for `quo/`.
|
|
6
6
|
import { readValue, writeValue } from '../crypto/index.js';
|
|
7
7
|
import { Door, readInvitation, WardKey } from '../quo/index.js';
|
|
8
|
-
import { digest, isSilence, isWord, OWNER, silence, WARD, word } from '../being/index.js';
|
|
8
|
+
import { digest, isSilence, isWord, kindOf, OWNER, silence, WARD, word } from '../being/index.js';
|
|
9
9
|
import { Partition, PartitionRelations } from './partition.js';
|
|
10
10
|
import { DEPTH, WardStance } from './stance.js';
|
|
11
11
|
import { WardBeing } from './ward-being.js';
|
|
@@ -47,10 +47,13 @@ export class Ward {
|
|
|
47
47
|
const own = ward.partition.being(WARD);
|
|
48
48
|
const stance = new RootStance(ward, own, ward.#steward);
|
|
49
49
|
const being = parts.root ? parts.root(stance) : new WardBeing(stance);
|
|
50
|
-
own.class = being.constructor
|
|
50
|
+
own.class = kindOf(being.constructor);
|
|
51
51
|
ward.#residents.set(WARD, { being, row: own, stance });
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// The standing on the dock is kept at once: its heir is spent, so a
|
|
53
|
+
// restart before the ward's first keep would find no way back.
|
|
54
|
+
if (parts.dock && !Object.hasOwn(own.standings, DOCK) && (await stance.standings.take(DOCK, parts.dock)) !== null && !(await ward.partition.kept())) {
|
|
55
|
+
throw new Error('the standing on the dock was not kept');
|
|
56
|
+
}
|
|
54
57
|
for (const [key, row] of Object.entries(ward.partition.beings)) {
|
|
55
58
|
const C = key === WARD ? undefined : parts.catalogue.classOf(row.class);
|
|
56
59
|
if (C)
|
|
@@ -67,6 +70,33 @@ export class Ward {
|
|
|
67
70
|
get wardBeing() {
|
|
68
71
|
return this.#residents.get(WARD).being;
|
|
69
72
|
}
|
|
73
|
+
// The being standing under a key, for the harbor's own faculties.
|
|
74
|
+
being(key) {
|
|
75
|
+
return this.#residents.get(key)?.being;
|
|
76
|
+
}
|
|
77
|
+
// Every being her row names, of the class the catalogue now resolves for
|
|
78
|
+
// her kind: an absent being is born, and one whose class a module
|
|
79
|
+
// replaced is born again of the new class on the stance she holds, so
|
|
80
|
+
// her relations and their lanes stay hers. A new class that throws at
|
|
81
|
+
// birth leaves her as she was.
|
|
82
|
+
bearAnew() {
|
|
83
|
+
for (const [key, row] of Object.entries(this.partition.beings)) {
|
|
84
|
+
const C = key === WARD ? undefined : this.#parts.catalogue.classOf(row.class);
|
|
85
|
+
const resident = this.#residents.get(key);
|
|
86
|
+
if (!C || resident?.being.constructor === C)
|
|
87
|
+
continue;
|
|
88
|
+
if (!resident) {
|
|
89
|
+
this.#bear(key, row, C);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
resident.being = new C(resident.stance);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
70
100
|
// The unsealed ask: the ward-being, asked by whoever holds this ward.
|
|
71
101
|
async root(method, args = {}) {
|
|
72
102
|
return this.#kept(WARD, () => this.#answer(WARD, { id: OWNER }, method, args));
|
|
@@ -89,7 +119,7 @@ export class Ward {
|
|
|
89
119
|
}
|
|
90
120
|
return silence;
|
|
91
121
|
}
|
|
92
|
-
// Behind: the zero head reaches the public being,
|
|
122
|
+
// Behind: the zero head reaches the public being, an heir the being that
|
|
93
123
|
// invited it.
|
|
94
124
|
get zero() {
|
|
95
125
|
const key = this.partition.head.public;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nervur",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
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",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"license": "Apache-2.0",
|
|
13
13
|
"homepage": "https://nervur.org",
|
|
14
14
|
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"nervur": "dist/cli/main.js"
|
|
17
|
+
},
|
|
15
18
|
"exports": {
|
|
16
19
|
".": {
|
|
17
20
|
"types": "./dist/index.d.ts",
|
|
@@ -20,6 +23,10 @@
|
|
|
20
23
|
"./folder": {
|
|
21
24
|
"types": "./dist/folder/index.d.ts",
|
|
22
25
|
"default": "./dist/folder/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./tcp": {
|
|
28
|
+
"types": "./dist/tcp/index.d.ts",
|
|
29
|
+
"default": "./dist/tcp/index.js"
|
|
23
30
|
}
|
|
24
31
|
},
|
|
25
32
|
"scripts": {
|
package/src/being/being.ts
CHANGED
|
@@ -28,8 +28,12 @@ const wrote = (self: object, name: string): boolean => {
|
|
|
28
28
|
type Method = (args: JsonObject, asker: Asker) => Reply | Promise<Reply>;
|
|
29
29
|
|
|
30
30
|
// A subclass's statics replace its parent's and never merge: her blueprint
|
|
31
|
-
// is what the class in front of you declares, in its order.
|
|
31
|
+
// is what the class in front of you declares, in its order. Her kind is
|
|
32
|
+
// never inherited: see `kind.ts`.
|
|
32
33
|
export class Being {
|
|
34
|
+
// The name her row keeps for this class. Every class that stands declares
|
|
35
|
+
// its own.
|
|
36
|
+
static readonly kind: string = 'org.nervur.being';
|
|
33
37
|
// Her cells' defaults, written at birth only where a key is missing.
|
|
34
38
|
static cells: JsonObject = {};
|
|
35
39
|
static asks: Record<string, AskSpec> = {};
|
package/src/being/faculty.ts
CHANGED
|
@@ -2,23 +2,30 @@
|
|
|
2
2
|
// A faculty: a being of the box ward that fulfils a contract. A contract is
|
|
3
3
|
// a class between `Faculty` and the class that stands, usually abstract:
|
|
4
4
|
//
|
|
5
|
-
// abstract class Timer extends Faculty {
|
|
6
|
-
//
|
|
5
|
+
// abstract class Timer extends Faculty { the contract
|
|
6
|
+
// static override kind = 'com.acme.timer' }
|
|
7
|
+
// class IntervalTimer extends Timer { one class that fulfils it
|
|
8
|
+
// static override kind = 'com.acme.interval-timer' }
|
|
7
9
|
//
|
|
8
|
-
// A being lends by the contract and never learns the class.
|
|
10
|
+
// A being lends by the contract's kind and never learns the class. Every
|
|
11
|
+
// class between the faculty and `Faculty` is a contract and declares its
|
|
12
|
+
// own kind, and a faculty fulfils each of them and her own. Two asks are the
|
|
9
13
|
// faculty's own and answered to the ward-being that opened her alone:
|
|
10
14
|
// `offer`, an invitation on herself for a ward the dock vouches for, and
|
|
11
15
|
// `retract`, an offer that was not taken.
|
|
12
16
|
import { Being } from './being.ts';
|
|
17
|
+
import { kindOf } from './kind.ts';
|
|
13
18
|
import { WARD, type Asker, type Blueprint, type JsonObject, type Reply } from './types.ts';
|
|
14
19
|
|
|
15
20
|
export class Faculty extends Being {
|
|
21
|
+
static override readonly kind: string = 'org.nervur.faculty';
|
|
22
|
+
|
|
16
23
|
// The contracts a class fulfils: itself and every class between it and
|
|
17
|
-
// `Faculty`, by
|
|
24
|
+
// `Faculty`, by kind. A class among them with no kind of its own throws.
|
|
18
25
|
static contracts(C: abstract new (...args: never[]) => unknown): string[] {
|
|
19
|
-
const
|
|
20
|
-
for (let c: unknown = C; typeof c === 'function' && c !== Faculty; c = Object.getPrototypeOf(c))
|
|
21
|
-
return
|
|
26
|
+
const kinds: string[] = [];
|
|
27
|
+
for (let c: unknown = C; typeof c === 'function' && c !== Faculty; c = Object.getPrototypeOf(c)) kinds.push(kindOf(c));
|
|
28
|
+
return kinds;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
static fulfils(C: unknown): C is typeof Faculty {
|
package/src/being/index.ts
CHANGED
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
export { Being, type AskSpec } from './being.ts';
|
|
4
4
|
export { canonical, digest } from './digest.ts';
|
|
5
5
|
export { Faculty } from './faculty.ts';
|
|
6
|
+
export { isKind, KIT, kindOf, ownKind } from './kind.ts';
|
|
6
7
|
export { OWNER, WARD, type Answer, type Ask, type Asker, type BeingClass, type BeingLike, type Blueprint, type Invitation, type Json, type JsonObject, type Occupants, type Reply, type Schema, type Stance, type Standing, type Standings, type Wanted } from './types.ts';
|
|
7
8
|
export { isSilence, isWord, silence, told, word, type Silence, type Word, type WordName } from './words.ts';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A kind: the name a row keeps for the class a being is born of, and the
|
|
3
|
+
// name a contract is lent by. It is declared on the class, never taken from
|
|
4
|
+
// the name the language gives it, because a bundler renames classes.
|
|
5
|
+
//
|
|
6
|
+
// static kind = 'com.acme.shop'
|
|
7
|
+
//
|
|
8
|
+
// A kind is a reversed domain its author owns, then a name: at least three
|
|
9
|
+
// segments joined by `.`, each lowercase letters, digits and `-`, starting
|
|
10
|
+
// with a letter or digit. `org.nervur.` is the kit's own. A class declares
|
|
11
|
+
// its own kind: one it inherits names its parent, so it is none.
|
|
12
|
+
export const KIT = 'org.nervur.';
|
|
13
|
+
|
|
14
|
+
const KIND = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*){2,}$/;
|
|
15
|
+
|
|
16
|
+
export const isKind = (text: unknown): text is string => typeof text === 'string' && KIND.test(text);
|
|
17
|
+
|
|
18
|
+
// The kind a class declares on itself, or undefined.
|
|
19
|
+
export const ownKind = (C: unknown): string | undefined => {
|
|
20
|
+
if (typeof C !== 'function' || !Object.hasOwn(C, 'kind')) return undefined;
|
|
21
|
+
const kind = (C as { kind?: unknown }).kind;
|
|
22
|
+
return isKind(kind) ? kind : undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The kind a class declares on itself, or a throw naming the class.
|
|
26
|
+
export const kindOf = (C: unknown): string => {
|
|
27
|
+
const kind = ownKind(C);
|
|
28
|
+
if (kind === undefined) throw new Error(`class ${typeof C === 'function' ? C.name : String(C)} declares no kind of its own`);
|
|
29
|
+
return kind;
|
|
30
|
+
};
|
package/src/being/types.ts
CHANGED
|
@@ -56,12 +56,13 @@ export interface Stance {
|
|
|
56
56
|
readonly cells: JsonObject;
|
|
57
57
|
readonly occupants: Occupants;
|
|
58
58
|
readonly standings: Standings;
|
|
59
|
-
// A new being of her ward, by
|
|
60
|
-
//
|
|
61
|
-
boot(
|
|
59
|
+
// A new being of her ward, by kind, under a key she chooses. With an id,
|
|
60
|
+
// the new being invites her under her key, and she takes it under id.
|
|
61
|
+
boot(kind: string, key: string, id?: string): Promise<string | null>;
|
|
62
62
|
// A standing under `id` at the faculty this harbor opened for a contract:
|
|
63
|
-
// the id, or null where none is open
|
|
64
|
-
|
|
63
|
+
// the id, or null where none is open, the contract declares no kind of
|
|
64
|
+
// its own, or the relation was refused.
|
|
65
|
+
lend(contract: abstract new (...args: never[]) => unknown, id: string): Promise<string | null>;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// Anything with `answer` is a being.
|
|
@@ -69,4 +70,5 @@ export interface BeingLike {
|
|
|
69
70
|
answer(asker: Asker, method: string | undefined, args: JsonObject): Reply | Promise<Reply>;
|
|
70
71
|
describe?(asker: Asker): Blueprint;
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
// A class that stands. Its kind is its own, as `kind.ts` says.
|
|
74
|
+
export type BeingClass = (new (stance: Stance) => BeingLike) & { readonly name: string; readonly kind: string };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur`: a harbor on this machine. Each command is one root request, the
|
|
3
|
+
// same the root line carries from any front, printed as one JSON line.
|
|
4
|
+
//
|
|
5
|
+
// nervur init [--dir D] the harbor made: its seed drawn,
|
|
6
|
+
// its catalogue stood, and an owner
|
|
7
|
+
// invitation for whoever made it
|
|
8
|
+
// nervur serve [--dir D] [--host H] [--port P] the harbor served: Quo over TCP,
|
|
9
|
+
// and the root line on its socket
|
|
10
|
+
// nervur module add <file> a module the catalogue runs
|
|
11
|
+
// nervur module remove <module> a module it runs no more
|
|
12
|
+
// nervur modules what the catalogue runs
|
|
13
|
+
// nervur census [--ward W] the empty ask
|
|
14
|
+
// nervur open <key> <kind> a faculty stands in the box ward
|
|
15
|
+
// nervur host <ward> [--seed S] a hosted ward stands
|
|
16
|
+
// nervur route <ward pk> <host:port | none> where a ward is reached
|
|
17
|
+
// nervur boot [--ward W] <key> <kind> a being stands
|
|
18
|
+
// nervur unboot [--ward W] <being> a being goes
|
|
19
|
+
// nervur public [--ward W] <key | none> the being a stranger reaches
|
|
20
|
+
// nervur invite [--ward W] <being> <id> an invitation on a being
|
|
21
|
+
// nervur ask [--ward W] [being] [method] [args] a being, asked as the owner
|
|
22
|
+
// nervur own [--ward W] <id> an invitation for an owner
|
|
23
|
+
// nervur disown [--ward W] <id> an owner no more
|
|
24
|
+
// nervur pilot <name> <invitation> <host:port> a far ward held as its owner, by a
|
|
25
|
+
// pilot being of the box ward
|
|
26
|
+
// nervur <command> --via <name> ... the command asked of the far ward
|
|
27
|
+
// that pilot holds, as its owner
|
|
28
|
+
//
|
|
29
|
+
// D is --dir, then $NERVUR_DIR, then ~/.nervur. A module file is named from
|
|
30
|
+
// where the command runs and kept by its absolute path. No --ward is the box
|
|
31
|
+
// ward, and a pilot holds one ward, so --via takes no --ward. The exit is 1
|
|
32
|
+
// when the answer is an error.
|
|
33
|
+
import { existsSync } from 'node:fs';
|
|
34
|
+
import { homedir } from 'node:os';
|
|
35
|
+
import { join, resolve } from 'node:path';
|
|
36
|
+
import type { JsonObject } from '../being/index.ts';
|
|
37
|
+
import type { RootRequest } from '../harbor/index.ts';
|
|
38
|
+
import { open, request, serve } from './harbor.ts';
|
|
39
|
+
|
|
40
|
+
const USAGE = 'usage: nervur <init|serve|module|modules|census|open|host|route|boot|unboot|public|invite|ask|own|disown|pilot> [args] [--dir D] [--ward W] [--via V] [--seed S] [--host H] [--port P]';
|
|
41
|
+
const FLAGS = new Set(['dir', 'ward', 'via', 'seed', 'host', 'port']);
|
|
42
|
+
|
|
43
|
+
export const parse = (argv: string[]): { command?: string; words: string[]; flags: Record<string, string> } => {
|
|
44
|
+
const flags: Record<string, string> = {};
|
|
45
|
+
const words: string[] = [];
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const arg = argv[i]!;
|
|
48
|
+
const name = arg.startsWith('--') ? arg.slice(2) : undefined;
|
|
49
|
+
if (name === undefined) words.push(arg);
|
|
50
|
+
else if (!FLAGS.has(name) || argv[i + 1] === undefined) throw new Error(`--${name} is no flag with a value`);
|
|
51
|
+
else flags[name] = argv[(i += 1)]!;
|
|
52
|
+
}
|
|
53
|
+
const [command, ...rest] = words;
|
|
54
|
+
return { ...(command === undefined ? {} : { command }), words: rest, flags };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const none = (word: string): string | null => (word === 'none' ? null : word);
|
|
58
|
+
const json = (word: string | undefined): JsonObject | undefined => (word === undefined ? undefined : (JSON.parse(word) as JsonObject));
|
|
59
|
+
const catalogue = (method: string, args: JsonObject = {}): RootRequest => ({ method: 'ask', args: { being: 'catalogue', method, args } });
|
|
60
|
+
|
|
61
|
+
// The root request a command names, or a throw saying what it lacks.
|
|
62
|
+
export const requestOf = (command: string, words: string[], flags: Record<string, string>): RootRequest => {
|
|
63
|
+
const need = (n: number): string[] => {
|
|
64
|
+
if (words.length !== n) throw new Error(`${command} takes ${n} argument${n === 1 ? '' : 's'}`);
|
|
65
|
+
return words;
|
|
66
|
+
};
|
|
67
|
+
const at = flags.ward === undefined ? {} : { ward: flags.ward };
|
|
68
|
+
switch (command) {
|
|
69
|
+
case 'module': {
|
|
70
|
+
const [verb, what] = need(2);
|
|
71
|
+
if (verb === 'add') return catalogue('add', { from: resolve(what!) });
|
|
72
|
+
if (verb === 'remove') return catalogue('remove', { module: what! });
|
|
73
|
+
throw new Error('module is add or remove');
|
|
74
|
+
}
|
|
75
|
+
case 'modules':
|
|
76
|
+
return (need(0), catalogue('modules'));
|
|
77
|
+
case 'census':
|
|
78
|
+
return (need(0), at);
|
|
79
|
+
case 'open': {
|
|
80
|
+
const [key, kind] = need(2);
|
|
81
|
+
return { method: 'open', args: { key: key!, class: kind! } };
|
|
82
|
+
}
|
|
83
|
+
case 'host': {
|
|
84
|
+
const [ward] = need(1);
|
|
85
|
+
return { method: 'host', args: { ward: ward!, ...(flags.seed === undefined ? {} : { seed: flags.seed }) } };
|
|
86
|
+
}
|
|
87
|
+
case 'route': {
|
|
88
|
+
const [ward, where] = need(2);
|
|
89
|
+
return { method: 'route', args: { ward: ward!, at: none(where!) } };
|
|
90
|
+
}
|
|
91
|
+
case 'boot': {
|
|
92
|
+
const [key, kind] = need(2);
|
|
93
|
+
return { ...at, method: 'boot', args: { key: key!, class: kind! } };
|
|
94
|
+
}
|
|
95
|
+
case 'unboot':
|
|
96
|
+
return { ...at, method: 'unboot', args: { being: need(1)[0]! } };
|
|
97
|
+
case 'public':
|
|
98
|
+
return { ...at, method: 'public', args: { key: none(need(1)[0]!) } };
|
|
99
|
+
case 'invite': {
|
|
100
|
+
const [being, id] = need(2);
|
|
101
|
+
return { ...at, method: 'invite', args: { being: being!, id: id! } };
|
|
102
|
+
}
|
|
103
|
+
case 'ask': {
|
|
104
|
+
if (words.length > 3) throw new Error('ask takes at most a being, a method and its args');
|
|
105
|
+
const [being, method, args] = words;
|
|
106
|
+
return { ...at, method: 'ask', args: { ...(being === undefined ? {} : { being }), ...(method === undefined ? {} : { method }), ...(args === undefined ? {} : { args: json(args)! }) } };
|
|
107
|
+
}
|
|
108
|
+
case 'own':
|
|
109
|
+
case 'disown':
|
|
110
|
+
return { ...at, method: command, args: { id: need(1)[0]! } };
|
|
111
|
+
default:
|
|
112
|
+
throw new Error(USAGE);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// An error answer at any depth the root line or an ask carries it.
|
|
117
|
+
const failed = (answer: unknown): boolean => {
|
|
118
|
+
if (typeof answer !== 'object' || answer === null) return false;
|
|
119
|
+
const a = answer as JsonObject;
|
|
120
|
+
return 'error' in a || failed(a.answer);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const main = async (argv: string[], env: Record<string, string | undefined>, print: (line: string) => void): Promise<number> => {
|
|
124
|
+
const { command, words, flags } = parse(argv);
|
|
125
|
+
if (command === undefined) throw new Error(USAGE);
|
|
126
|
+
const dir = flags.dir ?? env.NERVUR_DIR ?? join(homedir(), '.nervur');
|
|
127
|
+
const out = (answer: JsonObject): number => {
|
|
128
|
+
print(JSON.stringify(answer));
|
|
129
|
+
return failed(answer) ? 1 : 0;
|
|
130
|
+
};
|
|
131
|
+
if (command === 'init') {
|
|
132
|
+
if (words.length > 0) throw new Error('init takes no argument');
|
|
133
|
+
if (existsSync(join(dir, 'seed'))) return out({ error: `a harbor stands at ${dir}` });
|
|
134
|
+
const { harbor, carrier } = await open(dir);
|
|
135
|
+
try {
|
|
136
|
+
const owner = await harbor.root('own', { id: 'owner' });
|
|
137
|
+
return out({ pk: harbor.box.pk, owner: owner as JsonObject });
|
|
138
|
+
} finally {
|
|
139
|
+
await carrier.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (command === 'serve') {
|
|
143
|
+
if (words.length > 0) throw new Error('serve takes no argument');
|
|
144
|
+
const served = await serve(dir, flags.host, flags.port === undefined ? undefined : Number(flags.port));
|
|
145
|
+
const stop = (): void => void served.close().then(() => process.exit(0));
|
|
146
|
+
process.once('SIGINT', stop);
|
|
147
|
+
process.once('SIGTERM', stop);
|
|
148
|
+
print(JSON.stringify({ pk: served.pk, at: served.at, root: served.root }));
|
|
149
|
+
return SERVING;
|
|
150
|
+
}
|
|
151
|
+
if (command === 'pilot') {
|
|
152
|
+
if (words.length !== 3) throw new Error('pilot takes 3 arguments');
|
|
153
|
+
const [name, invitation, at] = words as [string, string, string];
|
|
154
|
+
const held = JSON.parse(invitation) as JsonObject;
|
|
155
|
+
for (const req of pilotRequests(name, held, at)) {
|
|
156
|
+
const answer = await request(dir, req);
|
|
157
|
+
if (!failed(answer)) continue;
|
|
158
|
+
if (req.method === 'ask') await request(dir, { method: 'unboot', args: { being: name } });
|
|
159
|
+
return out(answer);
|
|
160
|
+
}
|
|
161
|
+
return out({ piloting: name, ward: held.ward! });
|
|
162
|
+
}
|
|
163
|
+
if (flags.via === undefined) return out(await request(dir, requestOf(command, words, flags)));
|
|
164
|
+
if (flags.ward !== undefined) throw new Error('--via takes no --ward');
|
|
165
|
+
return out(farAnswer(await request(dir, via(flags.via, requestOf(command, words, flags)))));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// The root requests that hold a far ward: its route, the pilot, the take.
|
|
169
|
+
export const pilotRequests = (name: string, invitation: JsonObject, at: string): RootRequest[] => [
|
|
170
|
+
{ method: 'route', args: { ward: invitation.ward ?? null, at } },
|
|
171
|
+
{ method: 'boot', args: { key: name, class: 'org.nervur.pilot' } },
|
|
172
|
+
{ method: 'ask', args: { being: name, method: 'hold', args: { invitation } } },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
// A root request, asked of the far ward a pilot holds.
|
|
176
|
+
export const via = (pilot: string, far: RootRequest): RootRequest => ({
|
|
177
|
+
method: 'ask',
|
|
178
|
+
args: { being: pilot, method: 'pilot', args: { ...(far.method === undefined ? {} : { method: far.method }), ...(far.args === undefined ? {} : { args: far.args }) } },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// The far ward-being's answer out of the pilot's, or the local error.
|
|
182
|
+
export const farAnswer = (local: JsonObject): JsonObject => {
|
|
183
|
+
const inner = (local.answer as JsonObject | undefined)?.answer;
|
|
184
|
+
return typeof inner === 'object' && inner !== null && !Array.isArray(inner) ? inner : local;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// What `main` answers for a harbor it keeps serving: no exit yet.
|
|
188
|
+
export const SERVING = -1;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A harbor on this machine, as the `nervur` command stands it: its seed and
|
|
3
|
+
// sealed memory in a folder, its code imported from the files its
|
|
4
|
+
// catalogue names, Quo over TCP as its carrier, and the root line on a
|
|
5
|
+
// local socket beside them.
|
|
6
|
+
//
|
|
7
|
+
// <dir>/seed the harbor's seed
|
|
8
|
+
// <dir>/<place>/ its sealed memory
|
|
9
|
+
// <dir>/root.sock the root line while the harbor is served, open to
|
|
10
|
+
// this user alone, or in the temporary folder for a
|
|
11
|
+
// folder too deep for a socket path
|
|
12
|
+
//
|
|
13
|
+
// A root request goes to a served harbor over its socket. With none served,
|
|
14
|
+
// the command unpacks the harbor from its folder, asks, and closes it, so
|
|
15
|
+
// one process at a time writes the folder.
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { chmod, rm } from 'node:fs/promises';
|
|
18
|
+
import { connect, createServer, type Server } from 'node:net';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import type { JsonObject } from '../being/index.ts';
|
|
23
|
+
import { Loader, type Loaded } from '../contract/index.ts';
|
|
24
|
+
import { FolderCustody, FolderMemory } from '../folder/index.ts';
|
|
25
|
+
import { Harbor, rootLine, type RootRequest } from '../harbor/index.ts';
|
|
26
|
+
import { CryptoEntropy, PointerTerrain } from '../pointer/index.ts';
|
|
27
|
+
import { TcpCarrier, TcpListener } from '../tcp/index.ts';
|
|
28
|
+
|
|
29
|
+
// A socket path has a hundred bytes or so on every system, so a folder too
|
|
30
|
+
// deep for one puts its socket in this user's temporary folder, under a
|
|
31
|
+
// name drawn from the folder's own path.
|
|
32
|
+
const SOCKET_PATH = 100;
|
|
33
|
+
export const socketOf = (dir: string): string => {
|
|
34
|
+
const beside = join(resolve(dir), 'root.sock');
|
|
35
|
+
if (Buffer.byteLength(beside) <= SOCKET_PATH) return beside;
|
|
36
|
+
return join(tmpdir(), `nervur-${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 32)}.sock`);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Code from a file: a module that exports `module`, `version` and
|
|
40
|
+
// `classes`, found again by its absolute path. A file that cannot be
|
|
41
|
+
// imported, or exports no module, is none.
|
|
42
|
+
export class ImportLoader extends Loader {
|
|
43
|
+
async load(from: string): Promise<Loaded | null> {
|
|
44
|
+
const path = resolve(from);
|
|
45
|
+
let exported: { module?: unknown; version?: unknown; classes?: unknown };
|
|
46
|
+
try {
|
|
47
|
+
exported = (await import(pathToFileURL(path).href)) as typeof exported;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const { module, version, classes } = exported;
|
|
52
|
+
if (typeof module !== 'string' || typeof version !== 'string' || !Array.isArray(classes)) return null;
|
|
53
|
+
return { module, version, classes, from: path };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Opened = { harbor: Harbor; carrier: TcpCarrier };
|
|
58
|
+
|
|
59
|
+
export const open = async (dir: string): Promise<Opened> => {
|
|
60
|
+
const entropy = new CryptoEntropy();
|
|
61
|
+
const carrier = new TcpCarrier();
|
|
62
|
+
const harbor = await Harbor.open(new PointerTerrain({ entropy, carrier, loader: new ImportLoader(), custody: new FolderCustody(dir, entropy), memory: new FolderMemory(dir) }));
|
|
63
|
+
return { harbor, carrier };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// One root request over a served harbor's socket, or undefined where none
|
|
67
|
+
// is served.
|
|
68
|
+
const overSocket = (dir: string, request: RootRequest): Promise<JsonObject | undefined> =>
|
|
69
|
+
new Promise((done, fail) => {
|
|
70
|
+
const socket = connect(socketOf(dir));
|
|
71
|
+
let text = '';
|
|
72
|
+
socket.once('connect', () => socket.write(`${JSON.stringify(request)}\n`));
|
|
73
|
+
socket.on('data', (chunk: Buffer) => {
|
|
74
|
+
text += chunk.toString('utf8');
|
|
75
|
+
const end = text.indexOf('\n');
|
|
76
|
+
if (end < 0) return;
|
|
77
|
+
socket.end();
|
|
78
|
+
done(JSON.parse(text.slice(0, end)) as JsonObject);
|
|
79
|
+
});
|
|
80
|
+
socket.once('error', (e: NodeJS.ErrnoException) => (e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? done(undefined) : fail(e)));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const request = async (dir: string, req: RootRequest): Promise<JsonObject> => {
|
|
84
|
+
const served = await overSocket(dir, req);
|
|
85
|
+
if (served !== undefined) return served;
|
|
86
|
+
const { harbor, carrier } = await open(dir);
|
|
87
|
+
try {
|
|
88
|
+
return await rootLine(harbor, req);
|
|
89
|
+
} finally {
|
|
90
|
+
await carrier.close();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type Served = { pk: string; at: string; root: string; close(): Promise<void> };
|
|
95
|
+
|
|
96
|
+
// The harbor served: Quo over TCP on host and port, the root line on the
|
|
97
|
+
// socket. A socket file nobody answers on is a stale one, and is replaced.
|
|
98
|
+
export const serve = async (dir: string, host?: string, port?: number): Promise<Served> => {
|
|
99
|
+
if ((await overSocket(dir, {})) !== undefined) throw new Error(`a harbor is already served from ${dir}`);
|
|
100
|
+
const { harbor, carrier } = await open(dir);
|
|
101
|
+
const listener = await TcpListener.listen(harbor, host, port);
|
|
102
|
+
const root = socketOf(dir);
|
|
103
|
+
await rm(root, { force: true });
|
|
104
|
+
const server: Server = createServer((socket) => {
|
|
105
|
+
let text = '';
|
|
106
|
+
socket.on('data', (chunk: Buffer) => {
|
|
107
|
+
text += chunk.toString('utf8');
|
|
108
|
+
for (let end = text.indexOf('\n'); end >= 0; end = text.indexOf('\n')) {
|
|
109
|
+
const line = text.slice(0, end);
|
|
110
|
+
text = text.slice(end + 1);
|
|
111
|
+
let value: unknown;
|
|
112
|
+
try {
|
|
113
|
+
value = JSON.parse(line);
|
|
114
|
+
} catch {
|
|
115
|
+
value = undefined;
|
|
116
|
+
}
|
|
117
|
+
void rootLine(harbor, value)
|
|
118
|
+
.catch(() => ({ error: 'the harbor threw' }))
|
|
119
|
+
.then((answer) => socket.write(`${JSON.stringify(answer)}\n`));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
socket.on('error', () => socket.destroy());
|
|
123
|
+
});
|
|
124
|
+
await new Promise<void>((done) => server.listen(root, done));
|
|
125
|
+
await chmod(root, 0o600);
|
|
126
|
+
return {
|
|
127
|
+
pk: harbor.box.pk,
|
|
128
|
+
at: listener.at,
|
|
129
|
+
root,
|
|
130
|
+
close: async () => {
|
|
131
|
+
await new Promise((done) => server.close(done));
|
|
132
|
+
await rm(root, { force: true });
|
|
133
|
+
await listener.close();
|
|
134
|
+
await carrier.close();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
};
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// The `nervur` program: `command.ts` over this process's arguments. A
|
|
4
|
+
// command that cannot run exits 2 and says why on stderr.
|
|
5
|
+
import { main, SERVING } from './command.ts';
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const code = await main(process.argv.slice(2), process.env, (line) => process.stdout.write(`${line}\n`));
|
|
9
|
+
if (code !== SERVING) process.exitCode = code;
|
|
10
|
+
} catch (e) {
|
|
11
|
+
process.stderr.write(`nervur: ${(e as Error).message}\n`);
|
|
12
|
+
process.exitCode = 2;
|
|
13
|
+
}
|
package/src/contract/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The contracts: what the onion cannot unpack from cells,
|
|
3
|
-
// class, so a registry can tell at run time
|
|
4
|
-
// class differs by terrain, and the
|
|
2
|
+
// The contracts: what the onion cannot unpack from cells, a module's code
|
|
3
|
+
// among them, each an abstract class, so a registry can tell at run time
|
|
4
|
+
// which class fulfils which. The class differs by terrain, and the
|
|
5
|
+
// contract does not.
|
|
5
6
|
import type { Draw } from '../quo/index.ts';
|
|
6
7
|
|
|
7
8
|
// Drawn bytes.
|
|
@@ -20,25 +21,53 @@ export abstract class Clock {
|
|
|
20
21
|
abstract wait(ms: number): { done: Promise<void>; cancel(): void };
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
// One ward's partition,
|
|
24
|
-
// directly, told after every write, and kept once at the
|
|
24
|
+
// One ward's partition, as the harbor's package opens it: the rows the
|
|
25
|
+
// ward writes into directly, told after every write, and kept once at the
|
|
26
|
+
// end of an arrival.
|
|
25
27
|
export interface Rows {
|
|
26
28
|
readonly rows: Record<string, unknown>;
|
|
27
29
|
told(row: string): void;
|
|
28
30
|
kept(): Promise<boolean>;
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// A harbor's memory as a terrain keeps it: sealed entries it cannot read,
|
|
34
|
+
// in places, each entry by name. Every name is lowercase hex the kit
|
|
35
|
+
// chose, so a body may use it as a file name as it stands.
|
|
31
36
|
export abstract class Memory {
|
|
32
|
-
|
|
37
|
+
// Every entry kept in a place; a place nothing was kept in is empty.
|
|
38
|
+
abstract read(place: string): Promise<Map<string, Uint8Array>>;
|
|
39
|
+
// One keep: each entry written whole, or removed where it is null.
|
|
40
|
+
abstract write(place: string, entries: ReadonlyMap<string, Uint8Array | null>): Promise<void>;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
43
|
+
// The harbor's seed: thirty-two bytes, the same every time. It is the one
|
|
44
|
+
// secret a terrain holds.
|
|
37
45
|
export abstract class Custody {
|
|
38
|
-
abstract seed(
|
|
46
|
+
abstract seed(): Promise<Uint8Array>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A module: the code a harbor's classes come from. It declares its own
|
|
50
|
+
// name, a reversed domain as a kind is, and its version.
|
|
51
|
+
export type Module = { readonly module: string; readonly version: string; readonly classes: readonly unknown[] };
|
|
52
|
+
|
|
53
|
+
// A module as a loader found it, with `from`, the name this loader finds
|
|
54
|
+
// it by again.
|
|
55
|
+
export type Loaded = Module & { readonly from: string };
|
|
56
|
+
|
|
57
|
+
// Code, by the name a harbor keeps for it, or null where this loader has
|
|
58
|
+
// none. Code never crosses a door and never lives in a cell, so it is the
|
|
59
|
+
// one thing beside the seed a terrain hands in: a map, an import, a bundle.
|
|
60
|
+
export abstract class Loader {
|
|
61
|
+
abstract load(from: string): Promise<Loaded | null>;
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
// Bytes to a ward pk in; bytes, or null for nothing, out.
|
|
42
65
|
export abstract class Carrier {
|
|
43
66
|
abstract carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | null>;
|
|
67
|
+
// Where a ward pk is reached, or null to forget it: true when this
|
|
68
|
+
// carrier dials by address and took it. The addresses live in the dock's
|
|
69
|
+
// cells, and the harbor hands them here as it unpacks.
|
|
70
|
+
route(_pk: string, _at: string | null): boolean {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
44
73
|
}
|