Open History to see the last commit
1import * as DateTime from "effect/DateTime";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
8import { formatHostForUrl, isWildcardHost } from "@t3tools/shared/hostClassification";
9import packageJson from "../package.json" with { type: "json" };
10import type * as ServerConfig from "./config.ts";
11
12export const PersistedServerRuntimeState = Schema.Struct({
13 version: Schema.Literal(1),
14 pid: Schema.Int,
15 ownerId: Schema.optional(Schema.String),
16 host: Schema.optional(Schema.String),
17 privateNetworkOnly: Schema.optional(Schema.Boolean),
18 port: Schema.Int,
19 origin: Schema.String,
20 // Dev is single-origin: browsers must pair through this URL, not `origin`.
21 devUrl: Schema.optional(Schema.String),
22 startedAt: Schema.String,
23 serviceManaged: Schema.optional(Schema.Boolean),
24 serverVersion: Schema.optional(Schema.String),
25 // The managed policy this server loaded at startup, null for none; absent from older servers.
26 managedPolicy: Schema.optional(
27 Schema.NullOr(
28 Schema.Struct({
29 source: Schema.String,
30 organization: Schema.String,
31 fingerprint: Schema.String,
32 }),
33 ),
34 ),
35});
36export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;
37
38export class ServerRuntimeStateError extends Schema.TaggedError<ServerRuntimeStateError>()(
39 "ServerRuntimeStateError",
40 {
41 operation: Schema.Literals(["persist", "read", "decode", "clear"]),
42 statePath: Schema.String,
43 cause: Schema.Defect(),
44 },
45) {
46 override get message(): string {
47 return `Failed to ${this.operation} server runtime state at ${this.statePath}.`;
48 }
49}
50
51const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect(
52 Schema.fromJsonString(PersistedServerRuntimeState),
53);
54
55const runtimeOriginForConfig = (
56 config: Pick<ServerConfig.ServerConfig["Service"], "host">,
57 port: number,
58): PersistedServerRuntimeState["origin"] => {
59 const hostname =
60 config.host && !isWildcardHost(config.host) ? formatHostForUrl(config.host) : "127.0.0.1";
61 return `http://${hostname}:${port}`;
62};
63
64export const makePersistedServerRuntimeState = (input: {
65 readonly config: Pick<
66 ServerConfig.ServerConfig["Service"],
67 "host" | "privateNetworkOnly" | "devUrl"
68 >;
69 readonly port: number;
70 readonly serviceManaged?: boolean;
71 readonly managedPolicy?: PersistedServerRuntimeState["managedPolicy"];
72}): Effect.Effect<PersistedServerRuntimeState> =>
73 Effect.map(DateTime.now, (now) => ({
74 version: 1,
75 pid: process.pid,
76 ...(input.config.host ? { host: input.config.host } : {}),
77 ...(input.config.privateNetworkOnly ? { privateNetworkOnly: true } : {}),
78 port: input.port,
79 origin: runtimeOriginForConfig(input.config, input.port),
80 ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),
81 startedAt: DateTime.formatIso(now),
82 ...(input.serviceManaged ? { serviceManaged: true } : {}),
83 serverVersion: packageJson.version,
84 ...(input.managedPolicy === undefined ? {} : { managedPolicy: input.managedPolicy }),
85 }));
86
87export const persistServerRuntimeState = (input: {
88 readonly path: string;
89 readonly state: PersistedServerRuntimeState;
90}) =>
91 writeFileStringAtomically({
92 filePath: input.path,
93 contents: `${JSON.stringify(input.state)}\n`,
94 }).pipe(
95 Effect.mapError(
96 (cause) =>
97 new ServerRuntimeStateError({
98 operation: "persist",
99 statePath: input.path,
100 cause,
101 }),
102 ),
103 );
104
105export const clearPersistedServerRuntimeState = Effect.fn(
106 "serverRuntimeState.clearPersistedServerRuntimeState",
107)(function* (path: string) {
108 const fs = yield* FileSystem.FileSystem;
109 yield* fs.remove(path, { force: true }).pipe(
110 Effect.mapError(
111 (cause) =>
112 new ServerRuntimeStateError({
113 operation: "clear",
114 statePath: path,
115 cause,
116 }),
117 ),
118 Effect.catchTags({
119 ServerRuntimeStateError: (error) =>
120 Effect.logWarning(error.message).pipe(
121 Effect.annotateLogs({
122 operation: error.operation,
123 statePath: error.statePath,
124 cause: error,
125 }),
126 ),
127 }),
128 );
129});
130
131/** Signal 0 only probes existence; EPERM still means alive. */
132export const isProcessAlive = (pid: number): boolean => {
133 if (!Number.isInteger(pid) || pid <= 0) return false;
134 try {
135 process.kill(pid, 0);
136 return true;
137 } catch (error) {
138 return error instanceof Error && "code" in error && error.code === "EPERM";
139 }
140};
141
142export const readPersistedServerRuntimeState = Effect.fn(
143 "serverRuntimeState.readPersistedServerRuntimeState",
144)(
145 function* (path: string) {
146 const fs = yield* FileSystem.FileSystem;
147 const raw = yield* fs.readFileString(path).pipe(
148 Effect.matchEffect({
149 onFailure: (cause) =>
150 cause.reason._tag === "NotFound"
151 ? Effect.succeed(Option.none<string>())
152 : Effect.fail(
153 new ServerRuntimeStateError({
154 operation: "read",
155 statePath: path,
156 cause,
157 }),
158 ),
159 onSuccess: (contents) => Effect.succeedSome(contents),
160 }),
161 );
162 if (Option.isNone(raw)) {
163 return Option.none<PersistedServerRuntimeState>();
164 }
165
166 const trimmed = raw.value.trim();
167 if (trimmed.length === 0) {
168 return Option.none<PersistedServerRuntimeState>();
169 }
170
171 return yield* decodePersistedServerRuntimeState(trimmed).pipe(
172 Effect.asSome,
173 Effect.mapError(
174 (cause) =>
175 new ServerRuntimeStateError({
176 operation: "decode",
177 statePath: path,
178 cause,
179 }),
180 ),
181 );
182 },
183 Effect.catchTags({
184 ServerRuntimeStateError: (error) =>
185 Effect.logWarning(error.message).pipe(
186 Effect.annotateLogs({
187 operation: error.operation,
188 statePath: error.statePath,
189 cause: error,
190 }),
191 Effect.as(Option.none<PersistedServerRuntimeState>()),
192 ),
193 }),
194);
195
196/**
197 * The runtime record, only while its server process is alive. Read this before sending
198 * credentials to the recorded origin: a killed server leaves its record behind, and
199 * another process may own its port by now.
200 */
201export const readLiveServerRuntimeState = Effect.fn(
202 "serverRuntimeState.readLiveServerRuntimeState",
203)((path: string) =>
204 readPersistedServerRuntimeState(path).pipe(
205 Effect.map(Option.filter((state) => isProcessAlive(state.pid))),
206 ),
207);