1import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
2
3// Protocol 3 requires the standalone executable layout. Bump when runtimePaths
4// or the installed runtime tree changes incompatibly; launchers survive self-updates.
5export const SERVICE_LAUNCHER_PROTOCOL = 3 as const;
6export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
7export const SERVICE_STATE_FILE = "service-state.json";
8/** Written by the launcher just before an explicit stop kills its child, so
9 the child can tell "the service is going away" from "the launcher is about
10 to start my replacement" while a pending update is recorded. */
11export const SERVICE_STOP_MARKER_FILE = ".service-stopping";
12/** Written by `t3 update` when the unit was repointed at a new version but the
13 running service was deliberately left on the old one. The launcher removes
14 it when it starts (whoever restarted the service), so while it exists the
15 service is known to be behind its unit and status reports it that way. */
16export const SERVICE_RESTART_PENDING_FILE = ".restart-pending";
17
18export interface PendingServiceUpdate {
19 readonly id: string;
20 readonly fromVersion: string;
21 readonly targetVersion: string;
22 readonly dbPath: string;
23 readonly status: "pending";
24}
25
26export type ServiceUpdateRecord = PendingServiceUpdate | ServerSelfUpdateOutcome;
27
28export interface ServiceState {
29 readonly protocol: typeof SERVICE_LAUNCHER_PROTOCOL;
30 readonly activeVersion: string;
31 readonly update?: ServiceUpdateRecord;
32}
33
34/** Context is copied from launcher-owned state when a child is spawned. */
35export interface ServiceLauncherContext {
36 readonly protocol: typeof SERVICE_LAUNCHER_PROTOCOL;
37 readonly childVersion: string;
38 readonly update?: ServiceUpdateRecord;
39}
40
41export type ServiceLauncherChildMessage =
42 | {
43 readonly type: "request-update";
44 readonly targetVersion: string;
45 readonly dbPath: string;
46 }
47 | {
48 readonly type: "prepared";
49 readonly updateId: string;
50 };
51
52export type ServiceLauncherParentMessage =
53 | {
54 readonly type: "update-accepted";
55 readonly updateId: string;
56 }
57 | {
58 readonly type: "update-rejected";
59 readonly reason: string;
60 }
61 | {
62 readonly type: "committed";
63 readonly updateId: string;
64 };
65
66const SEMVER_NUMBER = "(?:0|[1-9]\\d*)";
67const SEMVER_PRERELEASE = `(?:${SEMVER_NUMBER}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`;
68const EXACT_SERVICE_VERSION = new RegExp(
69 `^${SEMVER_NUMBER}\\.${SEMVER_NUMBER}\\.${SEMVER_NUMBER}(?:-${SEMVER_PRERELEASE}(?:\\.${SEMVER_PRERELEASE})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`,
70);
71
72/** Accepts exact SemVer only: never dist-tags or ranges passed to npm or filesystem paths. */
73export const isExactServiceVersion = (version: string): boolean =>
74 EXACT_SERVICE_VERSION.test(version);
75
76const isRecord = (value: unknown): value is Record<string, unknown> =>
77 typeof value === "object" && value !== null && !Array.isArray(value);
78
79function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined {
80 if (!isRecord(value)) return undefined;
81 const { id, fromVersion, targetVersion, status } = value;
82 if (
83 typeof id !== "string" ||
84 id.trim() === "" ||
85 typeof fromVersion !== "string" ||
86 !isExactServiceVersion(fromVersion) ||
87 typeof targetVersion !== "string" ||
88 !isExactServiceVersion(targetVersion)
89 ) {
90 return undefined;
91 }
92 if (status === "pending") {
93 return typeof value.dbPath === "string" && value.dbPath.trim() !== ""
94 ? { id, fromVersion, targetVersion, dbPath: value.dbPath, status }
95 : undefined;
96 }
97 if (
98 (status === "committed" || status === "rolled-back" || status === "failed") &&
99 (value.reason === undefined || (typeof value.reason === "string" && value.reason.trim() !== ""))
100 ) {
101 return {
102 id,
103 fromVersion,
104 targetVersion,
105 status,
106 ...(typeof value.reason === "string" ? { reason: value.reason } : {}),
107 };
108 }
109 return undefined;
110}
111
112/** SemVer precedence for exact versions. Build metadata is ignored. */
113export function compareExactServiceVersions(left: string, right: string): number {
114 const parse = (version: string) => {
115 const withoutBuild = version.split("+", 1)[0] ?? version;
116 const separator = withoutBuild.indexOf("-");
117 const core = separator === -1 ? withoutBuild : withoutBuild.slice(0, separator);
118 const prerelease = separator === -1 ? undefined : withoutBuild.slice(separator + 1);
119 const [major = "0", minor = "0", patch = "0"] = core.split(".");
120 return {
121 core: [BigInt(major), BigInt(minor), BigInt(patch)] as const,
122 prerelease: prerelease?.split(".") ?? [],
123 };
124 };
125 const a = parse(left);
126 const b = parse(right);
127 for (let index = 0; index < 3; index += 1) {
128 const x = a.core[index] ?? 0n;
129 const y = b.core[index] ?? 0n;
130 if (x !== y) return x < y ? -1 : 1;
131 }
132 if (a.prerelease.length === 0 || b.prerelease.length === 0) {
133 return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
134 }
135 const count = Math.max(a.prerelease.length, b.prerelease.length);
136 for (let index = 0; index < count; index += 1) {
137 const x = a.prerelease[index];
138 const y = b.prerelease[index];
139 if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
140 if (x === y) continue;
141 const xNumeric = /^\d+$/.test(x);
142 const yNumeric = /^\d+$/.test(y);
143 if (xNumeric && yNumeric) return BigInt(x) < BigInt(y) ? -1 : 1;
144 if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
145 return x < y ? -1 : 1;
146 }
147 return 0;
148}
149
150export function decodeServiceState(value: unknown): ServiceState | undefined {
151 if (!isRecord(value)) return undefined;
152 const update = value.update === undefined ? undefined : decodeServiceUpdate(value.update);
153 if (
154 value.protocol !== SERVICE_LAUNCHER_PROTOCOL ||
155 typeof value.activeVersion !== "string" ||
156 !isExactServiceVersion(value.activeVersion) ||
157 (value.update !== undefined && update === undefined) ||
158 (update !== undefined &&
159 compareExactServiceVersions(update.targetVersion, update.fromVersion) <= 0) ||
160 (update?.status === "pending" && update.fromVersion !== value.activeVersion) ||
161 (update?.status === "committed" && update.targetVersion !== value.activeVersion) ||
162 ((update?.status === "rolled-back" || update?.status === "failed") &&
163 update.fromVersion !== value.activeVersion)
164 ) {
165 return undefined;
166 }
167 return {
168 protocol: SERVICE_LAUNCHER_PROTOCOL,
169 activeVersion: value.activeVersion,
170 ...(update === undefined ? {} : { update }),
171 };
172}
173
174export function parseServiceState(value: string): ServiceState | undefined {
175 try {
176 return decodeServiceState(JSON.parse(value) as unknown);
177 } catch {
178 return undefined;
179 }
180}
181
182/** Detects an in-flight update across launcher protocol versions before replacing its state. */
183export function serviceStateHasPendingUpdate(value: string): boolean {
184 try {
185 const parsed: unknown = JSON.parse(value);
186 return isRecord(parsed) && isRecord(parsed.update) && parsed.update.status === "pending";
187 } catch {
188 return false;
189 }
190}
191
192/** Reads the active version across launcher protocol revisions for downgrade protection. */
193export function serviceStateActiveVersion(value: string): string | undefined {
194 try {
195 const parsed: unknown = JSON.parse(value);
196 return isRecord(parsed) &&
197 typeof parsed.activeVersion === "string" &&
198 isExactServiceVersion(parsed.activeVersion)
199 ? parsed.activeVersion
200 : undefined;
201 } catch {
202 return undefined;
203 }
204}
205
206export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined {
207 let parsed: unknown;
208 try {
209 parsed = JSON.parse(value) as unknown;
210 } catch {
211 return undefined;
212 }
213 if (
214 !isRecord(parsed) ||
215 parsed.protocol !== SERVICE_LAUNCHER_PROTOCOL ||
216 typeof parsed.childVersion !== "string" ||
217 !isExactServiceVersion(parsed.childVersion)
218 ) {
219 return undefined;
220 }
221 const update = parsed.update === undefined ? undefined : decodeServiceUpdate(parsed.update);
222 if (parsed.update !== undefined && update === undefined) return undefined;
223 const selectedVersion =
224 update?.status === "pending" || update?.status === "committed"
225 ? update.targetVersion
226 : update === undefined
227 ? parsed.childVersion
228 : update.fromVersion;
229 if (parsed.childVersion !== selectedVersion) {
230 return undefined;
231 }
232 return {
233 protocol: SERVICE_LAUNCHER_PROTOCOL,
234 childVersion: parsed.childVersion,
235 ...(update === undefined ? {} : { update }),
236 };
237}
238
239export function decodeServiceLauncherChildMessage(
240 value: unknown,
241): ServiceLauncherChildMessage | undefined {
242 if (!isRecord(value)) return undefined;
243 if (
244 value.type === "request-update" &&
245 typeof value.targetVersion === "string" &&
246 typeof value.dbPath === "string"
247 ) {
248 return { type: value.type, targetVersion: value.targetVersion, dbPath: value.dbPath };
249 }
250 return value.type === "prepared" && typeof value.updateId === "string"
251 ? { type: value.type, updateId: value.updateId }
252 : undefined;
253}
254
255export function decodeServiceLauncherParentMessage(
256 value: unknown,
257): ServiceLauncherParentMessage | undefined {
258 if (!isRecord(value)) return undefined;
259 if (value.type === "update-rejected" && typeof value.reason === "string") {
260 return { type: value.type, reason: value.reason };
261 }
262 if (value.type === "update-accepted" && typeof value.updateId === "string") {
263 return { type: value.type, updateId: value.updateId };
264 }
265 return value.type === "committed" && typeof value.updateId === "string"
266 ? { type: value.type, updateId: value.updateId }
267 : undefined;
268}