@narumitw/pi-webui 0.20.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.
package/src/server.ts ADDED
@@ -0,0 +1,518 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
4
+ import type { Socket } from "node:net";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { ConversationEvent, ConversationProjection } from "./conversation.js";
8
+ import type { BrowserImageInput } from "./images.js";
9
+
10
+ const JSON_LIMIT = 64 * 1024 * 1024;
11
+ const CLIENT_ID = /^[A-Za-z0-9_-]{1,80}$/;
12
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,120}$/;
13
+ const MAX_REQUESTS = 128;
14
+ const SSE_FLUSH_TIMEOUT_MS = 250;
15
+
16
+ export interface WebSendRequest {
17
+ requestId: string;
18
+ text: string;
19
+ images: BrowserImageInput[];
20
+ delivery: "next" | "steer";
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ export interface WebSendResult {
25
+ delivery: "immediate" | "followUp" | "steer";
26
+ }
27
+
28
+ export interface WebUIServerOptions {
29
+ conversation: ConversationProjection;
30
+ send: (request: WebSendRequest) => Promise<WebSendResult>;
31
+ maxRequestBytes?: number;
32
+ }
33
+
34
+ interface SseClient {
35
+ response: ServerResponse;
36
+ }
37
+
38
+ interface RequestRecord {
39
+ hash: string;
40
+ promise: Promise<WebSendResult>;
41
+ settled: boolean;
42
+ }
43
+
44
+ class HttpError extends Error {
45
+ constructor(
46
+ readonly status: number,
47
+ message: string,
48
+ readonly closeConnection = false,
49
+ ) {
50
+ super(message);
51
+ }
52
+ }
53
+
54
+ export class WebUIServer {
55
+ readonly origin: string;
56
+ private bootstrapToken?: string;
57
+ private readonly sessionSecret = token();
58
+ private readonly cookieName = `pi_webui_${randomBytes(8).toString("hex")}`;
59
+ private readonly sockets = new Set<Socket>();
60
+ private readonly sseClients = new Set<SseClient>();
61
+ private readonly requests = new Map<string, RequestRecord>();
62
+ private readonly activeSendControllers = new Set<AbortController>();
63
+ private activeClientId?: string;
64
+ private leaseGeneration = 0;
65
+ private closed = false;
66
+ private closePromise?: Promise<void>;
67
+ private readonly unsubscribe: () => void;
68
+
69
+ private constructor(
70
+ private readonly server: Server,
71
+ private readonly options: WebUIServerOptions,
72
+ port: number,
73
+ ) {
74
+ this.origin = `http://127.0.0.1:${port}`;
75
+ server.on("connection", (socket) => {
76
+ this.sockets.add(socket);
77
+ socket.once("close", () => this.sockets.delete(socket));
78
+ });
79
+ server.unref();
80
+ this.unsubscribe = options.conversation.subscribe((event) => this.broadcastEvent(event));
81
+ }
82
+
83
+ static async start(options: WebUIServerOptions): Promise<WebUIServer> {
84
+ let instance: WebUIServer | undefined;
85
+ const server = createServer((request, response) => {
86
+ if (!instance) {
87
+ response.writeHead(503).end();
88
+ return;
89
+ }
90
+ void instance.handle(request, response);
91
+ });
92
+ await new Promise<void>((resolve, reject) => {
93
+ server.once("error", reject);
94
+ server.listen(0, "127.0.0.1", () => {
95
+ server.off("error", reject);
96
+ resolve();
97
+ });
98
+ });
99
+ const address = server.address();
100
+ if (!address || typeof address === "string") {
101
+ server.close();
102
+ throw new Error("Pi WebUI server did not receive a TCP port");
103
+ }
104
+ instance = new WebUIServer(server, options, address.port);
105
+ return instance;
106
+ }
107
+
108
+ issueLink(): string {
109
+ if (this.closed) throw new Error("Pi WebUI server is closed");
110
+ this.bootstrapToken = token();
111
+ return `${this.origin}/bootstrap?token=${encodeURIComponent(this.bootstrapToken)}`;
112
+ }
113
+
114
+ close(): Promise<void> {
115
+ if (this.closePromise) return this.closePromise;
116
+ this.closePromise = this.closeNow();
117
+ return this.closePromise;
118
+ }
119
+
120
+ private async closeNow(): Promise<void> {
121
+ if (this.closed) return;
122
+ this.closed = true;
123
+ this.bootstrapToken = undefined;
124
+ this.unsubscribe();
125
+ for (const controller of this.activeSendControllers) controller.abort();
126
+ this.activeSendControllers.clear();
127
+ this.requests.clear();
128
+ const responses = [...this.sseClients].map((client) => client.response);
129
+ this.broadcastControl("session-ended", { message: "Pi session ended" });
130
+ await Promise.all(responses.map((response) => finishResponse(response)));
131
+ this.sseClients.clear();
132
+ this.server.closeAllConnections?.();
133
+ for (const socket of this.sockets) socket.destroy();
134
+ await new Promise<void>((resolve) => this.server.close(() => resolve()));
135
+ }
136
+
137
+ private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
138
+ this.secureHeaders(response);
139
+ try {
140
+ if (this.closed) throw new HttpError(410, "Pi session ended");
141
+ if (request.headers.host !== new URL(this.origin).host) {
142
+ throw new HttpError(421, "Unexpected Host header");
143
+ }
144
+ const url = new URL(request.url ?? "/", this.origin);
145
+ if (request.method === "GET" && url.pathname === "/bootstrap") {
146
+ this.bootstrap(url, response);
147
+ return;
148
+ }
149
+ this.authenticate(request);
150
+ if (request.method === "GET" && url.pathname === "/") {
151
+ await this.asset(response, "index.html", "text/html; charset=utf-8");
152
+ return;
153
+ }
154
+ if (
155
+ request.method === "GET" &&
156
+ ["/app.js", "/state.js", "/markdown.js", "/transcript.js"].includes(url.pathname)
157
+ ) {
158
+ await this.asset(response, url.pathname.slice(1), "text/javascript; charset=utf-8");
159
+ return;
160
+ }
161
+ if (request.method === "GET" && url.pathname === "/styles.css") {
162
+ await this.asset(response, "styles.css", "text/css; charset=utf-8");
163
+ return;
164
+ }
165
+ if (request.method === "GET" && url.pathname === "/api/state") {
166
+ this.json(response, 200, {
167
+ ...this.options.conversation.snapshot(),
168
+ lease: this.leaseSnapshot(),
169
+ });
170
+ return;
171
+ }
172
+ if (request.method === "GET" && url.pathname === "/api/events") {
173
+ this.events(url, request, response);
174
+ return;
175
+ }
176
+ this.assertMutation(request);
177
+ if (request.method === "POST" && url.pathname === "/api/lease") {
178
+ const body = await readJson(request, 8 * 1024);
179
+ if (!isRecord(body)) throw new HttpError(400, "Invalid lease request");
180
+ const clientId = stringField(body, "clientId");
181
+ if (!CLIENT_ID.test(clientId) || request.headers["x-pi-web-client"] !== clientId) {
182
+ throw new HttpError(400, "Invalid client id");
183
+ }
184
+ this.activeClientId = clientId;
185
+ this.leaseGeneration += 1;
186
+ for (const controller of this.activeSendControllers) controller.abort();
187
+ this.broadcastControl("lease", {
188
+ activeClientId: clientId,
189
+ generation: this.leaseGeneration,
190
+ });
191
+ this.json(response, 200, {
192
+ activeClientId: clientId,
193
+ generation: this.leaseGeneration,
194
+ });
195
+ return;
196
+ }
197
+ const lease = this.assertActiveClient(request);
198
+ if (request.method === "POST" && url.pathname === "/api/messages") {
199
+ const body = await readJson(request, this.options.maxRequestBytes ?? JSON_LIMIT);
200
+ this.assertLease(lease);
201
+ const message = parseSendRequest(body);
202
+ const result = await this.sendDeduplicated(message, request, response);
203
+ this.json(response, 202, { accepted: true, requestId: message.requestId, ...result });
204
+ return;
205
+ }
206
+ throw new HttpError(404, "Not found");
207
+ } catch (error) {
208
+ const status = error instanceof HttpError ? error.status : 500;
209
+ if (error instanceof HttpError && error.closeConnection)
210
+ response.setHeader("Connection", "close");
211
+ this.json(response, status, { error: formatError(error) });
212
+ if (error instanceof HttpError && error.closeConnection) request.socket?.destroySoon?.();
213
+ }
214
+ }
215
+
216
+ private bootstrap(url: URL, response: ServerResponse): void {
217
+ const presented = url.searchParams.get("token");
218
+ const expected = this.bootstrapToken;
219
+ if (!presented || !expected || !secretEqual(presented, expected)) {
220
+ throw new HttpError(401, "Invalid or expired bootstrap token");
221
+ }
222
+ this.bootstrapToken = undefined;
223
+ response.setHeader(
224
+ "Set-Cookie",
225
+ `${this.cookieName}=${this.sessionSecret}; HttpOnly; SameSite=Strict; Path=/`,
226
+ );
227
+ response.writeHead(303, { Location: "/" }).end();
228
+ }
229
+
230
+ private authenticate(request: IncomingMessage): void {
231
+ const cookies = parseCookies(request.headers.cookie);
232
+ const presented = cookies.get(this.cookieName);
233
+ if (!presented || !secretEqual(presented, this.sessionSecret)) {
234
+ throw new HttpError(401, "Authentication required");
235
+ }
236
+ }
237
+
238
+ private assertMutation(request: IncomingMessage): void {
239
+ if (request.headers.origin !== this.origin) throw new HttpError(403, "Unexpected Origin");
240
+ }
241
+
242
+ private assertActiveClient(request: IncomingMessage): { clientId: string; generation: number } {
243
+ const clientId = request.headers["x-pi-web-client"];
244
+ if (typeof clientId !== "string" || clientId !== this.activeClientId) {
245
+ throw new HttpError(409, "This browser tab is stale");
246
+ }
247
+ return { clientId, generation: this.leaseGeneration };
248
+ }
249
+
250
+ private assertLease(lease: { clientId: string; generation: number }): void {
251
+ if (lease.clientId !== this.activeClientId || lease.generation !== this.leaseGeneration) {
252
+ throw new HttpError(409, "This browser tab became stale");
253
+ }
254
+ }
255
+
256
+ private async sendDeduplicated(
257
+ message: WebSendRequest,
258
+ request: IncomingMessage,
259
+ response: ServerResponse,
260
+ ): Promise<WebSendResult> {
261
+ const hash = messageDigest(message);
262
+ const current = this.requests.get(message.requestId);
263
+ if (current) {
264
+ if (current.hash !== hash)
265
+ throw new HttpError(409, "Request id was reused with different content");
266
+ return current.promise;
267
+ }
268
+ const controller = new AbortController();
269
+ this.activeSendControllers.add(controller);
270
+ const abort = () => controller.abort();
271
+ request.once("aborted", abort);
272
+ response.once("close", abort);
273
+ const promise = this.options
274
+ .send({ ...message, signal: controller.signal })
275
+ .then((result) => {
276
+ const record = this.requests.get(message.requestId);
277
+ if (record?.promise === promise) {
278
+ record.settled = true;
279
+ this.trimRequests();
280
+ }
281
+ return result;
282
+ })
283
+ .catch((error) => {
284
+ if (this.requests.get(message.requestId)?.promise === promise) {
285
+ this.requests.delete(message.requestId);
286
+ }
287
+ if (controller.signal.aborted) throw new HttpError(409, "Browser send was cancelled");
288
+ throw error;
289
+ })
290
+ .finally(() => {
291
+ request.off("aborted", abort);
292
+ response.off("close", abort);
293
+ this.activeSendControllers.delete(controller);
294
+ });
295
+ this.requests.set(message.requestId, { hash, promise, settled: false });
296
+ this.trimRequests();
297
+ return promise;
298
+ }
299
+
300
+ private trimRequests(): void {
301
+ if (this.requests.size <= MAX_REQUESTS) return;
302
+ for (const [requestId, record] of this.requests) {
303
+ if (!record.settled) continue;
304
+ this.requests.delete(requestId);
305
+ if (this.requests.size <= MAX_REQUESTS) return;
306
+ }
307
+ }
308
+
309
+ private events(url: URL, request: IncomingMessage, response: ServerResponse): void {
310
+ const sinceText = url.searchParams.get("since") ?? "0";
311
+ if (!/^\d+$/.test(sinceText)) throw new HttpError(400, "Invalid event cursor");
312
+ const since = Number(sinceText);
313
+ if (!Number.isSafeInteger(since)) throw new HttpError(400, "Invalid event cursor");
314
+ response.writeHead(200, {
315
+ "Content-Type": "text/event-stream; charset=utf-8",
316
+ Connection: "keep-alive",
317
+ "Cache-Control": "no-store",
318
+ "X-Accel-Buffering": "no",
319
+ });
320
+ response.flushHeaders();
321
+ const client: SseClient = { response };
322
+ this.sseClients.add(client);
323
+ if (!response.write(": connected\n\n")) {
324
+ this.sseClients.delete(client);
325
+ response.end();
326
+ return;
327
+ }
328
+ this.writeSse(client, "lease", this.leaseSnapshot());
329
+ const replay = this.options.conversation.eventsAfter(since);
330
+ if (replay === undefined) {
331
+ this.writeSse(client, "snapshot", this.options.conversation.snapshot());
332
+ } else {
333
+ for (const event of replay) this.writeSse(client, "conversation", event);
334
+ }
335
+ request.once("close", () => this.sseClients.delete(client));
336
+ }
337
+
338
+ private leaseSnapshot(): { activeClientId?: string; generation: number } {
339
+ return {
340
+ ...(this.activeClientId ? { activeClientId: this.activeClientId } : {}),
341
+ generation: this.leaseGeneration,
342
+ };
343
+ }
344
+
345
+ private broadcastEvent(event: ConversationEvent): void {
346
+ for (const client of [...this.sseClients]) this.writeSse(client, "conversation", event);
347
+ }
348
+
349
+ private broadcastControl(event: string, payload: unknown): void {
350
+ for (const client of [...this.sseClients]) this.writeSse(client, event, payload);
351
+ }
352
+
353
+ private writeSse(client: SseClient, event: string, payload: unknown): void {
354
+ if (client.response.destroyed || client.response.writableEnded) {
355
+ this.sseClients.delete(client);
356
+ return;
357
+ }
358
+ const accepted = client.response.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
359
+ if (!accepted) {
360
+ this.sseClients.delete(client);
361
+ client.response.end();
362
+ }
363
+ }
364
+
365
+ private async asset(response: ServerResponse, name: string, contentType: string): Promise<void> {
366
+ const content = await readAsset(name);
367
+ response.writeHead(200, { "Content-Type": contentType, "Content-Length": content.byteLength });
368
+ response.end(content);
369
+ }
370
+
371
+ private secureHeaders(response: ServerResponse): void {
372
+ response.setHeader("Cache-Control", "no-store");
373
+ response.setHeader(
374
+ "Content-Security-Policy",
375
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
376
+ );
377
+ response.setHeader("Referrer-Policy", "no-referrer");
378
+ response.setHeader("X-Content-Type-Options", "nosniff");
379
+ response.setHeader("X-Frame-Options", "DENY");
380
+ response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
381
+ }
382
+
383
+ private json(response: ServerResponse, status: number, value: unknown): void {
384
+ if (response.headersSent || response.writableEnded) return;
385
+ const body = JSON.stringify(value);
386
+ response.writeHead(status, {
387
+ "Content-Type": "application/json; charset=utf-8",
388
+ "Content-Length": Buffer.byteLength(body),
389
+ });
390
+ response.end(body);
391
+ }
392
+ }
393
+
394
+ function finishResponse(response: ServerResponse): Promise<void> {
395
+ if (response.destroyed || response.writableFinished) return Promise.resolve();
396
+ return new Promise((resolve) => {
397
+ let settled = false;
398
+ const done = () => {
399
+ if (settled) return;
400
+ settled = true;
401
+ clearTimeout(timeout);
402
+ resolve();
403
+ };
404
+ const timeout = setTimeout(done, SSE_FLUSH_TIMEOUT_MS);
405
+ if (response.writableEnded) {
406
+ response.once("finish", done);
407
+ response.once("close", done);
408
+ } else {
409
+ response.end(done);
410
+ }
411
+ });
412
+ }
413
+
414
+ async function readAsset(name: string): Promise<Buffer> {
415
+ const runtimePath = join(fileURLToPath(new URL(".", import.meta.url)), "web", name);
416
+ try {
417
+ return await readFile(runtimePath);
418
+ } catch (error) {
419
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
420
+ return readFile(join(process.cwd(), "extensions", "pi-webui", "src", "web", name));
421
+ }
422
+ }
423
+
424
+ function messageDigest(message: WebSendRequest): string {
425
+ const hash = createHash("sha256");
426
+ for (const value of [message.requestId, message.delivery, message.text]) {
427
+ hash
428
+ .update(String(Buffer.byteLength(value)))
429
+ .update(":")
430
+ .update(value);
431
+ }
432
+ for (const image of message.images) {
433
+ for (const value of [image.name ?? "", image.mimeType ?? "", image.data]) {
434
+ hash
435
+ .update(String(Buffer.byteLength(value)))
436
+ .update(":")
437
+ .update(value);
438
+ }
439
+ }
440
+ return hash.digest("hex");
441
+ }
442
+
443
+ function parseSendRequest(value: unknown): WebSendRequest {
444
+ if (!isRecord(value)) throw new HttpError(400, "Invalid message request");
445
+ const requestId = stringField(value, "requestId");
446
+ if (!REQUEST_ID.test(requestId)) throw new HttpError(400, "Invalid request id");
447
+ const text = stringField(value, "text");
448
+ const imagesValue = value.images;
449
+ if (!Array.isArray(imagesValue)) throw new HttpError(400, "Invalid image list");
450
+ if (!text.trim() && imagesValue.length === 0) throw new HttpError(400, "Message cannot be empty");
451
+ const delivery = value.delivery;
452
+ if (delivery !== "next" && delivery !== "steer")
453
+ throw new HttpError(400, "Invalid delivery mode");
454
+ const images = imagesValue.map((image) => {
455
+ if (!isRecord(image) || typeof image.data !== "string")
456
+ throw new HttpError(400, "Invalid image");
457
+ return {
458
+ data: image.data,
459
+ ...(typeof image.name === "string" ? { name: image.name } : {}),
460
+ ...(typeof image.mimeType === "string" ? { mimeType: image.mimeType } : {}),
461
+ };
462
+ });
463
+ return { requestId, text, images, delivery };
464
+ }
465
+
466
+ async function readJson(request: IncomingMessage, limit: number): Promise<unknown> {
467
+ const chunks: Buffer[] = [];
468
+ let total = 0;
469
+ for await (const chunk of request) {
470
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
471
+ total += bytes.byteLength;
472
+ if (total > limit) throw new HttpError(413, "Request body is too large", true);
473
+ chunks.push(bytes);
474
+ }
475
+ try {
476
+ return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
477
+ } catch {
478
+ throw new HttpError(400, "Invalid JSON body");
479
+ }
480
+ }
481
+
482
+ function stringField(value: Record<string, unknown>, key: string): string {
483
+ const field = value[key];
484
+ if (typeof field !== "string") throw new HttpError(400, `Invalid ${key}`);
485
+ return field;
486
+ }
487
+
488
+ function token(): string {
489
+ return randomBytes(32).toString("base64url");
490
+ }
491
+
492
+ function secretEqual(left: string, right: string): boolean {
493
+ const leftBytes = Buffer.from(left);
494
+ const rightBytes = Buffer.from(right);
495
+ return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes);
496
+ }
497
+
498
+ function parseCookies(header: string | undefined): Map<string, string> {
499
+ const cookies = new Map<string, string>();
500
+ for (const part of header?.split(";") ?? []) {
501
+ const separator = part.indexOf("=");
502
+ if (separator < 1) continue;
503
+ cookies.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
504
+ }
505
+ return cookies;
506
+ }
507
+
508
+ function isRecord(value: unknown): value is Record<string, unknown> {
509
+ return typeof value === "object" && value !== null && !Array.isArray(value);
510
+ }
511
+
512
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
513
+ return error instanceof Error && "code" in error;
514
+ }
515
+
516
+ function formatError(error: unknown): string {
517
+ return error instanceof Error ? error.message : String(error);
518
+ }
@@ -0,0 +1,174 @@
1
+ import {
2
+ link as linkFile,
3
+ lstat,
4
+ mkdir,
5
+ readFile,
6
+ rename,
7
+ unlink,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+
13
+ export const SETTINGS_FILE = "pi-webui.json";
14
+
15
+ export interface WebUISettings {
16
+ startOnSessionStart: boolean;
17
+ }
18
+
19
+ export const DEFAULT_SETTINGS: Readonly<WebUISettings> = Object.freeze({
20
+ startOnSessionStart: false,
21
+ });
22
+
23
+ export interface SettingsLoadResult {
24
+ kind: "missing" | "loaded" | "invalid";
25
+ path: string;
26
+ settings: WebUISettings;
27
+ source: "defaults" | "settings file";
28
+ document?: Record<string, unknown>;
29
+ warning?: string;
30
+ }
31
+
32
+ export interface SettingsFileOperations {
33
+ write(path: string, data: string): Promise<void>;
34
+ rename(source: string, destination: string): Promise<void>;
35
+ link(source: string, destination: string): Promise<void>;
36
+ }
37
+
38
+ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
39
+ write: (path, data) =>
40
+ writeFile(path, data, { encoding: "utf8", flag: "wx", mode: 0o600 }).then(() => undefined),
41
+ rename,
42
+ link: linkFile,
43
+ };
44
+
45
+ export function settingsFilePath(): string {
46
+ return join(getAgentDir(), SETTINGS_FILE);
47
+ }
48
+
49
+ export function normalizeSettings(value: unknown): WebUISettings | undefined {
50
+ if (!isRecord(value)) return undefined;
51
+ if (
52
+ Object.hasOwn(value, "startOnSessionStart") &&
53
+ typeof value.startOnSessionStart !== "boolean"
54
+ ) {
55
+ return undefined;
56
+ }
57
+ return {
58
+ startOnSessionStart:
59
+ typeof value.startOnSessionStart === "boolean"
60
+ ? value.startOnSessionStart
61
+ : DEFAULT_SETTINGS.startOnSessionStart,
62
+ };
63
+ }
64
+
65
+ export async function loadSettings(path = settingsFilePath()): Promise<SettingsLoadResult> {
66
+ let text: string;
67
+ try {
68
+ const stats = await lstat(path);
69
+ if (stats.isSymbolicLink()) return invalid(path, "symbolic links are not accepted");
70
+ if (!stats.isFile()) return invalid(path, "settings path is not a regular file");
71
+ text = await readFile(path, "utf8");
72
+ } catch (error) {
73
+ if (isNodeError(error) && error.code === "ENOENT") {
74
+ return {
75
+ kind: "missing",
76
+ path,
77
+ settings: { ...DEFAULT_SETTINGS },
78
+ source: "defaults",
79
+ document: {},
80
+ };
81
+ }
82
+ return invalid(path, formatError(error));
83
+ }
84
+
85
+ try {
86
+ const document = JSON.parse(text) as unknown;
87
+ if (!isRecord(document)) return invalid(path, "the top level must be a JSON object");
88
+ const settings = normalizeSettings(document);
89
+ if (!settings) return invalid(path, "startOnSessionStart must be a boolean");
90
+ return { kind: "loaded", path, settings, source: "settings file", document };
91
+ } catch (error) {
92
+ return invalid(path, formatError(error));
93
+ }
94
+ }
95
+
96
+ export async function saveSettings(
97
+ settings: WebUISettings,
98
+ document: Record<string, unknown>,
99
+ path = settingsFilePath(),
100
+ operations: Partial<SettingsFileOperations> = {},
101
+ ): Promise<Record<string, unknown>> {
102
+ const nextDocument = { ...document, startOnSessionStart: settings.startOnSessionStart };
103
+ const directory = dirname(path);
104
+ await mkdir(directory, { recursive: true });
105
+ const temporaryPath = temporaryFilePath(path);
106
+ try {
107
+ await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
108
+ temporaryPath,
109
+ `${JSON.stringify(nextDocument, null, 2)}\n`,
110
+ );
111
+ await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(temporaryPath, path);
112
+ return nextDocument;
113
+ } catch (error) {
114
+ await unlink(temporaryPath).catch(() => undefined);
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ export async function initializeSettings(
120
+ path = settingsFilePath(),
121
+ operations: Partial<SettingsFileOperations> = {},
122
+ ): Promise<"created" | "exists"> {
123
+ try {
124
+ await lstat(path);
125
+ return "exists";
126
+ } catch (error) {
127
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
128
+ }
129
+
130
+ const directory = dirname(path);
131
+ await mkdir(directory, { recursive: true });
132
+ const temporaryPath = temporaryFilePath(path);
133
+ try {
134
+ await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
135
+ temporaryPath,
136
+ `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`,
137
+ );
138
+ try {
139
+ await (operations.link ?? DEFAULT_FILE_OPERATIONS.link)(temporaryPath, path);
140
+ } catch (error) {
141
+ if (isNodeError(error) && error.code === "EEXIST") return "exists";
142
+ throw error;
143
+ }
144
+ return "created";
145
+ } finally {
146
+ await unlink(temporaryPath).catch(() => undefined);
147
+ }
148
+ }
149
+
150
+ function invalid(path: string, reason: string): SettingsLoadResult {
151
+ return {
152
+ kind: "invalid",
153
+ path,
154
+ settings: { ...DEFAULT_SETTINGS },
155
+ source: "defaults",
156
+ warning: `${SETTINGS_FILE} ignored (${path}: ${reason}); using defaults without overwriting it.`,
157
+ };
158
+ }
159
+
160
+ function temporaryFilePath(path: string): string {
161
+ return `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
162
+ }
163
+
164
+ function isRecord(value: unknown): value is Record<string, unknown> {
165
+ return typeof value === "object" && value !== null && !Array.isArray(value);
166
+ }
167
+
168
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
169
+ return error instanceof Error && "code" in error;
170
+ }
171
+
172
+ function formatError(error: unknown): string {
173
+ return error instanceof Error ? error.message : String(error);
174
+ }