204 lines · 6.8 KB · TypeScript
RawDownload
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),  /**   * Positive: `process.kill` reads 0 and negatives as signal-group targets, so   * a liveness probe on one reports "live" for a process that never existed.   */  pid: Schema.Int.check(Schema.isGreaterThan(0)),  host: Schema.optional(Schema.String),  port: Schema.Int,  origin: Schema.String,  /**   * The web origin users actually open in dev — the Vite server, or the shared   * tailnet URL when the dev runner published one. Recorded so tooling can mint   * a pairing URL for a running server without being told where it lives.   */  devUrl: Schema.optional(Schema.String),  startedAt: Schema.String,});export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; export class ServerRuntimeStateError extends Schema.TaggedErrorClass<ServerRuntimeStateError>()(  "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<ServerConfig.ServerConfig["Service"], "host">,  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<ServerConfig.ServerConfig["Service"], "host" | "devUrl">;  readonly port: number;}): Effect.Effect<PersistedServerRuntimeState> =>  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,            }),          ),      }),    );  }); /** * `process.kill` rejects an out-of-range pid with a TypeError rather than an * errno, so the catch below cannot assume either shape. */const isErrnoException = (cause: unknown): cause is NodeJS.ErrnoException =>  typeof cause === "object" && cause !== null && "code" in cause; /** * Whether the process that wrote a runtime-state file is still alive. * * The file is removed by a release finalizer, so a server killed with SIGKILL — * or one that crashed — leaves it behind. Treating a leftover file as "the * server is up" points callers at a port nothing is listening on. Signal 0 * performs the permission and existence checks without delivering a signal. * * PIDs are reused eventually, so a false positive is possible in principle; * that is acceptable for local dev tooling, where the alternative is an * HTTP probe that has to guess how long to wait for a starting server. */export const isPersistedServerRuntimeStateLive = (  state: PersistedServerRuntimeState,): Effect.Effect<boolean> =>  Effect.sync(() => {    // `process.kill` reads non-positive pids as signal-group targets rather    // than process lookups: 0 signals the caller's own group and -1 every    // permitted process, so neither throws and both would report "live"    // unconditionally. A server never records one; a corrupt or truncated file    // can, and that file would then be believed forever.    if (!Number.isSafeInteger(state.pid) || state.pid <= 0) {      return false;    }    try {      process.kill(state.pid, 0);      return true;    } catch (cause) {      // EPERM means the process exists but belongs to another user.      return isErrnoException(cause) && cause.code === "EPERM";    }  }); 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<string>())            : Effect.fail(                new ServerRuntimeStateError({                  operation: "read",                  statePath: path,                  cause,                }),              ),        onSuccess: (contents) => Effect.succeed(Option.some(contents)),      }),    );    if (Option.isNone(raw)) {      return Option.none<PersistedServerRuntimeState>();    }     const trimmed = raw.value.trim();    if (trimmed.length === 0) {      return Option.none<PersistedServerRuntimeState>();    }     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<PersistedServerRuntimeState>()),        ),    }),  );