@onlyne/node-host 3.0.0 → 3.2.0

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 (57) hide show
  1. package/README.md +22 -0
  2. package/dist/acquire.d.ts +62 -0
  3. package/dist/acquire.d.ts.map +1 -0
  4. package/dist/acquire.js +146 -0
  5. package/dist/acquire.js.map +1 -0
  6. package/dist/host-text.d.ts +2 -0
  7. package/dist/host-text.d.ts.map +1 -0
  8. package/dist/host-text.js +103 -0
  9. package/dist/host-text.js.map +1 -0
  10. package/dist/i18n.d.ts +9 -0
  11. package/dist/i18n.d.ts.map +1 -0
  12. package/dist/i18n.js +19 -0
  13. package/dist/i18n.js.map +1 -0
  14. package/dist/index.d.ts +29 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +74 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/keys.d.ts +20 -0
  19. package/dist/keys.d.ts.map +1 -0
  20. package/dist/keys.js +144 -0
  21. package/dist/keys.js.map +1 -0
  22. package/dist/log.d.ts +43 -0
  23. package/dist/log.d.ts.map +1 -0
  24. package/dist/log.js +77 -0
  25. package/dist/log.js.map +1 -0
  26. package/dist/network.d.ts +27 -0
  27. package/dist/network.d.ts.map +1 -0
  28. package/dist/network.js +350 -0
  29. package/dist/network.js.map +1 -0
  30. package/dist/node.d.ts +20 -0
  31. package/dist/node.d.ts.map +1 -0
  32. package/dist/node.js +153 -0
  33. package/dist/node.js.map +1 -0
  34. package/dist/store.d.ts +44 -0
  35. package/dist/store.d.ts.map +1 -0
  36. package/dist/store.js +219 -0
  37. package/dist/store.js.map +1 -0
  38. package/dist/tui-text.d.ts +37 -0
  39. package/dist/tui-text.d.ts.map +1 -0
  40. package/dist/tui-text.js +69 -0
  41. package/dist/tui-text.js.map +1 -0
  42. package/dist/tui.d.ts +19 -0
  43. package/dist/tui.d.ts.map +1 -0
  44. package/dist/tui.js +400 -0
  45. package/dist/tui.js.map +1 -0
  46. package/dist/types.d.ts +62 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +2 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +46 -10
  51. package/src/i18n.ts +0 -22
  52. package/src/index.ts +0 -80
  53. package/src/keys.ts +0 -158
  54. package/src/log.ts +0 -52
  55. package/src/network.ts +0 -382
  56. package/src/tui-text.ts +0 -73
  57. package/src/tui.ts +0 -318
