@x-tech-laboratories/x-libs 0.14.1 → 0.14.2

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 (150) hide show
  1. package/dist/agent/client.d.ts +32 -0
  2. package/dist/agent/client.js +148 -0
  3. package/dist/agent/commands.d.ts +23 -0
  4. package/dist/agent/commands.js +118 -0
  5. package/dist/agent/keys.d.ts +14 -0
  6. package/dist/agent/keys.js +81 -0
  7. package/dist/agent/long_runs.d.ts +32 -0
  8. package/dist/agent/long_runs.js +316 -0
  9. package/dist/agent/protocol.d.ts +148 -0
  10. package/dist/agent/protocol.js +131 -0
  11. package/dist/agent/session.d.ts +50 -0
  12. package/dist/agent/session.js +225 -0
  13. package/dist/agent/tasks.d.ts +17 -0
  14. package/dist/agent/tasks.js +15 -0
  15. package/dist/backups.d.ts +31 -0
  16. package/dist/backups.js +43 -0
  17. package/dist/dates.d.ts +9 -0
  18. package/dist/dates.js +60 -0
  19. package/dist/durations.d.ts +11 -0
  20. package/dist/durations.js +49 -0
  21. package/dist/electron/app.d.ts +13 -0
  22. package/dist/electron/app.js +27 -0
  23. package/dist/electron/bridge.d.ts +55 -0
  24. package/dist/electron/bridge.js +105 -0
  25. package/dist/electron/events.d.ts +21 -0
  26. package/dist/electron/events.js +25 -0
  27. package/dist/electron/fetch.d.ts +52 -0
  28. package/dist/electron/fetch.js +175 -0
  29. package/dist/electron/window.d.ts +96 -0
  30. package/dist/electron/window.js +128 -0
  31. package/dist/log.d.ts +30 -0
  32. package/dist/log.js +79 -0
  33. package/dist/server/agents/agent.d.ts +27 -0
  34. package/dist/server/agents/agent.js +16 -0
  35. package/dist/server/agents/connection.d.ts +51 -0
  36. package/dist/server/agents/connection.js +239 -0
  37. package/dist/server/agents/hub.d.ts +86 -0
  38. package/dist/server/agents/hub.js +241 -0
  39. package/dist/server/agents/local.d.ts +24 -0
  40. package/dist/server/agents/local.js +62 -0
  41. package/dist/server/agents/route.d.ts +3 -0
  42. package/dist/server/agents/route.js +24 -0
  43. package/dist/server/backup_files.d.ts +3 -0
  44. package/dist/server/backup_files.js +25 -0
  45. package/dist/server/config.d.ts +39 -0
  46. package/dist/server/config.js +109 -0
  47. package/dist/server/cors.d.ts +8 -0
  48. package/dist/server/cors.js +13 -0
  49. package/dist/server/events.d.ts +33 -0
  50. package/dist/server/events.js +68 -0
  51. package/dist/server/hash_password.d.ts +1 -0
  52. package/dist/server/hash_password.js +46 -0
  53. package/dist/server/login_page.d.ts +8 -0
  54. package/dist/server/login_page.js +79 -0
  55. package/dist/server/passwords.d.ts +8 -0
  56. package/dist/server/passwords.js +59 -0
  57. package/dist/server/replies.d.ts +12 -0
  58. package/dist/server/replies.js +41 -0
  59. package/dist/server/sign_in.d.ts +27 -0
  60. package/dist/server/sign_in.js +95 -0
  61. package/dist/server/web_folder.d.ts +11 -0
  62. package/dist/server/web_folder.js +40 -0
  63. package/dist/web/dom.d.ts +7 -0
  64. package/dist/web/dom.js +32 -0
  65. package/dist/web/drag.d.ts +3 -0
  66. package/dist/web/drag.js +13 -0
  67. package/dist/web/errors.d.ts +1 -0
  68. package/dist/web/errors.js +27 -0
  69. package/dist/web/format.d.ts +11 -0
  70. package/dist/web/format.js +82 -0
  71. package/dist/web/menus.d.ts +17 -0
  72. package/dist/web/menus.js +40 -0
  73. package/dist/web/prefs.d.ts +12 -0
  74. package/dist/web/prefs.js +62 -0
  75. package/dist/web/react/badges.d.ts +35 -0
  76. package/dist/web/react/badges.js +32 -0
  77. package/dist/web/react/controls.d.ts +86 -0
  78. package/dist/web/react/controls.js +154 -0
  79. package/dist/web/react/dialog.d.ts +45 -0
  80. package/dist/web/react/dialog.js +82 -0
  81. package/dist/web/react/following.d.ts +25 -0
  82. package/dist/web/react/following.js +34 -0
  83. package/dist/web/react/layout.d.ts +111 -0
  84. package/dist/web/react/layout.js +85 -0
  85. package/dist/web/react/list_picker.d.ts +19 -0
  86. package/dist/web/react/list_picker.js +50 -0
  87. package/dist/web/react/markdown.d.ts +8 -0
  88. package/dist/web/react/markdown.js +40 -0
  89. package/dist/web/react/media.d.ts +24 -0
  90. package/dist/web/react/media.js +18 -0
  91. package/dist/web/react/menu_button.d.ts +10 -0
  92. package/dist/web/react/menu_button.js +16 -0
  93. package/dist/web/react/menus.d.ts +31 -0
  94. package/dist/web/react/page_view.d.ts +16 -0
  95. package/dist/web/react/page_view.js +6 -0
  96. package/dist/web/react/prefs.d.ts +2 -0
  97. package/dist/web/react/prefs.js +11 -0
  98. package/dist/web/react/progress.d.ts +10 -0
  99. package/dist/web/react/progress.js +9 -0
  100. package/dist/web/react/rating.d.ts +8 -0
  101. package/dist/web/react/rating.js +23 -0
  102. package/dist/web/react/reveal.d.ts +10 -0
  103. package/dist/web/react/reveal.js +30 -0
  104. package/dist/web/react/shell.d.ts +46 -0
  105. package/dist/web/react/shell.js +94 -0
  106. package/dist/web/react/shortcuts.d.ts +23 -0
  107. package/dist/web/react/shortcuts.js +95 -0
  108. package/dist/web/react/split.d.ts +23 -0
  109. package/dist/web/react/split.js +94 -0
  110. package/dist/web/react/states.d.ts +36 -0
  111. package/dist/web/react/states.js +64 -0
  112. package/dist/web/react/store.d.ts +4 -0
  113. package/dist/web/react/store.js +7 -0
  114. package/dist/web/react/suggestions.d.ts +23 -0
  115. package/dist/web/react/suggestions.js +57 -0
  116. package/dist/web/react/table.d.ts +78 -0
  117. package/dist/web/react/table.js +404 -0
  118. package/dist/web/react/test_stubs.d.ts +1 -0
  119. package/dist/web/react/test_stubs.js +25 -0
  120. package/dist/web/react/time.d.ts +5 -0
  121. package/dist/web/react/time.js +19 -0
  122. package/dist/web/react/toasts.d.ts +29 -0
  123. package/dist/web/react/toasts.js +72 -0
  124. package/dist/web/react/tree.d.ts +28 -0
  125. package/dist/web/react/tree.js +183 -0
  126. package/dist/web/react/volume.d.ts +8 -0
  127. package/dist/web/react/volume.js +42 -0
  128. package/dist/web/react/watch.d.ts +3 -0
  129. package/dist/web/react/watch.js +5 -0
  130. package/dist/web/router.d.ts +7 -0
  131. package/dist/web/router.js +29 -0
  132. package/dist/web/selection.d.ts +25 -0
  133. package/dist/web/selection.js +106 -0
  134. package/dist/web/split.d.ts +15 -0
  135. package/dist/web/split.js +104 -0
  136. package/dist/web/states.d.ts +3 -0
  137. package/dist/web/states.js +10 -0
  138. package/dist/web/store.d.ts +11 -0
  139. package/dist/web/store.js +17 -0
  140. package/dist/web/tables.d.ts +28 -0
  141. package/dist/web/tables.js +88 -0
  142. package/dist/web/theme.d.ts +14 -0
  143. package/dist/web/theme.js +52 -0
  144. package/dist/web/tree.d.ts +40 -0
  145. package/dist/web/tree.js +130 -0
  146. package/dist/web/virtual.d.ts +7 -0
  147. package/dist/web/virtual.js +10 -0
  148. package/dist/web/watch.d.ts +11 -0
  149. package/dist/web/watch.js +37 -0
  150. package/package.json +1 -1
