Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"babel-plugin-react-compiler": "^1.0.0",
"happy-dom": "^20.1.0",
"jsdom": "^26.0.0",
"msw": "^2.7.0",
"postcss": "^8.5.6",
"typescript": "^5",
"vite": "^8.0.7",
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Demo agent (MSW prototype)

The `/demo` route mounts the studio against an in-browser mock of the Go agent
so visitors to stackpanel.com can poke at the UI without a real environment.

## Files

| Path | Purpose |
|---|---|
| `fixture.ts` | Frozen demo data: `stack.json` shape, Nix config, entity tables. |
| `handlers.ts` | MSW handlers for the agent REST surface + a Connect-RPC catch-all. |
| `worker.ts` | Lazy `setupWorker(...)` + idempotent `startDemoWorker()`. |
| `token.ts` | Synthesises a non-expiring fake JWT (only decoded, never verified). |
| `../routes/demo.tsx` | `/demo` route: boots the worker, mounts the studio. |

## One-time bootstrap

MSW ships its own service-worker script that needs to live in `public/`:

```bash
cd apps/web
bunx msw init public/ --save
```

That command writes `public/mockServiceWorker.js`. Commit it; both dev and
production builds load it from the static path `/mockServiceWorker.js`.

## What's mocked today

- `GET /health`, `GET /api/auth/validate`
- `GET /api/events` → 204 (forces fall-back to polling, no SSE stream yet)
- `GET|POST /api/nix/config`, `GET|POST /api/nix/data?entity=*`
- `POST /api/exec` (no-op echo)
- `POST /<service>.<method>` Connect-RPC catch-all returning `{}`

Everything else falls through (`onUnhandledRequest: "bypass"`); add handlers
as you discover blank panels.

## Next step: proto-driven mocks

Long-term these handlers should be generated from `packages/proto/`. A buf
plugin can emit `handlers.gen.ts` — one MSW stub per RPC method, typed off
the same descriptors that drive the Go agent and the TS Connect client — and
this hand-written `handlers.ts` becomes overrides on top of the generated
defaults.
117 changes: 117 additions & 0 deletions apps/web/src/demo/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Frozen fixture data for the demo agent.
*
* Mirrors the shape of `.stack/state/stack.json` plus the entity tables the
* studio reads through the agent's REST surface. Treat it as immutable from
* the handler side; if a handler simulates a write, mutate a shallow clone.
*/

export const DEMO_HOST = "demo-agent.stackpanel.local";
export const DEMO_PORT = 9876;
export const DEMO_BASE_URL = `http://${DEMO_HOST}:${DEMO_PORT}`;

export const demoStateJson = {
version: 1,
projectName: "stackpanel-demo",
basePort: 6400,
paths: {
state: ".stack/state",
gen: ".stack/gen",
data: ".stack",
},
apps: {
web: {
port: 6402,
domain: "stackpanel-demo.localhost",
url: "http://stackpanel-demo.localhost",
tls: false,
},
server: {
port: 6401,
domain: null,
url: null,
tls: false,
},
docs: {
port: 6400,
domain: "docs.stackpanel-demo.localhost",
url: "http://docs.stackpanel-demo.localhost",
tls: false,
},
},
services: {
postgres: {
key: "POSTGRES",
name: "PostgreSQL",
port: 6410,
envVar: "STACKPANEL_POSTGRES_PORT",
},
redis: {
key: "REDIS",
name: "Redis",
port: 6411,
envVar: "STACKPANEL_REDIS_PORT",
},
minio: {
key: "MINIO",
name: "MinIO",
port: 6412,
envVar: "STACKPANEL_MINIO_PORT",
},
},
network: {
step: { enable: false, caUrl: null },
},
} as const;

export const demoNixConfig = {
stack: {
enable: true,
name: "stackpanel-demo",
root: "/home/demo/stackpanel-demo",
apps: demoStateJson.apps,
services: demoStateJson.services,
ports: { basePort: demoStateJson.basePort, modulus: 100 },
users: {
"demo-user": {
name: "Demo User",
email: "demo@stackpanel.com",
github: "stackpanel-demo",
},
},
theme: { palette: "tokyo-night" },
},
} as const;

export const demoEntities: Record<string, Record<string, unknown>> = {
apps: {
web: { name: "web", port: 6402, domain: "stackpanel-demo.localhost" },
server: { name: "server", port: 6401 },
docs: { name: "docs", port: 6400, domain: "docs.stackpanel-demo.localhost" },
},
services: demoStateJson.services as Record<string, unknown>,
users: {
"demo-user": {
name: "Demo User",
email: "demo@stackpanel.com",
github: "stackpanel-demo",
},
},
variables: {
NODE_ENV: { value: "development", scope: "all" },
LOG_LEVEL: { value: "info", scope: "all" },
},
tasks: {
dev: { command: "bun run dev", description: "Start dev servers" },
build: { command: "bun run build", description: "Build all apps" },
},
"generated-files": {},
};

