import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import type * as ServerConfig from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ version: Schema.Literal(1), pid: Schema.Int, host: Schema.optional(Schema.String), port: Schema.Int, origin: Schema.String, // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). // Dev is single-origin: browsers must pair through this URL, not `origin`. devUrl: Schema.optional(Schema.String), startedAt: Schema.String, }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; export class ServerRuntimeStateError extends Schema.TaggedErrorClass()( "ServerRuntimeStateError", { operation: Schema.Literals(["persist", "read", "decode", "clear"]), statePath: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to ${this.operation} server runtime state at ${this.statePath}.`; } } const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( Schema.fromJsonString(PersistedServerRuntimeState), ); const runtimeOriginForConfig = ( config: Pick, port: number, ): PersistedServerRuntimeState["origin"] => { const hostname = config.host && !isWildcardHost(config.host) ? formatHostForUrl(config.host) : "127.0.0.1"; return `http://${hostname}:${port}`; }; export const makePersistedServerRuntimeState = (input: { readonly config: Pick; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ version: 1, pid: process.pid, ...(input.config.host ? { host: input.config.host } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), })); export const persistServerRuntimeState = (input: { readonly path: string; readonly state: PersistedServerRuntimeState; }) => writeFileStringAtomically({ filePath: input.path, contents: `${JSON.stringify(input.state)}\n`, }).pipe( Effect.mapError( (cause) => new ServerRuntimeStateError({ operation: "persist", statePath: input.path, cause, }), ), ); export const clearPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* fs.remove(path, { force: true }).pipe( Effect.mapError( (cause) => new ServerRuntimeStateError({ operation: "clear", statePath: path, cause, }), ), Effect.catchTags({ ServerRuntimeStateError: (error) => Effect.logWarning(error.message).pipe( Effect.annotateLogs({ operation: error.operation, statePath: error.statePath, cause: error, }), ), }), ); }); export const readPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const raw = yield* fs.readFileString(path).pipe( Effect.matchEffect({ onFailure: (cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( new ServerRuntimeStateError({ operation: "read", statePath: path, cause, }), ), onSuccess: (contents) => Effect.succeed(Option.some(contents)), }), ); if (Option.isNone(raw)) { return Option.none(); } const trimmed = raw.value.trim(); if (trimmed.length === 0) { return Option.none(); } return yield* decodePersistedServerRuntimeState(trimmed).pipe( Effect.map(Option.some), Effect.mapError( (cause) => new ServerRuntimeStateError({ operation: "decode", statePath: path, cause, }), ), ); }).pipe( Effect.catchTags({ ServerRuntimeStateError: (error) => Effect.logWarning(error.message).pipe( Effect.annotateLogs({ operation: error.operation, statePath: error.statePath, cause: error, }), Effect.as(Option.none()), ), }), );