go/pkg/coredeno/runtime/server.ts
Claude af98accc03
feat(coredeno): Tier 2 bidirectional bridge — Go↔Deno module lifecycle
Wire the CoreDeno sidecar into a fully bidirectional bridge:

- Deno→Go (gRPC): Deno connects as CoreService client via polyfilled
  @grpc/grpc-js over Unix socket. Polyfill patches Deno 2.x http2 gaps
  (getDefaultSettings, pre-connected socket handling, remoteSettings).
- Go→Deno (JSON-RPC): Go connects to Deno's newline-delimited JSON-RPC
  server for module lifecycle (LoadModule, UnloadModule, ModuleStatus).
  gRPC server direction avoided due to Deno http2.createServer limitations.
- ProcessStart/ProcessStop: gRPC handlers delegate to process.Service
  with manifest permission gating (run permissions).
- Deno runtime: main.ts boots DenoService server, connects CoreService
  client with retry + health-check round-trip, handles SIGTERM shutdown.

40 unit tests + 2 integration tests (Tier 1 boot + Tier 2 bidirectional).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:43:12 +00:00

124 lines
3.1 KiB
TypeScript

// DenoService JSON-RPC server — Go calls Deno for module lifecycle management.
// Uses length-prefixed JSON over raw Unix socket (Deno's http2 server is broken).
// Protocol: 4-byte big-endian length + JSON payload, newline-delimited.
import { ModuleRegistry } from "./modules.ts";
export interface DenoServer {
close(): void;
}
export async function startDenoServer(
socketPath: string,
registry: ModuleRegistry,
): Promise<DenoServer> {
// Remove stale socket
try {
Deno.removeSync(socketPath);
} catch {
// ignore
}
const listener = Deno.listen({ transport: "unix", path: socketPath });
const handleConnection = async (conn: Deno.UnixConn) => {
const reader = conn.readable.getReader();
const writer = conn.writable.getWriter();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete lines (newline-delimited JSON)
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIdx);
buffer = buffer.slice(newlineIdx + 1);
if (!line.trim()) continue;
try {
const req = JSON.parse(line);
const resp = dispatch(req, registry);
await writer.write(
new TextEncoder().encode(JSON.stringify(resp) + "\n"),
);
} catch (err) {
const errResp = {
error: err instanceof Error ? err.message : String(err),
};
await writer.write(
new TextEncoder().encode(JSON.stringify(errResp) + "\n"),
);
}
}
}
} catch {
// Connection closed or error — expected during shutdown
} finally {
try {
writer.close();
} catch {
/* already closed */
}
}
};
// Accept connections in background
const abortController = new AbortController();
(async () => {
try {
for await (const conn of listener) {
if (abortController.signal.aborted) break;
handleConnection(conn);
}
} catch {
// Listener closed
}
})();
return {
close() {
abortController.abort();
listener.close();
},
};
}
interface RPCRequest {
method: string;
code?: string;
entry_point?: string;
permissions?: string[];
process_id?: string;
}
function dispatch(
req: RPCRequest,
registry: ModuleRegistry,
): Record<string, unknown> {
switch (req.method) {
case "LoadModule": {
registry.load(
req.code ?? "",
req.entry_point ?? "",
req.permissions ?? [],
);
return { ok: true, error: "" };
}
case "UnloadModule": {
const ok = registry.unload(req.code ?? "");
return { ok };
}
case "ModuleStatus": {
return { code: req.code, status: registry.status(req.code ?? "") };
}
default:
return { error: `unknown method: ${req.method}` };
}
}