+ The sidecar spawns a Python HTTP server on an auto-assigned
+ port. AppKit proxies all requests under{" "}
+
+ /api/sidecar/sidecar-http/*
+ {" "}
+ directly to the child process. Health checks poll{" "}
+
+ GET /health
+ {" "}
+ periodically.
+
+
+
+
+
+
+
+
+
+
+
How it works
+
+ The sidecar spawns a Python script and communicates via
+ line-delimited JSON-RPC 2.0 over stdin/stdout. AppKit
+ translates HTTP requests arriving at{" "}
+
+ /api/sidecar/sidecar-stdio/*
+ {" "}
+ into JSON-RPC messages, correlating responses by ID.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts
index a4b6a2c6..59b9b140 100644
--- a/apps/dev-playground/server/index.ts
+++ b/apps/dev-playground/server/index.ts
@@ -1,5 +1,14 @@
import "reflect-metadata";
-import { analytics, createApp, files, genie, server } from "@databricks/appkit";
+import path from "node:path";
+import url from "node:url";
+import {
+ analytics,
+ createApp,
+ files,
+ genie,
+ server,
+ sidecar,
+} from "@databricks/appkit";
import { WorkspaceClient } from "@databricks/sdk-experimental";
import { lakebaseExamples } from "./lakebase-examples-plugin";
import { reconnect } from "./reconnect-plugin";
@@ -26,6 +35,33 @@ createApp({
}),
lakebaseExamples(),
files(),
+ sidecar([
+ {
+ id: "sidecar-http",
+ command: "python3",
+ args: ["main.py"],
+ cwd: path.join(
+ path.dirname(url.fileURLToPath(import.meta.url)),
+ "sidecar-app",
+ ),
+ mode: "http",
+ port: 0,
+ startupTimeout: 15000,
+ },
+ {
+ id: "sidecar-stdio",
+ command: "python3",
+ args: ["main.py"],
+ cwd: path.join(
+ path.dirname(url.fileURLToPath(import.meta.url)),
+ "sidecar-stdio-app",
+ ),
+ mode: "stdio",
+ stdio: {
+ requestTimeout: 10000,
+ },
+ },
+ ]),
],
...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }),
}).then((appkit) => {
diff --git a/apps/dev-playground/server/sidecar-app/main.py b/apps/dev-playground/server/sidecar-app/main.py
new file mode 100644
index 00000000..408921a2
--- /dev/null
+++ b/apps/dev-playground/server/sidecar-app/main.py
@@ -0,0 +1,23 @@
+from http.server import HTTPServer, BaseHTTPRequestHandler
+import json
+import os
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/health":
+ self._respond(200, {"status": "ok"})
+ elif self.path == "/hello":
+ self._respond(200, {"message": "Hello from Python"})
+ else:
+ self._respond(404, {"error": "not found"})
+
+ def _respond(self, status, body):
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(body).encode())
+
+
+port = int(os.environ.get("PORT", "8081"))
+HTTPServer(("0.0.0.0", port), Handler).serve_forever()
diff --git a/apps/dev-playground/server/sidecar-stdio-app/main.py b/apps/dev-playground/server/sidecar-stdio-app/main.py
new file mode 100644
index 00000000..c8eb7748
--- /dev/null
+++ b/apps/dev-playground/server/sidecar-stdio-app/main.py
@@ -0,0 +1,83 @@
+"""Stdio JSON-RPC 2.0 sidecar for the dev-playground.
+
+Communicates with the AppKit sidecar plugin over stdin/stdout using
+line-delimited JSON-RPC 2.0 messages. All debug output goes to stderr.
+"""
+
+import json
+import sys
+
+
+def send(obj: dict) -> None:
+ """Write a JSON-RPC message to stdout (one line, flushed)."""
+ print(json.dumps(obj), flush=True)
+
+
+def handle_request(params: dict) -> dict:
+ """Route an incoming HTTP-like request and return a stdio response payload."""
+ path: str = params.get("path", "")
+ method: str = params.get("method", "GET").upper()
+
+ if path == "/hello" and method == "GET":
+ return {
+ "status": 200,
+ "headers": {"content-type": "application/json"},
+ "body": {"message": "Hello from Python stdio sidecar!"},
+ }
+
+ if path == "/echo" and method == "POST":
+ body = params.get("body")
+ return {
+ "status": 200,
+ "headers": {"content-type": "application/json"},
+ "body": {"echo": body},
+ }
+
+ return {
+ "status": 404,
+ "headers": {"content-type": "application/json"},
+ "body": {"error": "not found"},
+ }
+
+
+def main() -> None:
+ # Signal readiness to the parent process.
+ send({"jsonrpc": "2.0", "method": "ready"})
+ print("[stdio-sidecar] ready", file=sys.stderr, flush=True)
+
+ for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+
+ try:
+ msg = json.loads(line)
+ except json.JSONDecodeError as exc:
+ print(f"[stdio-sidecar] invalid JSON: {exc}", file=sys.stderr, flush=True)
+ continue
+
+ msg_id = msg.get("id")
+ method = msg.get("method")
+
+ if method == "ping":
+ send({"jsonrpc": "2.0", "id": msg_id, "result": "pong"})
+ elif method == "request":
+ params = msg.get("params", {})
+ result = handle_request(params)
+ send({"jsonrpc": "2.0", "id": msg_id, "result": result})
+ else:
+ print(
+ f"[stdio-sidecar] unknown method: {method}",
+ file=sys.stderr,
+ flush=True,
+ )
+ if msg_id is not None:
+ send({
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
+ })
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md
index 8ce8d591..e8454be4 100644
--- a/docs/docs/api/appkit/Class.Plugin.md
+++ b/docs/docs/api/appkit/Class.Plugin.md
@@ -215,6 +215,26 @@ BasePlugin.abortActiveOperations
***
+### addSkipBodyParsingPath()
+
+```ts
+protected addSkipBodyParsingPath(path: string): void;
+```
+
+Register a path that should skip body parsing (e.g. proxy or file upload routes)
+
+#### Parameters
+
+| Parameter | Type |
+| ------ | ------ |
+| `path` | `string` |
+
+#### Returns
+
+`void`
+
+***
+
### asUser()
```ts
diff --git a/docs/docs/api/appkit/Class.ResourceRegistry.md b/docs/docs/api/appkit/Class.ResourceRegistry.md
index e0291fb6..dc940a45 100644
--- a/docs/docs/api/appkit/Class.ResourceRegistry.md
+++ b/docs/docs/api/appkit/Class.ResourceRegistry.md
@@ -35,7 +35,7 @@ Useful for testing or when rebuilding the registry.
### collectResources()
```ts
-collectResources(rawPlugins: PluginData[]): void;
+collectResources(rawPlugins: PluginData, unknown, string>[]): void;
```
Collects and registers resource requirements from an array of plugins.
@@ -45,7 +45,7 @@ For each plugin, loads its manifest (required) and runtime resource requirements
| Parameter | Type | Description |
| ------ | ------ | ------ |
-| `rawPlugins` | [`PluginData`](TypeAlias.PluginData.md)\<`PluginConstructor`, `unknown`, `string`\>[] | Array of plugin data entries from createApp configuration |
+| `rawPlugins` | [`PluginData`](TypeAlias.PluginData.md)\<`PluginConstructor`\<`any`\>, `unknown`, `string`\>[] | Array of plugin data entries from createApp configuration |
#### Returns
@@ -240,7 +240,7 @@ ValidationResult with validity status, missing resources, and all resources
#### Example
```typescript
-const registry = ResourceRegistry.getInstance();
+const registry = new ResourceRegistry();
const result = registry.validate();
if (!result.valid) {
diff --git a/docs/docs/api/appkit/Function.createApp.md b/docs/docs/api/appkit/Function.createApp.md
index cb703386..03e15675 100644
--- a/docs/docs/api/appkit/Function.createApp.md
+++ b/docs/docs/api/appkit/Function.createApp.md
@@ -20,7 +20,7 @@ with an `asUser(req)` method for user-scoped execution.
| Type Parameter |
| ------ |
-| `T` *extends* [`PluginData`](TypeAlias.PluginData.md)\<`PluginConstructor`, `unknown`, `string`\>[] |
+| `T` *extends* [`PluginData`](TypeAlias.PluginData.md)\<`PluginConstructor`\<`any`\>, `unknown`, `string`\>[] |
## Parameters
diff --git a/docs/docs/plugins/sidecar.md b/docs/docs/plugins/sidecar.md
new file mode 100644
index 00000000..c59b187e
--- /dev/null
+++ b/docs/docs/plugins/sidecar.md
@@ -0,0 +1,927 @@
+---
+sidebar_position: 8
+---
+
+# Sidecar plugin
+
+Run non-Node.js workloads as managed child processes alongside the AppKit server. The sidecar plugin spawns a child process (Python, Go, Ruby, or any executable), manages its lifecycle, and routes requests to it -- enabling polyglot architectures within a single Databricks App.
+
+**Key features:**
+- **Two communication modes**: HTTP (child runs its own web server) and stdio (JSON-RPC 2.0 over stdin/stdout)
+- **Automatic process lifecycle**: spawn, health monitoring, restart on crash, graceful shutdown
+- **Port auto-assignment**: no port conflicts in HTTP mode
+- **Auth context forwarding**: Databricks user identity (`x-forwarded-user`, `x-forwarded-access-token`) is passed to the sidecar
+- **OpenTelemetry instrumentation**: spans and metrics for every proxied and stdio request
+- **Multiple sidecars**: run several child processes from a single plugin instance
+- **Security hardening**: command validation, path traversal prevention, header filtering
+
+## Quick start
+
+### HTTP mode (default)
+
+The child process runs its own HTTP server. AppKit proxies all requests under `/api/sidecar/{id}/*` to it.
+
+```ts
+import { createApp, sidecar, server } from "@databricks/appkit";
+
+await createApp({
+ plugins: [
+ server(),
+ sidecar([
+ {
+ id: "python-api",
+ command: "python3",
+ args: ["-m", "uvicorn", "main:app", "--host", "0.0.0.0"],
+ cwd: "./python-api",
+ port: 0, // auto-assign
+ healthCheck: { path: "/health" },
+ },
+ ]),
+ ],
+});
+// GET /api/sidecar/python-api/users -> proxied to Python at http://localhost:{auto-port}/users
+```
+
+### stdio mode
+
+The child process communicates via stdin/stdout using line-delimited JSON-RPC 2.0. No HTTP server required in the child.
+
+```ts
+import { createApp, sidecar, server } from "@databricks/appkit";
+
+await createApp({
+ plugins: [
+ server(),
+ sidecar([
+ {
+ id: "ml-model",
+ mode: "stdio",
+ command: "python3",
+ args: ["inference.py"],
+ cwd: "./ml-model",
+ stdio: {
+ requestTimeout: 60_000,
+ maxConcurrency: 10,
+ },
+ },
+ ]),
+ ],
+});
+// POST /api/sidecar/ml-model/predict -> JSON-RPC over stdin -> Python responds on stdout
+```
+
+## Configuration reference
+
+### `ISidecarConfig`
+
+The plugin config accepts either a single `SidecarDefinition` or an array of `SidecarDefinition` entries:
+
+```ts
+type ISidecarConfig = SidecarDefinition | SidecarDefinition[];
+```
+
+Pass an array for multi-sidecar setups, or a single object for a one-sidecar setup:
+
+```ts
+// Single sidecar
+sidecar({ id: "api", command: "python3", args: ["main.py"], cwd: "./api" })
+
+// Multiple sidecars
+sidecar([
+ { id: "api", command: "python3", args: ["main.py"], cwd: "./api" },
+ { id: "worker", mode: "stdio", command: "go", args: ["run", "worker.go"], cwd: "./worker" },
+])
+```
+
+### `SidecarDefinition`
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `id` | `string` | **(required)** | Unique identifier. Used for route namespacing (`/api/{id}/*`). |
+| `mode` | `"http" \| "stdio"` | `"http"` | Communication mode. |
+| `command` | `string` | **(required)** | Command to execute (e.g., `"python3"`, `"ruby"`, `"go"`). |
+| `args` | `string[]` | `[]` | Arguments passed to the command. |
+| `cwd` | `string` | `process.cwd()` | Working directory for the child process. |
+| `env` | `Record` | `{}` | Additional environment variables. Merged with `process.env`. |
+| `startupTimeout` | `number` | `30000` | Milliseconds to wait for readiness during `setup()`. |
+| `restart` | `RestartConfig` | See below | Process restart configuration. |
+| `setupCommands` | `string[]` | `[]` | Commands to run before spawning the process. |
+| `setupShell` | `boolean` | `false` | When `true`, setup commands run in a shell (supports pipes, redirects, globbing). When `false`, commands are split on whitespace and executed directly with `execFile` (safer against command injection). |
+| `port` | `number` | `0` (auto) | **HTTP mode only.** Port the child listens on. `0` for auto-assign. |
+| `healthCheck` | `HealthCheckConfig` | See below | **HTTP mode only.** Health check configuration. |
+| `proxy` | `ProxyConfig` | See below | **HTTP mode only.** Proxy behavior configuration. |
+| `stdio` | `StdioConfig` | See below | **stdio mode only.** Communication layer configuration. |
+
+### `HealthCheckConfig`
+
+HTTP mode only. Controls readiness polling and periodic health monitoring.
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `path` | `string` | `"/health"` | HTTP path for health check requests. |
+| `interval` | `number` | `5000` | Milliseconds between periodic health checks. |
+| `timeout` | `number` | `3000` | Milliseconds before a single health check request times out. |
+| `unhealthyThreshold` | `number` | `3` | Consecutive failures before the process is considered unhealthy and a restart is triggered. |
+
+### `RestartConfig`
+
+Applies to both modes. Controls automatic restart behavior on process crash.
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `enabled` | `boolean` | `true` | Whether to automatically restart on crash. |
+| `maxRestarts` | `number` | `5` | Maximum restarts within the sliding window before giving up (status becomes `"crashed"`). |
+| `restartWindow` | `number` | `60000` | Sliding window in ms. The restart counter resets if this period elapses without a crash. |
+| `restartDelay` | `number` | `1000` | Delay in ms before restarting after a crash. |
+
+### `ProxyConfig`
+
+HTTP mode only. Controls how requests are forwarded to the sidecar.
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `forwardHeaders` | `string[] \| "all"` | `"all"` | Which request headers to forward. `"all"` forwards everything except `host` and hop-by-hop headers. A specific list forwards only those headers (plus auth headers, which are always forwarded). |
+| `injectHeaders` | `Record` | `{}` | Additional headers injected into every proxied request. |
+| `timeout` | `number` | `30000` | Milliseconds before a proxied request times out (504 response). |
+| `basePath` | `string` | `"/"` | Base path prefix prepended to the request path on the sidecar. |
+
+### `StdioConfig`
+
+stdio mode only. Controls the JSON-RPC communication layer.
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `requestTimeout` | `number` | `30000` | Milliseconds for a single request-response cycle before timing out. |
+| `pingInterval` | `number` | `10000` | Milliseconds between ping health checks. |
+| `pingFailureThreshold` | `number` | `3` | Consecutive ping failures before the process is considered unhealthy and a restart is triggered. |
+| `maxConcurrency` | `number` | `50` | Maximum pending concurrent requests. Excess requests receive a 503 error. |
+| `onNotification` | `(method: string, params: unknown) => void` | `undefined` | Callback for custom JSON-RPC notifications from the child. The bridge handles `ready` and `log` internally; all other methods are forwarded here. |
+
+## HTTP mode
+
+### How it works
+
+1. AppKit spawns the child process with `PORT`, `SIDECAR_PORT`, and `DATABRICKS_APP_PORT` environment variables set to the assigned port.
+2. The child process starts its own HTTP server on that port.
+3. AppKit polls the health check endpoint until it responds with a 2xx status.
+4. Once healthy, all requests to `/api/sidecar/{id}/*` are proxied to `http://localhost:{port}/*`.
+5. Periodic health checks continue. If the threshold is exceeded, the process is restarted.
+
+### Port assignment
+
+- **Auto-assign (default, `port: 0`):** AppKit binds a temporary `net.Server` on port 0, reads the OS-assigned port, closes the server, then passes the port to the child via `PORT`, `SIDECAR_PORT`, and `DATABRICKS_APP_PORT` environment variables. This avoids port conflicts.
+- **Explicit port:** Set `port: 8081` to use a fixed port. The child must listen on that port.
+
+### Health checks
+
+During startup, AppKit polls `GET http://localhost:{port}{healthCheck.path}` at 1-second intervals until a 2xx response or `startupTimeout` is reached. After the process is healthy, periodic checks run at `healthCheck.interval`. After `unhealthyThreshold` consecutive failures, the process is marked unhealthy and restarted.
+
+### Proxy behavior
+
+The proxy uses Node.js built-in `http.request` (no external dependencies). Request and response bodies are **streamed** without buffering, so large payloads and streaming responses work efficiently. Body parsing is automatically skipped for the sidecar route.
+
+**Header forwarding:**
+- `forwardHeaders: "all"` (default): All incoming request headers are forwarded, except `host` (rewritten to `localhost:{port}`) and hop-by-hop headers (`connection`, `keep-alive`, `transfer-encoding`, etc.).
+- `forwardHeaders: ["content-type", "authorization"]`: Only the listed headers are forwarded, plus `x-forwarded-user` and `x-forwarded-access-token` (always forwarded for auth context).
+- `injectHeaders`: Additional headers merged into every proxied request.
+
+**Error responses from the proxy:**
+
+| Status | Condition |
+| --- | --- |
+| 503 | Sidecar process is not healthy |
+| 502 | Connection refused or proxy error |
+| 504 | Proxied request timed out |
+
+### Example: Python Flask sidecar
+
+**`python-api/main.py`:**
+
+```python
+from flask import Flask, jsonify
+import os
+
+app = Flask(__name__)
+
+@app.route("/health")
+def health():
+ return jsonify(status="ok")
+
+@app.route("/predict", methods=["POST"])
+def predict():
+ return jsonify(prediction=0.95, model="v2")
+
+if __name__ == "__main__":
+ port = int(os.environ.get("PORT", "8080"))
+ app.run(host="0.0.0.0", port=port)
+```
+
+**AppKit configuration:**
+
+```ts
+sidecar({
+ id: "flask-api",
+ command: "python3",
+ args: ["main.py"],
+ cwd: "./python-api",
+ port: 0,
+ healthCheck: { path: "/health" },
+})
+```
+
+### Example: Python FastAPI sidecar
+
+**`python-api/main.py`:**
+
+```python
+from fastapi import FastAPI
+
+app = FastAPI()
+
+@app.get("/health")
+def health():
+ return {"status": "ok"}
+
+@app.post("/predict")
+def predict(input: dict):
+ return {"prediction": 0.95}
+```
+
+**AppKit configuration:**
+
+```ts
+sidecar({
+ id: "fastapi",
+ command: "python3",
+ args: ["-m", "uvicorn", "main:app", "--host", "0.0.0.0"],
+ cwd: "./python-api",
+ port: 0,
+ healthCheck: { path: "/health" },
+})
+```
+
+The `--port` argument is not needed -- uvicorn reads the `PORT` environment variable set by AppKit.
+
+### Example: Go HTTP sidecar
+
+**`go-api/main.go`:**
+
+```go
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+)
+
+func main() {
+ port := os.Getenv("PORT")
+ if port == "" {
+ port = "8080"
+ }
+
+ http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"status":"ok"}`)
+ })
+
+ http.HandleFunc("/compute", func(w http.ResponseWriter, r *http.Request) {
+ user := r.Header.Get("X-Forwarded-User")
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"result":"done","user":"%s"}`, user)
+ })
+
+ http.ListenAndServe(":"+port, nil)
+}
+```
+
+**AppKit configuration:**
+
+```ts
+sidecar({
+ id: "go-api",
+ command: "go",
+ args: ["run", "main.go"],
+ cwd: "./go-api",
+ port: 0,
+ healthCheck: { path: "/health" },
+})
+```
+
+## stdio mode
+
+### How it works
+
+1. AppKit spawns the child process with `stdin`, `stdout`, and `stderr` all piped.
+2. The child signals readiness by sending a `ready` notification on stdout, or by responding to a `ping` request.
+3. All requests to `/api/sidecar/{id}/*` are translated into JSON-RPC 2.0 messages written to the child's stdin.
+4. The child processes the request and writes a JSON-RPC response to stdout.
+5. Periodic ping/pong health checks monitor liveness.
+
+**stdout is reserved for the JSON-RPC protocol.** The child process must write only valid JSON-RPC messages (one per line) to stdout. Use stderr for all logging output. Non-JSON lines on stdout are silently ignored but may indicate a misconfiguration.
+
+### Protocol specification
+
+The stdio bridge uses a subset of [JSON-RPC 2.0](https://www.jsonrpc.org/specification) over newline-delimited JSON. Each message is a single JSON object terminated by `\n`.
+
+#### Ready notification (child -> parent)
+
+The child should send this once it is ready to accept requests:
+
+```json
+{"jsonrpc": "2.0", "method": "ready", "params": {}}
+```
+
+If the child does not send a `ready` notification, AppKit falls back to ping-based readiness detection. A minimal sidecar only needs to respond to `ping` -- the `ready` notification is optional but recommended for faster startup.
+
+#### Request (parent -> child)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "request",
+ "params": {
+ "path": "/predict",
+ "method": "POST",
+ "headers": {
+ "x-forwarded-user": "alice@example.com",
+ "x-forwarded-access-token": "dapi..."
+ },
+ "body": {"input": [1, 2, 3]}
+ }
+}
+```
+
+#### Response (child -> parent)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "status": 200,
+ "headers": {"content-type": "application/json"},
+ "body": {"prediction": 0.95}
+ }
+}
+```
+
+The `result` object maps to `StdioResponsePayload`:
+
+| Field | Type | Default | Description |
+| --- | --- | --- | --- |
+| `status` | `number` | `200` | HTTP status code to return to the client. |
+| `headers` | `Record` | `undefined` | Response headers to set. |
+| `body` | `unknown` | `undefined` | Response body (any JSON value). |
+
+#### Error response (child -> parent)
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "error": {
+ "code": -32000,
+ "message": "Database connection failed",
+ "data": {"detail": "timeout after 5s"}
+ }
+}
+```
+
+#### Ping / pong (parent -> child)
+
+Used for health checking. The child must respond with any result (conventionally `"pong"`):
+
+```json
+{"jsonrpc": "2.0", "id": 42, "method": "ping", "params": {}}
+```
+
+Expected response:
+
+```json
+{"jsonrpc": "2.0", "id": 42, "result": "pong"}
+```
+
+#### Log notification (child -> parent)
+
+Structured logging from the child, forwarded to the AppKit logger:
+
+```json
+{"jsonrpc": "2.0", "method": "log", "params": {"level": "info", "message": "Model loaded in 2.3s"}}
+```
+
+#### Custom notifications (child -> parent)
+
+Any notification method other than `ready` and `log` is forwarded to the `onNotification` callback:
+
+```json
+{"jsonrpc": "2.0", "method": "progress", "params": {"taskId": "abc", "percent": 75}}
+```
+
+#### Reserved methods
+
+| Method | Direction | Purpose |
+| --- | --- | --- |
+| `request` | parent -> child | HTTP request to handle |
+| `ping` | parent -> child | Health check (child must respond) |
+| `ready` | child -> parent | Readiness signal |
+| `log` | child -> parent | Structured log (forwarded to AppKit logger) |
+
+### Example: Python stdio sidecar
+
+**`inference.py`:**
+
+```python
+import sys
+import json
+
+
+def handle_request(params):
+ path = params.get("path", "")
+ method = params.get("method", "POST")
+ body = params.get("body", {})
+ headers = params.get("headers", {})
+
+ user = headers.get("x-forwarded-user", "anonymous")
+
+ if path == "/predict" and method == "POST":
+ return {
+ "status": 200,
+ "body": {"prediction": 0.95, "user": user},
+ }
+ elif path == "/health":
+ return {"status": 200, "body": {"status": "ok"}}
+ else:
+ return {"status": 404, "body": {"error": "not found"}}
+
+
+def main():
+ # Signal ready
+ sys.stdout.write(
+ json.dumps({"jsonrpc": "2.0", "method": "ready", "params": {}}) + "\n"
+ )
+ sys.stdout.flush()
+
+ # Process requests from stdin
+ for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+
+ msg = json.loads(line)
+ msg_id = msg.get("id")
+ method = msg.get("method")
+
+ if method == "ping":
+ response = {"jsonrpc": "2.0", "id": msg_id, "result": "pong"}
+ elif method == "request":
+ result = handle_request(msg.get("params", {}))
+ response = {"jsonrpc": "2.0", "id": msg_id, "result": result}
+ else:
+ response = {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "error": {"code": -32601, "message": "Method not found"},
+ }
+
+ sys.stdout.write(json.dumps(response) + "\n")
+ sys.stdout.flush()
+
+
+if __name__ == "__main__":
+ # Use stderr for logging -- stdout is reserved for the protocol
+ print("Starting inference worker...", file=sys.stderr)
+ main()
+```
+
+**AppKit configuration:**
+
+```ts
+sidecar({
+ id: "inference",
+ mode: "stdio",
+ command: "python3",
+ args: ["inference.py"],
+ cwd: "./ml-model",
+ stdio: {
+ requestTimeout: 60_000, // ML inference can be slow
+ maxConcurrency: 10,
+ },
+ restart: { enabled: true, maxRestarts: 3 },
+})
+```
+
+### Backpressure and concurrency
+
+The `maxConcurrency` option (default: 50) limits the number of in-flight requests. When the limit is reached, new requests receive a 503 response with `"Sidecar concurrency limit reached"`. This protects both the Node.js process and the child from unbounded memory growth.
+
+If the child process is slow to read from stdin, Node.js buffers writes internally. The bridge logs a debug message when backpressure occurs but does not reject the write -- the message is queued in the Node.js stream buffer.
+
+### Limitations vs HTTP mode
+
+| Concern | HTTP mode | stdio mode |
+| --- | --- | --- |
+| Streaming responses | Native (chunked HTTP) | Not supported. Response must fit in one JSON message. |
+| Binary data | Native (any content-type) | Must be base64-encoded in JSON (~33% overhead). |
+| Request body size | Streamed, no buffering | Buffered into JSON, limited by memory. |
+| Concurrent requests | Unlimited (TCP) | Bound by `maxConcurrency` (default 50). |
+| Child debugging | `curl localhost:{PORT}` | Must send JSON to stdin manually or use a test harness. |
+| Framework ecosystem | Any web framework | Must implement the JSON-RPC protocol. |
+| stdout usage | Logged by AppKit | **Reserved for protocol only.** Use stderr for logging. |
+
+## Process lifecycle
+
+### Startup sequence
+
+1. **Setup commands**: If `setupCommands` is provided, each command is executed sequentially in the sidecar's `cwd` before spawning. If any command fails, startup is aborted.
+2. **Spawn**: The child process is spawned via `child_process.spawn()` with `shell: false`.
+3. **Readiness check**:
+ - **HTTP mode**: Polls `GET http://localhost:{port}{healthCheck.path}` until a 2xx response.
+ - **stdio mode**: Waits for a `ready` notification or a successful `ping` response. Whichever comes first.
+4. **Health monitoring begins**: Periodic checks (HTTP polling or ping/pong) start after readiness is confirmed.
+
+If the process does not become ready within `startupTimeout` (default: 30 seconds), the process is killed and a `SidecarError` with code `SIDECAR_ERROR` is thrown. This halts `createApp()`.
+
+### Restart behavior
+
+When the child process exits unexpectedly (and `restart.enabled` is `true`):
+
+1. The restart counter is checked against `maxRestarts` within the sliding `restartWindow`.
+2. If under the limit, the process is re-spawned after `restartDelay` milliseconds.
+3. If `maxRestarts` is exceeded within the window, the status becomes `"crashed"` and no further restarts are attempted.
+4. The restart counter resets if `restartWindow` elapses without a crash.
+
+When a restart is triggered by health check failure (not process exit), the process is first stopped gracefully before re-spawning. In stdio mode, the bridge detaches from the old streams and reattaches to the new process's stdin/stdout.
+
+### Graceful shutdown
+
+On `SIGTERM` / `SIGINT` (or when `abortActiveOperations()` is called during server shutdown):
+
+1. Health checks are stopped.
+2. In stdio mode, all pending requests are rejected with a 503 error and the bridge is destroyed.
+3. `SIGTERM` is sent to the child process.
+4. If the child does not exit within 10 seconds, `SIGKILL` is sent.
+
+This fits within the AppKit server's 15-second shutdown window.
+
+### Status states
+
+| Status | Meaning |
+| --- | --- |
+| `starting` | Process has been spawned but is not yet ready. |
+| `healthy` | Process is running and passing health checks. |
+| `unhealthy` | Health checks are failing. A restart may be in progress. |
+| `stopped` | Process was intentionally stopped (graceful shutdown). |
+| `crashed` | Process exited unexpectedly and max restarts were exceeded. |
+
+## Security
+
+### Command validation
+
+- The `command` string must be non-empty and must not contain shell metacharacters (`;`, `|`, `&`, `$`, `` ` ``, `\n`, `\r`).
+- The process is spawned with `shell: false`, so arguments are passed directly to the executable without shell interpretation.
+- The `cwd` path is resolved to an absolute path and verified to exist. Null bytes in the path are rejected.
+
+### Proxy path traversal prevention (HTTP mode)
+
+Request paths are normalized with `path.posix.normalize()`. Paths that resolve to `..` (escaping the base path) or contain null bytes are rejected with a 400 response.
+
+### Header filtering (HTTP mode)
+
+- Hop-by-hop headers (`connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`, `transfer-encoding`, `upgrade`) are stripped from proxied requests and responses.
+- The `host` header is rewritten to `localhost:{port}`.
+
+### Auth context (both modes)
+
+The sidecar receives the requesting user's Databricks identity:
+- **HTTP mode**: `x-forwarded-user` and `x-forwarded-access-token` headers are always forwarded, even when `forwardHeaders` is a specific list.
+- **stdio mode**: Auth headers are extracted server-side from the incoming request and injected into the JSON-RPC `params.headers` field. They are never taken from the client payload, preventing spoofing.
+
+### stdin injection prevention (stdio mode)
+
+All messages written to stdin use `JSON.stringify()`, which escapes newline characters within strings. This prevents a malicious request body from injecting extra JSON-RPC lines.
+
+## Telemetry
+
+### OpenTelemetry spans
+
+#### HTTP mode
+
+| Span name | Kind | When | Key attributes |
+| --- | --- | --- | --- |
+| `sidecar.proxy.request` | `CLIENT` | Each proxied HTTP request | `path`, `method`, `target_port`, `duration_ms`, `response_status` |
+
+Span events include `sidecar.proxy.request_forwarded` (when the upstream response arrives) and error details on failure.
+
+#### stdio mode
+
+| Span name | Kind | When | Key attributes |
+| --- | --- | --- | --- |
+| `sidecar.stdio.request` | `CLIENT` | Each `sendRequest()` call | `request_id`, `path`, `method`, `pending_count`, `duration_ms`, `response_status` |
+| `sidecar.stdio.startup` | `INTERNAL` | `waitForReady()` during setup | `timeout`, `ready_signal` (`"notification"`, `"ping"`, or `"timeout"`) |
+
+Span events include `sidecar.stdio.message_sent` (when a message is written to stdin) and error details on failure.
+
+### Metrics
+
+#### HTTP mode
+
+| Metric | Type | Description |
+| --- | --- | --- |
+| `sidecar.proxy.request.count` | Counter | Total proxied requests, labeled by `path`, `method`, `status`. |
+| `sidecar.proxy.request.duration` | Histogram | Round-trip latency in ms, labeled by `path`, `method`. |
+| `sidecar.proxy.error.count` | Counter | Errors, labeled by `path`, `error_type`. |
+| `sidecar.proxy.pending` | UpDownCounter | Currently in-flight proxied request count. |
+
+#### stdio mode
+
+| Metric | Type | Description |
+| --- | --- | --- |
+| `sidecar.stdio.request.count` | Counter | Total requests, labeled by `path`, `method`, `status`. |
+| `sidecar.stdio.request.duration` | Histogram | Round-trip latency in ms, labeled by `path`, `method`. |
+| `sidecar.stdio.error.count` | Counter | Errors, labeled by `path`, `error_type`. |
+| `sidecar.stdio.pending` | UpDownCounter | Currently in-flight request count. |
+| `sidecar.stdio.healthcheck.count` | Counter | Ping attempts, labeled by `healthy`. |
+
+All telemetry is no-op safe. If `OTEL_EXPORTER_OTLP_ENDPOINT` is not configured, all span and metric calls are no-ops with zero performance impact.
+
+### Viewing traces
+
+Set the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable to your OpenTelemetry collector endpoint:
+
+```env
+OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
+```
+
+Traces appear under the `sidecar` scope in your observability backend (Jaeger, Grafana Tempo, Databricks, etc.).
+
+## Error handling
+
+### SidecarError types
+
+| Error | Status code | Retryable | When |
+| --- | --- | --- | --- |
+| Startup failed | 503 | No | Process did not become ready within `startupTimeout`. |
+| Process crashed | 503 | Yes | Child exited unexpectedly. |
+| Max restarts exceeded | 503 | No | Restart limit reached within the sliding window. |
+| Proxy failed | 502 | Yes | HTTP proxy request failed (HTTP mode). |
+| Bridge timeout | 504 | Yes | Child did not respond within `requestTimeout` (stdio mode). |
+| Bridge request failed | 502 | Depends | Child returned a JSON-RPC error response (stdio mode). Retryable if error code >= -32000. |
+| Concurrency exhausted | 503 | Yes | `maxConcurrency` pending requests reached (stdio mode). |
+| stdin write failed | 502 | Yes | Failed to write to child stdin (stdio mode). Child may have crashed. |
+
+### HTTP status codes returned to clients
+
+| Status | Condition |
+| --- | --- |
+| 200 | Successful proxied response (HTTP) or sidecar response (stdio). |
+| 400 | Invalid request path (HTTP) or invalid request payload (stdio). |
+| 502 | Sidecar connection refused, proxy error, or JSON-RPC error. |
+| 503 | Sidecar process is not ready, or concurrency limit reached. |
+| 504 | Proxied request or stdio request timed out. |
+
+## Programmatic API
+
+The `exports()` method returns a `SidecarExport` object, accessible as `appkit.sidecar` after `createApp()`:
+
+```ts
+const appkit = await createApp({
+ plugins: [
+ server(),
+ sidecar([
+ { id: "api", command: "python3", args: ["main.py"], cwd: "./api" },
+ { id: "worker", mode: "stdio", command: "go", args: ["run", "worker.go"], cwd: "./worker" },
+ ]),
+ ],
+});
+
+// Get a specific sidecar's export by id
+const api = appkit.sidecar.get("api");
+api?.getStatus(); // "healthy" | "starting" | "unhealthy" | "stopped" | "crashed"
+api?.getPort(); // assigned port (HTTP mode)
+
+// Shorthand helpers target a sidecar by id
+const status = appkit.sidecar.getStatus("api");
+const lines = appkit.sidecar.getOutput("worker", 50); // last 50 lines
+
+// Restart / stop a specific sidecar
+await appkit.sidecar.restart("api");
+await appkit.sidecar.stop("worker");
+
+// Iterate all sidecars
+for (const [id, sc] of appkit.sidecar.getAll()) {
+ console.log(id, sc.getStatus());
+}
+```
+
+### `SidecarExport` interface
+
+| Method | Signature | Description |
+| --- | --- | --- |
+| `get` | `(id: string) => SingleSidecarExport \| undefined` | Get the export API for a specific sidecar by id. |
+| `getAll` | `() => Map` | Get all sidecar exports as a Map keyed by id. |
+| `getStatus` | `(id: string) => SidecarStatus` | Current process status for a specific sidecar. |
+| `restart` | `(id: string) => Promise` | Stop and re-spawn a specific sidecar. |
+| `stop` | `(id: string) => Promise` | Stop a specific sidecar. |
+| `getOutput` | `(id: string, lines?: number) => string[]` | Recent stdout/stderr lines from the output ring buffer (up to 1000 lines). |
+| `getPort` | `(id: string) => number` | Assigned port (HTTP mode). Returns `0` in stdio mode. |
+
+### `SingleSidecarExport` interface
+
+| Method | Signature | Description |
+| --- | --- | --- |
+| `getStatus` | `() => SidecarStatus` | Current process status. |
+| `restart` | `() => Promise` | Stop and re-spawn the process. |
+| `stop` | `() => Promise` | Stop the process. |
+| `getOutput` | `(lines?: number) => string[]` | Recent stdout/stderr lines from the output ring buffer (up to 1000 lines). |
+| `getPort` | `() => number` | Assigned port (HTTP mode). Returns `0` in stdio mode. |
+
+### Route pattern
+
+All sidecar routes are mounted at `/api/sidecar/{id}/*`. In HTTP mode, the route is registered as `"proxy:{id}"`. In stdio mode, it is registered as `"stdio:{id}"`.
+
+### Request format (stdio mode)
+
+Clients send HTTP requests to `/api/sidecar/{id}/{path}`. The route handler:
+
+1. Validates the request with Zod (`path` must be non-empty, `method` must be a valid HTTP method).
+2. Extracts auth headers from the incoming request.
+3. Sends a JSON-RPC `request` to the child with `{ path, method, headers, body }`.
+4. Returns the child's response as the HTTP response.
+
+The client can send any HTTP method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) and any JSON body. The `path` parameter from the URL is passed through to the child process.
+
+## Recipes
+
+### Running multiple sidecars
+
+Pass an array to run multiple child processes from a single plugin instance:
+
+```ts
+await createApp({
+ plugins: [
+ server(),
+ sidecar([
+ {
+ id: "python-api",
+ command: "python3",
+ args: ["api.py"],
+ cwd: "./python-api",
+ },
+ {
+ id: "go-worker",
+ mode: "stdio",
+ command: "go",
+ args: ["run", "worker.go"],
+ cwd: "./go-worker",
+ },
+ ]),
+ ],
+});
+// /api/sidecar/python-api/* -> Python HTTP sidecar
+// /api/sidecar/go-worker/* -> Go stdio sidecar
+```
+
+All sidecars start concurrently during `setup()`. Each has independent health checking and restart logic.
+
+For a single sidecar, you can pass a plain object instead of an array:
+
+```ts
+sidecar({ id: "python-api", command: "python3", args: ["api.py"], cwd: "./python-api" })
+```
+
+### Passing environment variables
+
+```ts
+sidecar({
+ id: "python-api",
+ command: "python3",
+ args: ["main.py"],
+ cwd: "./python-api",
+ env: {
+ MODEL_PATH: "/mnt/models/v2",
+ LOG_LEVEL: "debug",
+ DATABASE_URL: process.env.DATABASE_URL ?? "",
+ },
+})
+```
+
+In HTTP mode, `PORT`, `SIDECAR_PORT`, and `DATABRICKS_APP_PORT` are automatically set. In stdio mode, no port variables are set.
+
+### Forwarding Databricks auth context
+
+The sidecar always receives the user's Databricks identity. In HTTP mode, this arrives as request headers. In stdio mode, it arrives in `params.headers`:
+
+**Python (HTTP mode -- Flask):**
+
+```python
+@app.route("/whoami")
+def whoami():
+ user = request.headers.get("X-Forwarded-User", "anonymous")
+ return jsonify(user=user)
+```
+
+**Python (stdio mode):**
+
+```python
+def handle_request(params):
+ user = params.get("headers", {}).get("x-forwarded-user", "anonymous")
+ return {"status": 200, "body": {"user": user}}
+```
+
+### Custom notifications (stdio mode)
+
+The child process can send custom notifications for progress reporting, metrics, or other events:
+
+**Child process (Python):**
+
+```python
+import json, sys
+
+# Send a custom notification
+def notify(method, params):
+ msg = {"jsonrpc": "2.0", "method": method, "params": params}
+ sys.stdout.write(json.dumps(msg) + "\n")
+ sys.stdout.flush()
+
+# Report progress
+notify("progress", {"taskId": "abc", "percent": 50})
+```
+
+**AppKit configuration:**
+
+```ts
+sidecar({
+ id: "worker",
+ mode: "stdio",
+ command: "python3",
+ args: ["worker.py"],
+ stdio: {
+ onNotification: (method, params) => {
+ if (method === "progress") {
+ const { taskId, percent } = params as { taskId: string; percent: number };
+ console.log(`Task ${taskId}: ${percent}%`);
+ }
+ },
+ },
+})
+```
+
+## Troubleshooting
+
+### Sidecar fails to start
+
+**Symptom:** `SidecarError: Sidecar process 'python3' failed to become ready within 30000ms`
+
+- Check that the command is installed and available in `PATH`.
+- Verify the `cwd` directory exists and contains the expected files.
+- In HTTP mode, ensure the child binds to `0.0.0.0` (not `127.0.0.1` or `localhost`) on the port from the `PORT` environment variable.
+- In stdio mode, ensure the child writes the `ready` notification to stdout (or responds to `ping`) before the timeout.
+- Increase `startupTimeout` for slow-starting processes (e.g., loading ML models).
+- Check recent output with `appkit.sidecar.getOutput("your-id")` for error messages from the child.
+
+### stdout pollution breaks stdio mode
+
+**Symptom:** Requests fail or never receive responses.
+
+The stdio protocol requires that **only valid JSON-RPC messages** appear on stdout, one per line. Common causes of stdout pollution:
+
+- Python `print()` statements default to stdout. Use `print(..., file=sys.stderr)` instead.
+- Library warnings or progress bars writing to stdout. Redirect them to stderr.
+- Python's `-u` flag (unbuffered) may be needed to prevent output buffering: `python3 -u inference.py`.
+
+### Sidecar keeps restarting
+
+**Symptom:** Logs show repeated "Restarting sidecar" messages.
+
+- Check health check configuration. The `path` must return a 2xx status (HTTP mode).
+- In stdio mode, ensure the child responds to `ping` requests promptly.
+- Increase `healthCheck.unhealthyThreshold` or `stdio.pingFailureThreshold` if the child occasionally takes longer to respond.
+- Check if the child process is crashing. Use `appkit.sidecar.getOutput("your-id")` to read stderr logs.
+
+### Port conflicts (HTTP mode)
+
+**Symptom:** `EADDRINUSE` error in sidecar logs.
+
+- Use `port: 0` (the default) for automatic port assignment.
+- If using a fixed port, ensure no other process is using it.
+
+### Connection refused (HTTP mode)
+
+**Symptom:** 502 responses with `"Sidecar process is unavailable"`.
+
+- Ensure the child listens on `0.0.0.0`, not just `127.0.0.1`.
+- Verify the child reads the `PORT` environment variable.
+- Check that the child's HTTP server is fully started before health checks pass.
+
+### Debug logging
+
+Enable verbose sidecar logging by setting the `LOG_LEVEL` environment variable:
+
+```bash
+LOG_LEVEL=debug pnpm dev
+```
+
+This enables debug-level messages from `sidecar`, `sidecar:process`, `sidecar:proxy`, `sidecar:health`, and `sidecar:stdio-bridge` loggers, showing request/response details, health check results, and stdout parsing.
diff --git a/packages/appkit/package.json b/packages/appkit/package.json
index f8be98b7..8f2130a2 100644
--- a/packages/appkit/package.json
+++ b/packages/appkit/package.json
@@ -75,7 +75,8 @@
"semver": "^7.7.3",
"shared": "workspace:*",
"vite": "npm:rolldown-vite@7.1.14",
- "ws": "^8.18.3"
+ "ws": "^8.18.3",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@types/express": "^4.17.25",
diff --git a/packages/appkit/src/core/appkit.ts b/packages/appkit/src/core/appkit.ts
index a2cba994..cc506cdf 100644
--- a/packages/appkit/src/core/appkit.ts
+++ b/packages/appkit/src/core/appkit.ts
@@ -160,7 +160,7 @@ export class AppKit {
}
static async _createApp<
- T extends PluginData[],
+ T extends PluginData, unknown, string>[],
>(
config: {
plugins?: T;
@@ -251,7 +251,7 @@ export class AppKit {
* ```
*/
export async function createApp<
- T extends PluginData[],
+ T extends PluginData, unknown, string>[],
>(
config: {
plugins?: T;
diff --git a/packages/appkit/src/errors/index.ts b/packages/appkit/src/errors/index.ts
index a367b843..8b979dc8 100644
--- a/packages/appkit/src/errors/index.ts
+++ b/packages/appkit/src/errors/index.ts
@@ -26,5 +26,6 @@ export { ConnectionError } from "./connection";
export { ExecutionError } from "./execution";
export { InitializationError } from "./initialization";
export { ServerError } from "./server";
+
export { TunnelError } from "./tunnel";
export { ValidationError } from "./validation";
diff --git a/packages/appkit/src/errors/sidecar.ts b/packages/appkit/src/errors/sidecar.ts
new file mode 100644
index 00000000..be090a3e
--- /dev/null
+++ b/packages/appkit/src/errors/sidecar.ts
@@ -0,0 +1,105 @@
+import { AppKitError } from "./base";
+
+export class SidecarError extends AppKitError {
+ readonly code = "SIDECAR_ERROR";
+ readonly statusCode: number;
+ readonly isRetryable: boolean;
+
+ constructor(
+ message: string,
+ options?: {
+ cause?: Error;
+ context?: Record;
+ statusCode?: number;
+ isRetryable?: boolean;
+ },
+ ) {
+ super(message, options);
+ this.statusCode = options?.statusCode ?? 503;
+ this.isRetryable = options?.isRetryable ?? true;
+ }
+
+ static startupFailed(command: string, timeout: number): SidecarError {
+ return new SidecarError(
+ `Sidecar process '${command}' failed to become ready within ${timeout}ms`,
+ { context: { command, timeout }, isRetryable: false },
+ );
+ }
+
+ static processCrashed(
+ command: string,
+ exitCode: number | null,
+ ): SidecarError {
+ return new SidecarError(
+ `Sidecar process '${command}' exited unexpectedly with code ${exitCode}`,
+ { context: { command, exitCode } },
+ );
+ }
+
+ static maxRestartsExceeded(
+ command: string,
+ maxRestarts: number,
+ ): SidecarError {
+ return new SidecarError(
+ `Sidecar process '${command}' exceeded maximum restarts (${maxRestarts})`,
+ { context: { command, maxRestarts }, isRetryable: false },
+ );
+ }
+
+ static proxyFailed(cause?: Error): SidecarError {
+ return new SidecarError("Failed to proxy request to sidecar process", {
+ cause,
+ statusCode: 502,
+ });
+ }
+
+ /** Child process did not respond within the configured requestTimeout. */
+ static bridgeTimeout(requestId: number, timeout: number): SidecarError {
+ return new SidecarError(
+ `Sidecar request ${requestId} timed out after ${timeout}ms`,
+ {
+ context: { requestId, timeout, errorType: "bridge_timeout" },
+ statusCode: 504,
+ isRetryable: true,
+ },
+ );
+ }
+
+ /** Child process returned a JSON-RPC error response. */
+ static bridgeRequestFailed(
+ message: string,
+ rpcError: { code: number; data?: unknown },
+ ): SidecarError {
+ return new SidecarError(`Sidecar request failed: ${message}`, {
+ context: {
+ rpcErrorCode: rpcError.code,
+ rpcErrorData: rpcError.data,
+ errorType: "bridge_request_failed",
+ },
+ statusCode: 502,
+ isRetryable: rpcError.code >= -32000,
+ });
+ }
+
+ /** Too many in-flight requests to the child process. */
+ static concurrencyExhausted(maxConcurrency: number): SidecarError {
+ return new SidecarError(
+ `Sidecar concurrency limit reached (${maxConcurrency} pending requests)`,
+ {
+ context: { maxConcurrency, errorType: "concurrency_exhausted" },
+ statusCode: 503,
+ isRetryable: true,
+ },
+ );
+ }
+
+ /** stdin write failed — child process may have crashed. */
+ static stdinWriteFailed(cause?: Error): SidecarError {
+ return new SidecarError("Failed to write to sidecar stdin", {
+ cause,
+ context: { errorType: "stdin_write_failed" },
+ statusCode: 502,
+ isRetryable: true,
+ });
+ }
+}
diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts
index 8db7f1d7..590a6a22 100644
--- a/packages/appkit/src/index.ts
+++ b/packages/appkit/src/index.ts
@@ -48,7 +48,7 @@ export {
} from "./errors";
// Plugin authoring
export { Plugin, type ToPlugin, toPlugin } from "./plugin";
-export { analytics, files, genie, lakebase, server } from "./plugins";
+export { analytics, files, genie, lakebase, server, sidecar } from "./plugins";
// Registry types and utilities for plugin manifests
export type {
ConfigSchema,
diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts
index 422c2a8c..3e11cd06 100644
--- a/packages/appkit/src/plugin/plugin.ts
+++ b/packages/appkit/src/plugin/plugin.ts
@@ -159,6 +159,11 @@ export abstract class Plugin<
/** Paths that opt out of JSON body parsing (e.g. file upload routes) */
private skipBodyParsingPaths: Set = new Set();
+ /** Register a path that should skip body parsing (e.g. proxy or file upload routes) */
+ protected addSkipBodyParsingPath(path: string): void {
+ this.skipBodyParsingPaths.add(path);
+ }
+
/**
* Plugin initialization phase.
* - 'core': Initialized first (e.g., config plugins)
diff --git a/packages/appkit/src/plugin/to-plugin.ts b/packages/appkit/src/plugin/to-plugin.ts
index 77725027..c65f8b33 100644
--- a/packages/appkit/src/plugin/to-plugin.ts
+++ b/packages/appkit/src/plugin/to-plugin.ts
@@ -6,7 +6,7 @@ import type { PluginConstructor, PluginData, ToPlugin } from "shared";
*
* @internal
*/
-export function toPlugin(
+export function toPlugin>(
plugin: T,
): ToPlugin[0], T["manifest"]["name"]> {
type Config = ConstructorParameters[0];
diff --git a/packages/appkit/src/plugins/index.ts b/packages/appkit/src/plugins/index.ts
index 7caa040f..23f7203b 100644
--- a/packages/appkit/src/plugins/index.ts
+++ b/packages/appkit/src/plugins/index.ts
@@ -3,3 +3,4 @@ export * from "./files";
export * from "./genie";
export * from "./lakebase";
export * from "./server";
+export * from "./sidecar";
diff --git a/packages/appkit/src/plugins/sidecar/health-checker.ts b/packages/appkit/src/plugins/sidecar/health-checker.ts
new file mode 100644
index 00000000..da098730
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/health-checker.ts
@@ -0,0 +1,86 @@
+import { createLogger } from "../../logging/logger";
+import type { HealthCheckConfig } from "./types";
+
+const logger = createLogger("sidecar:health");
+
+const DEFAULTS: Required = {
+ path: "/health",
+ interval: 5_000,
+ timeout: 3_000,
+ unhealthyThreshold: 3,
+};
+
+export class HealthChecker {
+ private interval: ReturnType | null = null;
+ private consecutiveFailures = 0;
+ private readonly config: Required;
+ private readonly port: number;
+
+ constructor(port: number, config?: HealthCheckConfig) {
+ this.port = port;
+ this.config = { ...DEFAULTS, ...config };
+ }
+
+ async waitForReady(timeout: number, signal?: AbortSignal): Promise {
+ const deadline = Date.now() + timeout;
+ const pollInterval = Math.min(1_000, this.config.timeout);
+
+ while (Date.now() < deadline) {
+ if (signal?.aborted) return false;
+
+ if (await this.check()) {
+ logger.info("Sidecar health check passed on port %d", this.port);
+ return true;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ }
+
+ return false;
+ }
+
+ start(callbacks: { onHealthy: () => void; onUnhealthy: () => void }): void {
+ this.stop();
+
+ this.interval = setInterval(async () => {
+ const healthy = await this.check();
+
+ if (healthy) {
+ this.consecutiveFailures = 0;
+ callbacks.onHealthy();
+ } else {
+ this.consecutiveFailures++;
+ logger.warn(
+ "Sidecar health check failed (%d/%d)",
+ this.consecutiveFailures,
+ this.config.unhealthyThreshold,
+ );
+
+ if (this.consecutiveFailures >= this.config.unhealthyThreshold) {
+ callbacks.onUnhealthy();
+ this.consecutiveFailures = 0;
+ }
+ }
+ }, this.config.interval);
+ }
+
+ stop(): void {
+ if (this.interval) {
+ clearInterval(this.interval);
+ this.interval = null;
+ }
+ }
+
+ private async check(): Promise {
+ const url = `http://localhost:${this.port}${this.config.path}`;
+ try {
+ const response = await fetch(url, {
+ method: "GET",
+ signal: AbortSignal.timeout(this.config.timeout),
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/packages/appkit/src/plugins/sidecar/index.ts b/packages/appkit/src/plugins/sidecar/index.ts
new file mode 100644
index 00000000..b21c88d2
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/index.ts
@@ -0,0 +1 @@
+export { sidecar } from "./sidecar";
diff --git a/packages/appkit/src/plugins/sidecar/lib/http.ts b/packages/appkit/src/plugins/sidecar/lib/http.ts
new file mode 100644
index 00000000..fc4327f0
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/lib/http.ts
@@ -0,0 +1,121 @@
+import { Router } from "express";
+import type { IAppRouter } from "shared";
+import { SidecarError } from "../../../errors/sidecar";
+import { createLogger } from "../../../logging/logger";
+import { HealthChecker } from "../health-checker";
+import { SidecarProxy } from "../proxy";
+import type {
+ ModeHandler,
+ PluginRouteHelpers,
+ SidecarInstance,
+} from "./shared";
+
+const logger = createLogger("sidecar:http");
+
+function narrowHttp(inst: SidecarInstance) {
+ if (inst.state.mode !== "http") {
+ throw new Error("Expected HTTP mode state");
+ }
+ return inst.state;
+}
+
+export const httpHandler: ModeHandler = {
+ async setup(inst, telemetry, timeout) {
+ const { definition: def, processManager } = inst;
+ const state = narrowHttp(inst);
+
+ await processManager.spawn();
+
+ const port = processManager.port;
+
+ state.healthChecker = new HealthChecker(port, def.healthCheck);
+
+ const ready = await state.healthChecker.waitForReady(timeout);
+ if (!ready) {
+ await processManager.stop();
+ throw SidecarError.startupFailed(def.command, timeout);
+ }
+
+ processManager.setHealthy();
+
+ httpHandler.startHealthChecks(inst, timeout, telemetry);
+
+ state.proxy = new SidecarProxy(port, telemetry, def.proxy);
+
+ logger.info("[%s] Sidecar ready on port %d", def.id, port);
+ },
+
+ startHealthChecks(inst, timeout, telemetry) {
+ const { definition: def, processManager } = inst;
+ const state = narrowHttp(inst);
+
+ const callbacks = {
+ onHealthy: () => processManager.setHealthy(),
+ onUnhealthy: async () => {
+ if (inst.restarting) return;
+ inst.restarting = true;
+ try {
+ processManager.setUnhealthy();
+ logger.warn("[%s] Sidecar unhealthy, triggering restart", def.id);
+
+ // Stop health checks during restart to avoid overlapping restarts
+ state.healthChecker?.stop();
+ await processManager.restart();
+
+ // Port may have changed after restart (auto-assign)
+ const newPort = processManager.port;
+ state.healthChecker = new HealthChecker(newPort, def.healthCheck);
+ state.proxy = new SidecarProxy(newPort, telemetry, def.proxy);
+
+ // Wait for the restarted process to become healthy before resuming checks
+ const ready = await state.healthChecker?.waitForReady(timeout);
+ if (ready) {
+ processManager.setHealthy();
+ }
+
+ // Resume periodic health checking with the same callbacks
+ state.healthChecker?.start(callbacks);
+ } catch (err) {
+ logger.error(
+ "[%s] Failed to restart sidecar: %s",
+ def.id,
+ (err as Error).message,
+ );
+ } finally {
+ inst.restarting = false;
+ }
+ },
+ };
+
+ state.healthChecker?.start(callbacks);
+ },
+
+ injectRoutes(
+ router: IAppRouter,
+ inst: SidecarInstance,
+ helpers: PluginRouteHelpers,
+ ) {
+ const state = narrowHttp(inst);
+ const { definition: def, processManager } = inst;
+
+ const subRouter = Router();
+ subRouter.all("/*", (req, res) => {
+ if (!state.proxy) {
+ res.status(503).json({ error: "Sidecar proxy not ready" });
+ return;
+ }
+ state.proxy.middleware(() => processManager.status)(req, res);
+ });
+ router.use(`/${def.id}`, subRouter);
+
+ const fullPath = `/api/${helpers.pluginName}/${def.id}/*`;
+ logger.info("[%s] Injecting HTTP routes: %s", def.id, fullPath);
+ helpers.addSkipBodyParsingPath(fullPath);
+ helpers.registerEndpoint(`proxy:${def.id}`, fullPath);
+ },
+
+ teardown(inst) {
+ const state = narrowHttp(inst);
+ state.healthChecker?.stop();
+ },
+};
diff --git a/packages/appkit/src/plugins/sidecar/lib/shared.ts b/packages/appkit/src/plugins/sidecar/lib/shared.ts
new file mode 100644
index 00000000..0812dd5d
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/lib/shared.ts
@@ -0,0 +1,59 @@
+import type { IAppRequest, IAppRouter } from "shared";
+import type { ITelemetry } from "../../../telemetry/types";
+import type { HealthChecker } from "../health-checker";
+import type { ProcessManager } from "../process-manager";
+import type { SidecarProxy } from "../proxy";
+import type { StdioBridge } from "../stdio-bridge";
+import type { SidecarDefinition } from "../types";
+
+export const DEFAULT_STARTUP_TIMEOUT = 30_000;
+
+export function extractAuthHeaders(req: IAppRequest): Record {
+ const headers: Record = {};
+ const user = req.headers["x-forwarded-user"];
+ if (typeof user === "string") headers["x-forwarded-user"] = user;
+ const token = req.headers["x-forwarded-access-token"];
+ if (typeof token === "string") headers["x-forwarded-access-token"] = token;
+ return headers;
+}
+
+export interface PluginRouteHelpers {
+ pluginName: string;
+ addSkipBodyParsingPath(path: string): void;
+ registerEndpoint(name: string, path: string): void;
+}
+
+export type ModeState =
+ | {
+ mode: "http";
+ healthChecker: HealthChecker | null;
+ proxy: SidecarProxy | null;
+ }
+ | { mode: "stdio"; stdioBridge: StdioBridge | null };
+
+export interface SidecarInstance {
+ definition: SidecarDefinition;
+ processManager: ProcessManager;
+ handler: ModeHandler;
+ state: ModeState;
+ restarting: boolean;
+}
+
+export interface ModeHandler {
+ setup(
+ inst: SidecarInstance,
+ telemetry: ITelemetry,
+ timeout: number,
+ ): Promise;
+ startHealthChecks(
+ inst: SidecarInstance,
+ timeout: number,
+ telemetry: ITelemetry,
+ ): void;
+ injectRoutes(
+ router: IAppRouter,
+ inst: SidecarInstance,
+ helpers: PluginRouteHelpers,
+ ): void;
+ teardown(inst: SidecarInstance): void;
+}
diff --git a/packages/appkit/src/plugins/sidecar/lib/stdio.ts b/packages/appkit/src/plugins/sidecar/lib/stdio.ts
new file mode 100644
index 00000000..0d1ac533
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/lib/stdio.ts
@@ -0,0 +1,179 @@
+import { Router } from "express";
+import type { IAppRequest, IAppRouter } from "shared";
+import { SidecarError } from "../../../errors/sidecar";
+import { createLogger } from "../../../logging/logger";
+import { StdioBridge } from "../stdio-bridge";
+import { stdioRequestSchema } from "../stdio-schema";
+import type {
+ ModeHandler,
+ PluginRouteHelpers,
+ SidecarInstance,
+} from "./shared";
+import { extractAuthHeaders } from "./shared";
+
+const logger = createLogger("sidecar:stdio");
+
+function narrowStdio(inst: SidecarInstance) {
+ if (inst.state.mode !== "stdio") {
+ throw new Error("Expected stdio mode state");
+ }
+ return inst.state;
+}
+
+export const stdioHandler: ModeHandler = {
+ async setup(inst, telemetry, timeout) {
+ const { definition: def, processManager } = inst;
+ const state = narrowStdio(inst);
+
+ await processManager.spawn();
+
+ state.stdioBridge = new StdioBridge(def.stdio ?? {}, telemetry);
+
+ const stdin = processManager.getStdin();
+ const stdout = processManager.getStdout();
+ if (!stdin || !stdout) {
+ await processManager.stop();
+ throw new SidecarError(
+ `[${def.id}] Failed to obtain stdio streams from child process`,
+ { isRetryable: false },
+ );
+ }
+
+ state.stdioBridge.attach(stdin, stdout);
+
+ const ready = await state.stdioBridge.waitForReady(timeout);
+ logger.info("[%s] Sidecar stdio ready: %s", def.id, ready);
+ if (!ready) {
+ state.stdioBridge.destroy();
+ await processManager.stop();
+ throw SidecarError.startupFailed(def.command, timeout);
+ }
+
+ processManager.setHealthy();
+
+ stdioHandler.startHealthChecks(inst, timeout, telemetry);
+
+ logger.info("[%s] Sidecar ready (stdio mode)", def.id);
+ },
+
+ startHealthChecks(inst, timeout, _telemetry) {
+ const { definition: def, processManager } = inst;
+ const state = narrowStdio(inst);
+
+ const callbacks = {
+ onHealthy: () => processManager.setHealthy(),
+ onUnhealthy: async () => {
+ if (inst.restarting) return;
+ inst.restarting = true;
+ try {
+ processManager.setUnhealthy();
+ logger.warn(
+ "[%s] Sidecar stdio unhealthy, triggering restart",
+ def.id,
+ );
+
+ const bridge = state.stdioBridge;
+ if (bridge) {
+ // Stop health checks and detach before restarting
+ bridge.stopHealthCheck();
+ bridge.detach();
+ await processManager.restart();
+
+ const newStdin = processManager.getStdin();
+ const newStdout = processManager.getStdout();
+ if (newStdin && newStdout) {
+ bridge.attach(newStdin, newStdout);
+ const ready = await bridge.waitForReady(timeout);
+ if (ready) {
+ processManager.setHealthy();
+ }
+ }
+
+ // Resume health checking with the same callbacks
+ bridge.startHealthCheck(callbacks);
+ }
+ } catch (err) {
+ logger.error(
+ "[%s] Failed to restart sidecar: %s",
+ def.id,
+ (err as Error).message,
+ );
+ } finally {
+ inst.restarting = false;
+ }
+ },
+ };
+
+ state.stdioBridge?.startHealthCheck(callbacks);
+ },
+
+ injectRoutes(
+ router: IAppRouter,
+ inst: SidecarInstance,
+ helpers: PluginRouteHelpers,
+ ) {
+ const state = narrowStdio(inst);
+ const { definition: def, processManager } = inst;
+ const getStatus = () => processManager.status;
+
+ const subRouter = Router();
+ subRouter.all("/*", async (req: IAppRequest, res) => {
+ if (!state.stdioBridge) {
+ res.status(503).json({ error: "Sidecar stdio bridge not ready" });
+ return;
+ }
+
+ const status = getStatus();
+ if (status !== "healthy") {
+ res.status(503).json({ error: "Sidecar process is not ready", status });
+ return;
+ }
+
+ const parsed = stdioRequestSchema.safeParse({
+ path: req.path,
+ method: req.method,
+ body: req.body,
+ });
+
+ if (!parsed.success) {
+ res.status(400).json({
+ error: "Invalid request payload",
+ details: parsed.error.flatten(),
+ });
+ return;
+ }
+
+ const authHeaders = extractAuthHeaders(req);
+
+ try {
+ const result = await state.stdioBridge.sendRequest({
+ ...parsed.data,
+ headers: authHeaders,
+ });
+
+ res.status(result.status ?? 200);
+ if (result.headers) {
+ for (const [k, v] of Object.entries(result.headers)) {
+ res.setHeader(k, v);
+ }
+ }
+ res.json(result.body);
+ } catch (err) {
+ if (err instanceof SidecarError) {
+ res.status(err.statusCode).json({ error: err.message });
+ } else {
+ res.status(502).json({ error: "Sidecar request failed" });
+ }
+ }
+ });
+ router.use(`/${def.id}`, subRouter);
+
+ const fullPath = `/api/${helpers.pluginName}/${def.id}/*`;
+ helpers.registerEndpoint(`stdio:${def.id}`, fullPath);
+ },
+
+ teardown(inst) {
+ const state = narrowStdio(inst);
+ state.stdioBridge?.destroy();
+ },
+};
diff --git a/packages/appkit/src/plugins/sidecar/manifest.json b/packages/appkit/src/plugins/sidecar/manifest.json
new file mode 100644
index 00000000..34d6857d
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/manifest.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json",
+ "name": "sidecar",
+ "displayName": "Sidecar",
+ "description": "Run another stack (Python, Ruby, Go, etc.) as a child process alongside the AppKit server",
+ "resources": {
+ "required": [],
+ "optional": []
+ },
+ "config": {
+ "schema": {
+ "type": "object",
+ "required": ["command"],
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": "Command to execute (e.g., 'python', 'ruby', 'go')"
+ },
+ "args": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Arguments to the command"
+ },
+ "port": {
+ "type": "number",
+ "description": "Port the child process listens on. 0 for auto-assign."
+ },
+ "startupTimeout": {
+ "type": "number",
+ "default": 30000,
+ "description": "Timeout in ms to wait for child process readiness"
+ },
+ "setupCommands": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Commands to run before spawning the sidecar process"
+ },
+ "setupShell": {
+ "type": "boolean",
+ "default": false,
+ "description": "When true, setup commands run in a shell (supports pipes, redirects). When false (default), commands use execFile for security."
+ }
+ }
+ }
+ }
+}
diff --git a/packages/appkit/src/plugins/sidecar/process-manager.ts b/packages/appkit/src/plugins/sidecar/process-manager.ts
new file mode 100644
index 00000000..66cdbd5e
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/process-manager.ts
@@ -0,0 +1,313 @@
+import { type ChildProcess, spawn } from "node:child_process";
+import fs from "node:fs";
+import net from "node:net";
+import path from "node:path";
+import type { Readable, Writable } from "node:stream";
+import { SidecarError } from "../../errors/sidecar";
+import { createLogger } from "../../logging/logger";
+import type { RestartConfig, SidecarDefinition, SidecarStatus } from "./types";
+
+const logger = createLogger("sidecar:process");
+
+const DEFAULT_RESTART: Required = {
+ enabled: true,
+ maxRestarts: 5,
+ restartWindow: 60_000,
+ restartDelay: 1_000,
+};
+
+const DEFAULT_BUFFER_SIZE = 1_000;
+
+export class ProcessManager {
+ private childProcess: ChildProcess | null = null;
+ private _status: SidecarStatus = "stopped";
+ private _port = 0;
+ private restartCount = 0;
+ private restartWindowStart = 0;
+ private outputBuffer: string[] = [];
+ private statusListeners: Array<(status: SidecarStatus) => void> = [];
+ private stopping = false;
+
+ private readonly command: string;
+ private readonly args: string[];
+ private readonly cwd: string;
+ private readonly env: Record;
+ private readonly configPort: number;
+ private readonly restartConfig: Required;
+ private readonly stdinEnabled: boolean;
+ private readonly bufferSize: number;
+ private restartTimer: ReturnType | null = null;
+
+ /** Shell metacharacters that must not appear in the command string. */
+ private static readonly SHELL_META = /[;|&$`\n\r]/;
+
+ constructor(config: SidecarDefinition) {
+ this.command = ProcessManager.validateCommand(config.command);
+ this.args = config.args ?? [];
+ this.cwd = ProcessManager.validateCwd(config.cwd);
+ this.env = config.env ?? {};
+ this.configPort = config.port ?? 0;
+ this.restartConfig = { ...DEFAULT_RESTART, ...config.restart };
+ this.bufferSize = DEFAULT_BUFFER_SIZE;
+ this.stdinEnabled = config.mode === "stdio";
+ }
+
+ private static validateCommand(command: string): string {
+ if (!command || !command.trim()) {
+ throw new SidecarError("Sidecar command must be a non-empty string", {
+ isRetryable: false,
+ });
+ }
+ if (ProcessManager.SHELL_META.test(command)) {
+ throw new SidecarError(
+ "Sidecar command must not contain shell metacharacters",
+ { context: { command }, isRetryable: false },
+ );
+ }
+ return command;
+ }
+
+ private static validateCwd(cwd?: string): string {
+ if (!cwd) return process.cwd();
+ if (cwd.includes("\0")) {
+ throw new SidecarError("Sidecar cwd must not contain null bytes", {
+ isRetryable: false,
+ });
+ }
+ const resolved = path.resolve(cwd);
+ if (!fs.existsSync(resolved)) {
+ throw new SidecarError(`Sidecar cwd does not exist: ${resolved}`, {
+ context: { cwd: resolved },
+ isRetryable: false,
+ });
+ }
+ return resolved;
+ }
+
+ get status(): SidecarStatus {
+ return this._status;
+ }
+
+ get port(): number {
+ return this._port;
+ }
+
+ async spawn(): Promise {
+ if (this.childProcess) {
+ await this.stop();
+ }
+
+ // Skip port resolution for stdio mode
+ if (!this.stdinEnabled) {
+ this._port = this.configPort || (await this.resolvePort());
+ }
+ this.setStatus("starting");
+
+ const childEnv: Record = {
+ ...process.env,
+ ...this.env,
+ };
+
+ // Only set PORT env vars in HTTP mode
+ if (!this.stdinEnabled) {
+ childEnv.PORT = String(this._port);
+ childEnv.SIDECAR_PORT = String(this._port);
+ childEnv.DATABRICKS_APP_PORT = String(this._port);
+ }
+
+ const stdioOpt: ["pipe", "pipe", "pipe"] | ["ignore", "pipe", "pipe"] = this
+ .stdinEnabled
+ ? ["pipe", "pipe", "pipe"]
+ : ["ignore", "pipe", "pipe"];
+
+ logger.info(
+ "Spawning sidecar: %s %s (mode: %s, %scwd: %s)",
+ this.command,
+ this.args.join(" "),
+ this.stdinEnabled ? "stdio" : "http",
+ this.stdinEnabled ? "" : `port: ${this._port}, `,
+ this.cwd,
+ );
+
+ const child = spawn(this.command, this.args, {
+ cwd: this.cwd,
+ env: childEnv,
+ stdio: stdioOpt,
+ shell: false,
+ });
+ this.childProcess = child;
+
+ // In stdio mode, stdout is owned by the StdioBridge — do not buffer it here.
+ if (!this.stdinEnabled) {
+ child.stdout?.on("data", (chunk: Buffer) => {
+ const lines = chunk.toString().split("\n").filter(Boolean);
+ for (const line of lines) {
+ this.appendOutput(`[stdout] ${line}`);
+ logger.debug("sidecar stdout: %s", line);
+ }
+ });
+ }
+
+ child.stderr?.on("data", (chunk: Buffer) => {
+ const lines = chunk.toString().split("\n").filter(Boolean);
+ for (const line of lines) {
+ this.appendOutput(`[stderr] ${line}`);
+ logger.debug("sidecar stderr: %s", line);
+ }
+ });
+
+ child.on("exit", (code, signal) => {
+ this.handleExit(code, signal);
+ });
+
+ child.on("error", (err) => {
+ logger.error("Sidecar process error: %s", err.message);
+ this.childProcess = null;
+ this.setStatus("crashed");
+ });
+ }
+
+ async stop(timeout = 10_000): Promise {
+ if (this.restartTimer) {
+ clearTimeout(this.restartTimer);
+ this.restartTimer = null;
+ }
+
+ if (!this.childProcess) return;
+
+ this.stopping = true;
+ const child = this.childProcess;
+
+ return new Promise((resolve) => {
+ const forceKillTimer = setTimeout(() => {
+ logger.warn("Sidecar did not exit in time, sending SIGKILL");
+ child.kill("SIGKILL");
+ }, timeout);
+
+ child.once("exit", () => {
+ clearTimeout(forceKillTimer);
+ this.childProcess = null;
+ this.setStatus("stopped");
+ this.stopping = false;
+ resolve();
+ });
+
+ child.kill("SIGTERM");
+ });
+ }
+
+ async restart(): Promise {
+ logger.info("Restarting sidecar process");
+ await this.stop();
+ await this.spawn();
+ }
+
+ setHealthy(): void {
+ if (this._status === "starting" || this._status === "unhealthy") {
+ this.setStatus("healthy");
+ }
+ }
+
+ setUnhealthy(): void {
+ if (this._status === "healthy" || this._status === "starting") {
+ this.setStatus("unhealthy");
+ }
+ }
+
+ onStatusChange(cb: (status: SidecarStatus) => void): void {
+ this.statusListeners.push(cb);
+ }
+
+ getOutput(lines?: number): string[] {
+ if (lines === undefined) return [...this.outputBuffer];
+ return this.outputBuffer.slice(-lines);
+ }
+
+ getStdin(): Writable | null {
+ return this.childProcess?.stdin ?? null;
+ }
+
+ getStdout(): Readable | null {
+ return this.childProcess?.stdout ?? null;
+ }
+
+ private setStatus(status: SidecarStatus): void {
+ this._status = status;
+ for (const listener of this.statusListeners) {
+ listener(status);
+ }
+ }
+
+ private appendOutput(line: string): void {
+ this.outputBuffer.push(line);
+ if (this.outputBuffer.length > this.bufferSize) {
+ this.outputBuffer.shift();
+ }
+ }
+
+ private handleExit(code: number | null, signal: string | null): void {
+ logger.info("Sidecar process exited (code: %s, signal: %s)", code, signal);
+ this.childProcess = null;
+
+ if (this.stopping) return;
+
+ if (!this.restartConfig.enabled) {
+ this.setStatus("crashed");
+ return;
+ }
+
+ this.resetRestartCountIfWindowExpired();
+ this.restartCount++;
+
+ if (this.restartCount > this.restartConfig.maxRestarts) {
+ logger.error(
+ "Sidecar exceeded max restarts (%d), giving up",
+ this.restartConfig.maxRestarts,
+ );
+ this.setStatus("crashed");
+ return;
+ }
+
+ logger.info(
+ "Restarting sidecar in %dms (attempt %d/%d)",
+ this.restartConfig.restartDelay,
+ this.restartCount,
+ this.restartConfig.maxRestarts,
+ );
+
+ this.restartTimer = setTimeout(() => {
+ this.restartTimer = null;
+ if (!this.stopping) {
+ this.spawn().catch((err) => {
+ logger.error("Failed to restart sidecar: %s", err.message);
+ this.setStatus("crashed");
+ });
+ }
+ }, this.restartConfig.restartDelay);
+ }
+
+ private resetRestartCountIfWindowExpired(): void {
+ const now = Date.now();
+ if (now - this.restartWindowStart > this.restartConfig.restartWindow) {
+ this.restartCount = 0;
+ this.restartWindowStart = now;
+ }
+ }
+
+ private resolvePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.listen(0, () => {
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ server.close();
+ reject(new Error("Failed to resolve port"));
+ return;
+ }
+ const port = address.port;
+ server.close(() => resolve(port));
+ });
+ server.on("error", reject);
+ });
+ }
+}
diff --git a/packages/appkit/src/plugins/sidecar/proxy.ts b/packages/appkit/src/plugins/sidecar/proxy.ts
new file mode 100644
index 00000000..29e3a753
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/proxy.ts
@@ -0,0 +1,307 @@
+import http from "node:http";
+import posixPath from "node:path/posix";
+import type { Counter, Histogram, UpDownCounter } from "@opentelemetry/api";
+import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
+import type { IAppRequest, IAppResponse } from "shared";
+import { SidecarError } from "../../errors/sidecar";
+import { createLogger } from "../../logging/logger";
+import type { ITelemetry } from "../../telemetry/types";
+import type { ProxyConfig, SidecarStatus } from "./types";
+
+const logger = createLogger("sidecar:proxy");
+
+const DEFAULTS: Required = {
+ forwardHeaders: "all",
+ injectHeaders: {},
+ timeout: 30_000,
+ basePath: "/",
+};
+
+const HOP_BY_HOP_HEADERS = new Set([
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+function classifyError(err: NodeJS.ErrnoException): string {
+ switch (err.code) {
+ case "ECONNREFUSED":
+ return "connection_refused";
+ case "ECONNRESET":
+ return "connection_reset";
+ case "ETIMEDOUT":
+ return "timeout";
+ default:
+ return "proxy_error";
+ }
+}
+
+export class SidecarProxy {
+ private readonly config: Required;
+ private readonly port: number;
+ private readonly telemetry: ITelemetry;
+ private readonly metrics: {
+ requestCount: Counter;
+ requestDuration: Histogram;
+ errorCount: Counter;
+ pendingGauge: UpDownCounter;
+ };
+
+ constructor(port: number, telemetry: ITelemetry, config?: ProxyConfig) {
+ this.port = port;
+ this.telemetry = telemetry;
+ this.config = { ...DEFAULTS, ...config };
+
+ const meter = this.telemetry.getMeter();
+ this.metrics = {
+ requestCount: meter.createCounter("sidecar.proxy.request.count", {
+ description: "Total proxied HTTP requests to sidecar",
+ unit: "1",
+ }),
+ requestDuration: meter.createHistogram("sidecar.proxy.request.duration", {
+ description: "Round-trip time for proxied HTTP requests",
+ unit: "ms",
+ }),
+ errorCount: meter.createCounter("sidecar.proxy.error.count", {
+ description: "Total proxy errors (timeout, connection, etc.)",
+ unit: "1",
+ }),
+ pendingGauge: meter.createUpDownCounter("sidecar.proxy.pending", {
+ description: "Currently pending (in-flight) proxied requests",
+ unit: "1",
+ }),
+ };
+ }
+
+ middleware(
+ getStatus: () => SidecarStatus,
+ ): (req: IAppRequest, res: IAppResponse) => void {
+ return (req: IAppRequest, res: IAppResponse) => {
+ const status = getStatus();
+ if (status !== "healthy") {
+ res.status(503).json({
+ error: "Sidecar process is not ready",
+ status,
+ });
+ return;
+ }
+
+ this.proxyRequest(req, res);
+ };
+ }
+
+ private proxyRequest(req: IAppRequest, res: IAppResponse): void {
+ let targetPath: string;
+ try {
+ targetPath = this.buildTargetPath(req.path);
+ } catch {
+ res.status(400).json({ error: "Invalid request path" });
+ return;
+ }
+ const headers = this.buildHeaders(req);
+ const fullPath = targetPath + this.extractQueryString(req.url);
+
+ // Fire-and-forget — all error handling is internal to executeProxy
+ void this.executeProxy(req, res, fullPath, headers);
+ }
+
+ private executeProxy(
+ req: IAppRequest,
+ res: IAppResponse,
+ fullPath: string,
+ headers: Record,
+ ): Promise {
+ return this.telemetry.startActiveSpan(
+ "sidecar.proxy.request",
+ {
+ kind: SpanKind.CLIENT,
+ attributes: {
+ "sidecar.proxy.path": req.path,
+ "sidecar.proxy.method": req.method,
+ "sidecar.proxy.target_port": this.port,
+ },
+ },
+ async (span) => {
+ const startTime = Date.now();
+ this.metrics.pendingGauge.add(1);
+
+ try {
+ logger.debug(
+ "%s %s → localhost:%d%s",
+ req.method,
+ req.path,
+ this.port,
+ fullPath,
+ );
+
+ const statusCode = await new Promise((resolve, reject) => {
+ const proxyReq = http.request(
+ {
+ hostname: "localhost",
+ port: this.port,
+ method: req.method,
+ path: fullPath,
+ headers,
+ timeout: this.config.timeout,
+ },
+ (proxyRes) => {
+ const status = proxyRes.statusCode ?? 502;
+ logger.debug("%s %s ← %d", req.method, req.path, status);
+
+ res.status(status);
+
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
+ if (
+ !HOP_BY_HOP_HEADERS.has(key.toLowerCase()) &&
+ value !== undefined
+ ) {
+ res.setHeader(key, value);
+ }
+ }
+
+ span.addEvent("sidecar.proxy.request_forwarded", {
+ "sidecar.proxy.response_status": status,
+ });
+
+ proxyRes.pipe(res);
+ proxyRes.on("end", () => resolve(status));
+ proxyRes.on("error", reject);
+ },
+ );
+
+ proxyReq.on("error", (err) => {
+ logger.error("Proxy request failed: %s", err.message);
+ if (!res.headersSent) {
+ if ((err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
+ res
+ .status(502)
+ .json({ error: "Sidecar process is unavailable" });
+ } else {
+ res
+ .status(502)
+ .json({ error: "Failed to proxy request to sidecar" });
+ }
+ }
+ reject(err);
+ });
+
+ proxyReq.on("timeout", () => {
+ proxyReq.destroy();
+ if (!res.headersSent) {
+ res.status(504).json({ error: "Sidecar request timed out" });
+ }
+ reject(
+ Object.assign(new Error("Sidecar request timed out"), {
+ code: "ETIMEDOUT",
+ }),
+ );
+ });
+
+ req.pipe(proxyReq);
+ });
+
+ const duration = Date.now() - startTime;
+ const metricAttrs = {
+ "sidecar.proxy.path": req.path,
+ "sidecar.proxy.method": req.method,
+ "sidecar.proxy.status": statusCode,
+ };
+ this.metrics.requestCount.add(1, metricAttrs);
+ this.metrics.requestDuration.record(duration, metricAttrs);
+
+ span.setAttribute("sidecar.proxy.duration_ms", duration);
+ span.setAttribute("sidecar.proxy.response_status", statusCode);
+ span.setStatus({ code: SpanStatusCode.OK });
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ const errorType = classifyError(error as NodeJS.ErrnoException);
+
+ span.recordException(error as Error);
+ span.setAttribute("sidecar.proxy.error_type", errorType);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: (error as Error).message,
+ });
+
+ this.metrics.errorCount.add(1, {
+ "sidecar.proxy.path": req.path,
+ "sidecar.proxy.error_type": errorType,
+ });
+ this.metrics.requestDuration.record(duration, {
+ "sidecar.proxy.path": req.path,
+ "sidecar.proxy.error": "true",
+ });
+ } finally {
+ this.metrics.pendingGauge.add(-1);
+ }
+ },
+ );
+ }
+
+ private buildTargetPath(originalPath: string): string {
+ if (originalPath.includes("\0")) {
+ throw SidecarError.proxyFailed(new Error("Invalid proxy path"));
+ }
+
+ const basePath = this.config.basePath.replace(/\/+$/, "") || "/";
+ const normalized = posixPath.normalize(originalPath);
+ const prefixed = normalized.startsWith("/") ? normalized : `/${normalized}`;
+ const full = posixPath.normalize(`${basePath}${prefixed}`);
+
+ if (!full.startsWith(basePath)) {
+ throw SidecarError.proxyFailed(new Error("Invalid proxy path"));
+ }
+
+ return full;
+ }
+
+ private extractQueryString(url: string | undefined): string {
+ if (!url) return "";
+ const queryIndex = url.indexOf("?");
+ return queryIndex >= 0 ? url.substring(queryIndex) : "";
+ }
+
+ private buildHeaders(req: IAppRequest): Record {
+ const headers: Record = {};
+
+ if (this.config.forwardHeaders === "all") {
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (
+ value !== undefined &&
+ key.toLowerCase() !== "host" &&
+ !HOP_BY_HOP_HEADERS.has(key.toLowerCase())
+ ) {
+ headers[key] = value as string | string[];
+ }
+ }
+ } else {
+ for (const key of this.config.forwardHeaders) {
+ const value = req.headers[key.toLowerCase()];
+ if (value !== undefined) {
+ headers[key.toLowerCase()] = value as string | string[];
+ }
+ }
+ // Always forward auth-related headers
+ const forwardUser = req.headers["x-forwarded-user"];
+ if (forwardUser) headers["x-forwarded-user"] = forwardUser as string;
+ const forwardToken = req.headers["x-forwarded-access-token"];
+ if (forwardToken)
+ headers["x-forwarded-access-token"] = forwardToken as string;
+ }
+
+ // Inject additional headers
+ for (const [key, value] of Object.entries(this.config.injectHeaders)) {
+ headers[key] = value;
+ }
+
+ // Rewrite host
+ headers.host = `localhost:${this.port}`;
+
+ return headers;
+ }
+}
diff --git a/packages/appkit/src/plugins/sidecar/sidecar.ts b/packages/appkit/src/plugins/sidecar/sidecar.ts
new file mode 100644
index 00000000..6befd496
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/sidecar.ts
@@ -0,0 +1,204 @@
+import { exec, execFile } from "node:child_process";
+import { promisify } from "node:util";
+import type { BasePluginConfig, IAppRouter, PluginData } from "shared";
+import { SidecarError } from "../../errors/sidecar";
+import { createLogger } from "../../logging/logger";
+import { Plugin } from "../../plugin";
+import type { PluginManifest } from "../../registry";
+import { httpHandler } from "./lib/http";
+import type { ModeHandler, SidecarInstance } from "./lib/shared";
+import { DEFAULT_STARTUP_TIMEOUT } from "./lib/shared";
+import { stdioHandler } from "./lib/stdio";
+import manifest from "./manifest.json";
+import { ProcessManager } from "./process-manager";
+import type {
+ ISidecarConfig,
+ SidecarDefinition,
+ SidecarExport,
+ SingleSidecarExport,
+} from "./types";
+
+const execAsync = promisify(exec);
+const execFileAsync = promisify(execFile);
+
+const logger = createLogger("sidecar");
+
+/** Internal config shape after factory normalization — always a plain object. */
+type NormalizedSidecarConfig = BasePluginConfig & {
+ definitions: SidecarDefinition[];
+};
+
+function normalizeSidecars(
+ config: NormalizedSidecarConfig,
+): SidecarDefinition[] {
+ return config.definitions;
+}
+
+function handlerForMode(mode: string | undefined): ModeHandler {
+ return mode === "stdio" ? stdioHandler : httpHandler;
+}
+
+class SidecarPlugin extends Plugin {
+ static manifest = manifest as PluginManifest<"sidecar">;
+
+ private instances = new Map();
+
+ constructor(config: NormalizedSidecarConfig) {
+ super(config);
+
+ const definitions = normalizeSidecars(config);
+ const ids = new Set();
+ for (const def of definitions) {
+ if (ids.has(def.id)) {
+ throw new SidecarError(`Duplicate sidecar id: "${def.id}"`, {
+ isRetryable: false,
+ });
+ }
+ ids.add(def.id);
+
+ const mode = def.mode ?? "http";
+ const handler = handlerForMode(mode);
+
+ this.instances.set(def.id, {
+ definition: def,
+ processManager: new ProcessManager(def),
+ handler,
+ state:
+ mode === "stdio"
+ ? { mode: "stdio", stdioBridge: null }
+ : { mode: "http", healthChecker: null, proxy: null },
+ restarting: false,
+ });
+ }
+ }
+
+ async setup(): Promise {
+ await Promise.all(
+ Array.from(this.instances.values()).map((inst) =>
+ this.setupInstance(inst),
+ ),
+ );
+ }
+
+ private async setupInstance(inst: SidecarInstance): Promise {
+ const { definition: def } = inst;
+
+ if (Array.isArray(def.setupCommands) && def.setupCommands.length > 0) {
+ for (const cmd of def.setupCommands) {
+ try {
+ logger.info(`[${def.id}] Running setup command: ${cmd}`);
+
+ let stdout: string;
+ let stderr: string;
+
+ if (def.setupShell) {
+ logger.warn(
+ `[${def.id}] Running setup command in shell mode (setupShell: true). Ensure commands are from trusted sources.`,
+ );
+ ({ stdout, stderr } = await execAsync(cmd, { cwd: def.cwd }));
+ } else {
+ const parts = cmd.split(/\s+/).filter(Boolean);
+ const [bin, ...args] = parts;
+ ({ stdout, stderr } = await execFileAsync(bin, args, {
+ cwd: def.cwd,
+ }));
+ }
+
+ logger.info(`[${def.id}] Setup command "${cmd}" stdout: ${stdout}`);
+ logger.info(`[${def.id}] Setup command "${cmd}" stderr: ${stderr}`);
+ } catch (err) {
+ logger.error(
+ `[${def.id}] Failed to run setup command "${cmd}": ${(err as Error).message}`,
+ );
+ throw SidecarError.startupFailed(cmd, 0);
+ }
+ }
+ }
+
+ const timeout = def.startupTimeout ?? DEFAULT_STARTUP_TIMEOUT;
+ await inst.handler.setup(inst, this.telemetry, timeout);
+ }
+
+ injectRoutes(router: IAppRouter): void {
+ const helpers = {
+ pluginName: this.name,
+ addSkipBodyParsingPath: (path: string) =>
+ this.addSkipBodyParsingPath(path),
+ registerEndpoint: (name: string, path: string) =>
+ this.registerEndpoint(name, path),
+ };
+
+ for (const inst of this.instances.values()) {
+ inst.handler.injectRoutes(router, inst, helpers);
+ }
+ }
+
+ abortActiveOperations(): void {
+ super.abortActiveOperations();
+ for (const inst of this.instances.values()) {
+ inst.handler.teardown(inst);
+ inst.processManager.stop(10_000).catch((err) => {
+ logger.error(
+ "[%s] Error stopping sidecar during shutdown: %s",
+ inst.definition.id,
+ err.message,
+ );
+ });
+ }
+ }
+
+ private buildSingleExport(inst: SidecarInstance): SingleSidecarExport {
+ return {
+ getStatus: () => inst.processManager.status,
+ restart: () => inst.processManager.restart(),
+ stop: () => inst.processManager.stop(),
+ getOutput: (lines) => inst.processManager.getOutput(lines),
+ getPort: () => inst.processManager.port,
+ };
+ }
+
+ private requireInstance(id: string): SidecarInstance {
+ const inst = this.instances.get(id);
+ if (!inst) {
+ throw new SidecarError(`Unknown sidecar id: "${id}"`, {
+ isRetryable: false,
+ });
+ }
+ return inst;
+ }
+
+ exports(): SidecarExport {
+ return {
+ get: (id) => {
+ const inst = this.instances.get(id);
+ return inst ? this.buildSingleExport(inst) : undefined;
+ },
+ getAll: () => {
+ const map = new Map();
+ for (const [id, inst] of this.instances) {
+ map.set(id, this.buildSingleExport(inst));
+ }
+ return map;
+ },
+ getStatus: (id) => this.requireInstance(id).processManager.status,
+ restart: (id) => this.requireInstance(id).processManager.restart(),
+ stop: (id) => this.requireInstance(id).processManager.stop(),
+ getOutput: (id, lines) =>
+ this.requireInstance(id).processManager.getOutput(lines),
+ getPort: (id) => this.requireInstance(id).processManager.port,
+ };
+ }
+}
+
+/**
+ * @internal
+ */
+export const sidecar = (
+ config: ISidecarConfig,
+): PluginData => ({
+ plugin: SidecarPlugin,
+ config: {
+ definitions: Array.isArray(config) ? config : [config],
+ },
+ name: "sidecar" as const,
+});
diff --git a/packages/appkit/src/plugins/sidecar/stdio-bridge.ts b/packages/appkit/src/plugins/sidecar/stdio-bridge.ts
new file mode 100644
index 00000000..1498edac
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/stdio-bridge.ts
@@ -0,0 +1,461 @@
+import type { Readable, Writable } from "node:stream";
+import type { Counter, Histogram, UpDownCounter } from "@opentelemetry/api";
+import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
+import type { StdioResponsePayload } from "shared";
+import { SidecarError } from "../../errors/sidecar";
+import { createLogger } from "../../logging/logger";
+import type { ITelemetry } from "../../telemetry/types";
+import type { StdioConfig } from "./types";
+
+const logger = createLogger("sidecar:stdio-bridge");
+
+interface JsonRpcRequest {
+ jsonrpc: "2.0";
+ id: number;
+ method: string;
+ params: unknown;
+}
+
+interface JsonRpcResponse {
+ jsonrpc: "2.0";
+ id: number;
+ result?: unknown;
+ error?: { code: number; message: string; data?: unknown };
+}
+
+interface JsonRpcNotification {
+ jsonrpc: "2.0";
+ method: string;
+ params?: unknown;
+}
+
+type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification;
+
+interface StdioRequestParams {
+ path: string;
+ method?: string;
+ headers?: Record;
+ body?: unknown;
+}
+
+interface PendingRequest {
+ resolve: (result: StdioResponsePayload) => void;
+ reject: (error: Error) => void;
+ timer: ReturnType;
+}
+
+const DEFAULT_STDIO_CONFIG = {
+ requestTimeout: 30_000,
+ pingInterval: 10_000,
+ pingFailureThreshold: 3,
+ maxConcurrency: 50,
+};
+
+export class StdioBridge {
+ private nextId = 1;
+ private pending = new Map();
+ private ready = false;
+ private readyResolve: (() => void) | null = null;
+ private lineBuffer = "";
+ private consecutiveFailures = 0;
+ private healthInterval: ReturnType | null = null;
+ private stdin: Writable | null = null;
+ private stdout: Readable | null = null;
+ private stdoutHandler: ((chunk: Buffer) => void) | null = null;
+
+ private readonly config: Required>;
+ private readonly onNotification?: (method: string, params: unknown) => void;
+ private readonly telemetry: ITelemetry;
+ private readonly metrics: {
+ requestCount: Counter;
+ requestDuration: Histogram;
+ errorCount: Counter;
+ pendingGauge: UpDownCounter;
+ healthCheckCount: Counter;
+ };
+
+ constructor(config: StdioConfig, telemetry: ITelemetry) {
+ this.config = {
+ requestTimeout:
+ config.requestTimeout ?? DEFAULT_STDIO_CONFIG.requestTimeout,
+ pingInterval: config.pingInterval ?? DEFAULT_STDIO_CONFIG.pingInterval,
+ pingFailureThreshold:
+ config.pingFailureThreshold ??
+ DEFAULT_STDIO_CONFIG.pingFailureThreshold,
+ maxConcurrency:
+ config.maxConcurrency ?? DEFAULT_STDIO_CONFIG.maxConcurrency,
+ };
+ this.onNotification = config.onNotification;
+ this.telemetry = telemetry;
+
+ const meter = this.telemetry.getMeter();
+ this.metrics = {
+ requestCount: meter.createCounter("sidecar.stdio.request.count", {
+ description: "Total stdio requests sent to child process",
+ unit: "1",
+ }),
+ requestDuration: meter.createHistogram("sidecar.stdio.request.duration", {
+ description: "Round-trip time for stdio request→response",
+ unit: "ms",
+ }),
+ errorCount: meter.createCounter("sidecar.stdio.error.count", {
+ description: "Total stdio errors (timeout, protocol, concurrency)",
+ unit: "1",
+ }),
+ pendingGauge: meter.createUpDownCounter("sidecar.stdio.pending", {
+ description: "Currently pending (in-flight) requests",
+ unit: "1",
+ }),
+ healthCheckCount: meter.createCounter("sidecar.stdio.healthcheck.count", {
+ description: "Health check ping attempts",
+ unit: "1",
+ }),
+ };
+ }
+
+ attach(stdin: Writable, stdout: Readable): void {
+ this.stdin = stdin;
+ this.stdout = stdout;
+ this.stdoutHandler = (chunk: Buffer) => this.onStdoutData(chunk);
+ this.stdout.on("data", this.stdoutHandler);
+ }
+
+ detach(): void {
+ if (this.stdout && this.stdoutHandler) {
+ this.stdout.removeListener("data", this.stdoutHandler);
+ }
+ this.stdin = null;
+ this.stdout = null;
+ this.stdoutHandler = null;
+ this.lineBuffer = "";
+ this.ready = false;
+ }
+
+ async waitForReady(timeout: number): Promise {
+ if (this.ready) return true;
+
+ return this.telemetry.startActiveSpan(
+ "sidecar.stdio.startup",
+ {
+ kind: SpanKind.INTERNAL,
+ attributes: { "sidecar.stdio.timeout": timeout },
+ },
+ async (span) => {
+ try {
+ const deadline = Date.now() + timeout;
+
+ // Poll with ping in a loop (like HTTP health checker), while also
+ // listening for a "ready" notification from the sidecar process.
+ const notificationPromise = new Promise<"notification">((resolve) => {
+ this.readyResolve = () => resolve("notification");
+ });
+
+ const pingPollPromise = (async (): Promise<"ping"> => {
+ const pingTimeout = Math.min(5_000, timeout);
+ const pollInterval = Math.min(1_000, pingTimeout);
+ while (Date.now() < deadline) {
+ if (this.ready) return "ping";
+ const ok = await this.ping(pingTimeout);
+ if (ok) return "ping";
+ await new Promise((r) => setTimeout(r, pollInterval));
+ }
+ // Should not reach here if timeout promise wins, but just in case
+ throw new Error("ping poll expired");
+ })();
+
+ const timeoutPromise = new Promise((resolve) =>
+ setTimeout(() => resolve(null), timeout),
+ );
+
+ const result = await Promise.race([
+ notificationPromise,
+ pingPollPromise.catch(() => null as null),
+ timeoutPromise,
+ ]);
+
+ const readySignal = result ?? "timeout";
+ span.setAttribute("sidecar.stdio.ready_signal", readySignal);
+
+ if (result) {
+ this.ready = true;
+ span.setStatus({ code: SpanStatusCode.OK });
+ return true;
+ }
+
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: "Startup timeout",
+ });
+ return false;
+ } finally {
+ this.readyResolve = null;
+ }
+ },
+ );
+ }
+
+ async sendRequest(params: StdioRequestParams): Promise {
+ if (this.pending.size >= this.config.maxConcurrency) {
+ this.metrics.errorCount.add(1, {
+ "sidecar.stdio.error_type": "concurrency_exhausted",
+ });
+ throw SidecarError.concurrencyExhausted(this.config.maxConcurrency);
+ }
+
+ const id = this.nextId++;
+ const startTime = Date.now();
+
+ return this.telemetry.startActiveSpan(
+ "sidecar.stdio.request",
+ {
+ kind: SpanKind.CLIENT,
+ attributes: {
+ "sidecar.stdio.request_id": id,
+ "sidecar.stdio.path": params.path,
+ "sidecar.stdio.method": params.method ?? "POST",
+ "sidecar.stdio.pending_count": this.pending.size,
+ },
+ },
+ async (span) => {
+ this.metrics.pendingGauge.add(1);
+
+ try {
+ const message: JsonRpcRequest = {
+ jsonrpc: "2.0",
+ id,
+ method: "request",
+ params,
+ };
+ this.write(message);
+ span.addEvent("sidecar.stdio.message_sent", {
+ "sidecar.stdio.id": id,
+ });
+
+ const result = await this.waitForResponse(id);
+
+ const duration = Date.now() - startTime;
+ span.setAttribute("sidecar.stdio.duration_ms", duration);
+ span.setAttribute(
+ "sidecar.stdio.response_status",
+ result.status ?? 200,
+ );
+ span.setStatus({ code: SpanStatusCode.OK });
+
+ const metricAttrs = {
+ "sidecar.stdio.path": params.path,
+ "sidecar.stdio.method": params.method ?? "POST",
+ "sidecar.stdio.status": result.status ?? 200,
+ };
+ this.metrics.requestCount.add(1, metricAttrs);
+ this.metrics.requestDuration.record(duration, metricAttrs);
+
+ return result;
+ } catch (error) {
+ const duration = Date.now() - startTime;
+ const errorType =
+ error instanceof SidecarError
+ ? ((error.context?.errorType as string) ?? "bridge_error")
+ : "unknown";
+
+ span.recordException(error as Error);
+ span.setStatus({
+ code: SpanStatusCode.ERROR,
+ message: (error as Error).message,
+ });
+ span.setAttribute("sidecar.stdio.error_type", errorType);
+
+ this.metrics.errorCount.add(1, {
+ "sidecar.stdio.path": params.path,
+ "sidecar.stdio.error_type": errorType,
+ });
+ this.metrics.requestDuration.record(duration, {
+ "sidecar.stdio.path": params.path,
+ "sidecar.stdio.error": "true",
+ });
+
+ throw error;
+ } finally {
+ this.metrics.pendingGauge.add(-1);
+ }
+ },
+ );
+ }
+
+ async ping(timeout?: number): Promise {
+ const id = this.nextId++;
+ const pingTimeout = timeout ?? this.config.requestTimeout;
+
+ const message: JsonRpcRequest = {
+ jsonrpc: "2.0",
+ id,
+ method: "ping",
+ params: {},
+ };
+
+ try {
+ logger.info("Sending ping request to sidecar", message);
+ this.write(message);
+ await this.waitForResponse(id, pingTimeout);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ startHealthCheck(callbacks: {
+ onHealthy: () => void;
+ onUnhealthy: () => void;
+ }): void {
+ this.healthInterval = setInterval(async () => {
+ const healthy = await this.ping();
+
+ this.metrics.healthCheckCount.add(1, {
+ "sidecar.stdio.healthy": healthy,
+ });
+
+ if (healthy) {
+ this.consecutiveFailures = 0;
+ callbacks.onHealthy();
+ } else {
+ this.consecutiveFailures++;
+ logger.warn(
+ "Sidecar stdio ping failed (%d/%d)",
+ this.consecutiveFailures,
+ this.config.pingFailureThreshold,
+ );
+ if (this.consecutiveFailures >= this.config.pingFailureThreshold) {
+ callbacks.onUnhealthy();
+ this.consecutiveFailures = 0;
+ }
+ }
+ }, this.config.pingInterval);
+ }
+
+ stopHealthCheck(): void {
+ if (this.healthInterval) {
+ clearInterval(this.healthInterval);
+ this.healthInterval = null;
+ }
+ this.consecutiveFailures = 0;
+ }
+
+ destroy(): void {
+ if (this.healthInterval) {
+ clearInterval(this.healthInterval);
+ this.healthInterval = null;
+ }
+
+ // Reject all pending requests
+ for (const [id, pending] of this.pending) {
+ clearTimeout(pending.timer);
+ pending.reject(
+ new SidecarError("Sidecar bridge destroyed", { statusCode: 503 }),
+ );
+ }
+ this.pending.clear();
+
+ this.detach();
+ }
+
+ private write(message: JsonRpcRequest): void {
+ if (!this.stdin || this.stdin.destroyed) {
+ throw SidecarError.stdinWriteFailed();
+ }
+ const line = `${JSON.stringify(message)}\n`;
+ const ok = this.stdin.write(line);
+ if (!ok) {
+ // Backpressure — for now we just log. The write is still queued.
+ logger.debug("sidecar stdin backpressure on message id=%d", message.id);
+ }
+ }
+
+ private waitForResponse(
+ id: number,
+ timeout?: number,
+ ): Promise {
+ const requestTimeout = timeout ?? this.config.requestTimeout;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(id);
+ reject(SidecarError.bridgeTimeout(id, requestTimeout));
+ }, requestTimeout);
+
+ this.pending.set(id, { resolve, reject, timer });
+ });
+ }
+
+ private onStdoutData(chunk: Buffer): void {
+ this.lineBuffer += chunk.toString();
+ const lines = this.lineBuffer.split("\n");
+ this.lineBuffer = lines.pop() ?? "";
+
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const msg = JSON.parse(line);
+ this.handleMessage(msg);
+ } catch {
+ // Not JSON — treat as plain log output
+ logger.debug("sidecar stdout (non-JSON): %s", line);
+ }
+ }
+ }
+
+ private handleMessage(msg: unknown): void {
+ if (
+ typeof msg !== "object" ||
+ msg === null ||
+ (msg as Record).jsonrpc !== "2.0"
+ ) {
+ logger.debug("sidecar stdout (invalid JSON-RPC): %O", msg);
+ return;
+ }
+
+ const m = msg as Record;
+
+ if ("id" in m && (m.result !== undefined || m.error !== undefined)) {
+ // Response — correlate to pending request
+ const id = m.id as number;
+ const pending = this.pending.get(id);
+ if (!pending) return;
+ clearTimeout(pending.timer);
+ this.pending.delete(id);
+
+ if (m.error) {
+ const err = m.error as {
+ code: number;
+ message: string;
+ data?: unknown;
+ };
+ pending.reject(
+ SidecarError.bridgeRequestFailed(err.message, {
+ code: err.code,
+ data: err.data,
+ }),
+ );
+ } else {
+ pending.resolve((m.result ?? {}) as StdioResponsePayload);
+ }
+ } else if ("method" in m && !("id" in m)) {
+ this.handleNotification(m as unknown as JsonRpcNotification);
+ }
+ }
+
+ private handleNotification(msg: JsonRpcNotification): void {
+ switch (msg.method) {
+ case "ready":
+ this.ready = true;
+ this.readyResolve?.();
+ break;
+ case "log":
+ logger.info(
+ "sidecar: %s",
+ (msg.params as Record)?.message,
+ );
+ break;
+ default:
+ this.onNotification?.(msg.method, msg.params);
+ break;
+ }
+ }
+}
diff --git a/packages/appkit/src/plugins/sidecar/stdio-schema.ts b/packages/appkit/src/plugins/sidecar/stdio-schema.ts
new file mode 100644
index 00000000..ef31c7c5
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/stdio-schema.ts
@@ -0,0 +1,23 @@
+import type { StdioRequestPayload, StdioResponsePayload } from "shared";
+import { z } from "zod";
+
+export const stdioRequestSchema = z.object({
+ path: z.string().min(1),
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("POST"),
+ body: z.unknown().optional(),
+});
+
+// Compile-time check: Zod-inferred type must be assignable to the shared type
+const _requestCheck: StdioRequestPayload = {} as z.infer<
+ typeof stdioRequestSchema
+>;
+
+const stdioResponseSchema = z.object({
+ status: z.number().int().min(100).max(599).default(200),
+ headers: z.record(z.string(), z.string()).optional(),
+ body: z.unknown().optional(),
+});
+
+const _responseCheck: StdioResponsePayload = {} as z.infer<
+ typeof stdioResponseSchema
+>;
diff --git a/packages/appkit/src/plugins/sidecar/tests/errors.test.ts b/packages/appkit/src/plugins/sidecar/tests/errors.test.ts
new file mode 100644
index 00000000..d0a5a8f8
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/tests/errors.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from "vitest";
+import { SidecarError } from "../../../errors/sidecar";
+
+describe("SidecarError", () => {
+ test("default statusCode is 503", () => {
+ const err = new SidecarError("test");
+ expect(err.statusCode).toBe(503);
+ expect(err.code).toBe("SIDECAR_ERROR");
+ });
+
+ test("default isRetryable is true", () => {
+ const err = new SidecarError("test");
+ expect(err.isRetryable).toBe(true);
+ });
+
+ test("startupFailed is not retryable", () => {
+ const err = SidecarError.startupFailed("python", 30000);
+ expect(err.isRetryable).toBe(false);
+ expect(err.message).toContain("python");
+ expect(err.message).toContain("30000");
+ });
+
+ test("processCrashed is retryable", () => {
+ const err = SidecarError.processCrashed("python", 1);
+ expect(err.isRetryable).toBe(true);
+ expect(err.message).toContain("python");
+ });
+
+ test("maxRestartsExceeded is not retryable", () => {
+ const err = SidecarError.maxRestartsExceeded("python", 5);
+ expect(err.isRetryable).toBe(false);
+ expect(err.message).toContain("5");
+ });
+
+ test("proxyFailed returns 502", () => {
+ const err = SidecarError.proxyFailed();
+ expect(err.statusCode).toBe(502);
+ expect(err.isRetryable).toBe(true);
+ });
+
+ test("bridgeTimeout returns 504 and is retryable", () => {
+ const err = SidecarError.bridgeTimeout(42, 5000);
+ expect(err.statusCode).toBe(504);
+ expect(err.isRetryable).toBe(true);
+ expect(err.message).toContain("42");
+ expect(err.message).toContain("5000");
+ });
+
+ test("bridgeRequestFailed with code < -32000 is not retryable", () => {
+ const err = SidecarError.bridgeRequestFailed("parse error", {
+ code: -32001,
+ });
+ expect(err.statusCode).toBe(502);
+ expect(err.isRetryable).toBe(false);
+ });
+
+ test("bridgeRequestFailed with code >= -32000 is retryable", () => {
+ const err = SidecarError.bridgeRequestFailed("temp error", {
+ code: -31999,
+ });
+ expect(err.statusCode).toBe(502);
+ expect(err.isRetryable).toBe(true);
+ });
+
+ test("concurrencyExhausted returns 503 and is retryable", () => {
+ const err = SidecarError.concurrencyExhausted(50);
+ expect(err.statusCode).toBe(503);
+ expect(err.isRetryable).toBe(true);
+ expect(err.message).toContain("50");
+ });
+
+ test("stdinWriteFailed returns 502 and is retryable", () => {
+ const err = SidecarError.stdinWriteFailed();
+ expect(err.statusCode).toBe(502);
+ expect(err.isRetryable).toBe(true);
+ });
+
+ test("stdinWriteFailed preserves cause", () => {
+ const cause = new Error("stream destroyed");
+ const err = SidecarError.stdinWriteFailed(cause);
+ expect(err.cause).toBe(cause);
+ });
+
+ test("context is preserved", () => {
+ const err = SidecarError.bridgeTimeout(1, 5000);
+ expect(err.context).toEqual(
+ expect.objectContaining({
+ requestId: 1,
+ timeout: 5000,
+ errorType: "bridge_timeout",
+ }),
+ );
+ });
+});
diff --git a/packages/appkit/src/plugins/sidecar/tests/health-checker.test.ts b/packages/appkit/src/plugins/sidecar/tests/health-checker.test.ts
new file mode 100644
index 00000000..96c78cf1
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/tests/health-checker.test.ts
@@ -0,0 +1,250 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+vi.mock("../../../logging/logger", () => ({
+ createLogger: () => ({
+ info: vi.fn(),
+ debug: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+const mockFetch = vi.fn();
+vi.stubGlobal("fetch", mockFetch);
+
+import { HealthChecker } from "../health-checker";
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe("HealthChecker", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // ──────────────── E. Health Checking ────────────────
+
+ describe("E. Health Checking", () => {
+ test("E1: responds 200 on /health → status healthy", async () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(3000);
+ const onHealthy = vi.fn();
+ const onUnhealthy = vi.fn();
+
+ checker.start({ onHealthy, onUnhealthy });
+
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(onHealthy).toHaveBeenCalled();
+ expect(onUnhealthy).not.toHaveBeenCalled();
+
+ checker.stop();
+ });
+
+ test("E2: custom healthCheck.path is used", async () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(3000, { path: "/ready" });
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy: vi.fn() });
+
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ "http://localhost:3000/ready",
+ expect.anything(),
+ );
+
+ checker.stop();
+ });
+
+ test("E3: health check timeout counts as failure", async () => {
+ mockFetch.mockRejectedValue(new Error("timeout"));
+ const checker = new HealthChecker(3000, {
+ timeout: 1000,
+ unhealthyThreshold: 1,
+ });
+ const onUnhealthy = vi.fn();
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy });
+
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(onUnhealthy).toHaveBeenCalled();
+
+ checker.stop();
+ });
+
+ test("E4: consecutive failures exceed threshold → unhealthy", async () => {
+ mockFetch.mockResolvedValue({ ok: false });
+ const checker = new HealthChecker(3000, {
+ interval: 100,
+ unhealthyThreshold: 3,
+ });
+ const onUnhealthy = vi.fn();
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy });
+
+ // 3 checks at 100ms intervals
+ await vi.advanceTimersByTimeAsync(350);
+
+ expect(onUnhealthy).toHaveBeenCalled();
+
+ checker.stop();
+ });
+
+ test("E5: recovery after transient failure resets counter", async () => {
+ let callCount = 0;
+ mockFetch.mockImplementation(() => {
+ callCount++;
+ // Fail first 2, then succeed
+ if (callCount <= 2) return Promise.resolve({ ok: false });
+ return Promise.resolve({ ok: true });
+ });
+
+ const checker = new HealthChecker(3000, {
+ interval: 100,
+ unhealthyThreshold: 3,
+ });
+ const onHealthy = vi.fn();
+ const onUnhealthy = vi.fn();
+
+ checker.start({ onHealthy, onUnhealthy });
+
+ // 2 failures + 1 success = should NOT trigger unhealthy
+ await vi.advanceTimersByTimeAsync(350);
+
+ expect(onUnhealthy).not.toHaveBeenCalled();
+ expect(onHealthy).toHaveBeenCalled();
+
+ checker.stop();
+ });
+
+ test("E6: health check interval is respected", async () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(3000, { interval: 2000 });
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy: vi.fn() });
+
+ // At 1500ms, only 0 checks should have fired (first fires at 2000ms)
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(mockFetch).toHaveBeenCalledTimes(0);
+
+ // At 2500ms, 1 check should have fired
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ checker.stop();
+ });
+ });
+
+ describe("waitForReady", () => {
+ test("returns true when health check passes", async () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(3000);
+
+ const readyPromise = checker.waitForReady(5000);
+
+ await vi.advanceTimersByTimeAsync(1500);
+
+ const result = await readyPromise;
+ expect(result).toBe(true);
+ });
+
+ test("returns false when timeout exceeded", async () => {
+ mockFetch.mockRejectedValue(new Error("connection refused"));
+ const checker = new HealthChecker(3000, { timeout: 100 });
+
+ const readyPromise = checker.waitForReady(500);
+
+ // Advance past the timeout, allowing each poll cycle to complete
+ for (let i = 0; i < 10; i++) {
+ await vi.advanceTimersByTimeAsync(200);
+ }
+
+ const result = await readyPromise;
+ expect(result).toBe(false);
+ });
+
+ test("returns false when aborted via signal", async () => {
+ mockFetch.mockResolvedValue({ ok: false });
+ const controller = new AbortController();
+ const checker = new HealthChecker(3000);
+
+ const readyPromise = checker.waitForReady(10000, controller.signal);
+
+ controller.abort();
+ await vi.advanceTimersByTimeAsync(1500);
+
+ const result = await readyPromise;
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("stop", () => {
+ test("clears interval", () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(3000);
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy: vi.fn() });
+ checker.stop();
+
+ // No further checks after stop
+ mockFetch.mockClear();
+ vi.advanceTimersByTime(10000);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ test("start after stop restarts the interval", async () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(3000, { interval: 100 });
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy: vi.fn() });
+ checker.stop();
+
+ const onHealthy = vi.fn();
+ checker.start({ onHealthy, onUnhealthy: vi.fn() });
+
+ await vi.advanceTimersByTimeAsync(150);
+
+ expect(onHealthy).toHaveBeenCalled();
+
+ checker.stop();
+ });
+ });
+
+ describe("defaults", () => {
+ test("uses /health path by default", async () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(8080);
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy: vi.fn() });
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ "http://localhost:8080/health",
+ expect.anything(),
+ );
+
+ checker.stop();
+ });
+
+ test("uses AbortSignal.timeout for request timeout", async () => {
+ mockFetch.mockResolvedValue({ ok: true });
+ const checker = new HealthChecker(3000, { timeout: 2000 });
+
+ checker.start({ onHealthy: vi.fn(), onUnhealthy: vi.fn() });
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+
+ checker.stop();
+ });
+ });
+});
diff --git a/packages/appkit/src/plugins/sidecar/tests/process-manager.test.ts b/packages/appkit/src/plugins/sidecar/tests/process-manager.test.ts
new file mode 100644
index 00000000..a3687ffe
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/tests/process-manager.test.ts
@@ -0,0 +1,621 @@
+import { EventEmitter } from "node:events";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { SidecarError } from "../../../errors/sidecar";
+import type { SidecarDefinition } from "../types";
+
+// ── Mocks ────────────────────────────────────────────────────────────────────
+
+const mockChildProcess = vi.hoisted(() => {
+ function createMockChild(opts?: { stdinEnabled?: boolean }) {
+ const child = new EventEmitter() as EventEmitter & {
+ stdin: any;
+ stdout: any;
+ stderr: any;
+ pid: number;
+ kill: ReturnType;
+ };
+ child.pid = 1234;
+ child.kill = vi.fn((signal?: string) => {
+ // Simulate immediate exit on SIGKILL
+ if (signal === "SIGKILL") {
+ process.nextTick(() => child.emit("exit", null, "SIGKILL"));
+ }
+ return true;
+ });
+
+ if (opts?.stdinEnabled) {
+ child.stdin = {
+ write: vi.fn().mockReturnValue(true),
+ destroyed: false,
+ on: vi.fn(),
+ };
+ } else {
+ child.stdin = null;
+ }
+
+ child.stdout = new EventEmitter();
+ child.stderr = new EventEmitter();
+
+ return child;
+ }
+
+ return { createMockChild };
+});
+
+const { mockSpawn, mockExistsSync } = vi.hoisted(() => ({
+ mockSpawn: vi.fn(),
+ mockExistsSync: vi.fn().mockReturnValue(true),
+}));
+
+vi.mock("node:child_process", () => ({
+ spawn: mockSpawn,
+}));
+
+vi.mock("node:fs", () => ({
+ default: { existsSync: mockExistsSync },
+}));
+
+vi.mock("node:net", () => ({
+ default: {
+ createServer: vi.fn(() => {
+ const server = new EventEmitter() as any;
+ server.listen = vi.fn((_port: number, cb: () => void) => {
+ server.emit("listening");
+ cb?.();
+ });
+ server.address = vi.fn().mockReturnValue({ port: 9999 });
+ server.close = vi.fn((cb?: () => void) => cb?.());
+ return server;
+ }),
+ },
+}));
+
+vi.mock("../../../logging/logger", () => ({
+ createLogger: () => ({
+ info: vi.fn(),
+ debug: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+import { ProcessManager } from "../process-manager";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function makeConfig(overrides: Partial = {}): SidecarDefinition {
+ return {
+ id: "test",
+ command: "python",
+ args: ["-m", "http.server"],
+ ...overrides,
+ };
+}
+
+function spawnReturningChild(stdinEnabled = false) {
+ const child = mockChildProcess.createMockChild({ stdinEnabled });
+ mockSpawn.mockReturnValue(child);
+ return child;
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe("ProcessManager", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ mockExistsSync.mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // ──────────────── B. Security & Input Validation ────────────────
+
+ describe("B. Security & Input Validation", () => {
+ test("B1: rejects shell metacharacters in command (semicolon)", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "python; rm -rf /" }))).toThrow(
+ /shell metacharacters/,
+ );
+ });
+
+ test("B1: rejects shell metacharacters in command (pipe)", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "cat | grep" }))).toThrow(
+ /shell metacharacters/,
+ );
+ });
+
+ test("B1: rejects shell metacharacters in command (ampersand)", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "cmd &" }))).toThrow(
+ /shell metacharacters/,
+ );
+ });
+
+ test("B1: rejects shell metacharacters in command (backtick)", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "echo `whoami`" }))).toThrow(
+ /shell metacharacters/,
+ );
+ });
+
+ test("B1: rejects shell metacharacters in command (dollar sign)", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "echo $PATH" }))).toThrow(
+ /shell metacharacters/,
+ );
+ });
+
+ test("B1: rejects shell metacharacters in command (newline)", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "cmd\nrm" }))).toThrow(
+ /shell metacharacters/,
+ );
+ });
+
+ test("B5: shell: false enforced on spawn", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+ expect(mockSpawn).toHaveBeenCalledWith(
+ "python",
+ ["-m", "http.server"],
+ expect.objectContaining({ shell: false }),
+ );
+ });
+
+ test("B1: rejects empty command", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "" }))).toThrow(
+ /non-empty string/,
+ );
+ });
+
+ test("B1: rejects whitespace-only command", () => {
+ expect(() => new ProcessManager(makeConfig({ command: " " }))).toThrow(
+ /non-empty string/,
+ );
+ });
+
+ test("cwd with null bytes is rejected", () => {
+ expect(() => new ProcessManager(makeConfig({ cwd: "/tmp/\0evil" }))).toThrow(
+ /null bytes/,
+ );
+ });
+
+ test("cwd that does not exist is rejected", () => {
+ mockExistsSync.mockReturnValue(false);
+ expect(() => new ProcessManager(makeConfig({ cwd: "/nonexistent" }))).toThrow(
+ /does not exist/,
+ );
+ });
+ });
+
+ // ──────────────── A. Initialization ────────────────
+
+ describe("A. Init & Config", () => {
+ test("A7: missing command throws validation error", () => {
+ expect(() => new ProcessManager(makeConfig({ command: "" }))).toThrow(SidecarError);
+ });
+
+ test("initial status is stopped", () => {
+ const pm = new ProcessManager(makeConfig());
+ expect(pm.status).toBe("stopped");
+ });
+
+ test("initial port is 0", () => {
+ const pm = new ProcessManager(makeConfig());
+ expect(pm.port).toBe(0);
+ });
+
+ test("spawn sets status to starting", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ const statuses: string[] = [];
+ pm.onStatusChange((s) => statuses.push(s));
+ await pm.spawn();
+ expect(statuses).toContain("starting");
+ });
+
+ test("spawn resolves port for http mode", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig({ port: 0 }));
+ await pm.spawn();
+ expect(pm.port).toBe(9999);
+ });
+
+ test("spawn uses fixed port when specified", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig({ port: 3000 }));
+ await pm.spawn();
+ expect(pm.port).toBe(3000);
+ });
+
+ test("spawn skips port resolution for stdio mode", async () => {
+ spawnReturningChild(true);
+ const pm = new ProcessManager(makeConfig({ mode: "stdio" }));
+ await pm.spawn();
+ expect(pm.port).toBe(0);
+ });
+
+ test("spawn sets PORT and SIDECAR_PORT env vars for http mode", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig({ port: 4000 }));
+ await pm.spawn();
+ const envArg = mockSpawn.mock.calls[0][2].env;
+ expect(envArg.PORT).toBe("4000");
+ expect(envArg.SIDECAR_PORT).toBe("4000");
+ });
+
+ test("spawn does not set PORT env vars for stdio mode", async () => {
+ spawnReturningChild(true);
+ const pm = new ProcessManager(makeConfig({ mode: "stdio" }));
+ await pm.spawn();
+ const envArg = mockSpawn.mock.calls[0][2].env;
+ expect(envArg.PORT).toBeUndefined();
+ expect(envArg.SIDECAR_PORT).toBeUndefined();
+ });
+
+ test("F11: custom env merged with process.env", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig({ env: { MY_VAR: "hello" } }));
+ await pm.spawn();
+ const envArg = mockSpawn.mock.calls[0][2].env;
+ expect(envArg.MY_VAR).toBe("hello");
+ // process.env vars should also be present
+ expect(envArg.PATH).toBeDefined();
+ });
+
+ test("F12: custom cwd applied", async () => {
+ mockExistsSync.mockReturnValue(true);
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig({ cwd: "/tmp" }));
+ await pm.spawn();
+ const cwdArg = mockSpawn.mock.calls[0][2].cwd;
+ expect(cwdArg).toContain("tmp");
+ });
+
+ test("stdio mode uses pipe for all three stdio channels", async () => {
+ spawnReturningChild(true);
+ const pm = new ProcessManager(makeConfig({ mode: "stdio" }));
+ await pm.spawn();
+ expect(mockSpawn.mock.calls[0][2].stdio).toEqual(["pipe", "pipe", "pipe"]);
+ });
+
+ test("http mode uses ignore for stdin", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+ expect(mockSpawn.mock.calls[0][2].stdio).toEqual(["ignore", "pipe", "pipe"]);
+ });
+ });
+
+ // ──────────────── F. Process Lifecycle & Restart ────────────────
+
+ describe("F. Process Lifecycle & Restart", () => {
+ test("F1: child crash triggers auto-restart when enabled", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(
+ makeConfig({ restart: { enabled: true, restartDelay: 100 } }),
+ );
+ await pm.spawn();
+
+ // Second spawn for restart
+ const child2 = spawnReturningChild();
+
+ // Simulate crash
+ child.emit("exit", 1, null);
+
+ // Wait for restart delay
+ await vi.advanceTimersByTimeAsync(150);
+
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
+ });
+
+ test("F2: restart.enabled false means no restart on crash", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(
+ makeConfig({ restart: { enabled: false } }),
+ );
+ await pm.spawn();
+
+ child.emit("exit", 1, null);
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(pm.status).toBe("crashed");
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
+ });
+
+ test("F3: restart delay is respected", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(
+ makeConfig({ restart: { enabled: true, restartDelay: 2000 } }),
+ );
+ await pm.spawn();
+
+ spawnReturningChild();
+ child.emit("exit", 1, null);
+
+ // Before delay
+ await vi.advanceTimersByTimeAsync(1500);
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
+
+ // After delay
+ await vi.advanceTimersByTimeAsync(600);
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
+ });
+
+ test("F4: max restarts exceeded sets status to crashed", async () => {
+ const pm = new ProcessManager(
+ makeConfig({
+ restart: {
+ enabled: true,
+ maxRestarts: 2,
+ restartDelay: 10,
+ restartWindow: 60000,
+ },
+ }),
+ );
+
+ // Initial spawn
+ let child = spawnReturningChild();
+ await pm.spawn();
+
+ // Crash 1 → auto-restart
+ spawnReturningChild(); // prep next child for auto-restart
+ child.emit("exit", 1, null);
+ await vi.advanceTimersByTimeAsync(50);
+
+ // Crash 2 → auto-restart
+ child = mockSpawn.mock.results[mockSpawn.mock.results.length - 1]?.value;
+ spawnReturningChild(); // prep next child
+ child.emit("exit", 1, null);
+ await vi.advanceTimersByTimeAsync(50);
+
+ // Crash 3 → exceeds max (2), should be crashed
+ child = mockSpawn.mock.results[mockSpawn.mock.results.length - 1]?.value;
+ child.emit("exit", 1, null);
+ await vi.advanceTimersByTimeAsync(50);
+
+ expect(pm.status).toBe("crashed");
+ });
+
+ test("F5: restart window expiry resets counter", async () => {
+ const pm = new ProcessManager(
+ makeConfig({
+ restart: {
+ enabled: true,
+ maxRestarts: 1,
+ restartDelay: 10,
+ restartWindow: 500,
+ },
+ }),
+ );
+
+ // Initial spawn
+ let child = spawnReturningChild();
+ await pm.spawn();
+
+ // Crash 1 → auto-restart (count: 1)
+ spawnReturningChild();
+ child.emit("exit", 1, null);
+ await vi.advanceTimersByTimeAsync(50);
+
+ // Now we've used our 1 restart. Wait for window to expire.
+ await vi.advanceTimersByTimeAsync(600);
+
+ // Crash again — window has expired, count should reset
+ child = mockSpawn.mock.results[mockSpawn.mock.results.length - 1]?.value;
+ spawnReturningChild();
+ child.emit("exit", 1, null);
+ await vi.advanceTimersByTimeAsync(50);
+
+ // If window reset worked, we should see another spawn (not crashed)
+ expect(mockSpawn.mock.calls.length).toBeGreaterThanOrEqual(3);
+ expect(pm.status).not.toBe("crashed");
+ });
+
+ test("F7: graceful shutdown sends SIGTERM", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ // Stop and simulate child exiting
+ const stopPromise = pm.stop();
+ child.emit("exit", 0, "SIGTERM");
+ await stopPromise;
+
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+ expect(pm.status).toBe("stopped");
+ });
+
+ test("F8: force kill after SIGTERM timeout", async () => {
+ const child = spawnReturningChild();
+ // Don't auto-exit on SIGKILL for this test
+ child.kill = vi.fn().mockImplementation((signal?: string) => {
+ if (signal === "SIGKILL") {
+ // Simulate delayed exit after SIGKILL
+ setTimeout(() => child.emit("exit", null, "SIGKILL"), 10);
+ }
+ return true;
+ });
+
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ const stopPromise = pm.stop(500);
+
+ // Don't exit on SIGTERM — wait for force kill
+ await vi.advanceTimersByTimeAsync(600);
+ await stopPromise;
+
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+ expect(child.kill).toHaveBeenCalledWith("SIGKILL");
+ });
+
+ test("F9: output buffer capped at 1000 lines", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ // Emit 1100 lines
+ for (let i = 0; i < 1100; i++) {
+ child.stderr.emit("data", Buffer.from(`line-${i}\n`));
+ }
+
+ const output = pm.getOutput();
+ expect(output.length).toBe(1000);
+ // Oldest lines should have been evicted
+ expect(output[0]).toContain("line-100");
+ expect(output[999]).toContain("line-1099");
+ });
+
+ test("getOutput returns last N lines when specified", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ for (let i = 0; i < 50; i++) {
+ child.stderr.emit("data", Buffer.from(`line-${i}\n`));
+ }
+
+ const output = pm.getOutput(5);
+ expect(output.length).toBe(5);
+ expect(output[4]).toContain("line-49");
+ });
+
+ test("stop when no process is running resolves immediately", async () => {
+ const pm = new ProcessManager(makeConfig());
+ await pm.stop();
+ // Should not throw
+ });
+
+ test("stop clears pending restart timer", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(
+ makeConfig({ restart: { enabled: true, restartDelay: 5000 } }),
+ );
+ await pm.spawn();
+
+ // Trigger a crash (starts restart timer)
+ child.emit("exit", 1, null);
+
+ // Stop immediately — should cancel the restart
+ const child2 = spawnReturningChild();
+ // Need to wait a bit to let the stop handler fire
+ await vi.advanceTimersByTimeAsync(0);
+
+ // Call stop explicitly
+ pm.onStatusChange(() => {}); // Just to verify no error
+ const stopPromise = pm.stop();
+ child2.emit?.("exit", 0, null);
+ // The restart timer should have been cleared
+ });
+
+ test("restart stops then spawns", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ const child2 = spawnReturningChild();
+
+ const restartPromise = pm.restart();
+ // First child exits on SIGTERM
+ child.emit("exit", 0, "SIGTERM");
+ await restartPromise;
+
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
+ });
+
+ test("process error event sets status to crashed", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ child.emit("error", new Error("ENOENT"));
+ expect(pm.status).toBe("crashed");
+ });
+
+ test("setHealthy transitions from starting to healthy", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+ expect(pm.status).toBe("starting");
+
+ pm.setHealthy();
+ expect(pm.status).toBe("healthy");
+ });
+
+ test("setUnhealthy transitions from healthy to unhealthy", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+ pm.setHealthy();
+ pm.setUnhealthy();
+ expect(pm.status).toBe("unhealthy");
+ });
+
+ test("onStatusChange notifies listeners", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ const statuses: string[] = [];
+ pm.onStatusChange((s) => statuses.push(s));
+ await pm.spawn();
+ pm.setHealthy();
+ expect(statuses).toEqual(["starting", "healthy"]);
+ });
+
+ test("getStdin returns null in http mode", async () => {
+ spawnReturningChild(false);
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+ expect(pm.getStdin()).toBeNull();
+ });
+
+ test("getStdin returns writable in stdio mode", async () => {
+ spawnReturningChild(true);
+ const pm = new ProcessManager(makeConfig({ mode: "stdio" }));
+ await pm.spawn();
+ expect(pm.getStdin()).not.toBeNull();
+ });
+
+ test("getStdout returns readable", async () => {
+ spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+ expect(pm.getStdout()).not.toBeNull();
+ });
+
+ test("spawn stops existing process before respawning", async () => {
+ const child1 = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ const child2 = spawnReturningChild();
+ const spawnPromise = pm.spawn();
+
+ // child1 should get SIGTERM
+ child1.emit("exit", 0, "SIGTERM");
+ await spawnPromise;
+
+ expect(child1.kill).toHaveBeenCalledWith("SIGTERM");
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
+ });
+
+ test("http mode buffers stdout", async () => {
+ const child = spawnReturningChild();
+ const pm = new ProcessManager(makeConfig());
+ await pm.spawn();
+
+ child.stdout.emit("data", Buffer.from("hello world\n"));
+ const output = pm.getOutput();
+ expect(output.some((l) => l.includes("hello world"))).toBe(true);
+ });
+
+ test("stdio mode does NOT buffer stdout (bridge owns it)", async () => {
+ const child = spawnReturningChild(true);
+ const pm = new ProcessManager(makeConfig({ mode: "stdio" }));
+ await pm.spawn();
+
+ child.stdout.emit("data", Buffer.from("should not appear\n"));
+ const output = pm.getOutput();
+ expect(output.every((l) => !l.includes("should not appear"))).toBe(true);
+ });
+ });
+});
diff --git a/packages/appkit/src/plugins/sidecar/tests/proxy.test.ts b/packages/appkit/src/plugins/sidecar/tests/proxy.test.ts
new file mode 100644
index 00000000..78d9f746
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/tests/proxy.test.ts
@@ -0,0 +1,604 @@
+import { EventEmitter } from "node:events";
+import http from "node:http";
+import { createMockTelemetry } from "@tools/test-helpers";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+// ── Mocks ────────────────────────────────────────────────────────────────────
+
+const mockProxyRes = vi.hoisted(() => {
+ function create(statusCode = 200, headers: Record = {}) {
+ const res = new EventEmitter() as EventEmitter & {
+ statusCode: number;
+ headers: Record;
+ pipe: ReturnType;
+ };
+ res.statusCode = statusCode;
+ res.headers = { "content-type": "application/json", ...headers };
+ res.pipe = vi.fn((target) => {
+ // Simulate pipe completion
+ process.nextTick(() => res.emit("end"));
+ return target;
+ });
+ return res;
+ }
+ return { create };
+});
+
+const mockHttpRequest = vi.hoisted(() => {
+ const fn = vi.fn();
+ return fn;
+});
+
+vi.mock("node:http", () => ({
+ default: {
+ request: mockHttpRequest,
+ },
+}));
+
+vi.mock("../../../logging/logger", () => ({
+ createLogger: () => ({
+ info: vi.fn(),
+ debug: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+import { SidecarProxy } from "../proxy";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function createExtendedMockTelemetry() {
+ const base = createMockTelemetry();
+ const meter = base.getMeter();
+ (meter as any).createUpDownCounter = vi.fn().mockReturnValue({ add: vi.fn() });
+ return base;
+}
+
+function createMockReq(overrides: Record = {}) {
+ const req = new EventEmitter() as any;
+ req.method = overrides.method ?? "GET";
+ req.path = overrides.path ?? "/api/test";
+ req.url = overrides.url ?? req.path;
+ req.headers = overrides.headers ?? {};
+ req.pipe = vi.fn().mockReturnThis();
+ return req;
+}
+
+function createMockRes() {
+ const res: any = {
+ headersSent: false,
+ };
+ res.status = vi.fn().mockReturnValue(res);
+ res.json = vi.fn().mockReturnValue(res);
+ res.setHeader = vi.fn().mockReturnValue(res);
+ res.pipe = vi.fn().mockReturnValue(res);
+ return res;
+}
+
+function setupHttpMock(
+ statusCode = 200,
+ responseHeaders: Record = {},
+) {
+ const proxyRes = mockProxyRes.create(statusCode, responseHeaders);
+ const proxyReq = new EventEmitter() as EventEmitter & {
+ destroy: ReturnType;
+ };
+ proxyReq.destroy = vi.fn();
+
+ mockHttpRequest.mockImplementation((_opts: any, callback: (res: any) => void) => {
+ process.nextTick(() => callback(proxyRes));
+ return proxyReq;
+ });
+
+ return { proxyReq, proxyRes };
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe("SidecarProxy", () => {
+ let telemetry: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ telemetry = createExtendedMockTelemetry();
+ });
+
+ // ──────────────── B. Security — Path Validation ────────────────
+
+ describe("B. Path Security", () => {
+ test("B2: path traversal with ../ is neutralized by normalize", async () => {
+ // With basePath "/", normalize removes ".." so /../../etc/passwd → /etc/passwd
+ // This is safe because the child only sees /etc/passwd, not a traversal
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ path: "/../../etc/passwd",
+ url: "/../../etc/passwd",
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ // The path is normalized — no ".." sent to child
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.path).toBe("/etc/passwd");
+ expect(opts.path).not.toContain("..");
+ });
+
+ test("B3: null bytes in path are rejected", () => {
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({ path: "/test\0evil" });
+ const res = createMockRes();
+
+ middleware(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error: "Invalid request path" });
+ });
+
+ test("B2: path with .. segments normalizes safely with non-root basePath", async () => {
+ // posixPath.normalize removes .. before it reaches the child
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry, { basePath: "/api/v1" });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ path: "/../secret",
+ url: "/../secret",
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ // normalize removes ".." → child sees /api/v1/secret (stays within basePath)
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.path).not.toContain("..");
+ expect(opts.path).toMatch(/^\/api\/v1/);
+ });
+ });
+
+ // ──────────────── C. HTTP Proxy Behavior ────────────────
+
+ describe("C. HTTP Proxy Behavior", () => {
+ test("returns 503 when sidecar is not healthy", () => {
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "starting");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: "Sidecar process is not ready" }),
+ );
+ });
+
+ test("C1: GET request is proxied correctly", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ method: "GET",
+ path: "/health",
+ url: "/health?foo=bar",
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.method).toBe("GET");
+ expect(opts.path).toBe("/health?foo=bar");
+ expect(opts.hostname).toBe("localhost");
+ expect(opts.port).toBe(3000);
+ });
+
+ test("C2: POST with body is piped to child", async () => {
+ setupHttpMock(201);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({ method: "POST", path: "/data" });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(req.pipe).toHaveBeenCalled();
+ });
+ });
+
+ test("C3: PUT and DELETE are forwarded", async () => {
+ for (const method of ["PUT", "DELETE"]) {
+ vi.clearAllMocks();
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({ method, path: "/resource" });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.method).toBe(method);
+ }
+ });
+
+ test("C6: forwardHeaders 'all' forwards non-hop-by-hop headers", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry, { forwardHeaders: "all" });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ headers: {
+ "content-type": "application/json",
+ "x-custom": "value",
+ connection: "keep-alive",
+ "keep-alive": "timeout=5",
+ },
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.headers["content-type"]).toBe("application/json");
+ expect(opts.headers["x-custom"]).toBe("value");
+ // Hop-by-hop headers should be stripped
+ expect(opts.headers.connection).toBeUndefined();
+ expect(opts.headers["keep-alive"]).toBeUndefined();
+ });
+
+ test("C7: forwardHeaders with specific list only forwards listed headers", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry, {
+ forwardHeaders: ["x-custom"],
+ });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ headers: {
+ "content-type": "application/json",
+ "x-custom": "value",
+ },
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.headers["x-custom"]).toBe("value");
+ expect(opts.headers["content-type"]).toBeUndefined();
+ });
+
+ test("C8: hop-by-hop headers are stripped from response", async () => {
+ const proxyRes = mockProxyRes.create(200, {
+ "content-type": "application/json",
+ connection: "keep-alive",
+ "transfer-encoding": "chunked",
+ });
+ const proxyReq = new EventEmitter() as any;
+ proxyReq.destroy = vi.fn();
+
+ mockHttpRequest.mockImplementation((_opts: any, callback: (res: any) => void) => {
+ process.nextTick(() => callback(proxyRes));
+ return proxyReq;
+ });
+
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(res.status).toHaveBeenCalledWith(200);
+ });
+
+ // Non-hop-by-hop headers should be set on response
+ expect(res.setHeader).toHaveBeenCalledWith("content-type", "application/json");
+ // Hop-by-hop headers should NOT be set
+ const setHeaderCalls = res.setHeader.mock.calls.map((c: any[]) => c[0]);
+ expect(setHeaderCalls).not.toContain("connection");
+ expect(setHeaderCalls).not.toContain("transfer-encoding");
+ });
+
+ test("C9: injectHeaders are added to proxied request", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry, {
+ injectHeaders: { "x-injected": "injected-value" },
+ });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.headers["x-injected"]).toBe("injected-value");
+ });
+
+ test("C10: auth headers always forwarded even with specific forwardHeaders", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry, {
+ forwardHeaders: ["x-custom"],
+ });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ headers: {
+ "x-forwarded-user": "user123",
+ "x-forwarded-access-token": "token456",
+ "x-custom": "val",
+ },
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.headers["x-forwarded-user"]).toBe("user123");
+ expect(opts.headers["x-forwarded-access-token"]).toBe("token456");
+ });
+
+ test("C11: host header is rewritten to localhost:port", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ headers: { host: "example.com" },
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.headers.host).toBe("localhost:3000");
+ });
+
+ test("C12: basePath is applied to target path", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry, { basePath: "/v1" });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({ path: "/users" });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.path).toBe("/v1/users");
+ });
+
+ test("C13: proxy timeout triggers 504", async () => {
+ const proxyReq = new EventEmitter() as any;
+ proxyReq.destroy = vi.fn();
+
+ mockHttpRequest.mockImplementation(() => proxyReq);
+
+ const proxy = new SidecarProxy(3000, telemetry, { timeout: 1000 });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+
+ // Simulate timeout event
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ proxyReq.emit("timeout");
+
+ await vi.waitFor(() => {
+ expect(res.status).toHaveBeenCalledWith(504);
+ });
+ expect(res.json).toHaveBeenCalledWith({ error: "Sidecar request timed out" });
+ expect(proxyReq.destroy).toHaveBeenCalled();
+ });
+
+ test("C14: ECONNREFUSED returns 502", async () => {
+ const proxyReq = new EventEmitter() as any;
+ proxyReq.destroy = vi.fn();
+
+ mockHttpRequest.mockImplementation(() => proxyReq);
+
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const connRefused = Object.assign(new Error("connect ECONNREFUSED"), {
+ code: "ECONNREFUSED",
+ });
+ proxyReq.emit("error", connRefused);
+
+ await vi.waitFor(() => {
+ expect(res.status).toHaveBeenCalledWith(502);
+ });
+ expect(res.json).toHaveBeenCalledWith({
+ error: "Sidecar process is unavailable",
+ });
+ });
+
+ test("C14: generic proxy error returns 502", async () => {
+ const proxyReq = new EventEmitter() as any;
+ proxyReq.destroy = vi.fn();
+
+ mockHttpRequest.mockImplementation(() => proxyReq);
+
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ proxyReq.emit("error", new Error("Something went wrong"));
+
+ await vi.waitFor(() => {
+ expect(res.status).toHaveBeenCalledWith(502);
+ });
+ expect(res.json).toHaveBeenCalledWith({
+ error: "Failed to proxy request to sidecar",
+ });
+ });
+
+ test("query string is forwarded", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({
+ path: "/search",
+ url: "/search?q=hello&page=2",
+ });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.path).toBe("/search?q=hello&page=2");
+ });
+
+ test("no query string when url has none", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({ path: "/data", url: "/data" });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.path).toBe("/data");
+ });
+
+ test("status code is forwarded from proxy response", async () => {
+ setupHttpMock(404);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(res.status).toHaveBeenCalledWith(404);
+ });
+ });
+
+ test("proxy response body is piped to client", async () => {
+ const proxyRes = mockProxyRes.create(200);
+ const proxyReq = new EventEmitter() as any;
+ proxyReq.destroy = vi.fn();
+
+ mockHttpRequest.mockImplementation((_opts: any, callback: any) => {
+ process.nextTick(() => callback(proxyRes));
+ return proxyReq;
+ });
+
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(proxyRes.pipe).toHaveBeenCalledWith(res);
+ });
+ });
+
+ test("timeout config is passed to http.request", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry, { timeout: 5000 });
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(mockHttpRequest).toHaveBeenCalled();
+ });
+
+ const opts = mockHttpRequest.mock.calls[0][0];
+ expect(opts.timeout).toBe(5000);
+ });
+ });
+
+ // ──────────────── H. Telemetry ────────────────
+
+ describe("H. Telemetry", () => {
+ test("H1: proxy request creates span", async () => {
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq({ path: "/test", method: "GET" });
+ const res = createMockRes();
+
+ middleware(req, res);
+ await vi.waitFor(() => {
+ expect(telemetry.startActiveSpan).toHaveBeenCalledWith(
+ "sidecar.proxy.request",
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ "sidecar.proxy.path": "/test",
+ "sidecar.proxy.method": "GET",
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+ });
+
+ test("H8: telemetry is no-op when not configured (uses mock)", async () => {
+ // The mock telemetry is essentially no-op — verify it doesn't throw
+ setupHttpMock(200);
+ const proxy = new SidecarProxy(3000, telemetry);
+ const middleware = proxy.middleware(() => "healthy");
+ const req = createMockReq();
+ const res = createMockRes();
+
+ expect(() => middleware(req, res)).not.toThrow();
+ });
+ });
+});
diff --git a/packages/appkit/src/plugins/sidecar/tests/sidecar.test.ts b/packages/appkit/src/plugins/sidecar/tests/sidecar.test.ts
new file mode 100644
index 00000000..9885447d
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/tests/sidecar.test.ts
@@ -0,0 +1,341 @@
+import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { SidecarError } from "../../../errors/sidecar";
+import type { ISidecarConfig, SidecarDefinition } from "../types";
+
+// ── Mocks ────────────────────────────────────────────────────────────────────
+
+const mockProcessManagerInstances: any[] = [];
+
+const { MockProcessManager } = vi.hoisted(() => {
+ const MockProcessManager = vi.fn().mockImplementation(() => {
+ const instance = {
+ spawn: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn().mockResolvedValue(undefined),
+ restart: vi.fn().mockResolvedValue(undefined),
+ status: "stopped" as string,
+ port: 3000,
+ setHealthy: vi.fn(),
+ setUnhealthy: vi.fn(),
+ onStatusChange: vi.fn(),
+ getOutput: vi.fn().mockReturnValue(["line1", "line2"]),
+ getStdin: vi.fn().mockReturnValue({
+ write: vi.fn().mockReturnValue(true),
+ destroyed: false,
+ }),
+ getStdout: vi.fn().mockReturnValue({
+ on: vi.fn(),
+ removeListener: vi.fn(),
+ }),
+ };
+ return instance;
+ });
+
+ return { MockProcessManager };
+});
+
+vi.mock("../process-manager", () => ({
+ ProcessManager: MockProcessManager,
+}));
+
+vi.mock("../health-checker", () => ({
+ HealthChecker: vi.fn(() => ({
+ waitForReady: vi.fn().mockResolvedValue(true),
+ start: vi.fn(),
+ stop: vi.fn(),
+ })),
+}));
+
+vi.mock("../stdio-bridge", () => ({
+ StdioBridge: vi.fn(() => ({
+ attach: vi.fn(),
+ detach: vi.fn(),
+ waitForReady: vi.fn().mockResolvedValue(true),
+ sendRequest: vi.fn().mockResolvedValue({ status: 200, body: {} }),
+ startHealthCheck: vi.fn(),
+ stopHealthCheck: vi.fn(),
+ destroy: vi.fn(),
+ })),
+}));
+
+vi.mock("../proxy", () => ({
+ SidecarProxy: vi.fn(() => ({
+ middleware: vi.fn().mockReturnValue(vi.fn()),
+ })),
+}));
+
+vi.mock("node:child_process", () => ({
+ exec: vi.fn((_cmd: string, _opts: any, cb: any) => cb?.(null, "", "")),
+ execFile: vi.fn((_bin: string, _args: any, _opts: any, cb: any) =>
+ cb?.(null, "", ""),
+ ),
+}));
+
+vi.mock("node:util", () => ({
+ promisify: () => vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
+}));
+
+vi.mock("express", () => ({
+ Router: vi.fn(() => ({
+ all: vi.fn(),
+ use: vi.fn(),
+ get: vi.fn(),
+ post: vi.fn(),
+ })),
+}));
+
+vi.mock("../../../logging/logger", () => ({
+ createLogger: () => ({
+ info: vi.fn(),
+ debug: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+vi.mock("../../../cache", () => ({
+ CacheManager: {
+ getInstanceSync: vi.fn(() => ({
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn(),
+ getOrExecute: vi.fn(async (_key: unknown[], fn: () => Promise) =>
+ fn(),
+ ),
+ generateKey: vi.fn(),
+ })),
+ },
+}));
+
+// Import AFTER mocks — `sidecar` is a factory (toPlugin wrapper)
+// We need the actual SidecarPlugin class to instantiate directly
+import { sidecar } from "../sidecar";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function makeHttpConfig(overrides: Partial = {}): ISidecarConfig {
+ return {
+ id: "test-http",
+ command: "python",
+ args: ["-m", "http.server"],
+ mode: "http",
+ ...overrides,
+ };
+}
+
+function makeStdioConfig(overrides: Partial = {}): ISidecarConfig {
+ return {
+ id: "test-stdio",
+ command: "python",
+ args: ["bridge.py"],
+ mode: "stdio",
+ ...overrides,
+ };
+}
+
+function makeMultiConfig(defs: SidecarDefinition[]): ISidecarConfig {
+ return defs;
+}
+
+/**
+ * Instantiate SidecarPlugin via the sidecar() factory.
+ * The factory normalizes the config, so we pass data.config to the constructor.
+ */
+function createPlugin(config: ISidecarConfig) {
+ const data = sidecar(config);
+ const PluginClass = data.plugin as any;
+ return new PluginClass(data.config);
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe("SidecarPlugin", () => {
+ let serviceContextMock: Awaited>;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ setupDatabricksEnv();
+ const { ServiceContext } = await import("../../../context/service-context");
+ ServiceContext.reset();
+ serviceContextMock = await mockServiceContext();
+ });
+
+ afterEach(() => {
+ serviceContextMock?.restore();
+ });
+
+ // ──────────────── A. Plugin Initialization & Configuration ────────────────
+
+ describe("A. Init & Config", () => {
+ test("A1: single HTTP sidecar with valid config", () => {
+ const data = sidecar(makeHttpConfig());
+ expect(data.name).toBe("sidecar");
+ });
+
+ test("A2: single stdio sidecar with valid config", () => {
+ const data = sidecar(makeStdioConfig());
+ expect(data.name).toBe("sidecar");
+ });
+
+ test("A3: multiple sidecars in sidecars[] array", () => {
+ const inst = createPlugin(
+ makeMultiConfig([
+ { id: "py", command: "python", mode: "http" },
+ { id: "go", command: "go", args: ["run", "main.go"], mode: "stdio" },
+ ]),
+ );
+ // ProcessManager should have been called twice
+ expect(MockProcessManager).toHaveBeenCalledTimes(2);
+ });
+
+ test("A4: legacy flat config treated as single sidecar", () => {
+ const inst = createPlugin({
+ id: "legacy",
+ command: "ruby",
+ args: ["server.rb"],
+ });
+ expect(MockProcessManager).toHaveBeenCalledTimes(1);
+ });
+
+ test("A6: duplicate sidecar id values throw error", () => {
+ expect(() =>
+ createPlugin(
+ makeMultiConfig([
+ { id: "dup", command: "python" },
+ { id: "dup", command: "ruby" },
+ ]),
+ ),
+ ).toThrow(/Duplicate sidecar id/);
+ });
+
+ test("A8: plugin name is always 'sidecar' from manifest", () => {
+ const data = sidecar(makeHttpConfig());
+ expect(data.name).toBe("sidecar");
+ // Instance name falls back to manifest name when no config.name
+ const inst = createPlugin(makeHttpConfig());
+ expect(inst.name).toBe("sidecar");
+ });
+ });
+
+ // ──────────────── G. Exports API ────────────────
+
+ describe("G. Exports API", () => {
+ test("G1: get(id) returns SingleSidecarExport for existing sidecar", () => {
+ const inst = createPlugin(makeHttpConfig({ id: "my-sc" }));
+ const exp = inst.exports();
+ const single = exp.get("my-sc");
+
+ expect(single).toBeDefined();
+ expect(typeof single!.getStatus).toBe("function");
+ expect(typeof single!.restart).toBe("function");
+ expect(typeof single!.stop).toBe("function");
+ expect(typeof single!.getOutput).toBe("function");
+ expect(typeof single!.getPort).toBe("function");
+ });
+
+ test("G2: get(id) returns undefined for nonexistent id", () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+ expect(exp.get("nonexistent")).toBeUndefined();
+ });
+
+ test("G3: getAll() returns Map of all sidecars", () => {
+ const config = makeMultiConfig([
+ { id: "a", command: "python" },
+ { id: "b", command: "ruby" },
+ ]);
+ const inst = createPlugin(config);
+ const exp = inst.exports();
+ const all = exp.getAll();
+
+ expect(all).toBeInstanceOf(Map);
+ expect(all.size).toBe(2);
+ expect(all.has("a")).toBe(true);
+ expect(all.has("b")).toBe(true);
+ });
+
+ test("G4: getStatus(id) returns current status string", () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+ const status = exp.getStatus("test-http");
+ expect(typeof status).toBe("string");
+ });
+
+ test("G4: getStatus(id) for unknown id throws SidecarError", () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+ expect(() => exp.getStatus("unknown")).toThrow(SidecarError);
+ expect(() => exp.getStatus("unknown")).toThrow(/Unknown sidecar id/);
+ });
+
+ test("G5: getPort(id) returns port number", () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+ const port = exp.getPort("test-http");
+ expect(typeof port).toBe("number");
+ });
+
+ test("G6: getOutput(id) returns output lines", () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+ const output = exp.getOutput("test-http");
+ expect(Array.isArray(output)).toBe(true);
+ });
+
+ test("G7: restart(id) calls processManager.restart()", async () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+ const result = exp.restart("test-http");
+ expect(result).toBeInstanceOf(Promise);
+ await result;
+ });
+
+ test("G8: stop(id) calls processManager.stop()", async () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+ const result = exp.stop("test-http");
+ expect(result).toBeInstanceOf(Promise);
+ await result;
+ });
+ });
+
+ // ──────────────── I. Edge Cases ────────────────
+
+ describe("I. Edge Cases", () => {
+ test("I3/I4: exports methods throw for unknown sidecar id", () => {
+ const inst = createPlugin(makeHttpConfig());
+ const exp = inst.exports();
+
+ expect(() => exp.getStatus("nope")).toThrow(SidecarError);
+ expect(() => exp.restart("nope")).toThrow(SidecarError);
+ expect(() => exp.stop("nope")).toThrow(SidecarError);
+ expect(() => exp.getOutput("nope")).toThrow(SidecarError);
+ expect(() => exp.getPort("nope")).toThrow(SidecarError);
+ });
+
+ test("injectRoutes does not throw", () => {
+ const inst = createPlugin(makeHttpConfig());
+ const mockRouter = {
+ use: vi.fn(),
+ get: vi.fn(),
+ post: vi.fn(),
+ all: vi.fn(),
+ } as any;
+
+ // Mode handler hasn't set up proxy yet (no setup() call), but injectRoutes
+ // still registers routes — the middleware handles proxy-not-ready at request time.
+ expect(() => inst.injectRoutes(mockRouter)).not.toThrow();
+ expect(mockRouter.use).toHaveBeenCalled();
+ });
+
+ test("abortActiveOperations does not throw for multiple instances", () => {
+ const inst = createPlugin(
+ makeMultiConfig([
+ { id: "a", command: "python" },
+ { id: "b", command: "ruby" },
+ ]),
+ );
+ expect(() => inst.abortActiveOperations()).not.toThrow();
+ });
+ });
+});
diff --git a/packages/appkit/src/plugins/sidecar/tests/stdio-bridge.test.ts b/packages/appkit/src/plugins/sidecar/tests/stdio-bridge.test.ts
new file mode 100644
index 00000000..d528a18e
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/tests/stdio-bridge.test.ts
@@ -0,0 +1,481 @@
+import { EventEmitter } from "node:events";
+import { PassThrough } from "node:stream";
+import { createMockTelemetry } from "@tools/test-helpers";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { SidecarError } from "../../../errors/sidecar";
+
+vi.mock("../../../logging/logger", () => ({
+ createLogger: () => ({
+ info: vi.fn(),
+ debug: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+import { StdioBridge } from "../stdio-bridge";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function createStreams() {
+ const stdin = new PassThrough();
+ const stdout = new PassThrough();
+ return { stdin, stdout };
+}
+
+function createExtendedMockTelemetry() {
+ const base = createMockTelemetry();
+ // Extend meter mock with createUpDownCounter (not in shared test-helpers)
+ const meter = base.getMeter();
+ (meter as any).createUpDownCounter = vi.fn().mockReturnValue({ add: vi.fn() });
+ return base;
+}
+
+function createBridge(configOverrides = {}, telemetry?: any) {
+ const t = telemetry ?? createExtendedMockTelemetry();
+ const bridge = new StdioBridge(configOverrides, t);
+ return { bridge, telemetry: t };
+}
+
+function sendJsonRpcResponse(stdout: PassThrough, msg: Record) {
+ stdout.write(`${JSON.stringify(msg)}\n`);
+}
+
+function sendNotification(stdout: PassThrough, method: string, params?: unknown) {
+ sendJsonRpcResponse(stdout, { jsonrpc: "2.0", method, params });
+}
+
+function sendResponse(stdout: PassThrough, id: number, result: unknown) {
+ sendJsonRpcResponse(stdout, { jsonrpc: "2.0", id, result });
+}
+
+function sendErrorResponse(
+ stdout: PassThrough,
+ id: number,
+ code: number,
+ message: string,
+) {
+ sendJsonRpcResponse(stdout, {
+ jsonrpc: "2.0",
+ id,
+ error: { code, message },
+ });
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe("StdioBridge", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe("D. JSON-RPC Bridge", () => {
+ test("D1: simple request → response with id correlation", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const requestPromise = bridge.sendRequest({ path: "/test" });
+
+ // Read what was written to stdin
+ const written = stdin.read()?.toString();
+ const parsed = JSON.parse(written!);
+ expect(parsed.jsonrpc).toBe("2.0");
+ expect(parsed.method).toBe("request");
+ expect(parsed.params.path).toBe("/test");
+
+ // Send back response with matching id
+ sendResponse(stdout, parsed.id, { status: 200, body: { ok: true } });
+
+ const result = await requestPromise;
+ expect(result).toEqual({ status: 200, body: { ok: true } });
+ });
+
+ test("D2: multiple concurrent requests resolved by id matching", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const p1 = bridge.sendRequest({ path: "/first" });
+ const p2 = bridge.sendRequest({ path: "/second" });
+
+ // Read both requests
+ const buf = stdin.read()?.toString() ?? "";
+ const lines = buf.split("\n").filter(Boolean);
+ const req1 = JSON.parse(lines[0]);
+ const req2 = JSON.parse(lines[1]);
+
+ // Respond in reverse order
+ sendResponse(stdout, req2.id, { status: 200, body: "second" });
+ sendResponse(stdout, req1.id, { status: 200, body: "first" });
+
+ const [r1, r2] = await Promise.all([p1, p2]);
+ expect(r1.body).toBe("first");
+ expect(r2.body).toBe("second");
+ });
+
+ test("D3: request timeout returns bridgeTimeout error", async () => {
+ const { bridge } = createBridge({ requestTimeout: 100 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const requestPromise = bridge.sendRequest({ path: "/slow" });
+
+ // Catch the rejection immediately to prevent unhandled rejection
+ const resultPromise = requestPromise.catch((err) => err);
+
+ await vi.advanceTimersByTimeAsync(150);
+
+ const err = await resultPromise;
+ expect(err).toBeInstanceOf(SidecarError);
+ expect(err.message).toMatch(/timed out/);
+ });
+
+ test("D4: max concurrency exceeded returns 503", async () => {
+ const { bridge } = createBridge({
+ requestTimeout: 5000,
+ maxConcurrency: 2,
+ });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ // Fill up concurrency
+ bridge.sendRequest({ path: "/1" });
+ bridge.sendRequest({ path: "/2" });
+
+ // Third should fail
+ await expect(bridge.sendRequest({ path: "/3" })).rejects.toThrow(
+ /concurrency limit/,
+ );
+ });
+
+ test("D5: JSON-RPC error with code < -32000 is not retryable", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const p = bridge.sendRequest({ path: "/err" });
+
+ const written = stdin.read()?.toString();
+ const req = JSON.parse(written!);
+ sendErrorResponse(stdout, req.id, -32001, "Parse error");
+
+ try {
+ await p;
+ expect.unreachable();
+ } catch (err) {
+ expect(err).toBeInstanceOf(SidecarError);
+ expect((err as SidecarError).isRetryable).toBe(false);
+ }
+ });
+
+ test("D6: JSON-RPC error with code >= -32000 is retryable", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const p = bridge.sendRequest({ path: "/err" });
+
+ const written = stdin.read()?.toString();
+ const req = JSON.parse(written!);
+ sendErrorResponse(stdout, req.id, -31999, "Temporary error");
+
+ try {
+ await p;
+ expect.unreachable();
+ } catch (err) {
+ expect(err).toBeInstanceOf(SidecarError);
+ expect((err as SidecarError).isRetryable).toBe(true);
+ }
+ });
+
+ test("D7: notification without id calls onNotification callback", async () => {
+ const onNotification = vi.fn();
+ const { bridge } = createBridge({
+ requestTimeout: 5000,
+ onNotification,
+ });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ sendNotification(stdout, "custom-event", { data: "hello" });
+
+ // Allow event processing
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(onNotification).toHaveBeenCalledWith("custom-event", { data: "hello" });
+ });
+
+ test("D7: 'ready' notification sets ready state", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const readyPromise = bridge.waitForReady(5000);
+
+ sendNotification(stdout, "ready");
+
+ const result = await readyPromise;
+ expect(result).toBe(true);
+ });
+
+ test("D8: partial line buffering works correctly", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const p = bridge.sendRequest({ path: "/test" });
+ const written = stdin.read()?.toString();
+ const req = JSON.parse(written!);
+
+ // Send response in two chunks (partial line)
+ const fullResponse = JSON.stringify({
+ jsonrpc: "2.0",
+ id: req.id,
+ result: { status: 200, body: "ok" },
+ });
+
+ const mid = Math.floor(fullResponse.length / 2);
+ stdout.write(fullResponse.substring(0, mid));
+ stdout.write(`${fullResponse.substring(mid)}\n`);
+
+ const result = await p;
+ expect(result.body).toBe("ok");
+ });
+
+ test("D9: invalid JSON from child is handled gracefully", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ // Send invalid JSON — should not crash
+ stdout.write("this is not valid json\n");
+ await vi.advanceTimersByTimeAsync(0);
+
+ // Bridge should still work after invalid JSON
+ const p = bridge.sendRequest({ path: "/test" });
+ const written = stdin.read()?.toString();
+ const lines = written!.split("\n").filter(Boolean);
+ const req = JSON.parse(lines[lines.length - 1]);
+
+ sendResponse(stdout, req.id, { status: 200, body: "still working" });
+
+ const result = await p;
+ expect(result.body).toBe("still working");
+ });
+
+ test("D10: stdin write fails when child died", () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ // Simulate destroyed stdin
+ stdin.destroy();
+
+ expect(() => bridge.sendRequest({ path: "/dead" })).rejects.toThrow(
+ /stdin/,
+ );
+ });
+
+ test("D10: stdin write fails when not attached", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+
+ await expect(bridge.sendRequest({ path: "/no-stdin" })).rejects.toThrow(
+ /stdin/,
+ );
+ });
+
+ test("D11: ping succeeds when child responds", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const pingPromise = bridge.ping(1000);
+
+ // Read ping request
+ await vi.advanceTimersByTimeAsync(0);
+ const written = stdin.read()?.toString();
+ const req = JSON.parse(written!);
+ expect(req.method).toBe("ping");
+
+ sendResponse(stdout, req.id, {});
+
+ const result = await pingPromise;
+ expect(result).toBe(true);
+ });
+
+ test("D12: ping failures trigger unhealthy callback", async () => {
+ const { bridge } = createBridge({
+ requestTimeout: 100,
+ pingInterval: 100,
+ pingFailureThreshold: 2,
+ });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const onHealthy = vi.fn();
+ const onUnhealthy = vi.fn();
+
+ bridge.startHealthCheck({ onHealthy, onUnhealthy });
+
+ // Let pings time out
+ await vi.advanceTimersByTimeAsync(100); // first check
+ await vi.advanceTimersByTimeAsync(150); // ping timeout
+ await vi.advanceTimersByTimeAsync(100); // second check
+ await vi.advanceTimersByTimeAsync(150); // ping timeout
+
+ expect(onUnhealthy).toHaveBeenCalled();
+
+ bridge.stopHealthCheck();
+ });
+ });
+
+ describe("waitForReady", () => {
+ test("returns true immediately if already ready", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ // Make ready via notification
+ sendNotification(stdout, "ready");
+ await vi.advanceTimersByTimeAsync(0);
+
+ const result = await bridge.waitForReady(1000);
+ expect(result).toBe(true);
+ });
+
+ test("returns false on timeout", async () => {
+ const { bridge } = createBridge({ requestTimeout: 100 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const readyPromise = bridge.waitForReady(200);
+
+ await vi.advanceTimersByTimeAsync(300);
+
+ const result = await readyPromise;
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("attach / detach", () => {
+ test("detach clears state and stops listening", () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+ bridge.detach();
+
+ // After detach, sending data should not cause errors
+ stdout.write('{"jsonrpc":"2.0","method":"ready"}\n');
+ // No crash = pass
+ });
+ });
+
+ describe("destroy", () => {
+ test("rejects all pending requests", async () => {
+ const { bridge } = createBridge({ requestTimeout: 30000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const p1 = bridge.sendRequest({ path: "/1" });
+ const p2 = bridge.sendRequest({ path: "/2" });
+
+ bridge.destroy();
+
+ await expect(p1).rejects.toThrow(/destroyed/);
+ await expect(p2).rejects.toThrow(/destroyed/);
+ });
+
+ test("stops health check interval", () => {
+ const { bridge } = createBridge({
+ requestTimeout: 5000,
+ pingInterval: 100,
+ });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ bridge.startHealthCheck({ onHealthy: vi.fn(), onUnhealthy: vi.fn() });
+ bridge.destroy();
+
+ // Should not throw after destroy
+ });
+ });
+
+ describe("non-JSON-RPC messages", () => {
+ test("ignores messages without jsonrpc: 2.0", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ stdout.write(`${JSON.stringify({ foo: "bar" })}\n`);
+ await vi.advanceTimersByTimeAsync(0);
+
+ // No crash, bridge still operational
+ });
+
+ test("response for unknown id is silently ignored", async () => {
+ const { bridge } = createBridge({ requestTimeout: 5000 });
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ sendResponse(stdout, 99999, { status: 200 });
+ await vi.advanceTimersByTimeAsync(0);
+
+ // No crash = pass
+ });
+ });
+
+ describe("H. Telemetry", () => {
+ test("H2: sendRequest creates span", async () => {
+ const telemetry = createExtendedMockTelemetry();
+ const { bridge } = createBridge({ requestTimeout: 5000 }, telemetry);
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ const p = bridge.sendRequest({ path: "/traced" });
+
+ const written = stdin.read()?.toString();
+ const req = JSON.parse(written!);
+ sendResponse(stdout, req.id, { status: 200 });
+
+ await p;
+
+ expect(telemetry.startActiveSpan).toHaveBeenCalledWith(
+ "sidecar.stdio.request",
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ "sidecar.stdio.path": "/traced",
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ test("H3: waitForReady creates startup span", async () => {
+ const telemetry = createExtendedMockTelemetry();
+ const { bridge } = createBridge({ requestTimeout: 5000 }, telemetry);
+ const { stdin, stdout } = createStreams();
+ bridge.attach(stdin, stdout);
+
+ // Start waiting first, THEN send ready notification
+ const readyPromise = bridge.waitForReady(5000);
+ sendNotification(stdout, "ready");
+ await readyPromise;
+
+ expect(telemetry.startActiveSpan).toHaveBeenCalledWith(
+ "sidecar.stdio.startup",
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ "sidecar.stdio.timeout": 5000,
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+ });
+});
diff --git a/packages/appkit/src/plugins/sidecar/types.ts b/packages/appkit/src/plugins/sidecar/types.ts
new file mode 100644
index 00000000..43f0b8f7
--- /dev/null
+++ b/packages/appkit/src/plugins/sidecar/types.ts
@@ -0,0 +1,157 @@
+export interface HealthCheckConfig {
+ /** Path for health check HTTP request. Default: "/health" */
+ path?: string;
+ /** Interval in ms between health checks. Default: 5000 */
+ interval?: number;
+ /** Timeout in ms for each health check request. Default: 3000 */
+ timeout?: number;
+ /** Number of consecutive failures before considering unhealthy. Default: 3 */
+ unhealthyThreshold?: number;
+}
+
+export interface RestartConfig {
+ /** Whether to automatically restart on crash. Default: true */
+ enabled?: boolean;
+ /** Maximum number of restarts before giving up. Default: 5 */
+ maxRestarts?: number;
+ /** Window in ms to count restarts (resets counter). Default: 60000 */
+ restartWindow?: number;
+ /** Delay in ms before restarting. Default: 1000 */
+ restartDelay?: number;
+}
+
+export interface ProxyConfig {
+ /** Headers to forward from the incoming request to the sidecar. Default: "all". */
+ forwardHeaders?: string[] | "all";
+ /** Additional headers to inject into proxied requests. */
+ injectHeaders?: Record;
+ /** Timeout in ms for proxied requests. Default: 30000 */
+ timeout?: number;
+ /** Base path prefix on the sidecar. Default: "/" */
+ basePath?: string;
+}
+
+/**
+ * Configuration specific to stdio mode.
+ *
+ * Controls timeouts, concurrency, health checking, and extensibility
+ * for the JSON-RPC communication layer. All fields are optional —
+ * defaults are tuned for typical ML inference / data processing workloads.
+ */
+export interface StdioConfig {
+ /** Timeout in ms for a single request→response cycle. Default: 30000 */
+ requestTimeout?: number;
+ /** Interval in ms between ping health checks. Default: 10000 */
+ pingInterval?: number;
+ /** Max consecutive ping failures before unhealthy. Default: 3 */
+ pingFailureThreshold?: number;
+ /** Max pending concurrent requests. Default: 50 */
+ maxConcurrency?: number;
+ /**
+ * Callback for custom JSON-RPC notifications from the child process.
+ *
+ * The bridge handles `ready` and `log` notifications internally.
+ * Any other notification method is forwarded to this callback.
+ */
+ onNotification?: (method: string, params: unknown) => void;
+}
+
+/**
+ * Per-sidecar definition describing a single child process.
+ *
+ * Two communication modes are available:
+ * - **`"http"` (default):** The child process runs its own HTTP server. AppKit proxies
+ * requests to it. Use this when your sidecar is a web app (Flask, FastAPI, etc.).
+ * - **`"stdio"`:** The child process communicates via stdin/stdout using line-delimited
+ * JSON-RPC 2.0. Use this for ML inference, data processing, CLI tools, or background workers.
+ */
+export interface SidecarDefinition {
+ /** Unique identifier for this sidecar. Used for route namespacing (`/api/{id}/*`). */
+ id: string;
+ /** Communication mode. Default: "http" */
+ mode?: "http" | "stdio";
+
+ // --- Shared (both modes) ---
+ /** Command to execute (e.g., "python", "ruby", "go"). */
+ command: string;
+ /** Arguments to the command (e.g., ["-m", "uvicorn", "main:app"]). */
+ args?: string[];
+ /** Working directory for the child process. Defaults to process.cwd(). */
+ cwd?: string;
+ /** Additional environment variables passed to the child process. */
+ env?: Record;
+ /** Timeout in ms to wait for child process to become ready during setup(). Default: 30000 */
+ startupTimeout?: number;
+ /** Process restart configuration. */
+ restart?: RestartConfig;
+ /** Shell commands to run before spawning the sidecar process. */
+ setupCommands?: string[];
+ /**
+ * When true, setup commands run in a shell (supports pipes, redirects, globbing, etc.).
+ * When false or omitted, commands are split on whitespace and executed directly with
+ * `execFile` (no shell) — safer against command injection.
+ *
+ * @default false
+ */
+ setupShell?: boolean;
+
+ // --- HTTP mode only ---
+ /** Port the child process listens on. 0 or omitted for auto-assign. */
+ port?: number;
+ /** Health check configuration. */
+ healthCheck?: HealthCheckConfig;
+ /** Proxy configuration. */
+ proxy?: ProxyConfig;
+
+ // --- stdio mode only ---
+ /** Configuration for stdio mode communication. */
+ stdio?: StdioConfig;
+}
+
+/**
+ * Configuration for the sidecar plugin.
+ *
+ * Accepts either:
+ * - A single {@link SidecarDefinition} object.
+ * - An array of {@link SidecarDefinition} entries for multi-sidecar setups.
+ */
+export type ISidecarConfig = SidecarDefinition | SidecarDefinition[];
+
+export type SidecarStatus =
+ | "starting"
+ | "healthy"
+ | "unhealthy"
+ | "stopped"
+ | "crashed";
+
+/** Exports for a single sidecar child process. */
+export interface SingleSidecarExport {
+ /** Current status of the sidecar process. */
+ getStatus(): SidecarStatus;
+ /** Restart the sidecar process. */
+ restart(): Promise;
+ /** Stop the sidecar process. */
+ stop(): Promise;
+ /** Get recent stdout/stderr output lines. */
+ getOutput(lines?: number): string[];
+ /** The port the sidecar is listening on. Only available in HTTP mode. */
+ getPort(): number;
+}
+
+/** Public API exported by the sidecar plugin, providing access to all managed sidecars. */
+export interface SidecarExport {
+ /** Get the export API for a specific sidecar by id. */
+ get(id: string): SingleSidecarExport | undefined;
+ /** Get all sidecar exports as a Map keyed by id. */
+ getAll(): Map;
+ /** Shorthand: get the status of a specific sidecar. */
+ getStatus(id: string): SidecarStatus;
+ /** Shorthand: restart a specific sidecar. */
+ restart(id: string): Promise;
+ /** Shorthand: stop a specific sidecar. */
+ stop(id: string): Promise;
+ /** Shorthand: get recent output lines from a specific sidecar. */
+ getOutput(id: string, lines?: number): string[];
+ /** Shorthand: get the port for a specific sidecar (HTTP mode only). */
+ getPort(id: string): number;
+}
diff --git a/packages/appkit/src/registry/resource-registry.ts b/packages/appkit/src/registry/resource-registry.ts
index fd8c7dfc..1a362098 100644
--- a/packages/appkit/src/registry/resource-registry.ts
+++ b/packages/appkit/src/registry/resource-registry.ts
@@ -94,7 +94,7 @@ export class ResourceRegistry {
* @throws {ConfigurationError} If any plugin is missing a manifest or manifest is invalid
*/
public collectResources(
- rawPlugins: PluginData[],
+ rawPlugins: PluginData, unknown, string>[],
): void {
for (const pluginData of rawPlugins) {
if (!pluginData?.plugin) continue;
diff --git a/packages/appkit/src/registry/types.generated.ts b/packages/appkit/src/registry/types.generated.ts
index 7e38af9b..348e7455 100644
--- a/packages/appkit/src/registry/types.generated.ts
+++ b/packages/appkit/src/registry/types.generated.ts
@@ -52,11 +52,7 @@ export type DatabasePermission = "CAN_CONNECT_AND_CREATE";
export type PostgresPermission = "CAN_CONNECT_AND_CREATE";
/** Permissions for GENIE_SPACE resources */
-export type GenieSpacePermission =
- | "CAN_VIEW"
- | "CAN_RUN"
- | "CAN_EDIT"
- | "CAN_MANAGE";
+export type GenieSpacePermission = "CAN_VIEW" | "CAN_RUN" | "CAN_EDIT" | "CAN_MANAGE";
/** Permissions for EXPERIMENT resources */
export type ExperimentPermission = "CAN_READ" | "CAN_EDIT" | "CAN_MANAGE";
@@ -81,10 +77,7 @@ export type ResourcePermission =
| AppPermission;
/** Permission hierarchy per resource type (weakest to strongest). Schema enum order. */
-export const PERMISSION_HIERARCHY_BY_TYPE: Record<
- ResourceType,
- readonly ResourcePermission[]
-> = {
+export const PERMISSION_HIERARCHY_BY_TYPE: Record = {
[ResourceType.SECRET]: ["READ", "WRITE", "MANAGE"],
[ResourceType.JOB]: ["CAN_VIEW", "CAN_MANAGE_RUN", "CAN_MANAGE"],
[ResourceType.SQL_WAREHOUSE]: ["CAN_USE", "CAN_MANAGE"],
@@ -101,7 +94,4 @@ export const PERMISSION_HIERARCHY_BY_TYPE: Record<
} as const;
/** Set of valid permissions per type (for validation). */
-export const PERMISSIONS_BY_TYPE: Record<
- ResourceType,
- readonly ResourcePermission[]
-> = PERMISSION_HIERARCHY_BY_TYPE;
+export const PERMISSIONS_BY_TYPE: Record = PERMISSION_HIERARCHY_BY_TYPE;
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 627d70d6..d3e9e6e8 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -2,5 +2,6 @@ export * from "./cache";
export * from "./execute";
export * from "./genie";
export * from "./plugin";
+export * from "./sidecar-schema";
export * from "./sql";
export * from "./tunnel";
diff --git a/packages/shared/src/schemas/plugin-manifest.generated.ts b/packages/shared/src/schemas/plugin-manifest.generated.ts
index 5d2e5d4a..e40d3c60 100644
--- a/packages/shared/src/schemas/plugin-manifest.generated.ts
+++ b/packages/shared/src/schemas/plugin-manifest.generated.ts
@@ -2,284 +2,267 @@
// Run: pnpm exec tsx tools/generate-schema-types.ts
/**
* Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements().
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "resourceRequirement".
*/
-export type ResourceRequirement = {
- type: ResourceType;
- /**
- * Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.
- */
- alias: string;
- /**
- * Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.
- */
- resourceKey: string;
- /**
- * Human-readable description of why this resource is needed
- */
- description: string;
- /**
- * Required permission level. Validated per resource type by the allOf/if-then rules below.
- */
- permission: string;
- /**
- * Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key).
- */
- fields?: {
- [k: string]: ResourceFieldEntry;
- };
-};
+export type ResourceRequirement = ({
+type: ResourceType
+/**
+ * Human-readable label for UI/display only. Deduplication uses resourceKey, not alias.
+ */
+alias: string
+/**
+ * Stable key for machine use: deduplication, env naming, composite keys, app.yaml. Required for registry lookup.
+ */
+resourceKey: string
+/**
+ * Human-readable description of why this resource is needed
+ */
+description: string
+/**
+ * Required permission level. Validated per resource type by the allOf/if-then rules below.
+ */
+permission: string
+/**
+ * Map of field name to env and optional description. Single-value types use one key (e.g. id); multi-value (database, secret) use multiple (e.g. instance_name, database_name or scope, key).
+ */
+fields?: {
+[k: string]: ResourceFieldEntry
+}
+})
/**
* Type of Databricks resource
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "resourceType".
*/
-export type ResourceType =
- | "secret"
- | "job"
- | "sql_warehouse"
- | "serving_endpoint"
- | "volume"
- | "vector_search_index"
- | "uc_function"
- | "uc_connection"
- | "database"
- | "postgres"
- | "genie_space"
- | "experiment"
- | "app";
+export type ResourceType = ("secret" | "job" | "sql_warehouse" | "serving_endpoint" | "volume" | "vector_search_index" | "uc_function" | "uc_connection" | "database" | "postgres" | "genie_space" | "experiment" | "app")
/**
* Permission for secret resources (order: weakest to strongest)
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "secretPermission".
*/
-export type SecretPermission = "READ" | "WRITE" | "MANAGE";
+export type SecretPermission = ("READ" | "WRITE" | "MANAGE")
/**
* Permission for job resources (order: weakest to strongest)
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "jobPermission".
*/
-export type JobPermission = "CAN_VIEW" | "CAN_MANAGE_RUN" | "CAN_MANAGE";
+export type JobPermission = ("CAN_VIEW" | "CAN_MANAGE_RUN" | "CAN_MANAGE")
/**
* Permission for SQL warehouse resources (order: weakest to strongest)
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "sqlWarehousePermission".
*/
-export type SqlWarehousePermission = "CAN_USE" | "CAN_MANAGE";
+export type SqlWarehousePermission = ("CAN_USE" | "CAN_MANAGE")
/**
* Permission for serving endpoint resources (order: weakest to strongest)
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "servingEndpointPermission".
*/
-export type ServingEndpointPermission = "CAN_VIEW" | "CAN_QUERY" | "CAN_MANAGE";
+export type ServingEndpointPermission = ("CAN_VIEW" | "CAN_QUERY" | "CAN_MANAGE")
/**
* Permission for Unity Catalog volume resources
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "volumePermission".
*/
-export type VolumePermission = "READ_VOLUME" | "WRITE_VOLUME";
+export type VolumePermission = ("READ_VOLUME" | "WRITE_VOLUME")
/**
* Permission for vector search index resources
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "vectorSearchIndexPermission".
*/
-export type VectorSearchIndexPermission = "SELECT";
+export type VectorSearchIndexPermission = "SELECT"
/**
* Permission for Unity Catalog function resources
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "ucFunctionPermission".
*/
-export type UcFunctionPermission = "EXECUTE";
+export type UcFunctionPermission = "EXECUTE"
/**
* Permission for Unity Catalog connection resources
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "ucConnectionPermission".
*/
-export type UcConnectionPermission = "USE_CONNECTION";
+export type UcConnectionPermission = "USE_CONNECTION"
/**
* Permission for database resources
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "databasePermission".
*/
-export type DatabasePermission = "CAN_CONNECT_AND_CREATE";
+export type DatabasePermission = "CAN_CONNECT_AND_CREATE"
/**
* Permission for Postgres resources
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "postgresPermission".
*/
-export type PostgresPermission = "CAN_CONNECT_AND_CREATE";
+export type PostgresPermission = "CAN_CONNECT_AND_CREATE"
/**
* Permission for Genie Space resources (order: weakest to strongest)
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "genieSpacePermission".
*/
-export type GenieSpacePermission =
- | "CAN_VIEW"
- | "CAN_RUN"
- | "CAN_EDIT"
- | "CAN_MANAGE";
+export type GenieSpacePermission = ("CAN_VIEW" | "CAN_RUN" | "CAN_EDIT" | "CAN_MANAGE")
/**
* Permission for MLflow experiment resources (order: weakest to strongest)
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "experimentPermission".
*/
-export type ExperimentPermission = "CAN_READ" | "CAN_EDIT" | "CAN_MANAGE";
+export type ExperimentPermission = ("CAN_READ" | "CAN_EDIT" | "CAN_MANAGE")
/**
* Permission for Databricks App resources
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "appPermission".
*/
-export type AppPermission = "CAN_USE";
+export type AppPermission = "CAN_USE"
/**
* Schema for Databricks AppKit plugin manifest files. Defines plugin metadata, resource requirements, and configuration options.
*/
export interface PluginManifest {
- /**
- * Reference to the JSON Schema for validation
- */
- $schema?: string;
- /**
- * Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.
- */
- name: string;
- /**
- * Human-readable display name for UI and CLI
- */
- displayName: string;
- /**
- * Brief description of what the plugin does
- */
- description: string;
- /**
- * Databricks resource requirements for this plugin
- */
- resources: {
- /**
- * Resources that must be available for the plugin to function
- */
- required: ResourceRequirement[];
- /**
- * Resources that enhance functionality but are not mandatory
- */
- optional: ResourceRequirement[];
- };
- /**
- * Configuration schema for the plugin
- */
- config?: {
- schema?: ConfigSchema;
- };
- /**
- * Author name or organization
- */
- author?: string;
- /**
- * Plugin version (semver format)
- */
- version?: string;
- /**
- * URL to the plugin's source repository
- */
- repository?: string;
- /**
- * Keywords for plugin discovery
- */
- keywords?: string[];
- /**
- * SPDX license identifier
- */
- license?: string;
- /**
- * Message displayed to the user after project initialization. Use this to inform about manual setup steps (e.g. environment variables, resource provisioning).
- */
- onSetupMessage?: string;
- /**
- * When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync.
- */
- hidden?: boolean;
+/**
+ * Reference to the JSON Schema for validation
+ */
+$schema?: string
+/**
+ * Plugin identifier. Must be lowercase, start with a letter, and contain only letters, numbers, and hyphens.
+ */
+name: string
+/**
+ * Human-readable display name for UI and CLI
+ */
+displayName: string
+/**
+ * Brief description of what the plugin does
+ */
+description: string
+/**
+ * Databricks resource requirements for this plugin
+ */
+resources: {
+/**
+ * Resources that must be available for the plugin to function
+ */
+required: ResourceRequirement[]
+/**
+ * Resources that enhance functionality but are not mandatory
+ */
+optional: ResourceRequirement[]
+}
+/**
+ * Configuration schema for the plugin
+ */
+config?: {
+schema?: ConfigSchema
+}
+/**
+ * Author name or organization
+ */
+author?: string
+/**
+ * Plugin version (semver format)
+ */
+version?: string
+/**
+ * URL to the plugin's source repository
+ */
+repository?: string
+/**
+ * Keywords for plugin discovery
+ */
+keywords?: string[]
+/**
+ * SPDX license identifier
+ */
+license?: string
+/**
+ * Message displayed to the user after project initialization. Use this to inform about manual setup steps (e.g. environment variables, resource provisioning).
+ */
+onSetupMessage?: string
+/**
+ * When true, this plugin is excluded from the template plugins manifest (appkit.plugins.json) during sync.
+ */
+hidden?: boolean
}
/**
* Defines a single field for a resource. Each field has its own environment variable and optional description. Single-value types use one key (e.g. id); multi-value types (database, secret) use multiple (e.g. instance_name, database_name or scope, key).
- *
+ *
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "resourceFieldEntry".
*/
export interface ResourceFieldEntry {
- /**
- * Environment variable name for this field
- */
- env?: string;
- /**
- * Human-readable description for this field
- */
- description?: string;
- /**
- * When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation.
- */
- bundleIgnore?: boolean;
- /**
- * Example values showing the expected format for this field
- */
- examples?: string[];
- /**
- * When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time.
- */
- localOnly?: boolean;
- /**
- * Static value for this field. Used when no prompted or resolved value exists.
- */
- value?: string;
- /**
- * Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow.
- */
- resolve?: string;
+/**
+ * Environment variable name for this field
+ */
+env?: string
+/**
+ * Human-readable description for this field
+ */
+description?: string
+/**
+ * When true, this field is excluded from Databricks bundle configuration (databricks.yml) generation.
+ */
+bundleIgnore?: boolean
+/**
+ * Example values showing the expected format for this field
+ */
+examples?: string[]
+/**
+ * When true, this field is only generated for local .env files. The Databricks Apps platform auto-injects it at deploy time.
+ */
+localOnly?: boolean
+/**
+ * Static value for this field. Used when no prompted or resolved value exists.
+ */
+value?: string
+/**
+ * Named resolver prefixed by resource type (e.g., 'postgres:host'). The CLI resolves this value during the init prompt flow.
+ */
+resolve?: string
}
/**
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "configSchema".
*/
export interface ConfigSchema {
- type: "object" | "array" | "string" | "number" | "boolean";
- properties?: {
- [k: string]: ConfigSchemaProperty;
- };
- items?: ConfigSchema;
- required?: string[];
- additionalProperties?: boolean;
+type: ("object" | "array" | "string" | "number" | "boolean")
+properties?: {
+[k: string]: ConfigSchemaProperty
+}
+items?: ConfigSchema
+required?: string[]
+additionalProperties?: boolean
}
/**
* This interface was referenced by `PluginManifest`'s JSON-Schema
* via the `definition` "configSchemaProperty".
*/
export interface ConfigSchemaProperty {
- type: "object" | "array" | "string" | "number" | "boolean" | "integer";
- description?: string;
- default?: unknown;
- enum?: unknown[];
- properties?: {
- [k: string]: ConfigSchemaProperty;
- };
- items?: ConfigSchemaProperty;
- minimum?: number;
- maximum?: number;
- minLength?: number;
- maxLength?: number;
- required?: string[];
+type: ("object" | "array" | "string" | "number" | "boolean" | "integer")
+description?: string
+default?: unknown
+enum?: unknown[]
+properties?: {
+[k: string]: ConfigSchemaProperty
+}
+items?: ConfigSchemaProperty
+minimum?: number
+maximum?: number
+minLength?: number
+maxLength?: number
+required?: string[]
}
diff --git a/packages/shared/src/sidecar-schema.ts b/packages/shared/src/sidecar-schema.ts
new file mode 100644
index 00000000..1f5761e3
--- /dev/null
+++ b/packages/shared/src/sidecar-schema.ts
@@ -0,0 +1,46 @@
+/** Payload the FE sends to the sidecar endpoint. */
+export interface StdioRequestPayload {
+ /** The action/path the child process should handle (e.g., "/predict"). */
+ path: string;
+ /** HTTP method. Default: "POST". */
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+ /** The request body — any valid JSON value. */
+ body?: unknown;
+}
+
+/** Response shape from the child process. */
+export interface StdioResponsePayload {
+ status?: number;
+ headers?: Record;
+ body?: unknown;
+}
+
+/** Lightweight client-side validation (no Zod required). */
+export function validateStdioRequest(
+ payload: unknown,
+):
+ | { success: true; data: StdioRequestPayload }
+ | { success: false; error: string } {
+ if (typeof payload !== "object" || payload === null) {
+ return { success: false, error: "Payload must be an object" };
+ }
+ const p = payload as Record;
+ if (typeof p.path !== "string" || p.path.length === 0) {
+ return { success: false, error: "path must be a non-empty string" };
+ }
+ const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
+ if (p.method !== undefined && !validMethods.includes(p.method as string)) {
+ return {
+ success: false,
+ error: `method must be one of: ${validMethods.join(", ")}`,
+ };
+ }
+ return {
+ success: true,
+ data: {
+ path: p.path,
+ method: (p.method as StdioRequestPayload["method"]) ?? "POST",
+ body: p.body,
+ },
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 67171ef7..f1ddd522 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -320,6 +320,9 @@ importers:
ws:
specifier: ^8.18.3
version: 8.18.3(bufferutil@4.0.9)
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
devDependencies:
'@types/express':
specifier: ^4.17.25
@@ -11299,6 +11302,9 @@ packages:
zod@4.1.13:
resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==}
+ zod@4.3.6:
+ resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
+
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
@@ -23939,6 +23945,8 @@ snapshots:
zod@4.1.13: {}
+ zod@4.3.6: {}
+
zrender@6.0.0:
dependencies:
tslib: 2.3.0
diff --git a/template/.env.example.tmpl b/template/.env.example.tmpl
index 35ffed20..9001f336 100644
--- a/template/.env.example.tmpl
+++ b/template/.env.example.tmpl
@@ -5,3 +5,8 @@ DATABRICKS_HOST=https://...
DATABRICKS_APP_PORT=8000
DATABRICKS_APP_NAME={{.projectName}}
FLASK_RUN_HOST=0.0.0.0
+{{- if .plugins.sidecar}}
+SIDECAR_COMMAND=python3
+SIDECAR_ARGS=main.py
+SIDECAR_CWD=./sidecar
+{{- end}}
diff --git a/template/.env.tmpl b/template/.env.tmpl
index afa7bb15..e6d39ced 100644
--- a/template/.env.tmpl
+++ b/template/.env.tmpl
@@ -9,3 +9,8 @@ DATABRICKS_HOST={{.workspaceHost}}
DATABRICKS_APP_PORT=8000
DATABRICKS_APP_NAME={{.projectName}}
FLASK_RUN_HOST=localhost
+{{- if .plugins.sidecar}}
+SIDECAR_COMMAND=python3
+SIDECAR_ARGS=main.py
+SIDECAR_CWD=./sidecar
+{{- end}}
diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json
index cf60a8af..96bc1666 100644
--- a/template/appkit.plugins.json
+++ b/template/appkit.plugins.json
@@ -149,6 +149,16 @@
"optional": []
},
"requiredByTemplate": true
+ },
+ "sidecar": {
+ "name": "sidecar",
+ "displayName": "Sidecar",
+ "description": "Run another stack (Python, Ruby, Go, etc.) as a child process alongside the AppKit server",
+ "package": "@databricks/appkit",
+ "resources": {
+ "required": [],
+ "optional": []
+ }
}
}
}
diff --git a/template/client/src/App.tsx b/template/client/src/App.tsx
index fb4c28e6..35725ca8 100644
--- a/template/client/src/App.tsx
+++ b/template/client/src/App.tsx
@@ -17,6 +17,9 @@ import { GeniePage } from './pages/genie/GeniePage';
{{- if .plugins.files}}
import { FilesPage } from './pages/files/FilesPage';
{{- end}}
+{{- if .plugins.sidecar}}
+import { SidecarPage } from './pages/sidecar/SidecarPage';
+{{- end}}
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
@@ -53,6 +56,11 @@ function Layout() {
Files
+{{- end}}
+{{- if .plugins.sidecar}}
+
+ Sidecar
+
{{- end}}
@@ -80,6 +88,9 @@ const router = createBrowserRouter([
{{- end}}
{{- if .plugins.files}}
{ path: '/files', element: },
+{{- end}}
+{{- if .plugins.sidecar}}
+ { path: '/sidecar', element: },
{{- end}}
],
},
diff --git a/template/client/src/pages/sidecar/SidecarPage.tsx b/template/client/src/pages/sidecar/SidecarPage.tsx
new file mode 100644
index 00000000..daa647ec
--- /dev/null
+++ b/template/client/src/pages/sidecar/SidecarPage.tsx
@@ -0,0 +1,70 @@
+{{- if .plugins.sidecar -}}
+import { useState } from 'react';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Button,
+ Skeleton,
+} from '@databricks/appkit-ui/react';
+
+interface SidecarResponse {
+ message: string;
+ user: string;
+}
+
+export function SidecarPage() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function callSidecar() {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/sidecar/hello');
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ setData(await res.json());
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Request failed');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
Sidecar
+
+ This page calls the Python sidecar process managed by AppKit.
+ Requests to /api/sidecar/* are
+ proxied to the child process.
+