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