package/src/index.ts DELETED
@@ -1,80 +0,0 @@
1
- import type { OnlyneNode, ServiceDescription } from '@onlyne/lib';
2
- import { NetworkDiscovery } from './network.js';
3
- import type { LogRing } from './log.js';
4
- import { GatewayTui } from './tui.js';
5
-
6
- export { createNodeKey } from './keys.js';
7
- export { NetworkDiscovery } from './network.js';
8
- export { createLogger } from './log.js';
9
- export type { GatewayLogger, LogRing } from './log.js';
10
- export { GatewayTui } from './tui.js';
11
-
12
- export interface NodeHostOptions {
13
- node: OnlyneNode;
14
- endpoint: string;
15
- bind: string;
16
- seeds?: string[];
17
- /** The log ring the TUI renders; the host's logger owns it. */
18
- ring?: LogRing;
19
- /** Defaults to the terminal surface when both ends of the process are a TTY. */
20
- interactive?: boolean;
21
- log(message: string, fields?: Record<string, unknown>): void;
22
- }
23
-
24
- /**
25
- * The host half every gateway shares: publish the bound endpoint over DNS-SD, browse for
26
- * peers, re-observe the catalog, and — on a terminal — hand the operator the trust surface.
27
- * Discovery only adds candidates; a key exchange still needs both fingerprints confirmed.
28
- */
29
- export async function startNodeHost(options: NodeHostOptions): Promise<{ close(): Promise<void> }> {
30
- const { node, log } = options;
31
- const controller = new AbortController();
32
- const response = await node.fetch(new Request(`${options.endpoint}/description`));
33
- const description = await response.json() as ServiceDescription;
34
- const network = new NetworkDiscovery({
35
- node_id: node.auth.actor, instance_id: description.instance_id, endpoint: options.endpoint, bind: options.bind,
36
- onCandidate: async (url, source, expectedNode) => {
37
- if (expectedNode === node.auth.actor) return;
38
- await node.discovery.observe(url, source, expectedNode, controller.signal);
39
- },
40
- onError: error => log('onlyne.discovery.failed', { error: String(error) }),
41
- });
42
- try { await network.start(); }
43
- catch (error) { log('onlyne.discovery.unavailable', { error: String(error) }); }
44
- for (const seed of options.seeds ?? []) {
45
- try { await node.discovery.observe(seed, 'bootstrap', undefined, controller.signal); }
46
- catch (error) { log('onlyne.discovery.failed', { seed, error: String(error) }); }
47
- }
48
- let refreshing = false;
49
- const timer = setInterval(() => {
50
- if (refreshing) return;
51
- refreshing = true;
52
- void node.discovery.refresh(controller.signal)
53
- .catch(error => { if (!controller.signal.aborted) log('onlyne.discovery.failed', { error: String(error) }); })
54
- .finally(() => { refreshing = false; });
55
- }, 30_000);
56
-
57
- let tui: GatewayTui | undefined;
58
- // `ONLYNE_NO_TUI=1` keeps the plain JSON log on stdout, for a pipe or a screen a TUI would spoil.
59
- const interactive = (options.interactive ?? true) && process.env.ONLYNE_NO_TUI !== '1';
60
- if (interactive && options.ring !== undefined && process.stdin.isTTY && process.stdout.isTTY) {
61
- const surface = new GatewayTui(node, options.ring, options.endpoint);
62
- try {
63
- log('onlyne.tui.starting');
64
- await surface.start();
65
- tui = surface;
66
- log('onlyne.tui.ready');
67
- } catch (error) {
68
- log('onlyne.tui.unavailable', { error: String(error) });
69
- }
70
- }
71
-
72
- return {
73
- async close(): Promise<void> {
74
- clearInterval(timer);
75
- await tui?.close();
76
- controller.abort();
77
- await network.close();
78
- },
79
- };
80
- }
package/src/keys.ts DELETED
@@ -1,158 +0,0 @@
1
- import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { basename, join } from "node:path";
5
- import type { KeyHook, KeyMaterial } from "@onlyne/lib";
6
- import { execFile } from "node:child_process";
7
- import { generateKeyMaterial } from "@onlyne/lib";
8
-
9
- /** RFC 0017 §2: the private key rides in its own file as base64 DER; the public half is derived on load. */
10
- const PRIVATE_NAME = "priv.pem";
11
- const PUBLIC_NAME = "pub.key";
12
-
13
- function projectKeysRoot(cwd: string): string {
14
- return join(cwd, ".onlyne", "keys");
15
- }
16
-
17
- function globalKeysRoot(): string {
18
- return join(homedir(), ".config", "onlyne", "keys");
19
- }
20
-
21
- function encodePrivate(material: KeyMaterial): string {
22
- return Buffer.from(material.private_key).toString("base64");
23
- }
24
-
25
- function decodePrivate(text: string): Uint8Array {
26
- return new Uint8Array(Buffer.from(text, "base64"));
27
- }
28
-
29
- /** Refuses group/world-readable private keys and symlinked or foreign-owned files. */
30
- function assertPrivatePermissions(path: string): void {
31
- const stats = statSync(path, { throwIfNoEntry: false });
32
- if (stats === undefined) return;
33
- if (stats.isSymbolicLink()) throw new Error(`The node key must not be a symlink: ${path}`);
34
- if ((stats.mode & 0o077) !== 0) {
35
- throw new Error(`The node key is readable beyond its owner. Fix it with: chmod 600 ${path}`);
36
- }
37
- }
38
-
39
- async function assertDirectoryPermissions(path: string): Promise<void> {
40
- const stats = await statSync(path, { throwIfNoEntry: false });
41
- if (stats === undefined || (stats.mode & 0o077) !== 0) {
42
- throw new Error(`The key directory is readable beyond its owner. Fix it with: chmod 700 ${path}`);
43
- }
44
- }
45
-
46
- /** The node id names a subdirectory; a traversal-looking id is a configuration error. */
47
- function keyDirectory(root: string, nodeId: string): string {
48
- if (nodeId.includes("/") || nodeId.includes("\\") || nodeId.includes("..")) {
49
- throw new Error(`The node id cannot name a key directory: ${nodeId}`);
50
- }
51
- return join(root, encodeURIComponent(nodeId));
52
- }
53
-
54
- /**
55
- * The git check is `git check-ignore --quiet`, the real semantics: an ancestor ignore,
56
- * a negation rule, or a .git/info/exclude all read the same way the user's git does.
57
- */
58
- function ignoredByGit(root: string, target: string): Promise<boolean> {
59
- return new Promise(resolve => {
60
- // `cwd` is the project root: the key directory does not exist yet on a first run.
61
- execFile("git", ["check-ignore", "--quiet", "--", target], { cwd: root }, error => {
62
- // Exit 0 = ignored, exit 1 = tracked-or-untracked but not ignored, other = outside a work tree.
63
- resolve(error === null || (typeof (error as { code?: number }).code === "number" && (error as { code?: number }).code === 0));
64
- });
65
- });
66
- }
67
-
68
- async function insideGitWorkTree(path: string): Promise<boolean> {
69
- return new Promise(resolve => {
70
- execFile("git", ["rev-parse", "--is-inside-work-tree"], { cwd: path }, (error, stdout) => {
71
- resolve(error === null && stdout.trim() === "true");
72
- });
73
- });
74
- }
75
-
76
- /**
77
- * Resolves the key directory for a node (RFC 0017 §2). A project key inside a git work
78
- * tree whose ignore rules do not cover `.onlyne/keys` is refused, with both escapes named.
79
- */
80
- export async function ensureKeyDir(cwd: string, nodeId: string, scope: "project" | "global"): Promise<string> {
81
- // The global directory holds the node's identity by default; a project directory is an
82
- // override for this working tree, read first when both exist.
83
- const root = scope === "project" ? projectKeysRoot(cwd) : globalKeysRoot();
84
- const dir = keyDirectory(root, nodeId);
85
- if (scope === "global") {
86
- await mkdir(dir, { recursive: true });
87
- await chmod(dir, 0o700);
88
- return dir;
89
- }
90
- if (await insideGitWorkTree(cwd) && !(await ignoredByGit(cwd, dir)) && !(await ignoredByGit(cwd, projectKeysRoot(cwd)))) {
91
- throw new Error(
92
- "Refusing to create a node key inside a git work tree: .onlyne/keys is not ignored. "
93
- + "Add '.onlyne/keys' to .gitignore, or set node.key_scope = \"global\" in the config.",
94
- );
95
- }
96
- await mkdir(dir, { recursive: true });
97
- await chmod(dir, 0o700);
98
- return dir;
99
- }
100
-
101
- /** Loads the node's identity key, generating it on first use. */
102
- export async function loadOrCreateKey(dir: string, nodeId: string): Promise<KeyMaterial> {
103
- await assertDirectoryPermissions(dir);
104
- const privatePath = join(dir, PRIVATE_NAME);
105
- if (!existsSync(privatePath)) {
106
- const material = generateKeyMaterial();
107
- await writeKeyMaterial(dir, material);
108
- return material;
109
- }
110
- assertPrivatePermissions(privatePath);
111
- const base: { private_key: Uint8Array; public_key: Uint8Array } = {
112
- private_key: decodePrivate((await readFile(privatePath, "utf8")).trim()),
113
- public_key: new Uint8Array(),
114
- };
115
- // The public half always comes from the private key: the file is a cache, never a source.
116
- const { createPrivateKey, createPublicKey } = await import("node:crypto");
117
- const pair = createPrivateKey({ key: Buffer.from(base.private_key), format: "der", type: "pkcs8" });
118
- const jwk = createPublicKey(pair).export({ format: "jwk" });
119
- return { private_key: base.private_key, public_key: new Uint8Array(Buffer.from(String(jwk.x), "base64url")) };
120
- }
121
-
122
- /**
123
- * Writes the key pair atomically. `replace` is the `plugins.keys.replace` path — the only
124
- * caller allowed to overwrite an existing private key, because `trust.rotate` has already
125
- * committed the rotation window and must complete the swap.
126
- */
127
- export async function writeKeyMaterial(dir: string, material: KeyMaterial, options: { replace?: boolean } = {}): Promise<void> {
128
- const privatePath = join(dir, PRIVATE_NAME);
129
- if (existsSync(privatePath) && options.replace !== true) throw new Error(`A node key already exists at ${privatePath}; use trust rotation instead of overwriting it.`);
130
- const publicPath = join(dir, PUBLIC_NAME);
131
- const stagingPrivate = join(dir, `.${basename(PRIVATE_NAME)}.${process.pid}.tmp`);
132
- const stagingPublic = join(dir, `.${basename(PUBLIC_NAME)}.${process.pid}.tmp`);
133
- // Every fallible step runs before the private key is renamed: a replace that throws must
134
- // leave the old key usable, or the rotation proof for peers pinning it is gone for good.
135
- await writeFile(stagingPrivate, encodePrivate(material), { mode: 0o600 });
136
- await chmod(stagingPrivate, 0o600);
137
- await writeFile(stagingPublic, Buffer.from(material.public_key).toString("base64"), { mode: 0o644 });
138
- await chmod(stagingPublic, 0o644);
139
- // The public file is a cache derived from the private key, so it may be replaced first.
140
- await rename(stagingPublic, publicPath);
141
- await rename(stagingPrivate, privatePath);
142
- }
143
-
144
- /** Synchronous guard for the startup path: the existing key must already be private. */
145
- export function assertKeyPermissions(dir: string): void {
146
- assertPrivatePermissions(join(dir, PRIVATE_NAME));
147
- void chmod;
148
- void chmodSync;
149
- void mkdirSync;
150
- void readFileSync;
151
- }
152
-
153
- export async function createNodeKey(nodeId: string): Promise<KeyHook> {
154
- const dir = await ensureKeyDir(process.cwd(), nodeId, "global");
155
- return Object.assign(() => loadOrCreateKey(dir, nodeId), {
156
- replace: (material: KeyMaterial) => writeKeyMaterial(dir, material, { replace: true }),
157
- });
158
- }
package/src/log.ts DELETED
@@ -1,52 +0,0 @@
1
- import { appendFileSync } from 'node:fs';
2
-
3
- /** A bounded tail of the gateway's own log lines, so the TUI can show them. */
4
- export interface LogRing {
5
- push(key: string, line: string): void;
6
- tail(count: number): string[];
7
- }
8
-
9
- export interface GatewayLogger {
10
- log(message: string, fields?: Record<string, unknown>): void;
11
- readonly ring: LogRing;
12
- }
13
-
14
- /**
15
- * The gateway logs JSON lines. Under the TUI those lines would corrupt the alternate
16
- * screen, so they go to a ring the TUI renders instead; without a TTY they stay on stdout.
17
- */
18
- export function createLogger(toStdout: boolean, capacity = 200, file?: string): GatewayLogger {
19
- // One entry per distinct message: a gateway that retries every five seconds would otherwise
20
- // fill the pane with the same line and hide everything else.
21
- const lines: Array<{ key: string; line: string; count: number }> = [];
22
- const ring: LogRing = {
23
- push(key: string, line: string): void {
24
- const last = lines.at(-1);
25
- if (last !== undefined && last.key === key) {
26
- last.line = line;
27
- last.count += 1;
28
- return;
29
- }
30
- lines.push({ key, line, count: 1 });
31
- if (lines.length > capacity) lines.splice(0, lines.length - capacity);
32
- },
33
- tail(count: number): string[] {
34
- return lines.slice(Math.max(0, lines.length - count))
35
- .map(entry => (entry.count === 1 ? entry.line : `${entry.line} ×${entry.count}`));
36
- },
37
- };
38
- return {
39
- ring,
40
- log(message: string, fields?: Record<string, unknown>): void {
41
- const line = JSON.stringify({ at: new Date().toISOString(), message, ...fields });
42
- if (toStdout) console.log(line);
43
- else ring.push(`${message}|${JSON.stringify(fields ?? {})}`, line);
44
- // The TUI owns the screen, so the durable record is the file; without one the line would
45
- // exist only in the ring and vanish with the process.
46
- if (file !== undefined) {
47
- try { appendFileSync(file, `${line}\n`); } catch { /* a full disk must not stop the gateway */ }
48
- }
49
- },
50
- };
51
- }
52
-
package/src/network.ts DELETED
@@ -1,382 +0,0 @@
1
- import { networkInterfaces } from "node:os";
2
-
3
- /** DNS-SD types used by the host adapter. */
4
- interface BonjourService {
5
- fqdn: string;
6
- name: string;
7
- port: number;
8
- host?: string;
9
- addresses?: string[];
10
- txt: unknown;
11
- referer?: { address?: string };
12
- records(): Array<{ type: string }>;
13
- }
14
- interface BonjourBrowser {
15
- on(event: "up" | "srv-update" | "txt-update" | "down", listener: (service: BonjourService) => void): void;
16
- stop(): void;
17
- }
18
- interface BonjourInstance {
19
- find(options: { type: string; protocol: string }): BonjourBrowser;
20
- publish(options: { name: string; type: string; protocol: string; port: number; host: string; probe: boolean; txt: Record<string, string> }): BonjourService;
21
- unpublishAll(callback: () => void): void;
22
- destroy(callback: () => void): void;
23
- }
24
- type BonjourConstructor = new (options: object, error: (error: unknown) => void) => BonjourInstance;
25
-
26
- /** RFC 0011 §5.1: the one service type every HTTP binding publishes. */
27
- const SERVICE_TYPE = "onlyne";
28
- const SERVICE_PROTOCOL = "tcp";
29
- const WIRE_PROTOCOL = "onlyne/3";
30
- const SERVICE_PATH_SUFFIX = "/onlyne/v3";
31
- const SOURCE = "mdns";
32
-
33
- export interface NetworkDiscoveryOptions {
34
- node_id: string;
35
- /** Identifies this process instance in the service name and TXT record. */
36
- instance_id: string;
37
- /** Absolute URL the node is listening on; its port is the allocated one. */
38
- endpoint: string;
39
- /** Local listener address; a wildcard binding advertises a concrete interface. */
40
- bind: string;
41
- /** `expectedNode` is the identity the advertisement claims, not a verified fact. */
42
- onCandidate(url: string, source: string, expectedNode?: string): Promise<void>;
43
- onError(error: unknown): void;
44
- /** A candidate that was delivered earlier is no longer advertised. */
45
- onUnavailable?(url: string): void;
46
- }
47
-
48
- /**
49
- * Same-link discovery over DNS-SD/mDNS: publishes `_onlyne._tcp.local` and
50
- * offers every advertisement to the library, which owns identity resolution
51
- * and membership. The browser keeps other regions' candidates; only the
52
- * HTTP declaration decides region.
53
- */
54
- export class NetworkDiscovery {
55
- readonly #options: NetworkDiscoveryOptions;
56
- #bonjour: BonjourInstance | undefined;
57
- #browser: BonjourBrowser | undefined;
58
- #service: BonjourService | undefined;
59
- #started = false;
60
- #closed: Promise<void> | undefined;
61
- #closing = false;
62
- /** Deliveries in flight, keyed by URL, so repeated advertisements run once. */
63
- #inflight = new Map<string, Promise<boolean>>();
64
- /** Delivered URL per service fqdn; a change or a goodbye withdraws the old one. */
65
- #delivered = new Map<string, string>();
66
-
67
- constructor(options: NetworkDiscoveryOptions) {
68
- this.#options = options;
69
- }
70
-
71
- async start(): Promise<void> {
72
- if (this.#started) return;
73
- this.#closed = undefined;
74
- this.#closing = false;
75
- const publication = plan(this.#options);
76
- let module: { default?: BonjourConstructor } | BonjourConstructor;
77
- try {
78
- module = await import("bonjour-service") as unknown as { default?: BonjourConstructor } | BonjourConstructor;
79
- } catch {
80
- throw new Error("DNS-SD discovery is unavailable. Check the bonjour-service installation.");
81
- }
82
- const Constructor = typeof module === "function" ? module : module.default;
83
- if (Constructor === undefined) throw new Error("bonjour-service does not export a constructor.");
84
- const bonjour = new Constructor({}, (error: unknown) => this.#fail(error));
85
- try {
86
- const browser = bonjour.find({ type: SERVICE_TYPE, protocol: SERVICE_PROTOCOL });
87
- browser.on("up", service => this.#consider(service));
88
- browser.on("srv-update", service => this.#consider(service));
89
- browser.on("txt-update", service => this.#consider(service));
90
- browser.on("down", service => this.#withdraw(service));
91
- this.#bonjour = bonjour;
92
- this.#browser = browser;
93
-
94
- // A probe keeps the first `records()` call asynchronous, which is what lets
95
- // the loopback filter below take effect before the announcement is sent.
96
- const service = bonjour.publish({
97
- name: publication.name,
98
- type: SERVICE_TYPE,
99
- protocol: SERVICE_PROTOCOL,
100
- port: publication.port,
101
- host: publication.host,
102
- probe: true,
103
- txt: publication.txt,
104
- });
105
- if (isLoopback(publication.host)) {
106
- // bonjour-service attaches every interface address to the SRV target. A
107
- // loopback binding is reachable only from this host, so publishing those
108
- // addresses (none of them loopback) would advertise a dead endpoint.
109
- const records = service.records.bind(service);
110
- service.records = () => records().filter(record => record.type !== "A" && record.type !== "AAAA");
111
- }
112
- this.#service = service;
113
- this.#started = true;
114
- } catch (error) {
115
- bonjour.destroy(() => undefined);
116
- throw error;
117
- }
118
- }
119
-
120
- async close(): Promise<void> {
121
- if (this.#closed !== undefined) return this.#closed;
122
- const bonjour = this.#bonjour;
123
- this.#closing = true;
124
- this.#started = false;
125
- this.#browser?.stop();
126
- this.#browser = undefined;
127
- this.#service = undefined;
128
- this.#bonjour = undefined;
129
- this.#inflight.clear();
130
- this.#delivered.clear();
131
- if (bonjour === undefined) {
132
- this.#closed = Promise.resolve();
133
- return this.#closed;
134
- }
135
- this.#closed = new Promise<void>(resolve => {
136
- bonjour.unpublishAll(() => bonjour.destroy(() => resolve()));
137
- });
138
- return this.#closed;
139
- }
140
-
141
- #consider(service: BonjourService): void {
142
- if (this.#closing) return;
143
- if (service.fqdn === this.#service?.fqdn) return;
144
- const txt = text(service);
145
- const node = txt["node_id"];
146
- const instance = txt["instance_id"];
147
- if (node === this.#options.node_id && instance === this.#options.instance_id) return;
148
-
149
- const protocol = txt["protocol"];
150
- if (protocol !== WIRE_PROTOCOL) {
151
- this.#fail(new Error(`Service ${service.name} does not speak ${WIRE_PROTOCOL}.`));
152
- return;
153
- }
154
- if (node === undefined || instance === undefined) {
155
- this.#fail(new Error(`Service ${service.name} is missing its node_id or instance_id.`));
156
- return;
157
- }
158
- const scheme = txt["scheme"];
159
- if (scheme !== "http" && scheme !== "https") {
160
- this.#fail(new Error(`Service ${service.name} advertises scheme ${scheme ?? "(missing)"}.`));
161
- return;
162
- }
163
- const path = txt["path"];
164
- if (path === undefined || !normalizePath(path).endsWith(SERVICE_PATH_SUFFIX)) {
165
- this.#fail(new Error(`Service ${service.name} does not advertise a ${SERVICE_PATH_SUFFIX} path.`));
166
- return;
167
- }
168
- if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65_535) {
169
- this.#fail(new Error(`Service ${service.name} advertises port ${String(service.port)}.`));
170
- return;
171
- }
172
-
173
- const host = candidateHost(service);
174
- if (host === undefined) {
175
- this.#fail(new Error(`Service ${service.name} advertises no address reachable from this host.`));
176
- return;
177
- }
178
- if (!isLoopback(host) && scheme !== "https") {
179
- this.#fail(new Error(`Service ${service.name} advertises plain HTTP across hosts; Onlyne requires HTTPS there.`));
180
- return;
181
- }
182
-
183
- const url = `${scheme}://${host.includes(":") ? `[${host}]` : host}:${String(service.port)}${normalizePath(path)}`;
184
- const previous = this.#delivered.get(service.fqdn);
185
- if (previous === url) return;
186
- if (previous !== undefined) {
187
- this.#delivered.delete(service.fqdn);
188
- this.#options.onUnavailable?.(previous);
189
- }
190
- void this.#deliver(service.fqdn, url, node);
191
- }
192
-
193
- #deliver(fqdn: string, url: string, expectedNode: string): Promise<boolean> {
194
- const running = this.#inflight.get(url);
195
- if (running !== undefined) return running;
196
- const attempt = this.#options
197
- .onCandidate(url, SOURCE, expectedNode)
198
- .then(() => true)
199
- .catch((error: unknown) => {
200
- this.#fail(error);
201
- return false;
202
- });
203
- const tracked = attempt.finally(() => {
204
- if (this.#inflight.get(url) === tracked) this.#inflight.delete(url);
205
- });
206
- tracked.then(delivered => {
207
- if (delivered && !this.#closing && !this.#delivered.has(fqdn)) this.#delivered.set(fqdn, url);
208
- });
209
- this.#inflight.set(url, tracked);
210
- return tracked;
211
- }
212
-
213
- #withdraw(service: BonjourService): void {
214
- const url = this.#delivered.get(service.fqdn);
215
- if (url === undefined) return;
216
- this.#delivered.delete(service.fqdn);
217
- this.#options.onUnavailable?.(url);
218
- }
219
-
220
- #fail(error: unknown): void {
221
- if (this.#closing) return;
222
- try {
223
- this.#options.onError(error);
224
- } catch {
225
- // The error sink is the end of the chain; a throwing sink must not kill the mDNS loop.
226
- }
227
- }
228
- }
229
-
230
- interface Publication {
231
- name: string;
232
- host: string;
233
- port: number;
234
- txt: Record<string, string>;
235
- }
236
-
237
- /** Validate everything the advertisement needs before the socket exists. */
238
- function plan(options: NetworkDiscoveryOptions): Publication {
239
- if (options.node_id.length === 0) throw new Error("Onlyne discovery needs a node id.");
240
- if (options.instance_id.length === 0) throw new Error("Onlyne discovery needs an instance id.");
241
- let endpoint: URL;
242
- try {
243
- endpoint = new URL(options.endpoint);
244
- } catch {
245
- throw new Error(`The Onlyne endpoint must be an absolute URL, got ${options.endpoint}.`);
246
- }
247
- if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") {
248
- throw new Error(`The Onlyne endpoint must use http or https, got ${endpoint.protocol}.`);
249
- }
250
- const scheme = endpoint.protocol.slice(0, -1);
251
- const path = servicePath(endpoint.pathname);
252
- const port = endpoint.port === "" ? (scheme === "https" ? 443 : 80) : Number(endpoint.port);
253
- if (!Number.isInteger(port) || port < 1 || port > 65_535) {
254
- throw new Error(`The Onlyne endpoint must name the listening port, got ${options.endpoint}.`);
255
- }
256
- const advertised = normalizeHost(endpoint.hostname);
257
- if (advertised === undefined) throw new Error(`The Onlyne endpoint has no host, got ${options.endpoint}.`);
258
- // A wildcard binding is not a connectable address; publish the interface it
259
- // listens on instead, and never the wildcard itself.
260
- const host = isWildcard(advertised) ? interfaceAddress(options.bind) : advertised;
261
- return {
262
- name: options.instance_id,
263
- host,
264
- port,
265
- txt: {
266
- protocol: WIRE_PROTOCOL,
267
- path,
268
- scheme,
269
- node_id: options.node_id,
270
- instance_id: options.instance_id,
271
- },
272
- };
273
- }
274
-
275
- /** The advertised address of a wildcard binding: its bind when concrete, else a live interface. */
276
- function interfaceAddress(bind: string): string {
277
- const concrete = normalizeHost(bind);
278
- if (concrete !== undefined && !isWildcard(concrete)) return concrete;
279
- const ipv4: string[] = [];
280
- const ipv6: string[] = [];
281
- for (const entries of Object.values(networkInterfaces())) {
282
- for (const entry of entries ?? []) {
283
- if (entry.internal) continue;
284
- const address = normalizeAddress(entry.address);
285
- if (address === undefined || address.startsWith("fe80:") || address.startsWith("169.254.")) continue;
286
- (address.includes(":") ? ipv6 : ipv4).push(address);
287
- }
288
- }
289
- const chosen = ipv4[0] ?? ipv6[0];
290
- if (chosen === undefined) {
291
- throw new Error("No interface address can be advertised; set server.bind to a concrete address.");
292
- }
293
- return chosen;
294
- }
295
-
296
- /**
297
- * The address this host can use to reach the advertised service. Addresses that
298
- * this host already owns are only applicable when the packet came from this
299
- * host: a remote service cannot own them, and a remote loopback is unreachable.
300
- */
301
- function candidateHost(service: BonjourService): string | undefined {
302
- const local = localAddresses();
303
- const referer = normalizeHost(service.referer?.address);
304
- const originLocal = referer !== undefined && (isLoopback(referer) || local.has(referer));
305
- for (const raw of service.addresses ?? []) {
306
- const address = normalizeAddress(raw);
307
- if (address === undefined || isWildcard(address)) continue;
308
- if (address.startsWith("fe80:") || address.startsWith("169.254.")) continue;
309
- if (address.startsWith("ff") || /^(22[4-9]|23\d)\./.test(address)) continue;
310
- if (isLoopback(address)) {
311
- if (originLocal) return address;
312
- continue;
313
- }
314
- if (local.has(address) && !originLocal) continue;
315
- return address;
316
- }
317
- const target = normalizeHost(service.host);
318
- if (target === undefined || isWildcard(target)) return undefined;
319
- if (isLoopback(target)) return originLocal ? target : undefined;
320
- return target;
321
- }
322
-
323
- function localAddresses(): Set<string> {
324
- const hosts = new Set<string>(["127.0.0.1", "::1", "localhost"]);
325
- for (const entries of Object.values(networkInterfaces())) {
326
- for (const entry of entries ?? []) {
327
- const address = normalizeAddress(entry.address);
328
- if (address !== undefined) hosts.add(address);
329
- }
330
- }
331
- return hosts;
332
- }
333
-
334
- function text(service: BonjourService): Record<string, string> {
335
- const fields: Record<string, string> = {};
336
- const raw: unknown = service.txt;
337
- if (raw === null || typeof raw !== "object") return fields;
338
- for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
339
- if (typeof value === "string") fields[key] = value;
340
- else if (Array.isArray(value) && typeof value[0] === "string") fields[key] = value[0];
341
- }
342
- return fields;
343
- }
344
-
345
- function servicePath(pathname: string): string {
346
- const path = normalizePath(pathname);
347
- if (!path.endsWith(SERVICE_PATH_SUFFIX)) {
348
- throw new Error(`The Onlyne endpoint path must end with ${SERVICE_PATH_SUFFIX}, got ${pathname}.`);
349
- }
350
- return path;
351
- }
352
-
353
- function normalizePath(pathname: string): string {
354
- const absolute = pathname.startsWith("/") ? pathname : `/${pathname}`;
355
- const trimmed = absolute.replace(/\/+$/, "");
356
- return trimmed === "" ? "/" : trimmed;
357
- }
358
-
359
- function normalizeAddress(value: string): string | undefined {
360
- let address = value.trim();
361
- if (address.startsWith("[") && address.endsWith("]")) address = address.slice(1, -1);
362
- const zone = address.indexOf("%");
363
- if (zone >= 0) address = address.slice(0, zone);
364
- return address.length === 0 ? undefined : address.toLowerCase();
365
- }
366
-
367
- function normalizeHost(value: string | undefined): string | undefined {
368
- if (value === undefined) return undefined;
369
- const host = normalizeAddress(value);
370
- if (host === undefined) return undefined;
371
- return host.endsWith(".") ? host.slice(0, -1) : host;
372
- }
373
-
374
- function isLoopback(host: string): boolean {
375
- return host === "localhost" || host.endsWith(".localhost") || host === "::1"
376
- || host === "0:0:0:0:0:0:0:1" || host.startsWith("127.");
377
- }
378
-
379
- function isWildcard(host: string): boolean {
380
- return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0";
381
- }
382
-