@onlyne/node-host 3.1.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 (56) hide show
  1. package/dist/acquire.d.ts +62 -0
  2. package/dist/acquire.d.ts.map +1 -0
  3. package/dist/acquire.js +146 -0
  4. package/dist/acquire.js.map +1 -0
  5. package/dist/host-text.d.ts +2 -0
  6. package/dist/host-text.d.ts.map +1 -0
  7. package/dist/host-text.js +103 -0
  8. package/dist/host-text.js.map +1 -0
  9. package/dist/i18n.d.ts +9 -0
  10. package/dist/i18n.d.ts.map +1 -0
  11. package/dist/i18n.js +19 -0
  12. package/dist/i18n.js.map +1 -0
  13. package/dist/index.d.ts +29 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +74 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/keys.d.ts +20 -0
  18. package/dist/keys.d.ts.map +1 -0
  19. package/dist/keys.js +144 -0
  20. package/dist/keys.js.map +1 -0
  21. package/dist/log.d.ts +43 -0
  22. package/dist/log.d.ts.map +1 -0
  23. package/dist/log.js +77 -0
  24. package/dist/log.js.map +1 -0
  25. package/dist/network.d.ts +27 -0
  26. package/dist/network.d.ts.map +1 -0
  27. package/dist/network.js +350 -0
  28. package/dist/network.js.map +1 -0
  29. package/dist/node.d.ts +20 -0
  30. package/dist/node.d.ts.map +1 -0
  31. package/dist/node.js +153 -0
  32. package/dist/node.js.map +1 -0
  33. package/dist/store.d.ts +44 -0
  34. package/dist/store.d.ts.map +1 -0
  35. package/dist/store.js +219 -0
  36. package/dist/store.js.map +1 -0
  37. package/dist/tui-text.d.ts +37 -0
  38. package/dist/tui-text.d.ts.map +1 -0
  39. package/dist/tui-text.js +69 -0
  40. package/dist/tui-text.js.map +1 -0
  41. package/dist/tui.d.ts +19 -0
  42. package/dist/tui.d.ts.map +1 -0
  43. package/dist/tui.js +400 -0
  44. package/dist/tui.js.map +1 -0
  45. package/dist/types.d.ts +62 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +2 -0
  48. package/dist/types.js.map +1 -0
  49. package/package.json +46 -10
  50. package/src/i18n.ts +0 -22
  51. package/src/index.ts +0 -82
  52. package/src/keys.ts +0 -158
  53. package/src/log.ts +0 -103
  54. package/src/network.ts +0 -382
  55. package/src/tui-text.ts +0 -73
  56. package/src/tui.ts +0 -329
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
-
package/src/tui-text.ts DELETED
@@ -1,73 +0,0 @@
1
- import { createTranslator } from './i18n.ts';
2
-
3
- const en = {
4
- 'title': 'Onlyne gateway · {actor} · {endpoint}',
5
- 'peers.none': 'No nodes discovered yet.',
6
- 'peers.count': 'Nodes ({count})',
7
- 'column.delivery': 'deliver',
8
- 'hint.idle': 'Press [c] to enter an address, or wait for a peer to appear over mDNS.',
9
- 'label.fingerprint': 'fingerprint',
10
- 'label.endpoint': 'endpoint',
11
- 'label.delivery': 'delivery',
12
- 'value.notExchanged': 'not exchanged',
13
- 'value.unknown': 'unknown',
14
- 'confirm.title': 'Confirm the fingerprint of {node}?',
15
- 'confirm.keys': ' [y] confirm [n] cancel',
16
- 'input.prompt': 'address> {value}▏',
17
- 'help.keys': '[t] request exchange [a] accept request [r] revoke [c] connect address [q] quit',
18
- 'notice.cancelled': 'Cancelled.',
19
- 'notice.enterAddress': 'Enter a node service address, then press Enter to connect; Esc cancels.',
20
- 'notice.refreshFailed': 'Refresh failed: {error}',
21
- 'notice.alreadyExchanged': '{node} has already exchanged keys.',
22
- 'notice.noRequest': '{node} has no request waiting for your confirmation.',
23
- 'notice.accepted': 'Approved the key of {node}.',
24
- 'notice.noTrust': '{node} has no trust record.',
25
- 'notice.revoked': 'Revoked trust for {node}.',
26
- 'notice.noAddress': 'No address entered.',
27
- 'notice.connected': 'Connected to {node}.',
28
- 'notice.submitted': '{node} submitted, current stage {phase}.',
29
- 'notice.operationFailed': 'Operation failed: {error}',
30
- 'phase.exchanged': 'verified',
31
- 'phase.requested': 'awaiting peer',
32
- 'phase.invited': 'needs approval',
33
- 'phase.revoked': 'revoked',
34
- 'list.separator': ', ',
35
- } as const;
36
-
37
- const zh: Record<keyof typeof en, string> = {
38
- 'title': 'Onlyne 网关 · {actor} · {endpoint}',
39
- 'peers.none': '还没有发现任何节点。',
40
- 'peers.count': '节点 ({count})',
41
- 'column.delivery': '投送',
42
- 'hint.idle': '用 [c] 输入一个地址,或用 mDNS 等对端出现。',
43
- 'label.fingerprint': '指纹',
44
- 'label.endpoint': '端点',
45
- 'label.delivery': '投送',
46
- 'value.notExchanged': '未交换',
47
- 'value.unknown': '未知',
48
- 'confirm.title': '确认 {node} 的指纹?',
49
- 'confirm.keys': ' [y] 确认 [n] 取消',
50
- 'input.prompt': '地址> {value}▏',
51
- 'help.keys': '[t] 请求交换 [a] 接受申请 [r] 撤销 [c] 连接地址 [q] 退出界面',
52
- 'notice.cancelled': '已取消。',
53
- 'notice.enterAddress': '输入节点服务地址,回车连接,Esc 取消。',
54
- 'notice.refreshFailed': '刷新失败:{error}',
55
- 'notice.alreadyExchanged': '{node} 已经完成交换。',
56
- 'notice.noRequest': '{node} 没有等待你确认的申请。',
57
- 'notice.accepted': '已批准 {node} 的密钥。',
58
- 'notice.noTrust': '{node} 没有信任记录。',
59
- 'notice.revoked': '已撤销 {node} 的信任。',
60
- 'notice.noAddress': '没有输入地址。',
61
- 'notice.connected': '已连接 {node}。',
62
- 'notice.submitted': '{node} 已提交,当前阶段 {phase}。',
63
- 'notice.operationFailed': '操作失败:{error}',
64
- 'phase.exchanged': '已认证',
65
- 'phase.requested': '等对方',
66
- 'phase.invited': '待确认',
67
- 'phase.revoked': '已撤销',
68
- 'list.separator': '、',
69
- };
70
-
71
- export type TuiTextKey = keyof typeof en;
72
-
73
- export const t = createTranslator({ en, 'zh-CN': zh });