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
package/src/line/push.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A ring with no content, as Web Push sends it: RFC 8030 with no payload,
|
|
3
|
+
// so nothing is encrypted and nothing is said, and RFC 8292's VAPID, so
|
|
4
|
+
// the push service knows who rings. It runs on fetch and Web Crypto, in
|
|
5
|
+
// every engine. The device a ring wakes asks its relay client to `ring`.
|
|
6
|
+
//
|
|
7
|
+
// subject { subject } the contact a push service reaches, a mailto:
|
|
8
|
+
// or https: URI, the owner's to set
|
|
9
|
+
// key the key a device subscribes with, as
|
|
10
|
+
// PushManager takes it
|
|
11
|
+
// ring { endpoint } a ring to that endpoint: `rung`, `gone` where
|
|
12
|
+
// the push service has let it go, or an error
|
|
13
|
+
//
|
|
14
|
+
// The signing key is drawn at the first `key` or `ring` and kept in the
|
|
15
|
+
// faculty's cells, sealed with the rest of the harbor.
|
|
16
|
+
import type { AskSpec, JsonObject } from '../being/index.ts';
|
|
17
|
+
import { concat, utf8 } from '../crypto/index.ts';
|
|
18
|
+
import { Push, type GroundFaculty } from '../harbor/index.ts';
|
|
19
|
+
import { ownerAsk } from '../ward/index.ts';
|
|
20
|
+
|
|
21
|
+
export const WEB_PUSH = 'org.nervur.web-push';
|
|
22
|
+
// How long a push service holds a ring for a device, in seconds.
|
|
23
|
+
export const PUSH_TTL = 60;
|
|
24
|
+
// How long a VAPID token stands, in seconds, inside RFC 8292's day.
|
|
25
|
+
export const VAPID_LIFE = 43_200;
|
|
26
|
+
|
|
27
|
+
type Vapid = { d: string; x: string; y: string };
|
|
28
|
+
const EC = { name: 'ECDSA', namedCurve: 'P-256' } as const;
|
|
29
|
+
|
|
30
|
+
export const base64url = (bytes: Uint8Array): string => {
|
|
31
|
+
let s = '';
|
|
32
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
33
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
34
|
+
};
|
|
35
|
+
const unbase64url = (text: string): Uint8Array => Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
|
36
|
+
const local = (host: string): boolean => host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
|
|
37
|
+
|
|
38
|
+
// A push endpoint: an https URL, or plain http to this machine alone.
|
|
39
|
+
export const pushEndpoint = (value: unknown): URL | null => {
|
|
40
|
+
if (typeof value !== 'string') return null;
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(value);
|
|
43
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && local(url.hostname)) ? url : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const isSubject = (value: unknown): value is string => typeof value === 'string' && /^(mailto:.+|https:\/\/.+)$/.test(value);
|
|
49
|
+
|
|
50
|
+
export const webPush: GroundFaculty = () =>
|
|
51
|
+
class WebPush extends Push {
|
|
52
|
+
static override readonly kind: string = WEB_PUSH;
|
|
53
|
+
static override asks: Record<string, AskSpec> = {
|
|
54
|
+
subject: ownerAsk('the contact a push service reaches, a mailto: or https: URI', { required: ['subject'] }),
|
|
55
|
+
key: { description: 'the key a device subscribes to pushes with', input: { type: 'object' } },
|
|
56
|
+
ring: { description: 'a ring with no content to a push endpoint', input: { type: 'object', required: ['endpoint'] } },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
#drawing: Promise<Vapid> | undefined;
|
|
60
|
+
|
|
61
|
+
subject(args: JsonObject): JsonObject {
|
|
62
|
+
if (!isSubject(args.subject)) return { error: 'subject is a mailto: or https: URI' };
|
|
63
|
+
this.cells.subject = args.subject;
|
|
64
|
+
return { subject: args.subject };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async key(): Promise<JsonObject> {
|
|
68
|
+
return { key: WebPush.#public(await this.#vapid()) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async ring(args: JsonObject): Promise<JsonObject> {
|
|
72
|
+
const endpoint = pushEndpoint(args.endpoint);
|
|
73
|
+
if (endpoint === null) return { error: 'endpoint is an https URL' };
|
|
74
|
+
const sub = this.cells.subject;
|
|
75
|
+
if (!isSubject(sub)) return { error: 'no subject is set' };
|
|
76
|
+
const vapid = await this.#vapid();
|
|
77
|
+
const signing = await crypto.subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', ...vapid }, EC, false, ['sign']);
|
|
78
|
+
const part = (value: JsonObject) => base64url(utf8(JSON.stringify(value)));
|
|
79
|
+
const unsigned = `${part({ typ: 'JWT', alg: 'ES256' })}.${part({ aud: endpoint.origin, exp: Math.floor(Date.now() / 1000) + VAPID_LIFE, sub })}`;
|
|
80
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, signing, new Uint8Array(utf8(unsigned))));
|
|
81
|
+
let status: number;
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(endpoint, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { TTL: String(PUSH_TTL), Urgency: 'high', 'Content-Length': '0', Authorization: `vapid t=${unsigned}.${base64url(signature)}, k=${WebPush.#public(vapid)}` },
|
|
86
|
+
});
|
|
87
|
+
status = response.status;
|
|
88
|
+
await response.body?.cancel();
|
|
89
|
+
} catch {
|
|
90
|
+
return { error: 'the push service was not reached' };
|
|
91
|
+
}
|
|
92
|
+
if (status === 404 || status === 410) return { gone: true };
|
|
93
|
+
return status >= 200 && status < 300 ? { rung: true } : { error: `the push service answered ${String(status)}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static #public({ x, y }: Vapid): string {
|
|
97
|
+
return base64url(concat(Uint8Array.of(4), unbase64url(x), unbase64url(y)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#vapid(): Promise<Vapid> {
|
|
101
|
+
const kept = this.cells.vapid as Vapid | undefined;
|
|
102
|
+
if (kept) return Promise.resolve(kept);
|
|
103
|
+
this.#drawing ??= (async () => {
|
|
104
|
+
const pair = await crypto.subtle.generateKey(EC, true, ['sign']);
|
|
105
|
+
const { d, x, y } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
106
|
+
const vapid = { d: d!, x: x!, y: y! };
|
|
107
|
+
this.cells.vapid = vapid;
|
|
108
|
+
return vapid;
|
|
109
|
+
})();
|
|
110
|
+
return this.#drawing;
|
|
111
|
+
}
|
|
112
|
+
};
|
package/src/line/web.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Quo over the web, `quo/CARRIER-WEB.md`, on what every engine has: fetch
|
|
3
|
+
// and the WebSocket client.
|
|
4
|
+
//
|
|
5
|
+
// WebDialer bytes to a ward pk at an address: `http` and `https` as
|
|
6
|
+
// one post each, `ws` and `wss` on one held line per
|
|
7
|
+
// address, which many asks share
|
|
8
|
+
// webFaculty the web carrier as a faculty of one harbor: it dials
|
|
9
|
+
// every web address the harbor routes, and listens where
|
|
10
|
+
// its cells say, through a listener the ground hands it
|
|
11
|
+
//
|
|
12
|
+
// A post that comes back with status 200 and a body is a reply, and every
|
|
13
|
+
// other status is nothing. A post the network refused, or a held line
|
|
14
|
+
// that did not open, was not dialed. A line that carried a box and closed
|
|
15
|
+
// answers nothing.
|
|
16
|
+
import type { AskSpec, JsonObject } from '../being/index.ts';
|
|
17
|
+
import { concat, hex, isHex, unhex } from '../crypto/index.ts';
|
|
18
|
+
import { CarrierFaculty, UNDIALED, type Dialed, type Ground, type GroundFaculty } from '../harbor/index.ts';
|
|
19
|
+
import { webAddress, type WebAddress } from '../quo/index.ts';
|
|
20
|
+
import { ownerAsk } from '../ward/index.ts';
|
|
21
|
+
import { askBody, readBody } from './frame.ts';
|
|
22
|
+
|
|
23
|
+
export const MIN_POST = 65;
|
|
24
|
+
export const MAX_POST = 64 + 1_048_576;
|
|
25
|
+
const MAX_ID = 0xffff_ffff;
|
|
26
|
+
|
|
27
|
+
// Bytes on a buffer of their own, as a body and a message take them.
|
|
28
|
+
const own = (bytes: Uint8Array): Uint8Array<ArrayBuffer> => new Uint8Array(bytes);
|
|
29
|
+
|
|
30
|
+
// One held line to one address.
|
|
31
|
+
class HeldLine {
|
|
32
|
+
readonly #socket: WebSocket;
|
|
33
|
+
readonly #waiting = new Map<number, (box: Uint8Array | null) => void>();
|
|
34
|
+
#next = 1;
|
|
35
|
+
|
|
36
|
+
private constructor(socket: WebSocket) {
|
|
37
|
+
this.#socket = socket;
|
|
38
|
+
socket.binaryType = 'arraybuffer';
|
|
39
|
+
socket.addEventListener('message', (e: MessageEvent) => {
|
|
40
|
+
const frame = typeof e.data === 'string' ? null : readBody(new Uint8Array(e.data as ArrayBuffer));
|
|
41
|
+
if (frame === null) return this.close();
|
|
42
|
+
if (frame.kind === 'ask') return;
|
|
43
|
+
const done = this.#waiting.get(frame.id);
|
|
44
|
+
this.#waiting.delete(frame.id);
|
|
45
|
+
done?.(frame.kind === 'reply' ? frame.box : null);
|
|
46
|
+
});
|
|
47
|
+
socket.addEventListener('close', () => {
|
|
48
|
+
for (const done of this.#waiting.values()) done(null);
|
|
49
|
+
this.#waiting.clear();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A line that opened with the subprotocol `quo`, or null.
|
|
54
|
+
static open(href: string, onClose: () => void): Promise<HeldLine | null> {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let socket: WebSocket;
|
|
57
|
+
try {
|
|
58
|
+
socket = new WebSocket(href, 'quo');
|
|
59
|
+
} catch {
|
|
60
|
+
resolve(null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const line = new HeldLine(socket);
|
|
64
|
+
socket.addEventListener('open', () => (socket.protocol === 'quo' ? resolve(line) : (socket.close(), resolve(null))));
|
|
65
|
+
socket.addEventListener('close', () => {
|
|
66
|
+
onClose();
|
|
67
|
+
resolve(null);
|
|
68
|
+
});
|
|
69
|
+
socket.addEventListener('error', () => resolve(null));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ask(pk: Uint8Array, box: Uint8Array): Promise<Uint8Array | null> {
|
|
74
|
+
if (this.#socket.readyState !== WebSocket.OPEN) return Promise.resolve(null);
|
|
75
|
+
const id = this.#next;
|
|
76
|
+
this.#next = this.#next === MAX_ID ? 1 : this.#next + 1;
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
this.#waiting.set(id, resolve);
|
|
79
|
+
this.#socket.send(own(askBody(id, pk, box)));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
close(): void {
|
|
84
|
+
this.#socket.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class WebDialer {
|
|
89
|
+
readonly #lines = new Map<string, Promise<HeldLine | null>>();
|
|
90
|
+
|
|
91
|
+
dial(at: string, pk: string, bytes: Uint8Array): Promise<Dialed> {
|
|
92
|
+
const address = webAddress(at);
|
|
93
|
+
if (address === null || !isHex(pk, 64)) return Promise.resolve(UNDIALED);
|
|
94
|
+
return address.held ? this.#held(address, pk, bytes) : this.#post(address, pk, bytes);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async #post(address: WebAddress, pk: string, bytes: Uint8Array): Promise<Dialed> {
|
|
98
|
+
let response: Response;
|
|
99
|
+
try {
|
|
100
|
+
response = await fetch(address.href, { method: 'POST', body: own(concat(unhex(pk), bytes)) });
|
|
101
|
+
} catch {
|
|
102
|
+
return UNDIALED;
|
|
103
|
+
}
|
|
104
|
+
const body = new Uint8Array(await response.arrayBuffer().catch(() => new ArrayBuffer(0)));
|
|
105
|
+
return response.status === 200 && body.length > 0 ? body : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async #held(address: WebAddress, pk: string, bytes: Uint8Array): Promise<Dialed> {
|
|
109
|
+
let line = this.#lines.get(address.href);
|
|
110
|
+
if (!line) {
|
|
111
|
+
line = HeldLine.open(address.href, () => this.#lines.delete(address.href));
|
|
112
|
+
this.#lines.set(address.href, line);
|
|
113
|
+
void line.then((held) => held ?? this.#lines.delete(address.href));
|
|
114
|
+
}
|
|
115
|
+
const held = await line;
|
|
116
|
+
return held ? held.ask(unhex(pk), bytes) : UNDIALED;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async close(): Promise<void> {
|
|
120
|
+
const lines = [...this.#lines.values()];
|
|
121
|
+
this.#lines.clear();
|
|
122
|
+
for (const line of await Promise.all(lines)) line?.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Where the web carrier listens, as its cells keep it: a host, the port it
|
|
127
|
+
// bound, the path it answers on, and the origins whose pages it answers.
|
|
128
|
+
export type WebListen = { host: string; port: number; path: string; origins: string[] };
|
|
129
|
+
|
|
130
|
+
// A listener a ground runs for the web carrier: it hands every ask it
|
|
131
|
+
// hears to the ground, and says the addresses it answers at, a post's and
|
|
132
|
+
// a held line's.
|
|
133
|
+
export interface WebListener {
|
|
134
|
+
readonly at: string[];
|
|
135
|
+
readonly port: number;
|
|
136
|
+
close(): Promise<void>;
|
|
137
|
+
}
|
|
138
|
+
export type WebServe = (ground: Ground, listen: WebListen) => Promise<WebListener>;
|
|
139
|
+
|
|
140
|
+
export const WEB = 'org.nervur.web';
|
|
141
|
+
export const WEB_OPENING = { key: 'web', kind: WEB } as const;
|
|
142
|
+
|
|
143
|
+
const listenOf = (value: unknown): WebListen | null => {
|
|
144
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
145
|
+
const { host, port, path, origins } = value as Record<string, unknown>;
|
|
146
|
+
const fine =
|
|
147
|
+
typeof host === 'string' &&
|
|
148
|
+
Number.isInteger(port) &&
|
|
149
|
+
(port as number) >= 0 &&
|
|
150
|
+
(port as number) <= 65_535 &&
|
|
151
|
+
typeof path === 'string' &&
|
|
152
|
+
path.startsWith('/') &&
|
|
153
|
+
Array.isArray(origins) &&
|
|
154
|
+
origins.every((o) => typeof o === 'string');
|
|
155
|
+
return fine ? { host, port: port as number, path, origins: origins as string[] } : null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Quo over the web as a carrier faculty of one harbor, listening through
|
|
159
|
+
// `serve` where the ground has one:
|
|
160
|
+
//
|
|
161
|
+
// listen { host?, port?, path?, origins? }
|
|
162
|
+
// listen there from now on, 127.0.0.1, a free port, `/`
|
|
163
|
+
// and every origin unless named, and on every wake at the
|
|
164
|
+
// port it bound
|
|
165
|
+
// deaf listen nowhere from now on
|
|
166
|
+
export const webFaculty =
|
|
167
|
+
(serve?: WebServe): GroundFaculty =>
|
|
168
|
+
(ground: Ground) =>
|
|
169
|
+
class WebFaculty extends CarrierFaculty {
|
|
170
|
+
static override readonly kind: string = WEB;
|
|
171
|
+
static override asks: Record<string, AskSpec> = {
|
|
172
|
+
listen: ownerAsk('listen for Quo over the web, and on every wake'),
|
|
173
|
+
deaf: ownerAsk('listen nowhere'),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
readonly #dialer = new WebDialer();
|
|
177
|
+
#listener: WebListener | undefined;
|
|
178
|
+
|
|
179
|
+
accepts(address: string): boolean {
|
|
180
|
+
return webAddress(address) !== null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
dial(address: string, pk: string, bytes: Uint8Array): Promise<Dialed> {
|
|
184
|
+
return this.#dialer.dial(address, pk, bytes);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
override listening(): string[] {
|
|
188
|
+
return this.#listener ? [...this.#listener.at] : [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
override async awake(): Promise<void> {
|
|
192
|
+
const at = listenOf(this.cells.listen);
|
|
193
|
+
if (at && serve && !this.#listener) this.#listener = await serve(ground, at);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
override async sleep(): Promise<void> {
|
|
197
|
+
await this.#dialer.close();
|
|
198
|
+
await this.#listener?.close();
|
|
199
|
+
this.#listener = undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async listen(args: JsonObject): Promise<JsonObject> {
|
|
203
|
+
if (!serve) return { error: 'this ground listens nowhere on the web' };
|
|
204
|
+
const at = listenOf({ host: args.host ?? '127.0.0.1', port: args.port ?? 0, path: args.path ?? '/', origins: args.origins ?? ['*'] });
|
|
205
|
+
if (at === null) return { error: 'host is text, port a port, path a path and origins a list' };
|
|
206
|
+
await this.#listener?.close();
|
|
207
|
+
this.#listener = undefined;
|
|
208
|
+
try {
|
|
209
|
+
this.#listener = await serve(ground, at);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return { error: (e as Error).message };
|
|
212
|
+
}
|
|
213
|
+
this.cells.listen = { ...at, port: this.#listener.port };
|
|
214
|
+
return { at: [...this.#listener.at] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async deaf(): Promise<JsonObject> {
|
|
218
|
+
await this.#listener?.close();
|
|
219
|
+
this.#listener = undefined;
|
|
220
|
+
delete this.cells.listen;
|
|
221
|
+
return { deaf: true };
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// The ward pk and box a post carries, or null for a body that is no ask.
|
|
226
|
+
export const readPost = (body: Uint8Array): { pk: string; box: Uint8Array } | null =>
|
|
227
|
+
body.length < MIN_POST || body.length > MAX_POST ? null : { pk: hex(body.subarray(0, 64)), box: body.slice(64) };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur/node`: the Node ground. A harbor keeps its seed and sealed
|
|
3
|
+
// memory in a folder, finds its code among the modules it is handed and
|
|
4
|
+
// then in files, speaks TCP from genesis and the web and Web Push where
|
|
5
|
+
// the root opens them, and serves the root line on a local socket, so the
|
|
6
|
+
// `nervur` command reaches it while it stands.
|
|
7
|
+
//
|
|
8
|
+
// <dir>/seed the harbor's seed
|
|
9
|
+
// <dir>/<place>/ its sealed memory
|
|
10
|
+
// <dir>/root.sock the root line while the harbor is served, open to
|
|
11
|
+
// this user alone, or in the temporary folder for a
|
|
12
|
+
// folder too deep for a socket path
|
|
13
|
+
//
|
|
14
|
+
// One run holds a folder: a terrain refuses a folder another run serves.
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { chmod, rm } from 'node:fs/promises';
|
|
17
|
+
import { connect, createServer, type Server } from 'node:net';
|
|
18
|
+
import { homedir, tmpdir } from 'node:os';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
import type { JsonObject } from '../being/index.ts';
|
|
22
|
+
import { Loader, type Loaded, type Module } from '../contract/index.ts';
|
|
23
|
+
import { FolderCustody, FolderMemory } from '../folder/index.ts';
|
|
24
|
+
import { rootLine, type GroundProbe, type Harbor, type RootRequest } from '../harbor/index.ts';
|
|
25
|
+
import { webGround } from '../http/index.ts';
|
|
26
|
+
import { webPush } from '../line/index.ts';
|
|
27
|
+
import { CryptoEntropy, HeldLoader, PointerTerrain } from '../pointer/index.ts';
|
|
28
|
+
import { TCP_OPENING, tcpFaculty } from '../tcp/index.ts';
|
|
29
|
+
|
|
30
|
+
export const NODE = 'node';
|
|
31
|
+
|
|
32
|
+
// Where a harbor of this user lives unless named: $NERVUR_DIR, then
|
|
33
|
+
// ~/.nervur.
|
|
34
|
+
export const harborDir = (env: Record<string, string | undefined> = process.env): string => env.NERVUR_DIR ?? join(homedir(), '.nervur');
|
|
35
|
+
|
|
36
|
+
// A socket path has a hundred bytes or so on every system, so a folder too
|
|
37
|
+
// deep for one puts its socket in this user's temporary folder, under a
|
|
38
|
+
// name drawn from the folder's own path.
|
|
39
|
+
const SOCKET_PATH = 100;
|
|
40
|
+
export const socketOf = (dir: string): string => {
|
|
41
|
+
const beside = join(resolve(dir), 'root.sock');
|
|
42
|
+
if (Buffer.byteLength(beside) <= SOCKET_PATH) return beside;
|
|
43
|
+
return join(tmpdir(), `nervur-${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 32)}.sock`);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Code from a file: a module that exports `module`, `version` and
|
|
47
|
+
// `classes`, found again by its absolute path. A file that cannot be
|
|
48
|
+
// imported, or exports no module, is none.
|
|
49
|
+
export class ImportLoader extends Loader {
|
|
50
|
+
async load(from: string): Promise<Loaded | null> {
|
|
51
|
+
const path = resolve(from);
|
|
52
|
+
let exported: { module?: unknown; version?: unknown; classes?: unknown };
|
|
53
|
+
try {
|
|
54
|
+
exported = (await import(pathToFileURL(path).href)) as typeof exported;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const { module, version, classes } = exported;
|
|
59
|
+
if (typeof module !== 'string' || typeof version !== 'string' || !Array.isArray(classes)) return null;
|
|
60
|
+
return { module, version, classes, from: path };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The modules a harbor is handed, by name, and then files.
|
|
65
|
+
export class NodeLoader extends ImportLoader {
|
|
66
|
+
readonly #held: HeldLoader;
|
|
67
|
+
constructor(modules: readonly Module[] = []) {
|
|
68
|
+
super();
|
|
69
|
+
this.#held = new HeldLoader(modules);
|
|
70
|
+
}
|
|
71
|
+
override async load(from: string): Promise<Loaded | null> {
|
|
72
|
+
return (await this.#held.load(from)) ?? super.load(from);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One root request over a served harbor's socket, or undefined where none
|
|
77
|
+
// is served.
|
|
78
|
+
export const askServed = (dir: string, request: RootRequest): Promise<JsonObject | undefined> =>
|
|
79
|
+
new Promise((done, fail) => {
|
|
80
|
+
const socket = connect(socketOf(dir));
|
|
81
|
+
let text = '';
|
|
82
|
+
socket.once('connect', () => socket.write(`${JSON.stringify(request)}\n`));
|
|
83
|
+
socket.on('data', (chunk: Buffer) => {
|
|
84
|
+
text += chunk.toString('utf8');
|
|
85
|
+
const end = text.indexOf('\n');
|
|
86
|
+
if (end < 0) return;
|
|
87
|
+
socket.end();
|
|
88
|
+
done(JSON.parse(text.slice(0, end)) as JsonObject);
|
|
89
|
+
});
|
|
90
|
+
socket.once('error', (e: NodeJS.ErrnoException) => (e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? done(undefined) : fail(e)));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export type NodeParts = {
|
|
94
|
+
readonly dir: string;
|
|
95
|
+
readonly modules?: readonly Module[];
|
|
96
|
+
// Whether its carriers wake, and whether it serves the root line.
|
|
97
|
+
readonly wakes?: boolean;
|
|
98
|
+
readonly serves?: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export class NodeTerrain extends PointerTerrain {
|
|
102
|
+
readonly dir: string;
|
|
103
|
+
readonly serves: boolean;
|
|
104
|
+
#server: Server | undefined;
|
|
105
|
+
|
|
106
|
+
constructor(parts: NodeParts) {
|
|
107
|
+
const entropy = new CryptoEntropy();
|
|
108
|
+
super({
|
|
109
|
+
entropy,
|
|
110
|
+
loader: new NodeLoader(parts.modules),
|
|
111
|
+
custody: new FolderCustody(parts.dir, entropy),
|
|
112
|
+
memory: new FolderMemory(parts.dir),
|
|
113
|
+
faculties: [tcpFaculty, webGround, webPush],
|
|
114
|
+
opens: [TCP_OPENING],
|
|
115
|
+
wakes: parts.wakes ?? true,
|
|
116
|
+
});
|
|
117
|
+
this.dir = parts.dir;
|
|
118
|
+
this.serves = parts.serves ?? true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
override async claim(): Promise<void> {
|
|
122
|
+
if ((await askServed(this.dir, {})) !== undefined) throw new Error(`a harbor is already served from ${this.dir}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The root line on the socket. A socket file nobody answers on is a
|
|
126
|
+
// stale one, and is replaced.
|
|
127
|
+
override async stand(harbor: Harbor): Promise<void> {
|
|
128
|
+
if (!this.serves) return;
|
|
129
|
+
const root = socketOf(this.dir);
|
|
130
|
+
await rm(root, { force: true });
|
|
131
|
+
const server = createServer((socket) => {
|
|
132
|
+
let text = '';
|
|
133
|
+
socket.on('data', (chunk: Buffer) => {
|
|
134
|
+
text += chunk.toString('utf8');
|
|
135
|
+
for (let end = text.indexOf('\n'); end >= 0; end = text.indexOf('\n')) {
|
|
136
|
+
const line = text.slice(0, end);
|
|
137
|
+
text = text.slice(end + 1);
|
|
138
|
+
let value: unknown;
|
|
139
|
+
try {
|
|
140
|
+
value = JSON.parse(line);
|
|
141
|
+
} catch {
|
|
142
|
+
value = undefined;
|
|
143
|
+
}
|
|
144
|
+
void rootLine(harbor, value)
|
|
145
|
+
.catch(() => ({ error: 'the harbor threw' }))
|
|
146
|
+
.then((answer) => socket.write(`${JSON.stringify(answer)}\n`));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
socket.on('error', () => socket.destroy());
|
|
150
|
+
});
|
|
151
|
+
await new Promise<void>((done) => server.listen(root, done));
|
|
152
|
+
await chmod(root, 0o600);
|
|
153
|
+
this.#server = server;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
override async release(): Promise<void> {
|
|
157
|
+
const server = this.#server;
|
|
158
|
+
if (!server) return;
|
|
159
|
+
this.#server = undefined;
|
|
160
|
+
await new Promise((done) => server.close(done));
|
|
161
|
+
await rm(socketOf(this.dir), { force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Node, and the engines that answer as Node does.
|
|
166
|
+
export const nodeGround: GroundProbe = {
|
|
167
|
+
name: NODE,
|
|
168
|
+
fits: () => typeof process !== 'undefined' && typeof process.versions?.node === 'string',
|
|
169
|
+
terrain: ({ modules, where }) => new NodeTerrain({ dir: where ?? harborDir(), ...(modules ? { modules } : {}) }),
|
|
170
|
+
};
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `nervur` as Node reads it: the main entry, with the Node ground added, so
|
|
3
|
+
// `Harbor.open()` keeps a harbor in a folder and serves its root line.
|
|
4
|
+
import { addGround } from './harbor/index.ts';
|
|
5
|
+
import { nodeGround } from './node/index.ts';
|
|
6
|
+
|
|
7
|
+
export * from './index.ts';
|
|
8
|
+
|
|
9
|
+
addGround(nodeGround);
|
package/src/pointer/bodies.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// The pointer terrain's bodies: each contract fulfilled in one process,
|
|
3
3
|
// with nothing beyond JavaScript.
|
|
4
|
-
import { Clock, Custody, Entropy, Memory, type
|
|
4
|
+
import { Clock, Custody, Entropy, Loader, Memory, type Loaded, type Module } from '../contract/index.ts';
|
|
5
5
|
|
|
6
6
|
export class CryptoEntropy extends Entropy {
|
|
7
7
|
draw(length: number): Uint8Array {
|
|
@@ -20,28 +20,57 @@ export class SystemClock extends Clock {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
//
|
|
23
|
+
// The harbor's seed, drawn once and held by this object.
|
|
24
24
|
export class HeldCustody extends Custody {
|
|
25
|
-
readonly #
|
|
26
|
-
readonly #seeds = new Map<string, Uint8Array>();
|
|
25
|
+
readonly #seed: Uint8Array;
|
|
27
26
|
constructor(entropy: Entropy) {
|
|
28
27
|
super();
|
|
29
|
-
this.#
|
|
28
|
+
this.#seed = entropy.draw(32);
|
|
30
29
|
}
|
|
31
|
-
seed(
|
|
32
|
-
|
|
33
|
-
if (!seed) this.#seeds.set(name, (seed = this.#entropy.draw(32)));
|
|
34
|
-
return Promise.resolve(seed);
|
|
30
|
+
seed(): Promise<Uint8Array> {
|
|
31
|
+
return Promise.resolve(this.#seed.slice());
|
|
35
32
|
}
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
//
|
|
39
|
-
|
|
35
|
+
// Modules handed to this object, found by the name each declares.
|
|
36
|
+
export class HeldLoader extends Loader {
|
|
37
|
+
readonly #modules = new Map<string, Module>();
|
|
38
|
+
constructor(modules: readonly Module[] = []) {
|
|
39
|
+
super();
|
|
40
|
+
for (const m of modules) this.#modules.set(m.module, m);
|
|
41
|
+
}
|
|
42
|
+
load(from: string): Promise<Loaded | null> {
|
|
43
|
+
const m = this.#modules.get(from);
|
|
44
|
+
return Promise.resolve(m ? { module: m.module, version: m.version, classes: m.classes, from } : null);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Entries that live as long as this object, copied in and out, which is
|
|
49
|
+
// what a restart in one process finds.
|
|
40
50
|
export class VolatileMemory extends Memory {
|
|
41
|
-
readonly #
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
readonly #places = new Map<string, Map<string, Uint8Array>>();
|
|
52
|
+
read(place: string): Promise<Map<string, Uint8Array>> {
|
|
53
|
+
const entries = this.#places.get(place) ?? new Map<string, Uint8Array>();
|
|
54
|
+
return Promise.resolve(new Map([...entries].map(([name, bytes]) => [name, bytes.slice()])));
|
|
55
|
+
}
|
|
56
|
+
// One object holds it, so a keep is whole by the run of one turn.
|
|
57
|
+
write(place: string, entries: ReadonlyMap<string, Uint8Array | null>): Promise<void> {
|
|
58
|
+
let kept = this.#places.get(place);
|
|
59
|
+
if (!kept) this.#places.set(place, (kept = new Map()));
|
|
60
|
+
for (const [name, bytes] of entries) {
|
|
61
|
+
if (bytes === null) kept.delete(name);
|
|
62
|
+
else kept.set(name, bytes.slice());
|
|
63
|
+
}
|
|
64
|
+
if (kept.size === 0) this.#places.delete(place);
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
places(): Promise<string[]> {
|
|
69
|
+
return Promise.resolve([...this.#places.keys()]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
forget(place: string): Promise<void> {
|
|
73
|
+
this.#places.delete(place);
|
|
74
|
+
return Promise.resolve();
|
|
46
75
|
}
|
|
47
76
|
}
|
package/src/pointer/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// The pointer dock: a whole world with `nervur` alone.
|
|
3
|
-
export { CryptoEntropy, HeldCustody, SystemClock, VolatileMemory } from './bodies.ts';
|
|
3
|
+
export { CryptoEntropy, HeldCustody, HeldLoader, SystemClock, VolatileMemory } from './bodies.ts';
|
|
4
4
|
export { NoCarrier, PointerCarrier, PointerTerrain, World, type PointerParts } from './world.ts';
|