Skip to content

Commit 07cfa5f

Browse files
committed
feat(transport): f00016 S4 — bridges stdio + HTTP
- packages/core/transport/json-rpc-protocol.ts: shared newline-delimited JSON-RPC 2.0 wire shape (types + parser + builders). Both bridges speak the same language; this is the only file that knows the wire shape. - packages/core/transport/bridge-error.ts: typed BridgeError + AppError → HTTP status mapping. Bridges raise BridgeError on carrier-level failures (token rejected, malformed envelope); the dispatcher raises IApiError on handler-level failures. - packages/core/transport/stdio-bridge.server.ts: sidecar bridge for the Tauri Desktop parent. Reads one JSON-RPC frame per line, dispatches through the real HandlerRegistry, writes one response per line. Cancellation via the standard $/cancelRequest request shape + per-id bridge abort controllers. 'caller' is pinned to 'desktop' so the dispatcher's caller discriminator is correct. - packages/core/transport/http-bridge.server.ts: browser bridge preserving the existing ui-server.service.ts security posture: loopback-only bind, per-run UUID token (X-Tanit-Token header), Origin validation, 1 body = 1 JSON-RPC envelope. Status mapping via httpStatusFromApi (UNKNOWN_HANDLER→404, INVALID_INPUT→400, EXECUTION_FAILED→500, EXPORT_FAILED→422, CANCELED→499). - packages/cli/commands/serve.script.ts: 'apisrc serve' with --stdio (default sidecar) and --http (preserves the security envelope). Imports the same HandlerRegistry the bridge tests use; zero handler logic duplication. Wired into cli.script.ts so 'apisrc serve' is dispatchable from the bin/apisrc launcher. - tests/transport/{stdio,http}-bridge.spec.ts: 30 tests / 69 expects covering happy path, error paths, large payloads (>1MB), cancellation, security envelope (token / Origin), status mapping, real handler registry integration. Validation: core typecheck clean (2 pre-existing), cli typecheck clean, lint:boundaries clean, 30/30 transport tests pass, 1255 core tests no regressions, 710 CLI tests no regressions, 1145 frameworks tests no regressions, 532 e2e tests no regressions. Desktop gap: the slice declared files under packages/desktop/ src-tauri/ but the real layout is packages/desktop/. The existing packages/desktop/src/main.rs already implements sidecar-spawn + window creation for the OLD HTTP-bridged UI ('apisrc ui --no-open'); the NEW '--stdio' sidecar for Tanit Desktop is delivered via this slice's stdio bridge, but migrating main.rs to spawn 'apisrc serve --stdio' is f00017's work (Angular Desktop refactor). Documented as a known gap.
1 parent edef799 commit 07cfa5f

8 files changed

Lines changed: 2229 additions & 0 deletions

File tree

packages/cli/cli.script.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ const COMMANDS: Record<string, ICommand> = {
6767
summary: "List past generations and inspections, most recent first",
6868
load: () => import("./commands/history.script.js"),
6969
},
70+
// f00016 S4 — Application API as a long-lived service. Same
71+
// handler registry as the HTTP/UI carrier; the carrier flag
72+
// (--stdio / --http) lives inside the command. See
73+
// `serve.script.ts` for the bridge implementation.
74+
serve: {
75+
summary:
76+
"Run the Application API as a long-lived service (--stdio sidecar or --http browser bridge)",
77+
load: () => import("./commands/serve.script.js"),
78+
},
7079
};
7180

