@onlyne/node-host 3.0.0 → 3.1.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/package.json +2 -2
- package/src/index.ts +11 -9
- package/src/log.ts +68 -17
- package/src/tui-text.ts +1 -1
- package/src/tui.ts +14 -3
package/README.md
CHANGED
|
@@ -20,6 +20,28 @@ library is not allowed to do.
|
|
|
20
20
|
30 seconds, and on a terminal hands the operator the trust screen. Discovery only adds
|
|
21
21
|
candidates: a key exchange still needs both fingerprints confirmed.
|
|
22
22
|
|
|
23
|
+
`startNodeHost({ status })` accepts an async callback that returns gateway-specific display lines.
|
|
24
|
+
The terminal refreshes these lines with its trust view once per second. Hosts use them for connection
|
|
25
|
+
state, server tool counts, scheduled tasks, and delivery queues. `ONLYNE_NO_TUI=1` keeps JSON logging
|
|
26
|
+
and skips terminal startup.
|
|
27
|
+
|
|
28
|
+
## Log levels
|
|
29
|
+
|
|
30
|
+
`createLogger` returns one callable line writer plus the other three levels: calling it, and
|
|
31
|
+
`logger.log`, write `info`, so every existing host line keeps its level. `ONLYNE_LOG_LEVEL` selects
|
|
32
|
+
the threshold, default `info`; a fourth argument overrides it for one process. Every level that
|
|
33
|
+
passes goes to the ring the TUI renders and to the file sink, and each JSON line names its `level`.
|
|
34
|
+
|
|
35
|
+
| Level | Meaning | Examples |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `debug` | Per-pass work and protocol internals, off by default | reconcile pulls, replays, injected-context details |
|
|
38
|
+
| `info` | State transitions and business milestones, one line each | `onlyne.tui.ready`, `connector.state`, a committed send |
|
|
39
|
+
| `warn` | Degraded but running | reconnects, retries, an unconfirmed delivery, a rate-limit hit, a failed reconcile pull |
|
|
40
|
+
| `error` | The operation failed and the caller lost something | a received event that could not be stored |
|
|
41
|
+
|
|
42
|
+
A sink that is a plain function rather than a `createLogger` result records every line it is
|
|
43
|
+
handed: it has no threshold of its own, so filtering belongs to the host that built it.
|
|
44
|
+
|
|
23
45
|
## Language
|
|
24
46
|
|
|
25
47
|
`resolveLocale` reads an explicit value first, then `ONLYNE_LANG`, then `LC_ALL` or `LANG`, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlyne/node-host",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Shared Onlyne node host pieces: identity keys, DNS-SD discovery, the gateway trust terminal, and message catalogs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"files": ["src", "README.md", "LICENSE"],
|
|
26
26
|
"scripts": { "check": "tsc --noEmit" },
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@onlyne/lib": "^3.
|
|
28
|
+
"@onlyne/lib": "^3.1.0",
|
|
29
29
|
"bonjour-service": "^1.4.4"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": { "@types/bun": "^1.3.0", "typescript": "^5.9.0" }
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { OnlyneNode, ServiceDescription } from '@onlyne/lib';
|
|
2
|
-
import { NetworkDiscovery } from './network.
|
|
3
|
-
import type { LogRing } from './log.
|
|
4
|
-
import { GatewayTui } from './tui.
|
|
2
|
+
import { NetworkDiscovery } from './network.ts';
|
|
3
|
+
import type { LogRing } from './log.ts';
|
|
4
|
+
import { GatewayTui } from './tui.ts';
|
|
5
5
|
|
|
6
|
-
export { createNodeKey } from './keys.
|
|
7
|
-
export { NetworkDiscovery } from './network.
|
|
8
|
-
export { createLogger } from './log.
|
|
9
|
-
export type { GatewayLogger, LogRing } from './log.
|
|
10
|
-
export { GatewayTui } from './tui.
|
|
6
|
+
export { createNodeKey } from './keys.ts';
|
|
7
|
+
export { NetworkDiscovery } from './network.ts';
|
|
8
|
+
export { createLogger, leveled, resolveLogLevel } from './log.ts';
|
|
9
|
+
export type { GatewayLogger, LeveledLog, LogLevel, LogLine, LogRing, LogSink } from './log.ts';
|
|
10
|
+
export { GatewayTui } from './tui.ts';
|
|
11
11
|
|
|
12
12
|
export interface NodeHostOptions {
|
|
13
13
|
node: OnlyneNode;
|
|
@@ -18,6 +18,8 @@ export interface NodeHostOptions {
|
|
|
18
18
|
ring?: LogRing;
|
|
19
19
|
/** Defaults to the terminal surface when both ends of the process are a TTY. */
|
|
20
20
|
interactive?: boolean;
|
|
21
|
+
/** Gateway-specific state rendered with the trust terminal. */
|
|
22
|
+
status?: () => Promise<string[]>;
|
|
21
23
|
log(message: string, fields?: Record<string, unknown>): void;
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -58,7 +60,7 @@ export async function startNodeHost(options: NodeHostOptions): Promise<{ close()
|
|
|
58
60
|
// `ONLYNE_NO_TUI=1` keeps the plain JSON log on stdout, for a pipe or a screen a TUI would spoil.
|
|
59
61
|
const interactive = (options.interactive ?? true) && process.env.ONLYNE_NO_TUI !== '1';
|
|
60
62
|
if (interactive && options.ring !== undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
61
|
-
const surface = new GatewayTui(node, options.ring, options.endpoint);
|
|
63
|
+
const surface = new GatewayTui(node, options.ring, options.endpoint, undefined, options.status);
|
|
62
64
|
try {
|
|
63
65
|
log('onlyne.tui.starting');
|
|
64
66
|
await surface.start();
|
package/src/log.ts
CHANGED
|
@@ -6,16 +6,61 @@ export interface LogRing {
|
|
|
6
6
|
tail(count: number): string[];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/** The four levels of the convention: `debug` loops, `info` milestones, `warn` degradation, `error` loss. */
|
|
10
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
11
|
+
|
|
12
|
+
/** One JSON line about to be written. */
|
|
13
|
+
export type LogLine = (message: string, fields?: Record<string, unknown>) => void;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A host's log sink. Calling it writes `info`, the level every existing call site uses; a sink
|
|
17
|
+
* that also carries `debug`/`warn`/`error` records those levels where the host put them.
|
|
18
|
+
*/
|
|
19
|
+
export type LogSink = LogLine & {
|
|
20
|
+
readonly debug?: LogLine;
|
|
21
|
+
readonly warn?: LogLine;
|
|
22
|
+
readonly error?: LogLine;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type LeveledLog = LogLine & {
|
|
26
|
+
log: LogLine;
|
|
27
|
+
debug: LogLine;
|
|
28
|
+
warn: LogLine;
|
|
29
|
+
error: LogLine;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type GatewayLogger = LeveledLog & { readonly level: LogLevel; readonly ring: LogRing };
|
|
33
|
+
|
|
34
|
+
const ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
35
|
+
|
|
36
|
+
/** `ONLYNE_LOG_LEVEL` selects a level; anything unrecognised (and unset) keeps `info`. */
|
|
37
|
+
export function resolveLogLevel(value: string | undefined): LogLevel {
|
|
38
|
+
return value === 'debug' || value === 'warn' || value === 'error' ? value : 'info';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalizes a sink to the four entry points. A sink without level methods records every line
|
|
43
|
+
* it is handed: it cannot filter, so the host's own store decides what the level means.
|
|
44
|
+
*/
|
|
45
|
+
export function leveled(sink?: LogSink): LeveledLog {
|
|
46
|
+
const call: LogLine = (message, fields) => { if (typeof sink === 'function') sink(message, fields); };
|
|
47
|
+
return Object.assign(call, {
|
|
48
|
+
log: call,
|
|
49
|
+
debug: sink?.debug ?? call,
|
|
50
|
+
warn: sink?.warn ?? call,
|
|
51
|
+
error: sink?.error ?? call,
|
|
52
|
+
});
|
|
12
53
|
}
|
|
13
54
|
|
|
14
55
|
/**
|
|
15
56
|
* The gateway logs JSON lines. Under the TUI those lines would corrupt the alternate
|
|
16
57
|
* screen, so they go to a ring the TUI renders instead; without a TTY they stay on stdout.
|
|
58
|
+
* The returned function is the `info` line — `logger.log` is the same entry point — and
|
|
59
|
+
* `debug`, `warn`, `error` are the other levels of the same convention.
|
|
17
60
|
*/
|
|
18
|
-
export function createLogger(toStdout: boolean, capacity = 200, file?: string): GatewayLogger {
|
|
61
|
+
export function createLogger(toStdout: boolean, capacity = 200, file?: string, level?: LogLevel): GatewayLogger {
|
|
62
|
+
const selected = level ?? resolveLogLevel(process.env.ONLYNE_LOG_LEVEL);
|
|
63
|
+
const threshold = ORDER[selected];
|
|
19
64
|
// One entry per distinct message: a gateway that retries every five seconds would otherwise
|
|
20
65
|
// fill the pane with the same line and hide everything else.
|
|
21
66
|
const lines: Array<{ key: string; line: string; count: number }> = [];
|
|
@@ -35,18 +80,24 @@ export function createLogger(toStdout: boolean, capacity = 200, file?: string):
|
|
|
35
80
|
.map(entry => (entry.count === 1 ? entry.line : `${entry.line} ×${entry.count}`));
|
|
36
81
|
},
|
|
37
82
|
};
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
},
|
|
83
|
+
const emit = (at: LogLevel, message: string, fields?: Record<string, unknown>): void => {
|
|
84
|
+
if (ORDER[at] < threshold) return;
|
|
85
|
+
const line = JSON.stringify({ at: new Date().toISOString(), level: at, message, ...fields });
|
|
86
|
+
if (toStdout) console.log(line);
|
|
87
|
+
else ring.push(`${at}|${message}|${JSON.stringify(fields ?? {})}`, line);
|
|
88
|
+
// The TUI owns the screen, so the durable record is the file; without one the line would
|
|
89
|
+
// exist only in the ring and vanish with the process.
|
|
90
|
+
if (file !== undefined) {
|
|
91
|
+
try { appendFileSync(file, `${line}\n`); } catch { /* a full disk must not stop the gateway */ }
|
|
92
|
+
}
|
|
50
93
|
};
|
|
94
|
+
const info: LogLine = (message, fields) => emit('info', message, fields);
|
|
95
|
+
return Object.assign(info, {
|
|
96
|
+
level: selected,
|
|
97
|
+
log: info,
|
|
98
|
+
debug: (message: string, fields?: Record<string, unknown>) => emit('debug', message, fields),
|
|
99
|
+
warn: (message: string, fields?: Record<string, unknown>) => emit('warn', message, fields),
|
|
100
|
+
error: (message: string, fields?: Record<string, unknown>) => emit('error', message, fields),
|
|
101
|
+
ring,
|
|
102
|
+
});
|
|
51
103
|
}
|
|
52
|
-
|
package/src/tui-text.ts
CHANGED
package/src/tui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { trustPhase, type KeyExchange, type OnlyneNode, type TrustPhase } from '@onlyne/lib';
|
|
2
|
-
import type { LogRing } from './log.
|
|
3
|
-
import { t, type TuiTextKey } from './tui-text.
|
|
2
|
+
import type { LogRing } from './log.ts';
|
|
3
|
+
import { t, type TuiTextKey } from './tui-text.ts';
|
|
4
4
|
|
|
5
5
|
/** One peer as the terminal shows it: a discovery address plus whatever the trust table knows. */
|
|
6
6
|
interface Row {
|
|
@@ -46,12 +46,14 @@ export class GatewayTui {
|
|
|
46
46
|
#busy = false;
|
|
47
47
|
#closed = false;
|
|
48
48
|
#refreshTimer?: ReturnType<typeof setInterval>;
|
|
49
|
+
#statusLines: string[] = [];
|
|
49
50
|
|
|
50
51
|
constructor(
|
|
51
52
|
private readonly node: OnlyneNode,
|
|
52
53
|
private readonly ring: LogRing,
|
|
53
54
|
private readonly endpoint: string,
|
|
54
55
|
private readonly onChange: () => void = () => undefined,
|
|
56
|
+
private readonly status?: () => Promise<string[]>,
|
|
55
57
|
) {}
|
|
56
58
|
|
|
57
59
|
async start(): Promise<void> {
|
|
@@ -119,10 +121,12 @@ export class GatewayTui {
|
|
|
119
121
|
async #refresh(): Promise<void> {
|
|
120
122
|
if (this.#closed) return;
|
|
121
123
|
try {
|
|
122
|
-
const [trust, records] = await Promise.all([
|
|
124
|
+
const [trust, records, status] = await Promise.all([
|
|
123
125
|
this.node.trust.list(this.node.auth),
|
|
124
126
|
this.node.discovery.listRecords(),
|
|
127
|
+
this.status?.() ?? [],
|
|
125
128
|
]);
|
|
129
|
+
this.#statusLines = status;
|
|
126
130
|
const handlers = new Map<string, string[]>();
|
|
127
131
|
const resources = await this.node.resources.list(this.node.auth, { page_size: 100 });
|
|
128
132
|
for (const resource of resources.items) {
|
|
@@ -231,6 +235,13 @@ export class GatewayTui {
|
|
|
231
235
|
push(t('title', { actor: this.node.auth.actor, endpoint: this.endpoint }));
|
|
232
236
|
push(rule(width));
|
|
233
237
|
|
|
238
|
+
if (this.#statusLines.length > 0) {
|
|
239
|
+
const budget = Math.max(3, Math.floor(height / 3));
|
|
240
|
+
for (const line of this.#statusLines.slice(0, budget)) push(line);
|
|
241
|
+
if (this.#statusLines.length > budget) push('…');
|
|
242
|
+
push(rule(width));
|
|
243
|
+
}
|
|
244
|
+
|
|
234
245
|
const peerHeader = this.#rows.length === 0 ? t('peers.none') : t('peers.count', { count: this.#rows.length });
|
|
235
246
|
push(peerHeader);
|
|
236
247
|
for (const [index, row] of this.#rows.entries()) {
|