| 1 | import { SERVER_VERSION } from "./version.ts"; |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | |
| 8 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 9 | import type * as ServerConfig from "./config.ts"; |
| 10 | import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; |
| 11 | |
| 12 | export const PersistedServerRuntimeState = Schema.Struct({ |
| 13 | // Schema version of this file, not the server build. See `serverVersion`. |
| 14 | version: Schema.Literal(1), |
| 15 | pid: Schema.Int, |
| 16 | ownerId: Schema.optional(Schema.String), |
| 17 | host: Schema.optional(Schema.String), |
| 18 | port: Schema.Int, |
| 19 | origin: Schema.String, |
| 20 | // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). |
| 21 | // Dev is single-origin: browsers must pair through this URL, not `origin`. |
| 22 | devUrl: Schema.optional(Schema.String), |
| 23 | startedAt: Schema.String, |
| 24 | /** |
| 25 | * Set when the boot-service launcher supervises this server. Lets a CLI |
| 26 | * tell a service-managed server apart from one started by hand, which is |
| 27 | * the difference between "restart the service" and "stop your terminal". |
| 28 | */ |
| 29 | serviceManaged: Schema.optional(Schema.Boolean), |
| 30 | /** |
| 31 | * The server build that wrote this record. `t3 triage` reports it because |
| 32 | * the CLI that runs triage can be a different build. Missing on records |
| 33 | * written by older servers. |
| 34 | */ |
| 35 | serverVersion: Schema.optional(Schema.String), |
| 36 | }); |
| 37 | export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; |
| 38 | |
| 39 | export class ServerRuntimeStateError extends Schema.TaggedError<ServerRuntimeStateError>()( |
| 40 | "ServerRuntimeStateError", |
| 41 | { |
| 42 | operation: Schema.Literals(["persist", "read", "decode", "clear"]), |
| 43 | statePath: Schema.String, |
| 44 | cause: Schema.Defect(), |
| 45 | }, |
| 46 | ) { |
| 47 | override get message(): string { |
| 48 | return `Failed to ${this.operation} server runtime state at ${this.statePath}.`; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( |
| 53 | Schema.fromJsonString(PersistedServerRuntimeState), |
| 54 | ); |
| 55 | |
| 56 | const runtimeOriginForConfig = ( |
| 57 | config: Pick<ServerConfig.ServerConfig["Service"], "host">, |
| 58 | port: number, |
| 59 | ): PersistedServerRuntimeState["origin"] => { |
| 60 | const hostname = |
| 61 | config.host && !isWildcardHost(config.host) ? formatHostForUrl(config.host) : "127.0.0.1"; |
| 62 | return `http://${hostname}:${port}`; |
| 63 | }; |
| 64 | |
| 65 | export const makePersistedServerRuntimeState = (input: { |
| 66 | readonly config: Pick<ServerConfig.ServerConfig["Service"], "host" | "devUrl">; |
| 67 | readonly port: number; |
| 68 | readonly serviceManaged?: boolean; |
| 69 | }): Effect.Effect<PersistedServerRuntimeState> => |
| 70 | Effect.map(DateTime.now, (now) => ({ |
| 71 | version: 1, |
| 72 | pid: process.pid, |
| 73 | ...(input.config.host ? { host: input.config.host } : {}), |
| 74 | port: input.port, |
| 75 | origin: runtimeOriginForConfig(input.config, input.port), |
| 76 | ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), |
| 77 | startedAt: DateTime.formatIso(now), |
| 78 | ...(input.serviceManaged ? { serviceManaged: true } : {}), |
| 79 | serverVersion: SERVER_VERSION, |
| 80 | })); |
| 81 | |
| 82 | export const persistServerRuntimeState = (input: { |
| 83 | readonly path: string; |
| 84 | readonly state: PersistedServerRuntimeState; |
| 85 | }) => |
| 86 | writeFileStringAtomically({ |
| 87 | filePath: input.path, |
| 88 | contents: `${JSON.stringify(input.state)}\n`, |
| 89 | }).pipe( |
| 90 | Effect.mapError( |
| 91 | (cause) => |
| 92 | new ServerRuntimeStateError({ |
| 93 | operation: "persist", |
| 94 | statePath: input.path, |
| 95 | cause, |
| 96 | }), |
| 97 | ), |
| 98 | ); |
| 99 | |
| 100 | export const clearPersistedServerRuntimeState = (path: string) => |
| 101 | Effect.gen(function* () { |
| 102 | const fs = yield* FileSystem.FileSystem; |
| 103 | yield* fs.remove(path, { force: true }).pipe( |
| 104 | Effect.mapError( |
| 105 | (cause) => |
| 106 | new ServerRuntimeStateError({ |
| 107 | operation: "clear", |
| 108 | statePath: path, |
| 109 | cause, |
| 110 | }), |
| 111 | ), |
| 112 | Effect.catchTags({ |
| 113 | ServerRuntimeStateError: (error) => |
| 114 | Effect.logWarning(error.message).pipe( |
| 115 | Effect.annotateLogs({ |
| 116 | operation: error.operation, |
| 117 | statePath: error.statePath, |
| 118 | cause: error, |
| 119 | }), |
| 120 | ), |
| 121 | }), |
| 122 | ); |
| 123 | }); |
| 124 | |
| 125 | /** |
| 126 | * Report whether the pid recorded in a persisted runtime state is still |
| 127 | * running. Signal 0 delivers nothing; it only reports whether the pid exists. |
| 128 | * EPERM means it exists but belongs to another user, which still counts as |
| 129 | * alive. |
| 130 | */ |
| 131 | export const isProcessAlive = (pid: number): boolean => { |
| 132 | if (!Number.isInteger(pid) || pid <= 0) return false; |
| 133 | try { |
| 134 | process.kill(pid, 0); |
| 135 | return true; |
| 136 | } catch (error) { |
| 137 | return error instanceof Error && "code" in error && error.code === "EPERM"; |
| 138 | } |
| 139 | }; |
| 140 | |
| 141 | export const readPersistedServerRuntimeState = (path: string) => |
| 142 | Effect.gen(function* () { |
| 143 | const fs = yield* FileSystem.FileSystem; |
| 144 | const raw = yield* fs.readFileString(path).pipe( |
| 145 | Effect.matchEffect({ |
| 146 | onFailure: (cause) => |
| 147 | cause.reason._tag === "NotFound" |
| 148 | ? Effect.succeed(Option.none<string>()) |
| 149 | : Effect.fail( |
| 150 | new ServerRuntimeStateError({ |
| 151 | operation: "read", |
| 152 | statePath: path, |
| 153 | cause, |
| 154 | }), |
| 155 | ), |
| 156 | onSuccess: (contents) => Effect.succeedSome(contents), |
| 157 | }), |
| 158 | ); |
| 159 | if (Option.isNone(raw)) { |
| 160 | return Option.none<PersistedServerRuntimeState>(); |
| 161 | } |
| 162 | |
| 163 | const trimmed = raw.value.trim(); |
| 164 | if (trimmed.length === 0) { |
| 165 | return Option.none<PersistedServerRuntimeState>(); |
| 166 | } |
| 167 | |
| 168 | return yield* decodePersistedServerRuntimeState(trimmed).pipe( |
| 169 | Effect.asSome, |
| 170 | Effect.mapError( |
| 171 | (cause) => |
| 172 | new ServerRuntimeStateError({ |
| 173 | operation: "decode", |
| 174 | statePath: path, |
| 175 | cause, |
| 176 | }), |
| 177 | ), |
| 178 | ); |
| 179 | }).pipe( |
| 180 | Effect.catchTags({ |
| 181 | ServerRuntimeStateError: (error) => |
| 182 | Effect.logWarning(error.message).pipe( |
| 183 | Effect.annotateLogs({ |
| 184 | operation: error.operation, |
| 185 | statePath: error.statePath, |
| 186 | cause: error, |
| 187 | }), |
| 188 | Effect.as(Option.none<PersistedServerRuntimeState>()), |
| 189 | ), |
| 190 | }), |
| 191 | ); |