1import { SERVER_VERSION } from "./version.ts";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
9import type * as ServerConfig from "./config.ts";
10import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts";
11
12export 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});
37export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;
38
39export 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
52const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect(
53 Schema.fromJsonString(PersistedServerRuntimeState),
54);
55
56const 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
65export 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
82export 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
100export 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 */
131export 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
141export 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 );