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.
Files changed (157) hide show
  1. package/README.md +229 -20
  2. package/dist/being/being.d.ts +1 -0
  3. package/dist/being/being.js +5 -1
  4. package/dist/being/faculty.d.ts +1 -0
  5. package/dist/being/faculty.js +13 -7
  6. package/dist/being/index.d.ts +1 -0
  7. package/dist/being/index.js +1 -0
  8. package/dist/being/kind.d.ts +4 -0
  9. package/dist/being/kind.js +28 -0
  10. package/dist/being/types.d.ts +3 -4
  11. package/dist/browser/index.d.ts +35 -0
  12. package/dist/browser/index.js +188 -0
  13. package/dist/browser/worker.d.ts +12 -0
  14. package/dist/browser/worker.js +40 -0
  15. package/dist/browser.d.ts +1 -0
  16. package/dist/browser.js +7 -0
  17. package/dist/cli/command.d.ts +13 -0
  18. package/dist/cli/command.js +237 -0
  19. package/dist/cli/harbor.d.ts +19 -0
  20. package/dist/cli/harbor.js +99 -0
  21. package/dist/cli/main.d.ts +2 -0
  22. package/dist/cli/main.js +14 -0
  23. package/dist/contract/index.d.ts +16 -2
  24. package/dist/contract/index.js +13 -3
  25. package/dist/edge/index.d.ts +57 -0
  26. package/dist/edge/index.js +306 -0
  27. package/dist/edge.d.ts +1 -0
  28. package/dist/edge.js +8 -0
  29. package/dist/folder/index.d.ts +9 -4
  30. package/dist/folder/index.js +89 -84
  31. package/dist/harbor/carrying.d.ts +17 -0
  32. package/dist/harbor/carrying.js +38 -0
  33. package/dist/harbor/catalogue.d.ts +27 -0
  34. package/dist/harbor/catalogue.js +101 -0
  35. package/dist/harbor/dock.d.ts +32 -3
  36. package/dist/harbor/dock.js +98 -12
  37. package/dist/harbor/harbor.d.ts +27 -8
  38. package/dist/harbor/harbor.js +217 -26
  39. package/dist/harbor/index.d.ts +8 -2
  40. package/dist/harbor/index.js +8 -1
  41. package/dist/harbor/package.d.ts +12 -0
  42. package/dist/harbor/package.js +130 -0
  43. package/dist/harbor/probe.d.ts +18 -0
  44. package/dist/harbor/probe.js +15 -0
  45. package/dist/harbor/registry.d.ts +6 -3
  46. package/dist/harbor/registry.js +83 -7
  47. package/dist/harbor/relay.d.ts +15 -0
  48. package/dist/harbor/relay.js +387 -0
  49. package/dist/harbor/root-line.d.ts +9 -0
  50. package/dist/harbor/root-line.js +38 -0
  51. package/dist/harbor/terrain.d.ts +14 -1
  52. package/dist/harbor/terrain.js +19 -0
  53. package/dist/http/index.d.ts +4 -0
  54. package/dist/http/index.js +156 -0
  55. package/dist/http/websocket.d.ts +29 -0
  56. package/dist/http/websocket.js +133 -0
  57. package/dist/index.d.ts +5 -4
  58. package/dist/index.js +12 -6
  59. package/dist/line/answer.d.ts +8 -0
  60. package/dist/line/answer.js +60 -0
  61. package/dist/line/frame.d.ts +34 -0
  62. package/dist/line/frame.js +81 -0
  63. package/dist/line/ground.d.ts +3 -0
  64. package/dist/line/ground.js +9 -0
  65. package/dist/line/index.d.ts +5 -0
  66. package/dist/line/index.js +9 -0
  67. package/dist/line/push.d.ts +7 -0
  68. package/dist/line/push.js +92 -0
  69. package/dist/line/web.d.ts +30 -0
  70. package/dist/line/web.js +181 -0
  71. package/dist/node/index.d.ts +32 -0
  72. package/dist/node/index.js +164 -0
  73. package/dist/node.d.ts +1 -0
  74. package/dist/node.js +7 -0
  75. package/dist/pointer/bodies.d.ts +11 -3
  76. package/dist/pointer/bodies.js +47 -18
  77. package/dist/pointer/index.d.ts +1 -1
  78. package/dist/pointer/index.js +1 -1
  79. package/dist/pointer/world.d.ts +12 -5
  80. package/dist/pointer/world.js +29 -14
  81. package/dist/quo/address.d.ts +19 -0
  82. package/dist/quo/address.js +84 -0
  83. package/dist/quo/door.d.ts +1 -1
  84. package/dist/quo/door.js +6 -4
  85. package/dist/quo/index.d.ts +1 -0
  86. package/dist/quo/index.js +1 -0
  87. package/dist/quo/invitation.d.ts +1 -0
  88. package/dist/quo/invitation.js +9 -5
  89. package/dist/quo/keys.js +1 -1
  90. package/dist/tcp/frame.d.ts +25 -0
  91. package/dist/tcp/frame.js +64 -0
  92. package/dist/tcp/index.d.ts +24 -0
  93. package/dist/tcp/index.js +260 -0
  94. package/dist/ward/index.d.ts +3 -2
  95. package/dist/ward/index.js +2 -1
  96. package/dist/ward/pilot.d.ts +8 -0
  97. package/dist/ward/pilot.js +50 -0
  98. package/dist/ward/stance.d.ts +4 -4
  99. package/dist/ward/stance.js +10 -7
  100. package/dist/ward/ward-being.d.ts +4 -1
  101. package/dist/ward/ward-being.js +8 -2
  102. package/dist/ward/ward.d.ts +11 -2
  103. package/dist/ward/ward.js +41 -5
  104. package/package.json +28 -2
  105. package/src/being/being.ts +5 -1
  106. package/src/being/faculty.ts +14 -7
  107. package/src/being/index.ts +1 -0
  108. package/src/being/kind.ts +30 -0
  109. package/src/being/types.ts +8 -6
  110. package/src/browser/index.ts +207 -0
  111. package/src/browser/worker.ts +66 -0
  112. package/src/browser.ts +9 -0
  113. package/src/cli/command.ts +224 -0
  114. package/src/cli/harbor.ts +103 -0
  115. package/src/cli/main.ts +13 -0
  116. package/src/contract/index.ts +42 -10
  117. package/src/edge/index.ts +359 -0
  118. package/src/edge.ts +10 -0
  119. package/src/folder/index.ts +87 -84
  120. package/src/harbor/carrying.ts +56 -0
  121. package/src/harbor/catalogue.ts +125 -0
  122. package/src/harbor/dock.ts +122 -15
  123. package/src/harbor/harbor.ts +240 -36
  124. package/src/harbor/index.ts +10 -3
  125. package/src/harbor/package.ts +147 -0
  126. package/src/harbor/probe.ts +34 -0
  127. package/src/harbor/registry.ts +73 -9
  128. package/src/harbor/relay.ts +399 -0
  129. package/src/harbor/root-line.ts +39 -0
  130. package/src/harbor/terrain.ts +31 -3
  131. package/src/http/index.ts +150 -0
  132. package/src/http/websocket.ts +124 -0
  133. package/src/index.ts +39 -6
  134. package/src/line/answer.ts +59 -0
  135. package/src/line/frame.ts +88 -0
  136. package/src/line/ground.ts +19 -0
  137. package/src/line/index.ts +9 -0
  138. package/src/line/push.ts +112 -0
  139. package/src/line/web.ts +227 -0
  140. package/src/node/index.ts +170 -0
  141. package/src/node.ts +9 -0
  142. package/src/pointer/bodies.ts +45 -16
  143. package/src/pointer/index.ts +1 -1
  144. package/src/pointer/world.ts +38 -19
  145. package/src/quo/address.ts +87 -0
  146. package/src/quo/door.ts +6 -4
  147. package/src/quo/index.ts +1 -0
  148. package/src/quo/invitation.ts +10 -6
  149. package/src/quo/keys.ts +1 -1
  150. package/src/stand/main.ts +1 -0
  151. package/src/stand/stand.ts +87 -6
  152. package/src/tcp/index.ts +284 -0
  153. package/src/ward/index.ts +3 -2
  154. package/src/ward/pilot.ts +50 -0
  155. package/src/ward/stance.ts +14 -8
  156. package/src/ward/ward-being.ts +9 -3
  157. package/src/ward/ward.ts +53 -6
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Addresses, as `SPEC.md` names them in an invitation's `at`: a URI of
3
+ // RFC 3986 whose scheme, read in any case, names a carrier.
4
+ // `CARRIER-TCP.md` writes `tcp://host:port`, and `CARRIER-WEB.md` writes
5
+ // `http` and `https` for the post and `ws` and `wss` for the held line.
6
+ // RFC 3986 appendix B, with the characters a URI may hold checked apart.
7
+ const SPLIT = /^(?:([^:/?#]+):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/;
8
+ const URI_CHARS = /^(?:[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]|%[0-9A-Fa-f]{2})*$/;
9
+ const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/;
10
+ const REG_NAME = /^(?:[A-Za-z0-9\-._~!$&'()*+,;=]|%[0-9A-Fa-f]{2})*$/;
11
+ const IP_LITERAL = /^\[(?:[0-9A-Fa-f:.]+|v[0-9A-Fa-f]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+)\]$/;
12
+ // A URI with a scheme, taken apart, or null. `host` is as written,
13
+ // brackets included, and `port` the digits as written.
14
+ const parse = (s) => {
15
+ if (typeof s !== 'string' || !URI_CHARS.test(s))
16
+ return null;
17
+ const m = SPLIT.exec(s);
18
+ if (!m || m[1] === undefined || !SCHEME.test(m[1]))
19
+ return null;
20
+ const out = { scheme: m[1].toLowerCase(), authority: m[2] ?? null, userinfo: null, host: null, port: null, path: m[3] ?? '', query: m[4] ?? null, fragment: m[5] ?? null };
21
+ if (out.authority === null)
22
+ return out;
23
+ let rest = out.authority;
24
+ const at = rest.lastIndexOf('@');
25
+ if (at >= 0) {
26
+ out.userinfo = rest.slice(0, at);
27
+ rest = rest.slice(at + 1);
28
+ }
29
+ const hp = /^(\[[^\]]*\]|[^:[\]]*)(?::([0-9]*))?$/.exec(rest);
30
+ if (!hp)
31
+ return null;
32
+ const host = hp[1];
33
+ if (host.startsWith('[') ? !IP_LITERAL.test(host) : !REG_NAME.test(host))
34
+ return null;
35
+ out.host = host;
36
+ out.port = hp[2] ?? null;
37
+ return out;
38
+ };
39
+ // The host as a socket takes it: an IPv6 literal without its brackets.
40
+ const bare = (host) => (host.startsWith('[') ? host.slice(1, -1) : host);
41
+ // The scheme an address names, lowercase, or null for what is no URI.
42
+ export const schemeOf = (s) => parse(s)?.scheme ?? null;
43
+ // A `tcp` address as `CARRIER-TCP.md` writes one, or null.
44
+ export const tcpAddress = (s) => {
45
+ const a = parse(s);
46
+ if (!a || a.scheme !== 'tcp' || a.authority === null)
47
+ return null;
48
+ if (a.userinfo !== null || a.path !== '' || a.query !== null || a.fragment !== null)
49
+ return null;
50
+ if (!a.host || a.port === null || !/^[0-9]+$/.test(a.port))
51
+ return null;
52
+ const port = Number(a.port);
53
+ if (port < 1 || port > 65_535)
54
+ return null;
55
+ return { host: bare(a.host), port };
56
+ };
57
+ // The `tcp` address of a host and port.
58
+ export const tcpAt = (host, port) => `tcp://${host.includes(':') ? `[${host}]` : host}:${port}`;
59
+ const WEB = new Set(['http', 'https', 'ws', 'wss']);
60
+ // An address of `CARRIER-WEB.md`, or null. `path` is the path and query
61
+ // a request names, `/` when both are empty.
62
+ export const webAddress = (s) => {
63
+ const a = parse(s);
64
+ if (!a || !WEB.has(a.scheme) || a.authority === null)
65
+ return null;
66
+ if (a.userinfo !== null || a.fragment !== null || !a.host)
67
+ return null;
68
+ if (a.port !== null && a.port !== '' && (!/^[0-9]+$/.test(a.port) || Number(a.port) > 65_535))
69
+ return null;
70
+ const scheme = a.scheme;
71
+ const secure = scheme === 'https' || scheme === 'wss';
72
+ const port = a.port ? Number(a.port) : secure ? 443 : 80;
73
+ const path = `${a.path || '/'}${a.query !== null ? `?${a.query}` : ''}`;
74
+ return { scheme, secure, held: scheme === 'ws' || scheme === 'wss', host: bare(a.host), port, path, href: s };
75
+ };
76
+ // Whether a tcp or web address names the unspecified host, where a
77
+ // listener binds every interface and no caller can dial.
78
+ export const unspecified = (s) => {
79
+ const host = (tcpAddress(s) ?? webAddress(s))?.host;
80
+ return host === '0.0.0.0' || host === '::';
81
+ };
82
+ // An invitation's `at` as `SPEC.md` reads it: the strings that are URIs
83
+ // with a scheme, in their order. A value that is no array is absent.
84
+ export const readAt = (value) => (Array.isArray(value) ? value.filter((s) => parse(s) !== null) : []);
@@ -25,7 +25,7 @@ export declare class Door {
25
25
  readonly behind: Behind;
26
26
  readonly draw: Draw;
27
27
  constructor(key: WardKey, relations: Relations, behind: Behind, draw: Draw);
28
- invite(): Promise<{
28
+ invite(at?: readonly string[]): Promise<{
29
29
  heir: string;
30
30
  invitation: Invitation;
31
31
  }>;
package/dist/quo/door.js CHANGED
@@ -23,16 +23,18 @@ export class Door {
23
23
  this.draw = draw;
24
24
  }
25
25
  // A new heir, held fresh, and its invitation. The lock is drawn at the
26
- // first invitation that names a heir, before the heir's secret.
27
- async invite() {
26
+ // first invitation that names an heir, before the heir's secret.
27
+ // A new heir, and its invitation, naming in `at` the addresses given.
28
+ async invite(at = []) {
28
29
  let lock = this.relations.lock();
29
30
  if (!lock)
30
31
  this.relations.setLock((lock = drawLock(this.draw)));
31
32
  const heir = await SigningKey.draw(this.draw);
32
33
  this.relations.set(heir.id, { state: 'fresh' });
33
- return { heir: heir.id, invitation: { ward: this.key.pk, heir: heir.id, secret: hex(heir.secret), lock: hex(lock.ek) } };
34
+ const invitation = { ward: this.key.pk, heir: heir.id, secret: hex(heir.secret), lock: hex(lock.ek), ...(at.length > 0 ? { at: [...at] } : {}) };
35
+ return { heir: heir.id, invitation };
34
36
  }
35
- // The door stops holding a heir. A fresh one leaves nothing, a spent one
37
+ // The door stops holding an heir. A fresh one leaves nothing, a spent one
36
38
  // leaves the keys kept at removal.
37
39
  remove(heir) {
38
40
  const record = this.relations.get(heir);
@@ -1,3 +1,4 @@
1
+ export { readAt, schemeOf, tcpAddress, tcpAt, unspecified, webAddress, type TcpAddress, type WebAddress } from './address.ts';
1
2
  export { Door, SPAN, type Behind, type Choice, type Judged } from './door.ts';
2
3
  export { readInvitation, type Invitation } from './invitation.ts';
3
4
  export { Ephemeral, SigningKey, WardKey, type Draw } from './keys.ts';
package/dist/quo/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Quo's wire: keys, the invitation, the payload, the reply, the boxes, the
3
3
  // door and the standing. It knows no being, no ward and no terrain.
4
+ export { readAt, schemeOf, tcpAddress, tcpAt, unspecified, webAddress } from './address.js';
4
5
  export { Door, SPAN } from './door.js';
5
6
  export { readInvitation } from './invitation.js';
6
7
  export { Ephemeral, SigningKey, WardKey } from './keys.js';
@@ -3,5 +3,6 @@ export type Invitation = {
3
3
  readonly heir: string;
4
4
  readonly secret: string;
5
5
  readonly lock: string;
6
+ readonly at?: string[];
6
7
  };
7
8
  export declare const readInvitation: (value: unknown) => Invitation | null;
@@ -1,15 +1,19 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // The invitation: `{ ward, heir, secret, lock }`, a value with no route.
2
+ // The invitation: `{ ward, heir, secret, lock, at? }`. A door reads no
3
+ // route from it; `at`, when present, names where the ward is reached.
3
4
  import { EK, encapsulate, isHex, unhex } from '../crypto/index.js';
4
- // An invitation, or null for any other shape. A field beside the four is
5
- // ignored, and the lock is held to the modulus check.
5
+ import { readAt } from './address.js';
6
+ // An invitation, or null for any other shape. A field beside the five is
7
+ // ignored, the lock is held to the modulus check, and `at` keeps the
8
+ // addresses it holds, an `at` with none or of another shape being absent.
6
9
  export const readInvitation = (value) => {
7
10
  if (typeof value !== 'object' || value === null || Array.isArray(value))
8
11
  return null;
9
- const { ward, heir, secret, lock } = value;
12
+ const { ward, heir, secret, lock, at } = value;
10
13
  if (!isHex(ward, 64) || !isHex(heir, 32) || !isHex(secret, 32) || !isHex(lock, EK))
11
14
  return null;
12
15
  if (encapsulate(unhex(lock), new Uint8Array(32)) === null)
13
16
  return null;
14
- return { ward, heir, secret, lock };
17
+ const addresses = readAt(at);
18
+ return { ward, heir, secret, lock, ...(addresses.length > 0 ? { at: addresses } : {}) };
15
19
  };
package/dist/quo/keys.js CHANGED
@@ -10,7 +10,7 @@ export const LABEL = {
10
10
  wardSign: utf8('quo-ward-sign'),
11
11
  wardSeal: utf8('quo-ward-seal'),
12
12
  };
13
- // An Ed25519 pair: a heir, or a standing's own key.
13
+ // An Ed25519 pair: an heir, or a standing's own key.
14
14
  export class SigningKey {
15
15
  secret;
16
16
  pk;
@@ -0,0 +1,25 @@
1
+ export declare const ASK = 0;
2
+ export declare const REPLY = 1;
3
+ export declare const NOTHING = 2;
4
+ export declare const MAX_BODY = 1048645;
5
+ export type Frame = {
6
+ kind: 'ask';
7
+ id: number;
8
+ pk: Uint8Array;
9
+ box: Uint8Array;
10
+ } | {
11
+ kind: 'reply';
12
+ id: number;
13
+ box: Uint8Array;
14
+ } | {
15
+ kind: 'nothing';
16
+ id: number;
17
+ };
18
+ export declare const askFrame: (id: number, pk: Uint8Array, box: Uint8Array) => Uint8Array;
19
+ export declare const replyFrame: (id: number, box: Uint8Array) => Uint8Array;
20
+ export declare const nothingFrame: (id: number) => Uint8Array;
21
+ export declare class FrameReader {
22
+ #private;
23
+ get bad(): boolean;
24
+ push(chunk: Uint8Array): Frame[];
25
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The frames of Quo over TCP, `quo/CARRIER-TCP.md`:
3
+ //
4
+ // frame = length (4, big-endian) || kind (1) || id (4, big-endian) || rest
5
+ // 00 ask rest = ward pk (64) || box
6
+ // 01 reply rest = box
7
+ // 02 nothing rest = empty
8
+ //
9
+ // A reader says a stream is not a frame as soon as its bytes say so, and
10
+ // reads nothing after it.
11
+ import { concat } from '../crypto/index.js';
12
+ export const ASK = 0;
13
+ export const REPLY = 1;
14
+ export const NOTHING = 2;
15
+ export const MAX_BODY = 1_048_645;
16
+ const PK = 64;
17
+ const frame = (kind, id, ...rest) => {
18
+ const head = new Uint8Array(9);
19
+ const view = new DataView(head.buffer);
20
+ view.setUint32(0, 5 + rest.reduce((n, r) => n + r.length, 0));
21
+ head[4] = kind;
22
+ view.setUint32(5, id >>> 0);
23
+ return concat(head, ...rest);
24
+ };
25
+ export const askFrame = (id, pk, box) => frame(ASK, id, pk, box);
26
+ export const replyFrame = (id, box) => frame(REPLY, id, box);
27
+ export const nothingFrame = (id) => frame(NOTHING, id);
28
+ // Whether a body of this length, and this first byte when known, is no frame.
29
+ const notAFrame = (length, kind) => length > MAX_BODY || length < 5 || (kind !== undefined && (kind > NOTHING || (kind === ASK && length < 5 + PK) || (kind === NOTHING && length !== 5)));
30
+ export class FrameReader {
31
+ #buffer = new Uint8Array(0);
32
+ #bad = false;
33
+ // True once bytes that are not a frame arrived; nothing is read after.
34
+ get bad() {
35
+ return this.#bad;
36
+ }
37
+ push(chunk) {
38
+ if (this.#bad)
39
+ return [];
40
+ this.#buffer = this.#buffer.length === 0 ? chunk.slice() : concat(this.#buffer, chunk);
41
+ const out = [];
42
+ while (this.#buffer.length >= 4) {
43
+ const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset);
44
+ const length = view.getUint32(0);
45
+ if (notAFrame(length, this.#buffer.length > 4 ? this.#buffer[4] : undefined)) {
46
+ this.#bad = true;
47
+ this.#buffer = new Uint8Array(0);
48
+ return out;
49
+ }
50
+ if (this.#buffer.length < 4 + length)
51
+ break;
52
+ const body = this.#buffer.subarray(4, 4 + length);
53
+ this.#buffer = this.#buffer.slice(4 + length);
54
+ const id = new DataView(body.buffer, body.byteOffset).getUint32(1);
55
+ if (body[0] === ASK)
56
+ out.push({ kind: 'ask', id, pk: body.slice(5, 5 + PK), box: body.slice(5 + PK) });
57
+ else if (body[0] === REPLY)
58
+ out.push({ kind: 'reply', id, box: body.slice(5) });
59
+ else
60
+ out.push({ kind: 'nothing', id });
61
+ }
62
+ return out;
63
+ }
64
+ }
@@ -0,0 +1,24 @@
1
+ import { Carrier } from '../contract/index.ts';
2
+ import { type Dialed, type GroundFaculty } from '../harbor/index.ts';
3
+ import { TCP, TCP_OPENING } from '../line/index.ts';
4
+ export interface Doors {
5
+ arrive(pk: string, bytes: Uint8Array): Promise<Uint8Array | null>;
6
+ }
7
+ export declare class TcpCarrier extends Carrier {
8
+ #private;
9
+ route(pk: string, at: readonly string[] | null): boolean;
10
+ routed(pk: string): boolean;
11
+ carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | null>;
12
+ carryAt(at: readonly string[], pk: string, bytes: Uint8Array): Promise<Uint8Array | null>;
13
+ dial(at: string, pk: string, bytes: Uint8Array): Promise<Dialed>;
14
+ close(): Promise<void>;
15
+ }
16
+ export declare class TcpListener {
17
+ #private;
18
+ private constructor();
19
+ static listen(doors: Doors, host?: string, port?: number): Promise<TcpListener>;
20
+ get at(): string;
21
+ close(): Promise<void>;
22
+ }
23
+ export { TCP, TCP_OPENING };
24
+ export declare const tcpFaculty: GroundFaculty;
@@ -0,0 +1,260 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Quo over TCP: `nervur/tcp`, an entry that names a platform, Node's
3
+ // sockets.
4
+ //
5
+ // TcpCarrier a Carrier that dials: bytes to a ward pk go in an ask
6
+ // frame to the `tcp://host:port` addresses its book holds
7
+ // for that pk, in their order
8
+ // TcpListener answers ask frames for the doors it is handed, a harbor
9
+ // among them, at a `tcp://` address
10
+ //
11
+ // A connection is opened per address, carries many asks at once, and is
12
+ // dropped when either side closes it; the next ask dials again. An ask
13
+ // goes to the next address only when the one before could not be dialed,
14
+ // since an ask a line took may have been heard.
15
+ import { connect, createServer } from 'node:net';
16
+ import { hex, isHex, unhex } from '../crypto/index.js';
17
+ import { Carrier } from '../contract/index.js';
18
+ import { CarrierFaculty, UNDIALED } from '../harbor/index.js';
19
+ import { tcpAddress, tcpAt } from '../quo/index.js';
20
+ import { ownerAsk } from '../ward/index.js';
21
+ import { askFrame, FrameReader, nothingFrame, replyFrame, TCP, TCP_OPENING } from '../line/index.js';
22
+ const MAX_ID = 0xffff_ffff;
23
+ // The `tcp` addresses among some, in their order.
24
+ const tcpAddresses = (at) => at.map(tcpAddress).filter((a) => a !== null);
25
+ class Line {
26
+ #socket;
27
+ #reader = new FrameReader();
28
+ #waiting = new Map();
29
+ #next = 1;
30
+ closed = false;
31
+ constructor(socket, onClose) {
32
+ this.#socket = socket;
33
+ socket.on('data', (chunk) => {
34
+ for (const f of this.#reader.push(new Uint8Array(chunk))) {
35
+ if (f.kind === 'ask')
36
+ continue;
37
+ const resolve = this.#waiting.get(f.id);
38
+ this.#waiting.delete(f.id);
39
+ resolve?.(f.kind === 'reply' ? f.box : null);
40
+ }
41
+ if (this.#reader.bad)
42
+ socket.destroy();
43
+ });
44
+ socket.on('error', () => socket.destroy());
45
+ socket.on('close', () => {
46
+ this.closed = true;
47
+ onClose();
48
+ for (const resolve of this.#waiting.values())
49
+ resolve(null);
50
+ this.#waiting.clear();
51
+ });
52
+ }
53
+ static open(host, port, onClose) {
54
+ return new Promise((resolve) => {
55
+ const socket = connect({ host, port });
56
+ socket.once('connect', () => resolve(new Line(socket, onClose)));
57
+ socket.once('error', () => {
58
+ socket.destroy();
59
+ resolve(null);
60
+ });
61
+ });
62
+ }
63
+ ask(pk, box) {
64
+ if (this.closed)
65
+ return Promise.resolve(null);
66
+ const id = this.#next;
67
+ this.#next = this.#next === MAX_ID ? 1 : this.#next + 1;
68
+ return new Promise((resolve) => {
69
+ this.#waiting.set(id, resolve);
70
+ this.#socket.write(askFrame(id, pk, box));
71
+ });
72
+ }
73
+ close() {
74
+ this.#socket.destroy();
75
+ }
76
+ }
77
+ export class TcpCarrier extends Carrier {
78
+ #routes = new Map();
79
+ #lines = new Map();
80
+ // Where a ward pk is reached, replacing any route before: its `tcp`
81
+ // addresses in order, the others skipped. A list with none is not
82
+ // taken; null forgets the route.
83
+ route(pk, at) {
84
+ if (!isHex(pk, 64))
85
+ return false;
86
+ if (at === null)
87
+ return this.#routes.delete(pk) || true;
88
+ const mine = tcpAddresses(at);
89
+ if (mine.length === 0)
90
+ return false;
91
+ this.#routes.set(pk, mine);
92
+ return true;
93
+ }
94
+ // Whether a ward pk has a route.
95
+ routed(pk) {
96
+ return this.#routes.has(pk);
97
+ }
98
+ // Nothing where the pk has no route, no address is reached, or the
99
+ // connection closed before an answer.
100
+ carry(pk, bytes) {
101
+ return this.#carry(this.#routes.get(pk) ?? [], pk, bytes);
102
+ }
103
+ // Bytes to a ward pk at the addresses named, as a route would carry them.
104
+ carryAt(at, pk, bytes) {
105
+ return isHex(pk, 64) ? this.#carry(tcpAddresses(at), pk, bytes) : Promise.resolve(null);
106
+ }
107
+ async #carry(addresses, pk, bytes) {
108
+ for (const address of addresses) {
109
+ const out = await this.#dial(address, pk, bytes);
110
+ if (out !== UNDIALED)
111
+ return out;
112
+ }
113
+ return null;
114
+ }
115
+ // Bytes to a ward pk at one address, or `UNDIALED` where no line opens.
116
+ dial(at, pk, bytes) {
117
+ const address = tcpAddress(at);
118
+ return address && isHex(pk, 64) ? this.#dial(address, pk, bytes) : Promise.resolve(UNDIALED);
119
+ }
120
+ async #dial(address, pk, bytes) {
121
+ const line = await this.#line(address);
122
+ return line ? line.ask(unhex(pk), bytes) : UNDIALED;
123
+ }
124
+ #line({ host, port }) {
125
+ const at = tcpAt(host, port);
126
+ let line = this.#lines.get(at);
127
+ if (!line) {
128
+ line = Line.open(host, port, () => this.#lines.delete(at)).then((dialed) => {
129
+ if (!dialed)
130
+ this.#lines.delete(at);
131
+ return dialed;
132
+ });
133
+ this.#lines.set(at, line);
134
+ }
135
+ return line;
136
+ }
137
+ async close() {
138
+ const lines = [...this.#lines.values()];
139
+ this.#lines.clear();
140
+ for (const line of await Promise.all(lines))
141
+ line?.close();
142
+ }
143
+ }
144
+ export class TcpListener {
145
+ #server;
146
+ #doors;
147
+ #sockets = new Set();
148
+ constructor(doors) {
149
+ this.#doors = doors;
150
+ this.#server = createServer((socket) => this.#serve(socket));
151
+ }
152
+ // A listener on `host` and `port`, 127.0.0.1 and a free port unless named.
153
+ static listen(doors, host = '127.0.0.1', port = 0) {
154
+ const listener = new TcpListener(doors);
155
+ return new Promise((resolve, reject) => {
156
+ listener.#server.once('error', reject);
157
+ listener.#server.listen(port, host, () => resolve(listener));
158
+ });
159
+ }
160
+ // The `tcp://host:port` address this listener answers at.
161
+ get at() {
162
+ const address = this.#server.address();
163
+ return tcpAt(address.address, address.port);
164
+ }
165
+ #serve(socket) {
166
+ this.#sockets.add(socket);
167
+ const reader = new FrameReader();
168
+ socket.on('data', (chunk) => {
169
+ for (const f of reader.push(new Uint8Array(chunk))) {
170
+ if (f.kind === 'ask')
171
+ void this.#answer(socket, f.id, f.pk, f.box);
172
+ }
173
+ if (reader.bad)
174
+ socket.destroy();
175
+ });
176
+ socket.on('error', () => socket.destroy());
177
+ socket.on('close', () => this.#sockets.delete(socket));
178
+ }
179
+ async #answer(socket, id, pk, box) {
180
+ let reply = null;
181
+ try {
182
+ reply = await this.#doors.arrive(hex(pk), box);
183
+ }
184
+ catch {
185
+ reply = null;
186
+ }
187
+ if (!socket.destroyed)
188
+ socket.write(reply === null ? nothingFrame(id) : replyFrame(id, reply));
189
+ }
190
+ close() {
191
+ for (const socket of this.#sockets)
192
+ socket.destroy();
193
+ return new Promise((resolve) => this.#server.close(() => resolve()));
194
+ }
195
+ }
196
+ export { TCP, TCP_OPENING };
197
+ const listenOf = (value) => {
198
+ if (typeof value !== 'object' || value === null)
199
+ return null;
200
+ const { host, port } = value;
201
+ return typeof host === 'string' && Number.isInteger(port) && port >= 0 && port <= 65_535 ? { host, port: port } : null;
202
+ };
203
+ // Quo over TCP as a carrier faculty of one harbor. It dials every `tcp://`
204
+ // address the harbor routes, and listens where its cells say:
205
+ //
206
+ // listen { host?, port? } listen there from now on, 127.0.0.1 and a
207
+ // free port unless named, and on every wake
208
+ // at the port it bound
209
+ // deaf listen nowhere from now on
210
+ export const tcpFaculty = (ground) => class TcpFaculty extends CarrierFaculty {
211
+ static kind = TCP;
212
+ static asks = {
213
+ listen: ownerAsk('listen for Quo over TCP, and on every wake', { properties: { host: { type: 'string' }, port: { type: 'integer' } } }),
214
+ deaf: ownerAsk('listen nowhere'),
215
+ };
216
+ #dialer = new TcpCarrier();
217
+ #listener;
218
+ accepts(address) {
219
+ return tcpAddress(address) !== null;
220
+ }
221
+ dial(address, pk, bytes) {
222
+ return this.#dialer.dial(address, pk, bytes);
223
+ }
224
+ listening() {
225
+ return this.#listener ? [this.#listener.at] : [];
226
+ }
227
+ async awake() {
228
+ const at = listenOf(this.cells.listen);
229
+ if (at && !this.#listener)
230
+ this.#listener = await TcpListener.listen(ground, at.host, at.port);
231
+ }
232
+ async sleep() {
233
+ await this.#dialer.close();
234
+ await this.#listener?.close();
235
+ this.#listener = undefined;
236
+ }
237
+ async listen(args) {
238
+ const at = listenOf({ host: args.host ?? '127.0.0.1', port: args.port ?? 0 });
239
+ if (at === null)
240
+ return { error: 'host is text and port a port' };
241
+ await this.#listener?.close();
242
+ this.#listener = undefined;
243
+ try {
244
+ this.#listener = await TcpListener.listen(ground, at.host, at.port);
245
+ }
246
+ catch (e) {
247
+ return { error: e.message };
248
+ }
249
+ // The port it bound, so a wake listens where every route and every
250
+ // invitation already points.
251
+ this.cells.listen = { host: at.host, port: tcpAddress(this.#listener.at).port };
252
+ return { at: this.#listener.at };
253
+ }
254
+ async deaf() {
255
+ await this.#listener?.close();
256
+ this.#listener = undefined;
257
+ delete this.cells.listen;
258
+ return { deaf: true };
259
+ }
260
+ };
@@ -1,5 +1,6 @@
1
1
  export { allow, CEILING, DEFAULT } from './allowance.ts';
2
2
  export { HEAD_ROW, Partition, type BeingRow, type Head, type StandingRow } from './partition.ts';
3
3
  export { DEPTH } from './stance.ts';
4
- export { DOCK, Ward, type Catalogue, type Lender, type WardParts } from './ward.ts';
5
- export { ownerAsk, pilots, WardBeing, type Steward, type WardBeingStance } from './ward-being.ts';
4
+ export { DOCK, Ward, type Catalogue, type Lender, type Routes, type WardParts } from './ward.ts';
5
+ export { ownerAsk, PILOT, pilots, rootAsk, WardBeing, type Steward, type WardBeingStance } from './ward-being.ts';
6
+ export { Pilot } from './pilot.ts';
@@ -4,4 +4,5 @@ export { allow, CEILING, DEFAULT } from './allowance.js';
4
4
  export { HEAD_ROW, Partition } from './partition.js';
5
5
  export { DEPTH } from './stance.js';
6
6
  export { DOCK, Ward } from './ward.js';
7
- export { ownerAsk, pilots, WardBeing } from './ward-being.js';
7
+ export { ownerAsk, PILOT, pilots, rootAsk, WardBeing } from './ward-being.js';
8
+ export { Pilot } from './pilot.js';
@@ -0,0 +1,8 @@
1
+ import { Being, type AskSpec, type JsonObject } from '../being/index.ts';
2
+ export declare class Pilot extends Being {
3
+ static readonly kind: string;
4
+ static asks: Record<string, AskSpec>;
5
+ hold(args: JsonObject): Promise<JsonObject>;
6
+ pilot(args: JsonObject): Promise<JsonObject>;
7
+ drop(): JsonObject;
8
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // The pilot: a kit being that holds an owner's relation on another ward's
3
+ // ward-being, so whoever pilots her ward pilots that ward from afar. The
4
+ // relation is an ordinary standing, kept in her ward's partition, so it
5
+ // moves its keys and survives a restart like any other.
6
+ //
7
+ // hold { invitation } the owner invitation taken, under `far`
8
+ // pilot { method?, args? } the far ward-being asked, as its owner
9
+ // drop the relation let go
10
+ //
11
+ // She is the kit's, of kind `org.nervur.pilot`, and answers whoever pilots
12
+ // her own ward alone.
13
+ import { Being, isSilence, isWord, told } from '../being/index.js';
14
+ import { readInvitation } from '../quo/index.js';
15
+ import { ownerAsk, PILOT } from './ward-being.js';
16
+ // The standing id the far ward-being is held under.
17
+ const FAR = 'far';
18
+ const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
19
+ export class Pilot extends Being {
20
+ static kind = PILOT;
21
+ static asks = {
22
+ hold: ownerAsk('an owner invitation on a far ward, held from now on', { required: ['invitation'] }),
23
+ pilot: ownerAsk('the far ward-being, asked as its owner'),
24
+ drop: ownerAsk('the far ward let go'),
25
+ };
26
+ async hold(args) {
27
+ const invitation = readInvitation(args.invitation);
28
+ if (invitation === null)
29
+ return { error: 'no invitation' };
30
+ if (this.stance.standings.get(FAR))
31
+ return { error: 'a ward is held' };
32
+ return (await this.stance.standings.take(FAR, invitation)) === null ? { error: 'not taken' } : { held: invitation.ward };
33
+ }
34
+ async pilot(args) {
35
+ if ((args.method !== undefined && typeof args.method !== 'string') || (args.args !== undefined && !object(args.args)))
36
+ return { error: 'method is text, args an object' };
37
+ const standing = this.stance.standings.get(FAR);
38
+ if (!standing)
39
+ return { error: 'no ward is held' };
40
+ const out = await standing.ask(args.method, args.args ?? {});
41
+ if (isSilence(out))
42
+ return { error: 'silence' };
43
+ if (isWord(out))
44
+ return { error: told(out) };
45
+ return { answer: out };
46
+ }
47
+ drop() {
48
+ return this.stance.standings.remove(FAR) ? { dropped: true } : { error: 'no ward is held' };
49
+ }
50
+ }
@@ -14,6 +14,8 @@ export interface Inside {
14
14
  boot(maker: string, className: string, key: string, id?: string): Promise<string | null>;
15
15
  offer(contract: string): Promise<Invitation | null>;
16
16
  retract(contract: string, heir: string): Promise<void>;
17
+ reach(): readonly string[];
18
+ learn(ward: string, at: readonly string[]): Promise<void>;
17
19
  }
18
20
  export declare class WardStance implements Stance {
19
21
  #private;
@@ -22,8 +24,6 @@ export declare class WardStance implements Stance {
22
24
  readonly standings: Standings;
23
25
  constructor(inside: Inside, key: string, row: BeingRow);
24
26
  get cells(): JsonObject;
25
- boot(className: string, key: string, id?: string): Promise<string | null>;
26
- lend(contract: {
27
- readonly name: string;
28
- }, id: string): Promise<string | null>;
27
+ boot(kind: string, key: string, id?: string): Promise<string | null>;
28
+ lend(contract: abstract new (...args: never[]) => unknown, id: string): Promise<string | null>;
29
29
  }