@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 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.0.0",
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.0.0",
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.js';
3
- import type { LogRing } from './log.js';
4
- import { GatewayTui } from './tui.js';
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.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';
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
- export interface GatewayLogger {
10
- log(message: string, fields?: Record<string, unknown>): void;
11
- readonly ring: LogRing;
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
- 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
- },
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
@@ -1,4 +1,4 @@
1
- import { createTranslator } from './i18n.js';
1
+ import { createTranslator } from './i18n.ts';
2
2
 
3
3
  const en = {
4
4
  'title': 'Onlyne gateway · {actor} · {endpoint}',
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.js';
3
- import { t, type TuiTextKey } from './tui-text.js';
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()) {