- commit
- 9ce0aaa
- parent
- 458d7cf
- author
- im_wower
- date
- 2026-03-22 01:03:21 +0800 CST
feat(status-api): add local host process
7 files changed,
+648,
-9
+4,
-1
1@@ -9,6 +9,9 @@
2 },
3 "scripts": {
4 "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/status-api/dist BAA_DIST_ENTRY=apps/status-api/src/index.js BAA_FIX_RELATIVE_EXTENSIONS=true pnpm -C ../.. run build:runtime-postprocess",
5- "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
6+ "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
7+ "serve": "pnpm run build && node dist/index.js",
8+ "start": "node dist/index.js",
9+ "smoke": "pnpm run build && node dist/index.js smoke"
10 }
11 }
+180,
-0
1@@ -0,0 +1,180 @@
2+import {
3+ getDefaultStatusApiHost,
4+ getDefaultStatusApiPort,
5+ runStatusApiSmokeCheck,
6+ startStatusApiServer,
7+ type StatusApiEnvironment
8+} from "./host.js";
9+
10+type StatusApiCliAction = "help" | "serve" | "smoke";
11+
12+export interface StatusApiTextWriter {
13+ write(chunk: string): unknown;
14+}
15+
16+export interface RunStatusApiCliOptions {
17+ argv?: readonly string[];
18+ env?: StatusApiEnvironment;
19+ stderr?: StatusApiTextWriter;
20+ stdout?: StatusApiTextWriter;
21+}
22+
23+interface StatusApiCliCommand {
24+ action: StatusApiCliAction;
25+ host?: string;
26+ port?: number;
27+}
28+
29+export async function runStatusApiCli(options: RunStatusApiCliOptions = {}): Promise<number> {
30+ const stdout = toTextWriter(options.stdout, console.log);
31+ const stderr = toTextWriter(options.stderr, console.error);
32+ const command = parseStatusApiCliCommand(options.argv ?? process?.argv ?? [], options.env ?? process?.env ?? {});
33+
34+ switch (command.action) {
35+ case "help":
36+ stdout.write(renderStatusApiCliHelp());
37+ return 0;
38+
39+ case "serve": {
40+ const server = await startStatusApiServer({
41+ host: command.host,
42+ port: command.port
43+ });
44+ const baseUrl =
45+ server.getBaseUrl() ??
46+ `http://${command.host ?? getDefaultStatusApiHost()}:${command.port ?? getDefaultStatusApiPort()}`;
47+
48+ stdout.write(`Status API listening on ${baseUrl}\n`);
49+
50+ for (const route of server.describeSurface()) {
51+ stdout.write(`- ${route}\n`);
52+ }
53+
54+ return 0;
55+ }
56+
57+ case "smoke": {
58+ const result = await runStatusApiSmokeCheck({
59+ host: command.host ?? getDefaultStatusApiHost(),
60+ port: command.port ?? 0
61+ });
62+
63+ stdout.write(`Smoke OK on ${result.baseUrl}\n`);
64+
65+ for (const check of result.checks) {
66+ stdout.write(`- ${check.status} ${check.path} ${check.detail}\n`);
67+ }
68+
69+ return 0;
70+ }
71+ }
72+}
73+
74+function parseStatusApiCliCommand(argv: readonly string[], env: StatusApiEnvironment): StatusApiCliCommand {
75+ const tokens = argv.slice(2);
76+ let action: StatusApiCliAction = "serve";
77+ let actionSet = false;
78+ let host: string | undefined;
79+ let port: number | undefined;
80+
81+ for (let index = 0; index < tokens.length; index += 1) {
82+ const token = tokens[index];
83+
84+ if (token == null) {
85+ continue;
86+ }
87+
88+ if (token === "--help" || token === "-h" || token === "help") {
89+ return { action: "help" };
90+ }
91+
92+ if (token === "--host") {
93+ host = readCliValue(tokens, index, "--host");
94+ index += 1;
95+ continue;
96+ }
97+
98+ if (token === "--port") {
99+ port = parseCliPort(readCliValue(tokens, index, "--port"));
100+ index += 1;
101+ continue;
102+ }
103+
104+ if (token.startsWith("--")) {
105+ throw new Error(`Unknown status-api flag "${token}".`);
106+ }
107+
108+ if (actionSet) {
109+ throw new Error(`Unexpected extra status-api argument "${token}".`);
110+ }
111+
112+ if (!isStatusApiCliAction(token)) {
113+ throw new Error(`Unknown status-api action "${token}".`);
114+ }
115+
116+ action = token;
117+ actionSet = true;
118+ }
119+
120+ return {
121+ action,
122+ host: host ?? env.BAA_STATUS_API_HOST ?? env.HOST,
123+ port: port
124+ };
125+}
126+
127+function isStatusApiCliAction(value: string): value is Exclude<StatusApiCliAction, "help"> {
128+ return value === "serve" || value === "smoke";
129+}
130+
131+function readCliValue(tokens: readonly string[], index: number, flag: string): string {
132+ const value = tokens[index + 1];
133+
134+ if (value == null || value.startsWith("--")) {
135+ throw new Error(`Missing value for ${flag}.`);
136+ }
137+
138+ return value;
139+}
140+
141+function parseCliPort(value: string): number {
142+ const port = Number(value);
143+
144+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
145+ throw new Error(`Invalid --port value "${value}".`);
146+ }
147+
148+ return port;
149+}
150+
151+function renderStatusApiCliHelp(): string {
152+ return [
153+ "Usage: node apps/status-api/dist/index.js [serve|smoke|help] [--host <host>] [--port <port>]",
154+ "",
155+ `Default listen address: http://${getDefaultStatusApiHost()}:${getDefaultStatusApiPort()}`,
156+ "Routes:",
157+ "- GET /healthz",
158+ "- GET /v1/status",
159+ "- GET /v1/status/ui",
160+ "",
161+ "Examples:",
162+ "- pnpm --filter @baa-conductor/status-api serve",
163+ "- pnpm --filter @baa-conductor/status-api smoke",
164+ "- node apps/status-api/dist/index.js --host 127.0.0.1 --port 4318"
165+ ].join("\n") + "\n";
166+}
167+
168+function toTextWriter(
169+ writer: StatusApiTextWriter | undefined,
170+ fallback: (message?: unknown, ...optionalParams: unknown[]) => void
171+): StatusApiTextWriter {
172+ if (writer != null) {
173+ return writer;
174+ }
175+
176+ return {
177+ write(chunk: string) {
178+ fallback(chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk);
179+ }
180+ };
181+}
+369,
-0
1@@ -0,0 +1,369 @@
2+import { createStatusApiRuntime, describeStatusApiRuntimeSurface, type StatusApiRuntime, type StatusApiRuntimeOptions } from "./runtime.js";
3+import type { StatusApiResponse } from "./contracts.js";
4+
5+const DEFAULT_STATUS_API_HOST = "127.0.0.1";
6+const DEFAULT_STATUS_API_PORT = 4318;
7+const NODE_HTTP_MODULE_SPECIFIER = "node:http";
8+
9+const ERROR_HEADERS = {
10+ "content-type": "application/json; charset=utf-8",
11+ "cache-control": "no-store"
12+} as const;
13+
14+export interface StatusApiEnvironment {
15+ [key: string]: string | undefined;
16+}
17+
18+export interface StatusApiListenOptions {
19+ host: string;
20+ port: number;
21+}
22+
23+export interface StatusApiServerOptions extends StatusApiRuntimeOptions {
24+ host?: string;
25+ port?: number;
26+}
27+
28+export interface StatusApiServerAddress {
29+ address: string;
30+ family: string;
31+ port: number;
32+}
33+
34+export interface StatusApiServer {
35+ readonly runtime: StatusApiRuntime;
36+ close(): Promise<void>;
37+ describeSurface(): string[];
38+ getAddress(): StatusApiServerAddress | string | null;
39+ getBaseUrl(): string | null;
40+}
41+
42+export interface StatusApiSmokeCheck {
43+ path: string;
44+ status: number;
45+ detail: string;
46+}
47+
48+export interface StatusApiSmokeCheckResult {
49+ baseUrl: string;
50+ checks: StatusApiSmokeCheck[];
51+}
52+
53+interface NodeHttpAddressInfo {
54+ address: string;
55+ family: string;
56+ port: number;
57+}
58+
59+interface NodeIncomingMessage {
60+ method?: string;
61+ url?: string;
62+}
63+
64+interface NodeServerResponse {
65+ statusCode: number;
66+ setHeader(name: string, value: string | readonly string[]): void;
67+ end(chunk?: string | Uint8Array): void;
68+}
69+
70+interface NodeHttpServer {
71+ address(): NodeHttpAddressInfo | string | null;
72+ close(callback?: (error?: Error) => void): void;
73+ listen(port: number, host: string, listeningListener?: () => void): void;
74+ once(event: string, listener: (...args: unknown[]) => void): void;
75+}
76+
77+interface NodeHttpModule {
78+ createServer(
79+ requestListener: (request: NodeIncomingMessage, response: NodeServerResponse) => void | Promise<void>
80+ ): NodeHttpServer;
81+}
82+
83+export function resolveStatusApiListenOptions(
84+ options: StatusApiServerOptions = {},
85+ env: StatusApiEnvironment = process?.env ?? {}
86+): StatusApiListenOptions {
87+ return {
88+ host: resolveStatusApiHost(options.host, env),
89+ port: resolveStatusApiPort(options.port, env)
90+ };
91+}
92+
93+export function createStatusApiNodeRequestListener(runtime: StatusApiRuntime) {
94+ return (request: NodeIncomingMessage, response: NodeServerResponse): void => {
95+ void handleStatusApiNodeRequest(runtime, request, response);
96+ };
97+}
98+
99+export async function startStatusApiServer(options: StatusApiServerOptions = {}): Promise<StatusApiServer> {
100+ const { createServer } = await importNodeHttp();
101+ const listenOptions = resolveStatusApiListenOptions(options);
102+ const runtime = createStatusApiRuntime({
103+ snapshotLoader: options.snapshotLoader
104+ });
105+ const server = createServer(createStatusApiNodeRequestListener(runtime));
106+
107+ await listenOnServer(server, listenOptions);
108+
109+ return {
110+ runtime,
111+ close: () => closeServer(server),
112+ describeSurface: () => describeStatusApiRuntimeSurface(runtime),
113+ getAddress: () => toServerAddress(server.address()),
114+ getBaseUrl: () => getStatusApiBaseUrl(server.address(), listenOptions.host)
115+ };
116+}
117+
118+export async function runStatusApiSmokeCheck(
119+ options: StatusApiServerOptions = {}
120+): Promise<StatusApiSmokeCheckResult> {
121+ const server = await startStatusApiServer({
122+ ...options,
123+ host: options.host ?? DEFAULT_STATUS_API_HOST,
124+ port: options.port ?? 0
125+ });
126+
127+ try {
128+ const baseUrl = server.getBaseUrl();
129+
130+ if (baseUrl == null) {
131+ throw new Error("Status API smoke check could not resolve a listening address.");
132+ }
133+
134+ const checks = [
135+ await verifyHealthz(baseUrl),
136+ await verifyStatus(baseUrl),
137+ await verifyUi(baseUrl)
138+ ];
139+
140+ return {
141+ baseUrl,
142+ checks
143+ };
144+ } finally {
145+ await server.close();
146+ }
147+}
148+
149+export function getDefaultStatusApiHost(): string {
150+ return DEFAULT_STATUS_API_HOST;
151+}
152+
153+export function getDefaultStatusApiPort(): number {
154+ return DEFAULT_STATUS_API_PORT;
155+}
156+
157+async function handleStatusApiNodeRequest(
158+ runtime: StatusApiRuntime,
159+ request: NodeIncomingMessage,
160+ response: NodeServerResponse
161+): Promise<void> {
162+ try {
163+ const runtimeResponse = await runtime.handle({
164+ method: request.method ?? "GET",
165+ path: request.url ?? "/"
166+ });
167+
168+ writeStatusApiResponse(response, runtimeResponse);
169+ } catch (error) {
170+ writeUnhandledErrorResponse(response, error);
171+ }
172+}
173+
174+function writeStatusApiResponse(response: NodeServerResponse, runtimeResponse: StatusApiResponse): void {
175+ response.statusCode = runtimeResponse.status;
176+
177+ for (const [name, value] of Object.entries(runtimeResponse.headers)) {
178+ response.setHeader(name, value);
179+ }
180+
181+ response.end(runtimeResponse.body);
182+}
183+
184+function writeUnhandledErrorResponse(response: NodeServerResponse, error: unknown): void {
185+ const message = error instanceof Error ? error.message : String(error);
186+
187+ response.statusCode = 500;
188+
189+ for (const [name, value] of Object.entries(ERROR_HEADERS)) {
190+ response.setHeader(name, value);
191+ }
192+
193+ response.end(
194+ `${JSON.stringify(
195+ {
196+ ok: false,
197+ error: "internal_error",
198+ message
199+ },
200+ null,
201+ 2
202+ )}\n`
203+ );
204+}
205+
206+function resolveStatusApiHost(explicitHost: string | undefined, env: StatusApiEnvironment): string {
207+ const host = explicitHost ?? env.BAA_STATUS_API_HOST ?? env.HOST ?? DEFAULT_STATUS_API_HOST;
208+
209+ return host.trim() === "" ? DEFAULT_STATUS_API_HOST : host;
210+}
211+
212+function resolveStatusApiPort(explicitPort: number | undefined, env: StatusApiEnvironment): number {
213+ if (explicitPort != null) {
214+ return normalizePort(explicitPort);
215+ }
216+
217+ const rawPort = env.BAA_STATUS_API_PORT ?? env.PORT;
218+
219+ if (rawPort == null || rawPort.trim() === "") {
220+ return DEFAULT_STATUS_API_PORT;
221+ }
222+
223+ return normalizePort(Number(rawPort));
224+}
225+
226+function normalizePort(value: number): number {
227+ if (!Number.isInteger(value) || value < 0 || value > 65_535) {
228+ throw new TypeError(`Expected a valid TCP port, received "${String(value)}".`);
229+ }
230+
231+ return value;
232+}
233+
234+async function importNodeHttp(): Promise<NodeHttpModule> {
235+ return import(NODE_HTTP_MODULE_SPECIFIER) as Promise<NodeHttpModule>;
236+}
237+
238+function listenOnServer(server: NodeHttpServer, options: StatusApiListenOptions): Promise<void> {
239+ return new Promise((resolve, reject) => {
240+ let settled = false;
241+
242+ server.once("error", (error: unknown) => {
243+ if (settled) {
244+ return;
245+ }
246+
247+ settled = true;
248+ reject(error);
249+ });
250+
251+ server.listen(options.port, options.host, () => {
252+ if (settled) {
253+ return;
254+ }
255+
256+ settled = true;
257+ resolve();
258+ });
259+ });
260+}
261+
262+function closeServer(server: NodeHttpServer): Promise<void> {
263+ return new Promise((resolve, reject) => {
264+ server.close((error?: Error) => {
265+ if (error != null) {
266+ reject(error);
267+ return;
268+ }
269+
270+ resolve();
271+ });
272+ });
273+}
274+
275+function toServerAddress(value: ReturnType<NodeHttpServer["address"]>): StatusApiServerAddress | string | null {
276+ if (value == null || typeof value === "string") {
277+ return value;
278+ }
279+
280+ return {
281+ address: value.address,
282+ family: value.family,
283+ port: value.port
284+ };
285+}
286+
287+function getStatusApiBaseUrl(
288+ value: ReturnType<NodeHttpServer["address"]>,
289+ fallbackHost: string
290+): string | null {
291+ if (value == null || typeof value === "string") {
292+ return null;
293+ }
294+
295+ return `http://${formatHostForUrl(selectReachableHost(value.address, fallbackHost))}:${value.port}`;
296+}
297+
298+function selectReachableHost(address: string, fallbackHost: string): string {
299+ if (address === "::" || address === "0.0.0.0") {
300+ return fallbackHost === "::" || fallbackHost === "0.0.0.0" ? DEFAULT_STATUS_API_HOST : fallbackHost;
301+ }
302+
303+ return address;
304+}
305+
306+function formatHostForUrl(host: string): string {
307+ return host.includes(":") ? `[${host}]` : host;
308+}
309+
310+async function verifyHealthz(baseUrl: string): Promise<StatusApiSmokeCheck> {
311+ const response = await fetch(new URL("/healthz", baseUrl));
312+ const body = await response.text();
313+
314+ assertStatus(response, 200, "/healthz");
315+
316+ if (body.trim() !== "ok") {
317+ throw new Error(`Expected /healthz to return "ok", received ${JSON.stringify(body)}.`);
318+ }
319+
320+ return {
321+ path: "/healthz",
322+ status: response.status,
323+ detail: 'body="ok"'
324+ };
325+}
326+
327+async function verifyStatus(baseUrl: string): Promise<StatusApiSmokeCheck> {
328+ const response = await fetch(new URL("/v1/status", baseUrl));
329+ const payload = (await response.json()) as {
330+ ok?: boolean;
331+ data?: {
332+ source?: string;
333+ };
334+ };
335+
336+ assertStatus(response, 200, "/v1/status");
337+
338+ if (payload.ok !== true || payload.data?.source !== "empty") {
339+ throw new Error(`Expected /v1/status to return an empty snapshot envelope, received ${JSON.stringify(payload)}.`);
340+ }
341+
342+ return {
343+ path: "/v1/status",
344+ status: response.status,
345+ detail: `source=${payload.data.source}`
346+ };
347+}
348+
349+async function verifyUi(baseUrl: string): Promise<StatusApiSmokeCheck> {
350+ const response = await fetch(new URL("/v1/status/ui", baseUrl));
351+ const html = await response.text();
352+
353+ assertStatus(response, 200, "/v1/status/ui");
354+
355+ if (!html.includes("<title>BAA Conductor Status</title>")) {
356+ throw new Error("Expected /v1/status/ui to return the HTML status page shell.");
357+ }
358+
359+ return {
360+ path: "/v1/status/ui",
361+ status: response.status,
362+ detail: "html shell rendered"
363+ };
364+}
365+
366+function assertStatus(response: Response, expectedStatus: number, path: string): void {
367+ if (response.status !== expectedStatus) {
368+ throw new Error(`Expected ${path} to return ${expectedStatus}, received ${response.status}.`);
369+ }
370+}
+57,
-0
1@@ -3,3 +3,60 @@ export * from "./data-source.js";
2 export * from "./render.js";
3 export * from "./runtime.js";
4 export * from "./service.js";
5+export * from "./host.js";
6+export * from "./cli.js";
7+
8+import { runStatusApiCli } from "./cli.js";
9+
10+if (shouldRunStatusApiCli(import.meta.url)) {
11+ try {
12+ const exitCode = await runStatusApiCli();
13+
14+ if (exitCode !== 0 && typeof process !== "undefined") {
15+ process.exitCode = exitCode;
16+ }
17+ } catch (error) {
18+ console.error(formatStatusApiCliError(error));
19+
20+ if (typeof process !== "undefined") {
21+ process.exitCode = 1;
22+ }
23+ }
24+}
25+
26+function shouldRunStatusApiCli(metaUrl: string): boolean {
27+ if (typeof process === "undefined") {
28+ return false;
29+ }
30+
31+ const executedPath = normalizeCliEntryPath(process.argv[1]);
32+
33+ if (executedPath == null) {
34+ return false;
35+ }
36+
37+ const sourceEntryPath = normalizeCliEntryPath(toFsPath(metaUrl));
38+ const distShimPath = normalizeCliEntryPath(toFsPath(new URL("../../../index.js", metaUrl).href));
39+
40+ return executedPath === sourceEntryPath || executedPath === distShimPath;
41+}
42+
43+function normalizeCliEntryPath(value: string | undefined): string | null {
44+ if (value == null || value === "") {
45+ return null;
46+ }
47+
48+ return value.endsWith("/") ? value.slice(0, -1) : value;
49+}
50+
51+function toFsPath(value: string): string {
52+ return decodeURIComponent(new URL(value).pathname);
53+}
54+
55+function formatStatusApiCliError(error: unknown): string {
56+ if (error instanceof Error) {
57+ return error.stack ?? `${error.name}: ${error.message}`;
58+ }
59+
60+ return `Status API startup failed: ${String(error)}`;
61+}
+11,
-0
1@@ -0,0 +1,11 @@
2+declare global {
3+ const process:
4+ | {
5+ argv: string[];
6+ env: Record<string, string | undefined>;
7+ exitCode?: number;
8+ }
9+ | undefined;
10+}
11+
12+export {};
+1,
-1
1@@ -5,5 +5,5 @@
2 "rootDir": "../..",
3 "outDir": "dist"
4 },
5- "include": ["src/**/*.ts", "../../packages/db/src/**/*.ts"]
6+ "include": ["src/**/*.ts", "src/**/*.d.ts", "../../packages/db/src/**/*.ts"]
7 }
+26,
-7
1@@ -1,10 +1,10 @@
2 ---
3 task_id: T-020
4 title: Status API 本地宿主进程
5-status: todo
6+status: review
7 branch: feat/T-020-status-host
8 repo: /Users/george/code/baa-conductor
9-base_ref: main
10+base_ref: main@458d7cf
11 depends_on:
12 - T-017
13 write_scope:
14@@ -62,23 +62,42 @@ updated_at: 2026-03-22
15
16 ## files_changed
17
18-- 待填写
19+- `apps/status-api/package.json`
20+- `apps/status-api/tsconfig.json`
21+- `apps/status-api/src/index.ts`
22+- `apps/status-api/src/cli.ts`
23+- `apps/status-api/src/host.ts`
24+- `apps/status-api/src/node-shims.d.ts`
25+- `coordination/tasks/T-020-status-host.md`
26
27 ## commands_run
28
29-- 待填写
30+- `npx --yes pnpm install`
31+- `npx --yes pnpm --filter @baa-conductor/status-api typecheck`
32+- `npx --yes pnpm --filter @baa-conductor/status-api build`
33+- `npx --yes pnpm --filter @baa-conductor/status-api smoke`
34+- `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328`
35+- `curl --silent --show-error http://127.0.0.1:4328/healthz`
36+- `curl --silent --show-error http://127.0.0.1:4328/v1/status`
37+- `curl --silent --show-error http://127.0.0.1:4328/v1/status/ui | sed -n '1,8p'`
38
39 ## result
40
41-- 待填写
42+- 已在 `apps/status-api/**` 内补齐 Node 宿主层,新增本地 HTTP request listener 与 `startStatusApiServer()`,把 `createStatusApiRuntime()` 真正挂到监听端口。
43+- 已补 `runStatusApiCli()` 与主入口直启逻辑,`node apps/status-api/dist/index.js` 现在默认监听 `127.0.0.1:4318`,可直接给 launchd 复用。
44+- 已新增包内脚本:`pnpm --filter @baa-conductor/status-api serve`、`start`、`smoke`;本地可访问 `GET /healthz`、`GET /v1/status`、`GET /v1/status/ui`。
45+- 已补最小真实 HTTP 冒烟验证:`smoke` 会拉起临时端口并验证三条 GET 路由,另外也手动确认了 `node apps/status-api/dist/index.js --host 127.0.0.1 --port 4328` 的实际监听响应。
46
47 ## risks
48
49-- 待填写
50+- 当前宿主进程默认仍使用 `StaticStatusSnapshotLoader`,返回的是本地空快照;真实 D1 / 控制平面接线仍需后续整合任务注入。
51+- `dist/index.js` 的直启能力依赖当前统一 build 产物布局;如果后续全仓库再调整 `BAA_DIST_ENTRY` 或 shim 结构,需要同步验证直启检测逻辑。
52
53 ## next_handoff
54
55-- 待填写
56+- 在后续整合中,把真实 snapshot loader 注入 `startStatusApiServer()` 或 CLI 启动路径,让 `/v1/status` 返回真实控制平面状态。
57+- 视 launchd/bootstrap 任务需要,在安装副本里补 `BAA_STATUS_API_HOST` / `BAA_STATUS_API_PORT` 等环境变量,或继续沿用默认 `127.0.0.1:4318`。
58+- 如果后续希望把 status-api 嵌入到更大的 Node 宿主里,可直接复用本任务新增的 request listener / server adapter,而不必再改 service/runtime 层。
59
60 开始时建议直接把 `status` 改为 `in_progress`。
61