Frontend architecture (SPA + BFF)
Deployed — the SaaS app runs on Azure Container Apps (staging: open.soundverse.ai).
The web app (soundverse-saas-2.0) is
one Next.js 15 project wearing two hats: a browser SPA that is the entire product UI,
rendered client-side, and a thin backend-for-frontend (BFF) that proxies every data call to
the consumer gRPC gateway. The split is the whole design: the browser never speaks gRPC and
never holds a bearer token — it only ever calls /api/* on its own origin, and gRPC, HTTP/2,
and the proto types live exclusively on the server.
Stack: TypeScript + React 19 + Next.js 15 (App Router), shipped as an output: "standalone"
container on Azure Container Apps. The package.json name is still agent-synth — a leftover
from the pre-Next Vite app, not a typo.
Shape: a client SPA behind a catch-all route
Section titled “Shape: a client SPA behind a catch-all route”Although this is the App Router, almost nothing is server-rendered. The whole product is a
client SPA that runs its own location.pathname router, mounted behind a single optional
catch-all route. The chain is short but deliberately split so the app is still crawlable:
-
app/[[...slug]]/page.tsxmatches every path and stays a server component. It emits per-route metadata (generateMetadata), a crawlable`<main class="sv-seo">`link graph, and JSON-LD. -
It renders the
"use client"islandapp/[[...slug]]/AppShell.tsx, which dynamically imports the SPA with SSR off:app/[[...slug]]/AppShell.tsx const AppClient = dynamic(() => import("@/src/AppClient"), { ssr: false });export default function AppShell() { return <AppClient />; } -
src/AppClient.tsxrenderssrc/App.jsx, which parseslocation.pathnameitself (theROUTE0block) and keeps the URL in sync withhistory.replaceState— Next’s router is not used for in-app navigation.
ssr: false is load-bearing, not laziness: the SPA is heavily browser-bound (Web Audio,
canvas, imperative DOM injection) and would not survive server rendering. Every product route
maps onto the same island — /studio (the DAW), /agent and /agent-one (Agent One chat),
and the library shell routes (/home, /library, /explore, /account, /song, …).
The request path
Section titled “The request path”flowchart LR
subgraph app["soundverse-saas-2.0 (same origin)"]
SPA["Browser SPA<br/>src/App.jsx (ssr:false)"]
BFF["BFF: app/api/*/route.ts<br/>runProxy + lib/grpc"]
end
GW["core-gateway-consumer<br/>consumer.v1 gRPC"]
AGENT["core-tool-agent<br/>(queue worker)"]
REDIS[("Redis event stream")]
SPA -->|"fetch /api/* — JSON, session cookie"| BFF
BFF -->|"Connect/gRPC + Authorization Bearer"| GW
GW -->|"QueueTask"| AGENT
AGENT -->|"XADD events"| REDIS
GW -->|"XREAD BLOCK"| REDIS
BFF -->|"SSE: data frames"| SPA
classDef frontend fill:#6366f1,color:#fff,stroke:#4338ca
classDef gateway fill:#0ea5e9,color:#fff,stroke:#0369a1
classDef data fill:#8b5cf6,color:#fff,stroke:#6d28d9
classDef worker fill:#10b981,color:#fff,stroke:#047857
class SPA,BFF frontend
class GW gateway
class AGENT worker
class REDIS data
The BFF: /api/* route handlers proxy to gRPC
Section titled “The BFF: /api/* route handlers proxy to gRPC”Every app/api/*/route.ts is a Node-runtime handler that turns a plain HTTP request into a
typed Connect/gRPC call against core-gateway-consumer and serializes the reply back to JSON.
This is the only place gRPC, HTTP/2, and proto types exist — the browser bundle never
imports them. The plumbing lives in
lib/grpc/ and is
small on purpose:
| File | Responsibility |
|---|---|
lib/grpc/endpoints.ts |
resolveEndpoint("core-gateway-consumer") reads CORE_GATEWAY_CONSUMER_GRPC; normalizeBaseUrl turns a scheme-less host:port into a URL — a :443 port → https, anything else → http (h2c, the right default on the Azure intranet). |
lib/grpc/transport.ts |
getTransport(baseUrl) — one cached createGrpcTransport per upstream (a transport owns an HTTP/2 session manager, so it is reused, not recreated per request). |
lib/grpc/client.ts |
Typed Connect clients for the consumer services: AccountService, GenerationService, LibraryService, WorkspaceProjectService. |
lib/grpc/auth-metadata.ts |
Resolves the upstream bearer — from an explicit Authorization: Bearer header for native/mobile clients, else off the encrypted session cookie — or throws UnauthenticatedError → HTTP 401. |
lib/grpc/otel-interceptor.ts |
Injects the active span’s W3C traceparent into outbound gRPC metadata (connect-node dials node:http2 directly, so fetch auto-instrumentation never sees these calls). |
lib/grpc/proxy.ts |
runProxy(req, fn) — the one-liner wrapper: authenticate → call → map a ConnectError to an HTTP status + friendly JSON. |
A typical route reads as one line of intent:
export async function GET(req: Request) { return runProxy(req, async (opts) => { const resp = await getLibraryClient().listLibrary({ /* …query… */ }, opts); return toJson(ListLibraryResponseSchema, resp); });}runProxy faithfully maps each Connect Code to the real HTTP status (NotFound→404,
PermissionDenied→403, ResourceExhausted→429, Unauthenticated→401, DeadlineExceeded→504,
…) and unpacks the gateway’s structured ErrorDetail into a JSON body of
{ error, code, friendlyMessage, retryable, requestId, traceId }. The browser-side ApiError
class reads that same shape back out, so a component gets a clean error with a copyable trace
id — see Observability & tracing.
The full route → consumer.v1 RPC table lives in the
BFF routes map; the mechanics of adding one are in
Add or extend a consumer-facing API. A representative slice:
| HTTP route | consumer.v1 RPC |
|---|---|
POST /api/generation |
GenerationService.CreateGeneration |
GET /api/generation/:taskId/stream (SSE) |
GenerationService.StreamGeneration |
GET /api/tools |
GenerationService.ListTools |
GET /api/account/balance |
AccountService.GetTokenBalance |
GET /api/library · POST /api/library/upload |
LibraryService.ListLibrary · UploadFile |
GET / POST /api/workspaces |
WorkspaceProjectService.ListWorkspaces · CreateWorkspace |
Agent One: the SSE turn
Section titled “Agent One: the SSE turn”Agent One chat is where the client/server split pays off. The flow lives in
src/agentone/ChatPage.jsx
(UI) and
src/agentone/agentLLM.js
(transport). A turn is two HTTP calls:
-
POST creates the generation.
createGeneration({ toolId, projectId, messageId, payloadJson })→POST /api/generation→CreateGeneration, returning ataskId. IftoolIdis omitted the BFF falls back to the server-sideAGENT_TOOL_ID.projectIdis required and ownership-checked by the gateway, which adopts the project’s workspace — the client-sentworkspaceIdis not trusted. -
GET opens the SSE stream.
generationStreamUrl(taskId, afterSeq)opens/api/generation/:taskId/streamas Server-Sent Events. Eachdata:frame is the JSON of oneGenerationEvent.
PARTIAL_OUTPUT multiplexing
Section titled “PARTIAL_OUTPUT multiplexing”The agent (core-tool-agent) multiplexes both its
reasoning text and its tool-call lifecycle onto a single PARTIAL_OUTPUT event type,
distinguished by a kind field inside partialOutputJson. consumeAgentStream parses each
frame and dispatches typed callbacks:
kind |
Meaning | Callback |
|---|---|---|
tool_call |
a sub-tool was invoked | onToolCall |
tool_progress |
sub-tool progress tick | onToolProgress |
tool_result |
sub-tool finished | onToolResult |
thinking |
reasoning trace delta | (accumulated) |
compaction |
history was summarised (a “Compacting…” chip) | (status) |
status |
DB-authoritative working label (see below) | onStatus |
(no kind) |
an answer-text delta | onTextDelta |
The nested tool-call → second billing pipeline behind tool_call is covered in
The agent + MCP path.
A dropped stream is not a failed run
Section titled “A dropped stream is not a failed run”Two robustness properties make the live view converge with a reload:
- DB-authoritative status. The gateway’s
StreamGenerationsynthesizes astatusevent on every connect whenGetTaskreports the task isDISPATCHED/PROCESSING, and re-emits one on idle ticks. A fresh connect therefore behaves exactly like a reload-resume even when the Redis relay is momentarily empty — the fix for the intermittent “planning… forever” symptom on staging. The event carries noseq, so it never advances the resume cursor, and older clients ignore the unknownkind(services deploy independently). See SSE DB-authoritative status for the tracing story. - Durable steps are the source of truth. The terminal output’s
metadata_jsoncarries an authoritativesteps[]timeline.onTerminalreconciles the live step subset against it (bycall_id), so a reconnect that lands after completion still renders every middle step instead of jumping from the first tool to the final card. The live stream is a progressive accelerator; the durable output is what the finish path trusts.
Errors are first-class
Section titled “Errors are first-class”A failed run surfaces friendlyMessage plus a traceId; the TraceBadge component renders the
first 8 chars as a small clickable badge (copy-to-clipboard + a deep link to the trace UI), so a
support screenshot is enough to debug. A message persisted as MESSAGE_STATUS_FAILED renders its
stored errorMessage rather than an eternal spinner.
Traps that will bite you
Section titled “Traps that will bite you”Related
Section titled “Related”- BFF routes → RPC map — the full route table
- Add or extend a consumer-facing API — add a route + mapper
- core-gateway-consumer — the upstream this BFF proxies to
- The agent + MCP path — what
tool_calltriggers server-side - Configure Logto OIDC · Identity & auth — the auth wiring
- Wire a tool into the AI Tools panel — the schema-driven form
- Environment variable catalog — the
AUTH_*/*_GRPCnames