Skip to content

Commit 172fb7d

Browse files
committed
Expose browser-safe control plane endpoints
1 parent 3c1fede commit 172fb7d

2 files changed

Lines changed: 73 additions & 11 deletions

File tree

services/control-plane/src/server.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createServer } from "node:http";
1+
import { createServer, type ServerResponse } from "node:http";
22
import { fileURLToPath } from "node:url";
33

44
import { dispatchJsonRpc } from "./json-rpc.ts";
@@ -9,6 +9,22 @@ interface ServerOptions {
99
port: number;
1010
}
1111

12+
const jsonHeaders = {
13+
"access-control-allow-headers": "content-type",
14+
"access-control-allow-methods": "GET,POST,OPTIONS",
15+
"access-control-allow-origin": "*",
16+
"content-type": "application/json",
17+
};
18+
19+
function jsonResult(response: ReturnType<typeof dispatchJsonRpc>): unknown {
20+
return Array.isArray(response) ? response : response?.result ?? response;
21+
}
22+
23+
function writeJson(res: ServerResponse, statusCode: number, body: unknown): void {
24+
res.writeHead(statusCode, jsonHeaders);
25+
res.end(`${JSON.stringify(body)}\n`);
26+
}
27+
1228
function parseArgs(args: string[]): ServerOptions {
1329
const options: ServerOptions = {
1430
host: "127.0.0.1",
@@ -44,16 +60,26 @@ function parseArgs(args: string[]): ServerOptions {
4460
export function startControlPlaneServer(options: ServerOptions): ReturnType<typeof createServer> {
4561
const state = loadControlPlaneState();
4662
const server = createServer((req, res) => {
63+
if (req.method === "OPTIONS") {
64+
res.writeHead(204, jsonHeaders);
65+
res.end();
66+
return;
67+
}
68+
4769
if (req.method === "GET" && req.url === "/health") {
4870
const response = dispatchJsonRpc({ jsonrpc: "2.0", id: "health", method: "health" }, { state });
49-
res.writeHead(200, { "content-type": "application/json" });
50-
res.end(`${JSON.stringify(Array.isArray(response) ? response : response?.result ?? response)}\n`);
71+
writeJson(res, 200, jsonResult(response));
72+
return;
73+
}
74+
75+
if (req.method === "GET" && req.url === "/state") {
76+
const response = dispatchJsonRpc({ jsonrpc: "2.0", id: "state", method: "devnet_state" }, { state });
77+
writeJson(res, 200, jsonResult(response));
5178
return;
5279
}
5380

5481
if (req.method !== "POST" || req.url !== "/rpc") {
55-
res.writeHead(404, { "content-type": "application/json" });
56-
res.end(JSON.stringify({ error: "not found" }));
82+
writeJson(res, 404, { error: "not found" });
5783
return;
5884
}
5985

@@ -67,15 +93,13 @@ export function startControlPlaneServer(options: ServerOptions): ReturnType<type
6793
const payload = JSON.parse(body) as unknown;
6894
const response = dispatchJsonRpc(payload, { state });
6995
if (response === undefined) {
70-
res.writeHead(204);
96+
res.writeHead(204, jsonHeaders);
7197
res.end();
7298
return;
7399
}
74-
res.writeHead(200, { "content-type": "application/json" });
75-
res.end(`${JSON.stringify(response)}\n`);
100+
writeJson(res, 200, response);
76101
} catch (error) {
77-
res.writeHead(400, { "content-type": "application/json" });
78-
res.end(JSON.stringify({
102+
writeJson(res, 400, {
79103
jsonrpc: "2.0",
80104
id: null,
81105
error: {
@@ -87,7 +111,7 @@ export function startControlPlaneServer(options: ServerOptions): ReturnType<type
87111
localOnly: true,
88112
},
89113
},
90-
}));
114+
});
91115
}
92116
});
93117
});

services/control-plane/test/control-plane.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from "node:assert/strict";
2+
import { once } from "node:events";
23
import { mkdtempSync, rmSync } from "node:fs";
34
import { tmpdir } from "node:os";
45
import { join } from "node:path";
@@ -11,6 +12,7 @@ import {
1112
type RpcErrorResponse,
1213
type RpcSuccessResponse,
1314
} from "../src/index.ts";
15+
import { startControlPlaneServer } from "../src/server.ts";
1416
import { runControlPlaneSmoke } from "../src/smoke.ts";
1517

1618
test("dispatches JSON-RPC methods against local fixture state", () => {
@@ -170,3 +172,39 @@ test("smoke client queries the complete local lifecycle surface", () => {
170172
assert.equal(smoke.methodCount, 31);
171173
assert.ok((smoke.responseSchemas as string[]).includes("flowmemory.control_plane.raw_json.v0"));
172174
});
175+
176+
test("HTTP server exposes browser-safe health and state endpoints", async () => {
177+
const server = startControlPlaneServer({ host: "127.0.0.1", port: 0 });
178+
179+
try {
180+
await once(server, "listening");
181+
const address = server.address();
182+
assert.equal(typeof address, "object");
183+
assert.notEqual(address, null);
184+
const port = address?.port;
185+
186+
const health = await fetch(`http://127.0.0.1:${port}/health`, {
187+
headers: { Origin: "http://127.0.0.1:5173" },
188+
});
189+
assert.equal(health.status, 200);
190+
assert.equal(health.headers.get("access-control-allow-origin"), "*");
191+
assert.equal((await health.json()).status, "ok");
192+
193+
const state = await fetch(`http://127.0.0.1:${port}/state`, {
194+
headers: { Origin: "http://127.0.0.1:5173" },
195+
});
196+
assert.equal(state.status, 200);
197+
assert.equal(state.headers.get("access-control-allow-origin"), "*");
198+
assert.equal((await state.json()).schema, "flowmemory.control_plane.devnet_state.v0");
199+
} finally {
200+
await new Promise<void>((resolve, reject) => {
201+
server.close((error) => {
202+
if (error) {
203+
reject(error);
204+
return;
205+
}
206+
resolve();
207+
});
208+
});
209+
}
210+
});

0 commit comments

Comments
 (0)