im_wower
·
2026-03-26
service.ts
1import {
2 type StatusApiHandler,
3 type StatusApiRequest,
4 type StatusApiResponse,
5 type StatusApiRoute,
6 type StatusSnapshotLoader
7} from "./contracts.js";
8import { renderStatusPage } from "./render.js";
9
10const JSON_HEADERS = {
11 "content-type": "application/json; charset=utf-8",
12 "cache-control": "no-store"
13} as const;
14
15const HTML_HEADERS = {
16 "content-type": "text/html; charset=utf-8",
17 "cache-control": "no-store"
18} as const;
19
20const TEXT_HEADERS = {
21 "content-type": "text/plain; charset=utf-8",
22 "cache-control": "no-store"
23} as const;
24
25type StatusApiRouteId = "describe" | "healthz" | "status" | "ui";
26
27type StatusApiRouteDefinition = StatusApiRoute & {
28 id: StatusApiRouteId;
29};
30
31export interface StatusApiHandlerOptions {
32 truthSourceBaseUrl?: string;
33 controlApiBase?: string;
34 publicBaseUrl?: string;
35 version?: string;
36}
37
38const STATUS_API_ROUTE_DEFINITIONS: ReadonlyArray<StatusApiRouteDefinition> = [
39 {
40 id: "describe",
41 method: "GET",
42 path: "/describe",
43 summary: "读取状态服务自描述 JSON",
44 contentType: "application/json"
45 },
46 {
47 id: "healthz",
48 method: "GET",
49 path: "/healthz",
50 summary: "状态服务健康检查",
51 contentType: "text/plain"
52 },
53 {
54 id: "status",
55 method: "GET",
56 path: "/v1/status",
57 summary: "读取全局自动化状态快照",
58 contentType: "application/json"
59 },
60 {
61 id: "ui",
62 method: "GET",
63 path: "/v1/status/ui",
64 aliases: ["/", "/ui"],
65 summary: "读取最小 HTML 状态面板",
66 contentType: "text/html"
67 }
68];
69
70const STATUS_API_ROUTE_LOOKUP = createStatusApiRouteLookup();
71
72export const STATUS_API_ROUTES: StatusApiRoute[] = STATUS_API_ROUTE_DEFINITIONS.map((route) => ({
73 method: route.method,
74 path: route.path,
75 summary: route.summary,
76 contentType: route.contentType,
77 ...(route.aliases == null ? {} : { aliases: [...route.aliases] })
78}));
79
80export function describeStatusApiSurface(): string[] {
81 return STATUS_API_ROUTE_DEFINITIONS.map((route) => {
82 const paths = [route.path, ...(route.aliases ?? [])].join(", ");
83
84 return `${route.method} ${paths} - ${route.summary}`;
85 });
86}
87
88export function createStatusApiHandler(
89 snapshotLoader: StatusSnapshotLoader,
90 options: StatusApiHandlerOptions = {}
91): StatusApiHandler {
92 return {
93 routes: STATUS_API_ROUTES,
94 handle: (request) => handleStatusApiRequest(request, snapshotLoader, options)
95 };
96}
97
98export async function handleStatusApiRequest(
99 request: StatusApiRequest,
100 snapshotLoader: StatusSnapshotLoader,
101 options: StatusApiHandlerOptions = {}
102): Promise<StatusApiResponse> {
103 const method = request.method.toUpperCase();
104 const path = normalizePath(request.path);
105
106 if (method !== "GET") {
107 return jsonResponse(
108 405,
109 {
110 ok: false,
111 error: "method_not_allowed",
112 message: "Status API is read-only and only accepts GET requests."
113 },
114 { Allow: "GET" }
115 );
116 }
117
118 const route = resolveStatusApiRoute(path);
119
120 if (route == null) {
121 return jsonResponse(404, {
122 ok: false,
123 error: "not_found",
124 message: `No status route matches "${path}".`
125 });
126 }
127
128 switch (route.id) {
129 case "describe":
130 return jsonResponse(200, {
131 ok: true,
132 data: buildStatusApiDescribeData(options)
133 });
134
135 case "healthz":
136 return {
137 status: 200,
138 headers: { ...TEXT_HEADERS },
139 body: "ok"
140 };
141
142 case "status":
143 return jsonResponse(200, {
144 ok: true,
145 data: await snapshotLoader.loadSnapshot()
146 });
147
148 case "ui":
149 return {
150 status: 200,
151 headers: { ...HTML_HEADERS },
152 body: renderStatusPage(await snapshotLoader.loadSnapshot())
153 };
154 }
155}
156
157function buildStatusApiDescribeData(options: StatusApiHandlerOptions): Record<string, unknown> {
158 const processInfo = getProcessInfo();
159 const truthSourceBaseUrl = options.truthSourceBaseUrl ?? options.controlApiBase ?? "http://100.71.210.78:4317";
160
161 return {
162 name: "baa-conductor-status-api",
163 version: resolveStatusApiVersion(options.version),
164 description:
165 "Read-only compatibility status view service. It does not own conductor truth; new callers should prefer conductor /v1/status while this service preserves the legacy local status-api contract.",
166 pid: processInfo.pid,
167 uptime_sec: processInfo.uptimeSec,
168 cwd: processInfo.cwd,
169 truth_source: {
170 summary: "Current truth comes from conductor /v1/system/state.",
171 type: "conductor-api",
172 base_url: truthSourceBaseUrl,
173 endpoint: "/v1/system/state"
174 },
175 endpoints: STATUS_API_ROUTE_DEFINITIONS.map((route) => ({
176 method: route.method,
177 path: route.path,
178 aliases: route.aliases ?? [],
179 summary: route.summary,
180 content_type: route.contentType
181 })),
182 responses: {
183 "/healthz": "plain text ok",
184 "/v1/status": "{ ok, data } JSON snapshot",
185 "/v1/status/ui": "HTML status panel",
186 "/describe": "{ ok, data } JSON service description"
187 },
188 examples: [
189 `curl '${options.publicBaseUrl ?? "https://conductor.makefile.so"}/describe'`,
190 `curl '${options.publicBaseUrl ?? "https://conductor.makefile.so"}/v1/status'`
191 ],
192 notes: [
193 "Status API is read-only.",
194 "Preferred entry lives on conductor: /v1/status and /v1/status/ui.",
195 "Default truth source comes from BAA_CONDUCTOR_LOCAL_API.",
196 "Use BAA_CONTROL_API_BASE only for legacy ad-hoc compatibility overrides."
197 ]
198 };
199}
200
201function resolveStatusApiVersion(value: string | undefined): string {
202 if (value == null || value.trim() === "") {
203 return "dev";
204 }
205
206 return value.trim();
207}
208
209function getProcessInfo(): { cwd: string | null; pid: number | null; uptimeSec: number | null } {
210 const runtimeProcess = (typeof process !== "undefined" ? process : undefined) as
211 | {
212 cwd?: () => string;
213 pid?: number;
214 uptime?: () => number;
215 }
216 | undefined;
217
218 return {
219 pid: typeof runtimeProcess?.pid === "number" ? runtimeProcess.pid : null,
220 uptimeSec: typeof runtimeProcess?.uptime === "function" ? Math.floor(runtimeProcess.uptime()) : null,
221 cwd: typeof runtimeProcess?.cwd === "function" ? runtimeProcess.cwd() : null
222 };
223}
224
225function jsonResponse(
226 status: number,
227 payload: unknown,
228 extraHeaders: Record<string, string> = {}
229): StatusApiResponse {
230 return {
231 status,
232 headers: {
233 ...JSON_HEADERS,
234 ...extraHeaders
235 },
236 body: `${JSON.stringify(payload, null, 2)}\n`
237 };
238}
239
240function normalizePath(value: string): string {
241 const baseUrl = "http://status.local";
242 const url = new URL(value || "/", baseUrl);
243 const normalized = url.pathname.replace(/\/+$/, "");
244
245 return normalized === "" ? "/" : normalized;
246}
247
248function createStatusApiRouteLookup(): Map<string, StatusApiRouteDefinition> {
249 const lookup = new Map<string, StatusApiRouteDefinition>();
250
251 for (const route of STATUS_API_ROUTE_DEFINITIONS) {
252 lookup.set(route.path, route);
253
254 for (const alias of route.aliases ?? []) {
255 lookup.set(alias, route);
256 }
257 }
258
259 return lookup;
260}
261
262function resolveStatusApiRoute(path: string): StatusApiRouteDefinition | null {
263 return STATUS_API_ROUTE_LOOKUP.get(path) ?? null;
264}