7281
/**
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* `apisrc serve` — the Application API as a long-lived service.
4+
*
5+
* Two carriers, one registry:
6+
*
7+
* - `--stdio`: newline-delimited JSON-RPC 2.0 over stdin/stdout.
8+
* No token, no Origin. The Tauri sidecar (Tanit Desktop) uses
9+
* this mode — IPC is local; only the user can reach it.
10+
* - `--http`: HTTP/1.1 on `127.0.0.1`, with the same security
11+
* envelope the existing `apisrc ui` enforces (loopback + token
12+
* + Origin). The browser carrier speaks it.
13+
*
14+
* Both carriers call the same `HandlerRegistry` from
15+
* `packages/core/application-api/handlers.ts`. Zero handler logic
16+
* duplicated; the bridge files are the only place that knows which
17+
* carrier is active.
18+
*
19+
* Usage:
20+
* apisrc serve --stdio # sidecar mode (default)
21+
* apisrc serve --http --port 4771 # browser/UI mode
22+
* apisrc serve --stdio --workspace /path/to/api
23+
*/
24+
import { resolveProjectContext } from "../../core/discovery/project-context.service.js";
25+
import {
26+
buildRegistry,
27+
} from "../../core/application-api/handlers.js";
28+
import {
29+
defaultOrchestrator,
30+
} from "../../frameworks/index.js";
31+
import type {
32+
IDiscoveryOrchestrator,
33+
} from "../../contracts/interfaces/core/scanner.interface.js";
34+
import { hasFlag, readFlag } from "../../core/helpers/argv.helper.js";
35+
import { serveStdio } from "../../core/transport/stdio-bridge.server.js";
36+
import {
37+
startHttpBridge,
38+
type IHttpBridge,
39+
} from "../../core/transport/http-bridge.server.js";
40+
import type {
41+
IProjectContext,
42+
} from "../../contracts/interfaces/core/project-context.interface.js";
43+
44+
/**
45+
* Returns the orchestrator the registry handlers expect.
46+
*
47+
* `defaultOrchestrator()` (from `packages/frameworks/index.js`)
48+
* is the canonical factory the CLI's other commands use; reusing
49+
* it here means `apisrc serve` dispatches to the **same** scanners
50+
* as `apisrc generate`, `apisrc inspect`, ... without a second
51+
* registry to keep in sync.
52+
*/
53+
function buildOrchestrator(): IDiscoveryOrchestrator {
54+
return defaultOrchestrator();
55+
}
56+
57+
/**
58+
* Entry point used by both the CLI dispatch (`cli.script.ts`) and
59+
* the stdio bridge's self-invocation fallback (when the file is run
60+
* with `bun run` directly).
61+
*
62+
* Returns the process exit code. `0` on a clean shutdown, `2` on a
63+
* fatal startup error.
64+
*/
65+
export async function runServe(
66+
argv: readonly string[],
67+
context?: IProjectContext,
68+
): Promise<number> {
69+
// Mode resolution: stdio is the default (the documented
70+
// sidecar entry). HTTP is opt-in via `--http` or `--port`.
71+
// Both flags trigger HTTP mode because the sidecar rarely
72+
// needs a port flag.
73+
const wantsHttp =
74+
hasFlag(argv, "--http") || hasFlag(argv, "--port");
75+
const isHttp = wantsHttp;
76+
const isStdio = !isHttp;
77+
78+
// Workspace resolution: `--workspace` is preferred (sidecar
79+
// contract); when missing we fall back to `resolveProjectContext`
80+
// (the same path `apisrc generate` uses).
81+
const workspaceFlag = readFlag(argv, "--workspace");
82+
const projectContext =
83+
workspaceFlag !== undefined
84+
? { projectRoot: workspaceFlag }
85+
: (context ?? resolveProjectContext({ argv: [...argv] }));
86+
87+
const registry = buildRegistry();
88+
const orchestrator = buildOrchestrator();
89+
90+
try {
91+
if (isHttp) {
92+
const portFlag = readFlag(argv, "--port");
93+
const port = portFlag !== undefined ? Number(portFlag) : 0;
94+
if (Number.isNaN(port) || port < 0 || port > 65535) {
95+
// eslint-disable-next-line no-console
96+
console.error(`[serve] invalid --port: ${portFlag}`);
97+
return 2;
98+
}
99+
return await runHttpMode({
100+
registry,
101+
workspace: projectContext.projectRoot,
102+
orchestrator,
103+
port,
104+
});
105+
}
106+
if (isStdio) {
107+
return await runStdioMode({
108+
registry,
109+
workspace: projectContext.projectRoot,
110+
orchestrator,
111+
});
112+
}
113+
// Defensive default: stdio (the loop above already covers this).
114+
return await runStdioMode({
115+
registry,
116+
workspace: projectContext.projectRoot,
117+
orchestrator,
118+
});
119+
} catch (err) {
120+
// eslint-disable-next-line no-console
121+
console.error("[serve] fatal:", (err as Error).message ?? err);
122+
return 2;
123+
}
124+
}
125+
126+
async function runStdioMode(opts: {
127+
registry: ReturnType<typeof buildRegistry>;
128+
workspace: string;
129+
orchestrator: IDiscoveryOrchestrator;
130+
}): Promise<number> {
131+
const bridge = serveStdio({
132+
registry: opts.registry,
133+
input: makeStdinSource(),
134+
output: { write: (line) => process.stdout.write(line) },
135+
workspace: opts.workspace,
136+
orchestrator: opts.orchestrator,
137+
onError: (err) => {
138+
// eslint-disable-next-line no-console
139+
console.error("[serve:stdio] frame error:", (err as Error).message ?? err);
140+
},
141+
});
142+
143+
await waitForShutdown();
144+
bridge.close();
145+
await bridge.closed;
146+
return 0;
147+
}
148+
149+
async function runHttpMode(opts: {
150+
registry: ReturnType<typeof buildRegistry>;
151+
workspace: string;
152+
orchestrator: IDiscoveryOrchestrator;
153+
port: number;
154+
}): Promise<number> {
155+
let server: IHttpBridge | null = null;
156+
try {
157+
server = startHttpBridge({
158+
registry: opts.registry,
159+
...(opts.port > 0 ? { port: opts.port } : {}),
160+
workspace: opts.workspace,
161+
orchestrator: opts.orchestrator,
162+
caller: "browser",
163+
});
164+
// eslint-disable-next-line no-console
165+
console.log(
166+
`[serve:http] listening on ${server.url}` +
167+
`\n token: ${server.token}` +
168+
`\n POST ${server.url}/api with header x-tanit-token: <token>`,
169+
);
170+
await waitForShutdown();
171+
return 0;
172+
} finally {
173+
server?.stop();
174+
}
175+
}
176+
177+
/* ────────────────────────────────────────────────────────────────────── *
178+
* Stdin helpers *
179+
* ────────────────────────────────────────────────────────────────────── */
180+
181+
/**
182+
* Line-buffered stdin source.
183+
*
184+
* The bridge takes an `AsyncIterable<string>` and pulls one line at
185+
* a time. The project's `interactive.script.ts` already proves the
186+
* pattern: split the byte stream on `\r?\n`, keep the trailing
187+
* partial line for the next read.
188+
*
189+
* This avoids depending on `node:readline` (not in the binary) and
190+
* matches the existing project's discipline: minimal ambient
191+
* declarations, no extra runtime deps.
192+
*/
193+
function makeStdinSource(): AsyncIterable<string> {
194+
return readLinesFromStdin();
195+
}
196+
197+
async function* readLinesFromStdin(): AsyncGenerator<string> {
198+
const decoder = new TextDecoder();
199+
let rest = "";
200+
const iterator = Bun.stdin.stream()[Symbol.asyncIterator]();
201+
while (true) {
202+
const chunk = await iterator.next();
203+
if (chunk.done) {
204+
if (rest.length > 0) yield rest;
205+
return;
206+
}
207+
rest += decoder.decode(chunk.value, { stream: true });
208+
const parts = rest.split(/\r?\n/);
209+
rest = parts.pop() ?? "";
210+
for (const part of parts) {
211+
if (part.length > 0) yield part;
212+
}
213+
}
214+
}
215+
216+
/* ────────────────────────────────────────────────────────────────────── *
217+
* Signal handling — clean shutdown on SIGINT / SIGTERM. *
218+
* ────────────────────────────────────────────────────────────────────── */
219+
220+
function waitForShutdown(): Promise<void> {
221+
return new Promise<void>((resolve) => {
222+
const onSignal = (): void => {
223+
resolve();
224+
};
225+
process.once("SIGINT", onSignal);
226+
process.once("SIGTERM", onSignal);
227+
});
228+
}
229+
230+
/**
231+
* CLI entry — when the file is invoked directly (`bun run` /
232+
* `bunx`), dispatch to `main()`. When imported as a module, only
233+
* `runServe()` is exported.
234+
*/
235+
export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
236+
return runServe(argv);
237+
}
238+
239+
if (import.meta.main) {
240+
void main().then((code) => {
241+
process.exit(code);
242+
});
243+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* `bridge-error.ts` — typed errors shared by every transport bridge.
3+
*
4+
* The Application API (`packages/core/application-api/`) speaks the
5+
* `IApiError` envelope (f00016 S3). The wire shape (stdio, HTTP)
6+
* speaks JSON-RPC 2.0 (see `json-rpc-protocol.ts`). This module is
7+
* the **bridge between the two** — it knows the Application codes
8+
* and the JSON-RPC codes, and provides:
9+
*
10+
* - `BridgeError` — a typed exception the bridge code `throw`s
11+
* when it cannot reach the dispatcher (e.g. token rejected,
12+
* malformed envelope, internal failure that isn't a handler
13+
* error). The bridge catches it and emits the right
14+
* JSON-RPC error response.
15+
*
16+
* - `httpErrorFromApi()` / `httpErrorFromBridge()` — HTTP status
17+
* mappers so the HTTP bridge surfaces a sane status code
18+
* alongside the JSON body. The body is the same JSON-RPC error
19+
* envelope either way, so a curl caller can debug without a
20+
* library.
21+
*
22+
* The Application API's `IApiError` is a runtime union, not a class;
23+
* throwing `apiError(...)` from the dispatcher returns a `fail()`
24+
* which the bridge wraps. The bridges themselves throw `BridgeError`
25+
* only on carrier-level failures (security rejected the request,
26+
* line could not be framed, ...).
27+
*/
28+
29+
import type { IApiError, ApiErrorCode } from "../application-api/error.js";
30+
import { apiError } from "../application-api/error.js";
31+
32+
/**
33+
* A carrier-level failure the bridge raises when it cannot reach
34+
* the dispatcher.
35+
*
36+
* `public` — the JSON-RPC code the wire surface uses (typically
37+
* `INVALID_PARAMS` or `INTERNAL_ERROR`).
38+
* `app` — the `IApiError` envelope to embed into `data`.
39+
*
40+
* The constructor is the only sanctioned path. Bridges catch this
41+
* type and emit a JSON-RPC error response without ever re-throwing
42+
* to the client.
43+
*/
44+
export class BridgeError extends Error {
45+
readonly public: number;
46+
readonly app: IApiError;
47+
constructor(opts: { code: number; app: IApiError }) {
48+
super(opts.app.message);
49+
this.name = "BridgeError";
50+
this.public = opts.code;
51+
this.app = opts.app;
52+
}
53+
}
54+
55+
/** Convenience: build a `BridgeError` from an `IApiError` + code. */
56+
export function bridgeError(
57+
publicCode: number,
58+
appError: IApiError,
59+
): BridgeError {
60+
return new BridgeError({ code: publicCode, app: appError });
61+
}
62+
63+
/** Convenience: build a `BridgeError` directly from primitives. */
64+
export function bridgeFailure(
65+
publicCode: number,
66+
code: ApiErrorCode,
67+
message: string,
68+
details?: Readonly<Record<string, unknown>>,
69+
): BridgeError {
70+
return new BridgeError({
71+
code: publicCode,
72+
app: details ? apiError(code, message, details) : apiError(code, message),
73+
});
74+
}
75+
76+
/* ────────────────────────────────────────────────────────────────────── *
77+
* HTTP status mapping. *
78+
* *
79+
* The Application API's error codes map cleanly onto HTTP statuses: *
80+
* the ones below are the cases a caller can react to. Anything else *
81+
* (an unexpected internal failure) is `500`. *
82+
* ────────────────────────────────────────────────────────────────────── */
83+
84+
/**
85+
* Picks the HTTP status the `http-bridge.server.ts` returns for an
86+
* `IApiError`. The bridge always emits the JSON-RPC error envelope
87+
* in the body too — the status is for HTTP-aware callers.
88+
*
89+
* - `INVALID_INPUT` → 400 (caller sent malformed data).
90+
* - `UNKNOWN_HANDLER` → 404 (the path is unknown).
91+
* - `SESSION_NOT_FOUND` → 404 (the session is unknown).
92+
* - `SERVICE_NOT_FOUND` → 404.
93+
* - `OPERATION_NOT_FOUND` → 404.
94+
* - `CANCELED` → 499 (client closed request; nginx-style).
95+
* - `EXPORT_FAILED` → 422 (semantically unprocessable).
96+
* - `EXECUTION_FAILED` → 500.
97+
*/
98+
export function httpStatusFromApi(app: IApiError): number {
99+
switch (app.code) {
100+
case "INVALID_INPUT":
101+
return 400;
102+
case "UNKNOWN_HANDLER":
103+
case "SESSION_NOT_FOUND":
104+
case "SERVICE_NOT_FOUND":
105+
case "OPERATION_NOT_FOUND":
106+
return 404;
107+
case "CANCELED":
108+
return 499;
109+
case "EXPORT_FAILED":
110+
return 422;
111+
case "EXECUTION_FAILED":
112+
default:
113+
return 500;
114+
}
115+
}
116+
117+
/** Same as above, but for carrier-level `BridgeError` values. */
118+
export function httpStatusFromBridge(err: BridgeError): number {
119+
return err.public >= 400 && err.public < 600 ? err.public : 500;
120+
}

0 commit comments

Comments
 (0)