@@ -0,0 +1,241 @@
1
+ // The agents the server can reach, by host name: those that have dialled in with their host's declared key or a token
2
+ // the app issued, and the one built into the server for its own machine. A host with a declared key logs in only with
3
+ // that key, so a leaked token cannot stand in for it.
4
+ // ── Imports ──────────────────────────────────────────────────────────────────
5
+ import { randomBytes } from "node:crypto";
6
+ import { EventEmitter } from "node:events";
7
+ import { parse_public_key, verify_challenge } from "../../agent/keys.js";
8
+ import { encode_message, is_agent_message, key_host_header, parse_agent_message, } from "../../agent/protocol.js";
9
+ import { AgentError, ended_stream } from "./agent.js";
10
+ import { AgentConnection } from "./connection.js";
11
+ // Close codes the hub hangs up with.
12
+ export const close_codes = {
13
+ protocol: 4002,
14
+ replaced: 4003,
15
+ revoked: 4004,
16
+ no_hello: 4005,
17
+ bad_key: 4006,
18
+ };
19
+ const default_connection = {
20
+ window_in_bytes: 1024 * 1024,
21
+ request_timeout_in_milliseconds: 60_000,
22
+ heartbeat_in_milliseconds: 15_000,
23
+ };
24
+ // ── Hub ──────────────────────────────────────────────────────────────────────
25
+ export class AgentHub extends EventEmitter {
26
+ app;
27
+ local;
28
+ tokens;
29
+ on_message;
30
+ hello_timeout;
31
+ connection_options;
32
+ connections = new Map();
33
+ logins = new WeakMap();
34
+ // Each host's declared public key, with the line it was read from.
35
+ agent_keys = new Map();
36
+ constructor(options) {
37
+ super();
38
+ this.app = options.app;
39
+ this.local = options.local === undefined ? null : { ...options.local };
40
+ this.tokens = options.tokens ?? null;
41
+ this.on_message = options.on_message ?? null;
42
+ this.hello_timeout = options.hello_timeout_in_milliseconds ?? 10_000;
43
+ this.connection_options = { ...default_connection, ...options.connection };
44
+ }
45
+ // The host the built-in agent answers for, which follows the config as it names this server's own host.
46
+ set_local_host(host) {
47
+ if (this.local !== null)
48
+ this.local.host = host;
49
+ }
50
+ // ── Enrolment ────────────────────────────────────────────────────────────
51
+ // Takes each host's declared OpenSSH public key line, and hangs up on an agent whose host's key is new, changed or
52
+ // gone, so it logs in again the way the config now asks.
53
+ set_agent_keys(lines) {
54
+ const next = new Map([...lines].map(([host, line]) => [host, { line, key: parse_public_key(line) }]));
55
+ for (const host of new Set([...this.agent_keys.keys(), ...next.keys()])) {
56
+ if (this.agent_keys.get(host)?.line === next.get(host)?.line)
57
+ continue;
58
+ this.disconnect(host, close_codes.revoked, "The host's declared agent key changed.");
59
+ }
60
+ this.agent_keys = next;
61
+ }
62
+ has_agent_key(host) {
63
+ return this.agent_keys.has(host);
64
+ }
65
+ // The hosts with a declared key, in order.
66
+ declared_hosts() {
67
+ return [...this.agent_keys.keys()].sort();
68
+ }
69
+ // Hangs up on a host's agent that logged in with a token, as when the app replaces or revokes that token.
70
+ disconnect_token_login(host, reason) {
71
+ const connection = this.connections.get(host);
72
+ if (connection !== undefined && this.logins.get(connection) === "token")
73
+ connection.close(close_codes.revoked, reason);
74
+ }
75
+ // The host an Authorization header's bearer token belongs to.
76
+ authenticate(authorization) {
77
+ const match = /^Bearer\s+(\S+)$/.exec(authorization ?? "");
78
+ return match === null || this.tokens === null ? null : this.tokens.host_for(match[1]);
79
+ }
80
+ // Who an agent's upgrade request says it is: the host it names to log in with that host's key, or a token's host.
81
+ admit(headers) {
82
+ const header = key_host_header(this.app.name);
83
+ const named = headers[header];
84
+ if (typeof named === "string" && named !== "") {
85
+ if (named === this.local?.host)
86
+ return { error: `'${named}' is the server's own host, which needs no agent.` };
87
+ if (!this.agent_keys.has(named))
88
+ return { error: `Host '${named}' has no declared agent key.` };
89
+ return { host: named, login: "key" };
90
+ }
91
+ if (this.tokens === null)
92
+ return { error: `Name the host in the ${header} header.` };
93
+ const host = this.authenticate(headers.authorization);
94
+ if (host === null)
95
+ return { error: "This agent token is not enrolled." };
96
+ if (this.agent_keys.has(host))
97
+ return { error: `Host '${host}' has a declared agent key, so it logs in with that key, not a token.` };
98
+ return { host, login: "token" };
99
+ }
100
+ // ── Connections ──────────────────────────────────────────────────────────
101
+ // Takes a socket whose upgrade named the host, and waits for its hello before using it; one logging in with its key
102
+ // is sent a nonce first, which its hello must carry signed.
103
+ accept(socket, host, login) {
104
+ let connection = null;
105
+ const hello_timer = setTimeout(() => socket.close(close_codes.no_hello, "No hello came."), this.hello_timeout);
106
+ const nonce = login === "key" ? randomBytes(32).toString("base64url") : null;
107
+ if (nonce !== null)
108
+ socket.send(encode_message({ type: "challenge", nonce }, this.app));
109
+ socket.on("message", (raw, is_binary) => {
110
+ const data = as_buffer(raw);
111
+ if (connection !== null) {
112
+ connection.receive(data, is_binary);
113
+ return;
114
+ }
115
+ const hello = is_binary ? null : parse_agent_message(data.toString("utf8"), this.app);
116
+ if (hello === null || !is_agent_message(hello) || hello.type !== "hello")
117
+ return;
118
+ clearTimeout(hello_timer);
119
+ if (hello.protocol !== this.app.protocol_version) {
120
+ socket.close(close_codes.protocol, `This server speaks agent protocol ${this.app.protocol_version}, not ${hello.protocol}.`);
121
+ return;
122
+ }
123
+ const { signature, ...greeting } = hello;
124
+ if (nonce !== null && !this.proves_key(host, nonce, signature)) {
125
+ socket.close(close_codes.bad_key, `The agent's key is not the one declared for ${host}.`);
126
+ return;
127
+ }
128
+ this.connections.get(host)?.close(close_codes.replaced, "A newer connection from the same host replaced this one.");
129
+ const current = new AgentConnection(socket, this.app, host, greeting, this.connection_options);
130
+ current.on_message = (message) => this.on_message?.(host, message, current);
131
+ connection = current;
132
+ this.connections.set(host, current);
133
+ this.logins.set(current, login);
134
+ this.emit("connect", this.status_of(current));
135
+ });
136
+ socket.on("error", () => { });
137
+ socket.on("close", () => {
138
+ clearTimeout(hello_timer);
139
+ if (connection === null)
140
+ return;
141
+ connection.closed(`The agent on ${host} disconnected.`);
142
+ if (this.logins.get(connection) === "token")
143
+ this.tokens?.seen?.(host);
144
+ if (this.connections.get(host) !== connection)
145
+ return;
146
+ this.connections.delete(host);
147
+ this.emit("disconnect", host);
148
+ });
149
+ }
150
+ proves_key(host, nonce, signature) {
151
+ const declared = this.agent_keys.get(host);
152
+ return declared !== undefined && typeof signature === "string" && verify_challenge(declared.key, this.app.name, host, nonce, signature);
153
+ }
154
+ disconnect(host, code = 1000, reason = "The server hung up.") {
155
+ this.connections.get(host)?.close(code, reason);
156
+ }
157
+ close() {
158
+ for (const host of this.connections.keys())
159
+ this.disconnect(host, 1001, "The server is stopping.");
160
+ }
161
+ is_connected(host) {
162
+ return this.local?.host === host || this.connections.get(host)?.is_open === true;
163
+ }
164
+ status(host) {
165
+ if (this.local?.host === host)
166
+ return local_status(host);
167
+ const connection = this.connections.get(host);
168
+ return connection?.is_open ? this.status_of(connection) : null;
169
+ }
170
+ // The built-in agent, then every connected one.
171
+ statuses() {
172
+ const remote = [...this.connections.values()].filter((connection) => connection.is_open).map((connection) => this.status_of(connection));
173
+ return this.local === null ? remote : [local_status(this.local.host), ...remote];
174
+ }
175
+ // The host's agent, or null when it is not connected.
176
+ agent(host) {
177
+ if (this.local?.host === host)
178
+ return this.local.agent;
179
+ const connection = this.connections.get(host);
180
+ return connection?.is_open ? connection : null;
181
+ }
182
+ // ── Commands ─────────────────────────────────────────────────────────────
183
+ // A run on a host with no agent answers with exit code -1 rather than rejecting, as a command that failed does.
184
+ run(host, task, options) {
185
+ const agent = this.agent(host);
186
+ return agent === null ? Promise.resolve({ exit_code: -1, stdout: "", stderr: not_connected(host) }) : agent.run(task, options);
187
+ }
188
+ stream(host, task, options = {}) {
189
+ return this.agent(host)?.stream(task, options) ?? ended_stream(not_connected(host));
190
+ }
191
+ start(host, run_id, task, options = {}) {
192
+ return this.require(host).then((agent) => agent.start(run_id, task, options));
193
+ }
194
+ poll(host, run_id, offset) {
195
+ return this.require(host).then((agent) => agent.poll(run_id, offset));
196
+ }
197
+ attach(host, run_id, offset) {
198
+ return this.agent(host)?.attach(run_id, offset) ?? ended_stream(not_connected(host));
199
+ }
200
+ stop(host, run_id) {
201
+ return this.require(host).then((agent) => agent.stop(run_id));
202
+ }
203
+ forget(host, run_id) {
204
+ return this.require(host).then((agent) => agent.forget(run_id));
205
+ }
206
+ runs(host) {
207
+ return this.require(host).then((agent) => agent.runs());
208
+ }
209
+ request(host, type, body = {}) {
210
+ return this.require(host).then((agent) => agent.request(type, body));
211
+ }
212
+ async require(host) {
213
+ const agent = this.agent(host);
214
+ if (agent === null)
215
+ throw new AgentError(not_connected(host), true);
216
+ return agent;
217
+ }
218
+ status_of(connection) {
219
+ return {
220
+ host: connection.host,
221
+ reported_host: connection.hello.host,
222
+ agent_version: connection.hello.agent_version,
223
+ connected_epoch: connection.connected_epoch,
224
+ built_in: false,
225
+ login: this.logins.get(connection) ?? null,
226
+ runs: connection.hello.runs,
227
+ };
228
+ }
229
+ }
230
+ // ── Helpers ──────────────────────────────────────────────────────────────────
231
+ function not_connected(host) {
232
+ return `No agent is connected for ${host}.`;
233
+ }
234
+ function local_status(host) {
235
+ return { host, reported_host: host, agent_version: "built in", connected_epoch: 0, built_in: true, login: null, runs: [] };
236
+ }
237
+ function as_buffer(raw) {
238
+ if (Array.isArray(raw))
239
+ return Buffer.concat(raw);
240
+ return Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
241
+ }
@@ -0,0 +1,24 @@
1
+ import { LongRuns, type LongRunOptions } from "../../agent/long_runs.ts";
2
+ import type { AppRequests } from "../../agent/session.ts";
3
+ import type { TaskRunner } from "../../agent/tasks.ts";
4
+ import { type Agent, type AgentRunOptions, type AgentStreamOptions, type CommandStream, type LongRun, type LongRunPoll, type RunResult } from "./agent.ts";
5
+ export interface LocalAgentOptions<Task> {
6
+ runner: TaskRunner<Task>;
7
+ long_runs?: LongRunOptions;
8
+ requests?: AppRequests;
9
+ }
10
+ export declare class LocalAgent<Task> implements Agent<Task> {
11
+ readonly long_runs: LongRuns<Task>;
12
+ private readonly runner;
13
+ private readonly requests;
14
+ constructor(state_directory: string, options: LocalAgentOptions<Task>);
15
+ run(task: Task, options: AgentRunOptions): Promise<RunResult>;
16
+ stream(task: Task, options?: AgentStreamOptions): CommandStream;
17
+ start(run_id: string, task: Task, options?: AgentStreamOptions): Promise<LongRun>;
18
+ poll(run_id: string, offset: number): Promise<LongRunPoll>;
19
+ attach(run_id: string, offset: number): CommandStream;
20
+ stop(run_id: string): Promise<LongRun>;
21
+ forget(run_id: string): Promise<void>;
22
+ runs(): Promise<LongRun[]>;
23
+ request(type: string, body?: Record<string, unknown>): Promise<unknown>;
24
+ }
@@ -0,0 +1,62 @@
1
+ // The agent built into the server for its own machine. It runs commands itself with no socket between, through the
2
+ // same code a remote agent uses, so a command behaves the same on every host.
3
+ // ── Imports ──────────────────────────────────────────────────────────────────
4
+ import { LongRuns } from "../../agent/long_runs.js";
5
+ import { AgentError, ended_stream, } from "./agent.js";
6
+ // ── Local agent ──────────────────────────────────────────────────────────────
7
+ export class LocalAgent {
8
+ long_runs;
9
+ runner;
10
+ requests;
11
+ // Long runs keep their output under the state directory, so they outlive a restart of the server.
12
+ constructor(state_directory, options) {
13
+ this.runner = options.runner;
14
+ this.long_runs = new LongRuns(state_directory, options.runner, options.long_runs);
15
+ this.requests = options.requests ?? {};
16
+ }
17
+ run(task, options) {
18
+ return this.runner.run(task, options);
19
+ }
20
+ stream(task, options = {}) {
21
+ if (this.runner.stream === undefined)
22
+ return ended_stream("This agent does not stream its tasks.");
23
+ return this.runner.stream(task, options);
24
+ }
25
+ start(run_id, task, options = {}) {
26
+ return as_agent_error(() => this.long_runs.start(run_id, task, options.stdin ?? null));
27
+ }
28
+ poll(run_id, offset) {
29
+ return as_agent_error(() => this.long_runs.poll(run_id, offset));
30
+ }
31
+ attach(run_id, offset) {
32
+ try {
33
+ return this.long_runs.tail(run_id, offset);
34
+ }
35
+ catch (error) {
36
+ return ended_stream(error.message);
37
+ }
38
+ }
39
+ stop(run_id) {
40
+ return as_agent_error(() => this.long_runs.stop(run_id));
41
+ }
42
+ forget(run_id) {
43
+ return as_agent_error(() => this.long_runs.forget(run_id));
44
+ }
45
+ async runs() {
46
+ return this.long_runs.list();
47
+ }
48
+ request(type, body = {}) {
49
+ if (!Object.hasOwn(this.requests, type))
50
+ return Promise.reject(new AgentError(`This agent does not know the request '${type}'.`));
51
+ const message = { ...body, type, id: "local" };
52
+ return as_agent_error(() => this.requests[type](message));
53
+ }
54
+ }
55
+ async function as_agent_error(work) {
56
+ try {
57
+ return await work();
58
+ }
59
+ catch (error) {
60
+ throw error instanceof AgentError ? error : new AgentError(error.message);
61
+ }
62
+ }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { AgentHub } from "./hub.ts";
3
+ export declare function register_agent_route<Task>(app: FastifyInstance, hub: AgentHub<Task>, path?: string): Promise<void>;
@@ -0,0 +1,24 @@
1
+ // The WebSocket agents dial in on. The upgrade names the host, by the header an agent logging in with its key sends or
2
+ // by the token in its Authorization header, so an unknown one is refused before any socket opens.
3
+ // ── Imports ──────────────────────────────────────────────────────────────────
4
+ import fastify_websocket from "@fastify/websocket";
5
+ // A run's output can be large, so one message may be up to this size.
6
+ const largest_message_in_bytes = 64 * 1024 * 1024;
7
+ // ── Route ────────────────────────────────────────────────────────────────────
8
+ export async function register_agent_route(app, hub, path = "/api/agent") {
9
+ await app.register(fastify_websocket, { options: { maxPayload: largest_message_in_bytes } });
10
+ const admitted = new WeakMap();
11
+ app.get(path, {
12
+ websocket: true,
13
+ preValidation: async (request, reply) => {
14
+ const admission = hub.admit(request.headers);
15
+ if ("error" in admission)
16
+ return reply.code(401).send({ error: admission.error });
17
+ admitted.set(request, admission);
18
+ },
19
+ }, (socket, request) => {
20
+ const { host, login } = admitted.get(request);
21
+ hub.accept(socket, host, login);
22
+ });
23
+ app.addHook("onClose", async () => hub.close());
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { BackupNaming, BackupStore } from "../backups.ts";
2
+ export declare function backup_path(folder: string, naming: BackupNaming, name: string): string;
3
+ export declare function file_backup_store(folder: () => string, naming: BackupNaming, copy_to: (path: string) => void): BackupStore;
@@ -0,0 +1,25 @@
1
+ // Backups kept as files in a folder on disk, for an app's main process or server.
2
+ import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ // The path of a backup in the folder, refusing any name the naming did not write.
5
+ export function backup_path(folder, naming, name) {
6
+ if (!naming.pattern.test(name))
7
+ throw new Error(`not a backup name: ${name}`);
8
+ return join(folder, name);
9
+ }
10
+ // A folder of backup files, made when first used; copy_to writes the app's data to the path it is given, such as through SQLite's own backup.
11
+ export function file_backup_store(folder, naming, copy_to) {
12
+ const path_of = (name) => backup_path(folder(), naming, name);
13
+ return {
14
+ list: () => {
15
+ mkdirSync(folder(), { recursive: true });
16
+ return readdirSync(folder()).filter((name) => naming.pattern.test(name)).map((name) => ({ name, size_bytes: statSync(path_of(name)).size }));
17
+ },
18
+ take: (name) => {
19
+ mkdirSync(folder(), { recursive: true });
20
+ copy_to(path_of(name));
21
+ return statSync(path_of(name)).size;
22
+ },
23
+ remove: (name) => rmSync(path_of(name)),
24
+ };
25
+ }
@@ -0,0 +1,39 @@
1
+ export interface ConfigOption {
2
+ variable?: string;
3
+ flag?: string;
4
+ help: string;
5
+ placeholder?: string;
6
+ default?: string;
7
+ required?: boolean;
8
+ }
9
+ export interface ServerConfigSpec<Name extends string = never> {
10
+ program: string;
11
+ summary: string;
12
+ env_prefix: string;
13
+ default_port: number;
14
+ options?: Record<Name, ConfigOption>;
15
+ built_in?: Partial<Record<BuiltInName, Partial<ConfigOption>>>;
16
+ web_folder_default?: string;
17
+ secret?: string | false;
18
+ argv?: string[];
19
+ env?: Record<string, string | undefined>;
20
+ exit_on_help?: boolean;
21
+ }
22
+ export interface ServerConfig<Name extends string = never> {
23
+ host: string;
24
+ port: number;
25
+ web_folder: string;
26
+ secret: string;
27
+ values: Record<Name, string | undefined>;
28
+ usage: string;
29
+ }
30
+ type BuiltInName = "host" | "port" | "web_folder";
31
+ export declare class HelpRequested extends Error {
32
+ readonly usage: string;
33
+ constructor(usage: string);
34
+ }
35
+ export declare const loopback_hosts: string[];
36
+ export declare function is_loopback(host: string): boolean;
37
+ export declare function read_secret_file(path: string): string;
38
+ export declare function read_server_config<Name extends string = never>(spec: ServerConfigSpec<Name>): ServerConfig<Name>;
39
+ export {};
@@ -0,0 +1,109 @@
1
+ // A server's settings from the command line and the environment: the address and port, the web folder, the app's own
2
+ // options, and the secret browsers sign in with, read from a file or a variable. Without a secret the server may only
3
+ // listen on this machine.
4
+ // ── Imports ──────────────────────────────────────────────────────────────────
5
+ import { readFileSync } from "node:fs";
6
+ import { parseArgs } from "node:util";
7
+ export class HelpRequested extends Error {
8
+ usage;
9
+ constructor(usage) {
10
+ super(usage);
11
+ this.usage = usage;
12
+ }
13
+ }
14
+ // ── Settings ─────────────────────────────────────────────────────────────────
15
+ export const loopback_hosts = ["127.0.0.1", "::1", "localhost"];
16
+ export function is_loopback(host) {
17
+ return loopback_hosts.includes(host);
18
+ }
19
+ // ── Secrets ──────────────────────────────────────────────────────────────────
20
+ // The file's text without its final newline; an unreadable or empty file is an error.
21
+ export function read_secret_file(path) {
22
+ let secret;
23
+ try {
24
+ secret = readFileSync(path, "utf8").replace(/\r?\n$/, "");
25
+ }
26
+ catch (error) {
27
+ throw new Error(`Could not read the secret at ${path}: ${error.message}`);
28
+ }
29
+ if (secret.trim() === "")
30
+ throw new Error(`The secret at ${path} is empty`);
31
+ return secret.trim();
32
+ }
33
+ // ── Reading ──────────────────────────────────────────────────────────────────
34
+ function capitals(name) {
35
+ return name.replaceAll("-", "_").toUpperCase();
36
+ }
37
+ function usage_text(program, summary, options, secret_note) {
38
+ const entries = Object.values(options);
39
+ const synopsis = entries
40
+ .map((option) => (option.required ? `--${option.flag} ${option.placeholder}` : `[--${option.flag} ${option.placeholder}]`))
41
+ .join(" ");
42
+ const flag_width = Math.max(...entries.map((option) => option.flag.length)) + 2;
43
+ const variable_width = Math.max(...entries.map((option) => option.variable.length));
44
+ const rows = entries.map((option) => {
45
+ const suffix = option.default === undefined ? "" : ` (default ${option.default})`;
46
+ return ` ${`--${option.flag}`.padEnd(flag_width)} ${option.variable.padEnd(variable_width)} ${option.help}${suffix}`;
47
+ });
48
+ return `Usage: ${program} ${synopsis}\n\n${summary} Every option can also be set in the environment:\n${rows.join("\n")}${secret_note}`;
49
+ }
50
+ export function read_server_config(spec) {
51
+ const { env_prefix, argv = process.argv.slice(2), env = process.env, exit_on_help = true } = spec;
52
+ const secret_name = spec.secret === undefined ? "token" : spec.secret;
53
+ const own = (spec.options ?? {});
54
+ const changes = spec.built_in ?? {};
55
+ const built_in = {
56
+ host: { help: "the address to listen on", placeholder: "<address>", default: "127.0.0.1", ...changes.host },
57
+ port: { help: "the port to listen on", placeholder: "<number>", default: String(spec.default_port), ...changes.port },
58
+ };
59
+ if (spec.web_folder_default !== undefined) {
60
+ built_in.web_folder = { help: "the built web page", placeholder: "<folder>", default: spec.web_folder_default, ...changes.web_folder };
61
+ }
62
+ const options = Object.fromEntries(Object.entries({ ...own, ...built_in }).map(([name, option]) => [name, {
63
+ ...option,
64
+ variable: option.variable ?? `${env_prefix}_${capitals(name)}`,
65
+ flag: option.flag ?? name.replaceAll("_", "-"),
66
+ placeholder: option.placeholder ?? "<value>",
67
+ }]));
68
+ const secret_note = secret_name === false ? "" : `\n\nThe ${secret_name} browsers sign in with is read from the file named by ${env_prefix}_${capitals(secret_name)}_FILE,\nor from ${env_prefix}_${capitals(secret_name)}. Without one the server only listens on this machine.`;
69
+ const usage = usage_text(spec.program, spec.summary, options, secret_note);
70
+ const { values: flags } = parseArgs({
71
+ args: argv,
72
+ options: { ...Object.fromEntries(Object.values(options).map((option) => [option.flag, { type: "string" }])), help: { type: "boolean" } },
73
+ });
74
+ if (flags.help) {
75
+ if (!exit_on_help)
76
+ throw new HelpRequested(usage);
77
+ console.log(usage);
78
+ process.exit(0);
79
+ }
80
+ const value_of = (name) => {
81
+ const option = options[name];
82
+ return flags[option.flag] ?? env[option.variable] ?? option.default;
83
+ };
84
+ for (const [name, option] of Object.entries(options)) {
85
+ if (option.required && value_of(name) === undefined)
86
+ throw new Error(`no ${option.flag} given\n\n${usage}`);
87
+ }
88
+ const port_text = value_of("port");
89
+ const port = Number(port_text);
90
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
91
+ throw new Error(`not a port: ${port_text}`);
92
+ let secret = "";
93
+ if (secret_name !== false) {
94
+ const secret_file = env[`${env_prefix}_${capitals(secret_name)}_FILE`];
95
+ secret = secret_file ? read_secret_file(secret_file) : (env[`${env_prefix}_${capitals(secret_name)}`] ?? "").trim();
96
+ }
97
+ const host = value_of("host");
98
+ if (secret_name !== false && secret === "" && !is_loopback(host)) {
99
+ throw new Error(`listening on ${host} needs a ${secret_name}; set ${env_prefix}_${capitals(secret_name)}_FILE or ${env_prefix}_${capitals(secret_name)}`);
100
+ }
101
+ return {
102
+ host,
103
+ port,
104
+ web_folder: "web_folder" in options ? value_of("web_folder") : "",
105
+ secret,
106
+ values: Object.fromEntries(Object.keys(own).map((name) => [name, value_of(name)])),
107
+ usage,
108
+ };
109
+ }
@@ -0,0 +1,8 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ export interface OpenCorsOptions {
3
+ methods?: string[];
4
+ allowed_headers?: string[];
5
+ client_id_header?: string;
6
+ max_age_seconds?: number;
7
+ }
8
+ export declare function register_open_cors(app: FastifyInstance, options?: OpenCorsOptions): Promise<void>;
@@ -0,0 +1,13 @@
1
+ // Cross-origin access for an API that a native or mobile shell calls from a page of its own: any origin may, since a
2
+ // browser sends no cookies with such a call and the server's secret is still needed.
3
+ import fastify_cors from "@fastify/cors";
4
+ import { default_client_id_header } from "./events.js";
5
+ export async function register_open_cors(app, options = {}) {
6
+ const { methods = ["GET", "POST"], allowed_headers = [], client_id_header = default_client_id_header, max_age_seconds = 86_400 } = options;
7
+ await app.register(fastify_cors, {
8
+ origin: "*",
9
+ methods,
10
+ allowedHeaders: ["authorization", "content-type", client_id_header, ...allowed_headers],
11
+ maxAge: max_age_seconds,
12
+ });
13
+ }
@@ -0,0 +1,33 @@
1
+ import type { FastifyInstance, FastifyRequest } from "fastify";
2
+ export interface EventStreamOptions {
3
+ heartbeat_ms?: number;
4
+ retry_ms?: number;
5
+ hello_event?: string | null;
6
+ changed_event?: string;
7
+ client_id_header?: string;
8
+ }
9
+ export interface StreamClient {
10
+ id: string;
11
+ request: FastifyRequest;
12
+ }
13
+ export interface SendOptions {
14
+ event?: string;
15
+ except_client_id?: string;
16
+ to?: (client: StreamClient) => boolean;
17
+ }
18
+ export declare const default_client_id_header = "x-client-id";
19
+ export declare class EventStream {
20
+ readonly heartbeat_ms: number;
21
+ readonly retry_ms: number;
22
+ readonly hello_event: string | null;
23
+ readonly changed_event: string;
24
+ readonly client_id_header: string;
25
+ private readonly streams;
26
+ constructor(options?: EventStreamOptions);
27
+ get client_count(): number;
28
+ client_id_of(request: FastifyRequest): string;
29
+ send(data: string, options?: SendOptions): void;
30
+ announce_change(except_client_id?: string): void;
31
+ register(app: FastifyInstance, path?: string): void;
32
+ close(): void;
33
+ }