export const demoHealth = {
status: "ok",
projectRoot: "/home/demo/stackpanel-demo",
hasProject: true,
agentId: "demo-agent",
version: "demo",
};
103 changes: 103 additions & 0 deletions apps/web/src/demo/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* MSW request handlers for the demo agent.
*
* The studio talks to the agent over two protocols, and both terminate as
* `fetch()` calls so MSW can intercept them:
* - REST (AgentHttpClient) GET/POST `${baseUrl}/api/...`
* - Connect-RPC (createAgentTransport) POST `${baseUrl}/<service>/<method>`
*
* For now we hand-write a handful of high-traffic REST handlers and a
* Connect-RPC catch-all that returns empty-but-shaped responses. The longer-
* term plan is to generate handler stubs from the same `.proto` files that
* already drive the Go agent and the TS client.
*/

import { http, HttpResponse, passthrough } from "msw";
import {
DEMO_BASE_URL,
demoEntities,
demoHealth,
demoNixConfig,
demoStateJson,
} from "./fixture";

const url = (path: string) => `${DEMO_BASE_URL}${path}`;

const ok = <T>(data: T) => HttpResponse.json({ success: true, data });

export const demoHandlers = [
// ---------------------------------------------------------------------------
// Health + auth
// ---------------------------------------------------------------------------
http.get(url("/health"), () => HttpResponse.json(demoHealth)),
http.get(url("/api/auth/validate"), () =>
HttpResponse.json({ valid: true, agentId: demoHealth.agentId }),
),

// SSE: respond 204 so the EventSource fails fast and the provider falls
// back to polling. Mocking a real event-stream is possible but noisier.
http.get(url("/api/events"), () => new HttpResponse(null, { status: 204 })),

// ---------------------------------------------------------------------------
// Nix config + entity data
// ---------------------------------------------------------------------------
http.get(url("/api/nix/config"), () =>
ok({
config: demoNixConfig,
last_updated: new Date().toISOString(),
cached: true,
source: "demo",
}),
),
http.post(url("/api/nix/config"), () =>
ok({
config: demoNixConfig,
last_updated: new Date().toISOString(),
refreshed: true,
source: "demo",
}),
),

http.get(url("/api/nix/data"), ({ request }) => {
const entity = new URL(request.url).searchParams.get("entity") ?? "";
const data = demoEntities[entity];
return ok({
entity,
exists: data !== undefined,
data: data ?? null,
});
}),
http.post(url("/api/nix/data"), () =>
HttpResponse.json({ success: true, path: "demo (read-only)" }),
),

http.get(url("/api/state"), () => HttpResponse.json(demoStateJson)),

// ---------------------------------------------------------------------------
// Process / service control: accept and no-op so the UI feels responsive
// ---------------------------------------------------------------------------
http.post(url("/api/exec"), () =>
HttpResponse.json({
success: true,
data: {
exitCode: 0,
stdout: "[demo] command acknowledged (no real execution)\n",
stderr: "",
},
}),
),

// ---------------------------------------------------------------------------
// Connect-RPC catch-all
//
// Connect uses POST to `/<fully.qualified.Service>/<Method>`. Returning an
// empty JSON object keeps most queries from throwing; specific methods can
// be peeled off into dedicated handlers as the demo grows.
// ---------------------------------------------------------------------------
http.post(`${DEMO_BASE_URL}/:service/:method`, ({ params }) => {
const service = String(params.service ?? "");
// Only intercept Connect-style service paths (contain a dot)
if (!service.includes(".")) return passthrough();
return HttpResponse.json({});
}),
];
30 changes: 30 additions & 0 deletions apps/web/src/demo/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Build a fake JWT for the demo agent.
*
* `AgentProvider` only *decodes* the JWT (it never verifies the signature),
* so a hand-crafted token with valid base64url segments and an `exp` claim in
* the future is enough to flip the studio into a "connected" state.
*/

function base64UrlEncode(input: string): string {
const base64 =
typeof window === "undefined"
? Buffer.from(input, "utf-8").toString("base64")
: btoa(input);
return base64.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}

export function buildDemoToken(): string {
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = base64UrlEncode(
JSON.stringify({
agent_id: "demo-agent",
// year 2099
exp: 4_070_908_800,
demo: true,
}),
);
return `${header}.${payload}.demo-signature`;
}

export const DEMO_TOKEN = buildDemoToken();
40 changes: 40 additions & 0 deletions apps/web/src/demo/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Browser-side MSW worker setup.
*
* Lazily imports `msw/browser` so the demo bundle is only pulled in on the
* `/demo` route. `start()` is idempotent and resolves once the service worker
* is intercepting requests.
*/

import { demoHandlers } from "./handlers";

type SetupWorker = Awaited<ReturnType<typeof loadWorker>>;

let workerPromise: Promise<SetupWorker> | null = null;

async function loadWorker() {
const { setupWorker } = await import("msw/browser");
return setupWorker(...demoHandlers);
}

export async function startDemoWorker(): Promise<void> {
if (typeof window === "undefined") return;
if (!workerPromise) {
workerPromise = loadWorker();
}
const worker = await workerPromise;
await worker.start({
// Don't warn about real requests the studio fires (auth, fonts, etc.)
onUnhandledRequest: "bypass",
serviceWorker: {
url: "/mockServiceWorker.js",
},
});
}

export async function stopDemoWorker(): Promise<void> {
if (!workerPromise) return;
const worker = await workerPromise;
worker.stop();
workerPromise = null;
}
Loading
Loading