BFF routes → RPC map
The browser never talks gRPC and never sees a backend hostname. The
SaaS frontend ships as a client-only SPA whose
every backend call goes to a same-origin /api/* route. Each of those routes is a
Node-runtime handler in soundverse-saas-2.0
that authenticates the caller, makes one typed
Connect/gRPC call to
core-gateway-consumer, and translates the
result (or a Connect error) back to JSON. This is the BFF (backend-for-frontend) seam.
Deployed — the SPA + BFF are live. A handful of routes proxy to services that are code-complete but not yet deployed (billing, DNA); those are flagged in the passthrough table below.
The proxy shape
Section titled “The proxy shape”Every gRPC-backed route reads as one line of intent. runProxy
(lib/grpc/proxy.ts)
does the auth, the trace threading, and the error mapping; the route body just names the RPC:
import { getLibraryClient } from "@/lib/grpc/client";import { runProxy } from "@/lib/grpc/proxy";
export const runtime = "nodejs"; // never Edge — connect-node needs node:http2export const dynamic = "force-dynamic"; // per-user, never cached
/** GET /api/library → LibraryService.ListLibrary */export async function GET(req: Request) { return runProxy(req, async (opts) => { const resp = await getLibraryClient().listLibrary({ /* … */ }, opts); return toJson(ListLibraryResponseSchema, resp); // toJson keeps int64/bigint safe });}The full mechanics of adding one — plus the mapper and the Connect-code translation — are in Add or extend a consumer-facing API.
gRPC-proxied routes → consumer.v1
Section titled “gRPC-proxied routes → consumer.v1”All of these go through runProxy to core-gateway-consumer. The RPC column is the
soundverse_proto.consumer.v1 method
(consumer.proto).
GenerationService
Section titled “GenerationService”| Route | Method | RPC |
|---|---|---|
/api/generation |
POST | CreateGeneration |
/api/generation/:taskId |
GET | GetGeneration |
/api/generation/:taskId/stream |
GET (SSE) | StreamGeneration (relayed as text/event-stream) |
/api/tools |
GET | ListTools (active tool registry for the AI Tools panel) |
AccountService
Section titled “AccountService”| Route | Method | RPC |
|---|---|---|
/api/account |
GET | GetCurrentUser |
/api/account/balance |
GET | GetTokenBalance |
LibraryService
Section titled “LibraryService”| Route | Method | RPC |
|---|---|---|
/api/library |
GET | ListLibrary (query: workspaceId, projectId, …) |
/api/library/upload |
POST | UploadFile (client-streaming; first message is metadata, rest are chunks) |
/api/library/info/:fileId |
GET | GetFileInfo |
/api/library/provenance/:fileId |
GET | GetFileProvenanceGraph |
/api/library/mint-url |
POST | MintDownloadUrl |
/api/library/stream-url |
POST | MintDownloadUrl (chooses HLS vs single-file — see below) |
/api/library/rename |
POST | RenameLibraryFile |
/api/library/cover |
POST | SetLibraryCover |
WorkspaceProjectService
Section titled “WorkspaceProjectService”| Route | Method | RPC |
|---|---|---|
/api/workspaces |
GET / POST | ListWorkspaces / CreateWorkspace |
/api/workspaces/:workspaceId/members |
GET / POST | ListWorkspaceMembers / AddWorkspaceMember |
/api/workspaces/:workspaceId/members/:userId |
DELETE | RemoveWorkspaceMember |
/api/projects |
GET / POST | ListProjects / CreateProject |
/api/projects/:projectId |
GET | GetProject |
/api/projects/:projectId/messages |
GET / POST / PATCH | ListProjectMessages / CreateMessage / UpdateMessage |
/api/projects/recent-with-messages |
GET | ListProjects + ListProjectMessages (one combined round-trip) |
NotificationService
Section titled “NotificationService”| Route | Method | RPC |
|---|---|---|
/api/notifications |
GET / POST | ListNotifications / MarkNotificationRead | MarkAllNotificationsRead |
/api/notifications/stream |
GET (SSE) | StreamNotifications (relayed as text/event-stream) |
Reverse-proxy & passthrough routes (not consumer.v1)
Section titled “Reverse-proxy & passthrough routes (not consumer.v1)”Not every /api/* route is a gRPC proxy. These forward raw HTTP, run OIDC, or relay to a
legacy or sibling backend. They do not go through runProxy.
| Route | Kind | Upstream / behaviour |
|---|---|---|
/api/auth/[...nextauth] |
Auth.js | Logto OIDC callback + session handlers |
/api/federated-logout |
Redirect builder | Builds the Logto end_session URL for federated sign-out |
/api/diag-auth |
Diagnostic | Reports the resolved bearer/session state (no token material) |
/api/billing/[...path] |
REST reverse-proxy | → core-billing /v1/billing/* via CORE_BILLING_GRPC upstream in code, not deployed |
/api/stripe/connect/start, /status |
Stripe SDK | Stripe Connect onboarding / status for creator payouts |
/api/dna/[...path], /api/dna/proxy/[...path], /api/dna/identity |
REST reverse-proxy | → core-dna-api /dna/* via CORE_DNA_API_URL / CORE_DNA_API_GRPC; injects the server-verified user id + INTERNAL_RPC_SECRET upstream in code, not deployed |
/api/collaborative-studio/[...path] |
REST reverse-proxy | → legacy monolith /collaborative-studio/* (NEXT_PUBLIC_BACKEND_URL) |
/api/sparks/[...path] |
REST reverse-proxy | → legacy monolith public /sparks/v1* feed (admin paths blocked) |
/api/home/* |
REST reverse-proxy | → legacy monolith /home/* (recent tracks, staff picks, prompts, workflows, …) |
/api/compat/chat-history, /api/compat/chat-history/projects |
Legacy read | Read-only legacy chat history, owner-gated by session email — see legacy bridge |
/api/library/stream/:token |
Same-origin media proxy | Streams the sealed SAS URL back to <audio>, hiding the blob URL |
/api/otel/[...signal] |
OTLP proxy | Forwards browser traces to OTEL_EXPORTER_OTLP_ENDPOINT (accept-and-drop when unset) |
/api/docs, /api/openapi |
Static | Scalar API docs + the OpenAPI document |
lib/grpc/ — module responsibilities
Section titled “lib/grpc/ — module responsibilities”The whole gRPC seam lives in six small files under
lib/grpc/.
Each is server-only, so none can leak into a client bundle.
| Module | Responsibility |
|---|---|
client.ts |
getClient() + the per-service accessors (getAccountClient, getGenerationClient, getLibraryClient, getNotificationClient, getWorkspaceProjectClient) |
endpoints.ts |
Resolves upstream FQDNs from CI-injected env vars: resolveEndpoint("<service>") → <SERVICE>_GRPC, the UPSTREAM map, resolveCoreBillingUrl, resolveCoreDnaApiUrl, normalizeBaseUrl (:443 → https, else h2c) |
transport.ts |
Cached HTTP/2 gRPC transports, one per base URL (connect-node, node:http2) |
proxy.ts |
runProxy — auth, trace continuation, and the Connect-code → HTTP-status + friendly-message mapping |
auth-metadata.ts |
resolveBearer (header-first, cookie fallback), authCallOptions, getUpstreamBearer, getVerifiedUserId |
otel-interceptor.ts |
Injects the active span’s W3C traceparent into the outgoing gRPC metadata |
Connect Code → HTTP status map
Section titled “Connect Code → HTTP status map”runProxy maps every Connect Code the gateway emits to a faithful HTTP status (the
error-handling overhaul stopped collapsing everything to
500). Unmapped codes fall back to 500.
Connect Code |
HTTP | Connect Code |
HTTP |
|---|---|---|---|
Canceled |
499 | PermissionDenied |
403 |
InvalidArgument |
400 | ResourceExhausted |
429 |
DeadlineExceeded |
504 | FailedPrecondition |
412 |
NotFound |
404 | Unimplemented |
501 |
AlreadyExists |
409 | Unavailable |
503 |
Unauthenticated |
401 | (everything else) | 500 |
The JSON error body carries the gateway’s structured ErrorDetail when present —
friendlyMessage, retryable, requestId, and the traceId that opens the exact SigNoz
trace — so the browser can render a real message instead of a bare status.
Gotchas worth knowing
Section titled “Gotchas worth knowing”Related
Section titled “Related”- Add or extend a consumer-facing API — the how-to for adding a route + mapper
- Frontend architecture (SPA + BFF) — where these routes sit
- core-gateway-consumer — the single upstream door
- Environment & service-discovery — how
<SERVICE>_GRPCis injected - Proto contract reference — the
consumer.v1package + Connect codes - Legacy 1.0 → 2.0 bridge — the
/api/compat/*and legacy-proxy routes