Conversation
Adds an sst.aws.Router with oac-with-edge-signing that fronts both the
Vite StaticSite (at /) and the tRPCAPI Function URL (at /api). The
StaticSite no longer creates its own CloudFront distribution, the
Function URL becomes Router-only (OAC 403 on direct hits), and the SPA
calls ${router.url}/api instead of the bare Function URL.
- infra/api.ts: convert to createApi(router) factory so the Function can
bind to an existing Router via url.router.instance.
- sst.config.ts: construct Router before importing the api module,
attach Web StaticSite via router.instance, set
VITE_tRPCAPI_url to ${router.url}/api, return Url: router.url.
- trpcUi.ts: read SST_RESOURCE_Main and append /api so the dev panel
goes through the Router (required: U2 also strips /api in the
handler, and U1 makes the Function URL OAC-protected so direct calls
would 403).
The sst.aws.Router attaches the Lambda at /api/* and forwards the full path verbatim. tRPC's awsLambdaRequestHandler uses the path as the procedure name, so /api/<procedure> looks up the literal procedure 'api/<procedure>' and 404s. Wraps awsLambdaRequestHandler with a shim that strips the leading /api on V2 events (rawPath + requestContext.http.path) and V1 events (path) before delegating. ROUTER_PREFIX is the shared contract with infra/api.ts's url.router.path: '/api'.
Carries the institutional learning that justifies oac-with-edge-signing and the /api prefix-stripping shim into this template, so any repo forked from purple-stack inherits the warning instead of rediscovering the same two failure modes (POST SigV4 errors with plain OAC, tRPC NOT_FOUND on every procedure when the prefix isn't stripped). Ported from accounting-tool/docs/solutions/integration-issues/sst-router-trpc-lambda-gotchas-2026-04-23.md with project-name and date adjustments.
📝 WalkthroughSummary by CodeRabbitRelease Notes
WalkthroughThese changes implement an architecture to fix SST Router + tRPC + Lambda integration issues. An Sequence DiagramsequenceDiagram
actor Client
participant Router as SST Router<br/>(oac-with-edge-signing)
participant Lambda as tRPCAPI Lambda
participant Handler as Handler Function
participant tRPC as tRPC Handler
Client->>Router: POST /api/my.procedure
activate Router
Router->>Lambda: Forward request with path: /api/my.procedure
deactivate Router
activate Lambda
Lambda->>Handler: awsLambdaRequestHandler(event, context)
deactivate Lambda
activate Handler
Handler->>Handler: Normalize path<br/>Remove /api prefix<br/>rawPath: /my.procedure
Handler->>tRPC: Call trpcHandler<br/>with cleaned request
deactivate Handler
activate tRPC
tRPC->>tRPC: Route to my.procedure
tRPC-->>Client: JSON response
deactivate tRPC
🚥 Pre-merge checks | ❌ 1❌ Failed checks (1 warning)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Review rate limit: 4/5 reviews remaining, refill in 12 minutes. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/trpc-api/src/trpcUi.ts (1)
11-30: Parse the router resource once at startup.
SST_RESOURCE_Mainis process-static, so parsing it inside the/panelhandler adds avoidable per-request work and delays config failures until the first hit.♻️ Suggested refactor
import express from 'express' ;(async (): Promise<void> => { const app = express() const { renderTrpcPanel } = await import('trpc-ui') const { appRouter } = await import('../../../infra/src/apiHandler') + + const routerResource = process.env.SST_RESOURCE_Main + if (!routerResource) { + throw new Error('SST_RESOURCE_Main environment variable is missing') + } + + const parsedResource = JSON.parse(routerResource) + const routerUrl = parsedResource.url + if (!routerUrl) { + throw new Error('URL not found in SST_RESOURCE_Main resource') + } app.use('/panel', (_, res) => { - const routerResource = process.env.SST_RESOURCE_Main - if (!routerResource) { - throw new Error('SST_RESOURCE_Main environment variable is missing') - } - - const parsedResource = JSON.parse(routerResource) - const routerUrl = parsedResource.url - if (!routerUrl) { - throw new Error('URL not found in SST_RESOURCE_Main resource') - } - const url = `${routerUrl}/api` return res.send( renderTrpcPanel(appRouter, {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/trpc-api/src/trpcUi.ts` around lines 11 - 30, Move SST_RESOURCE_Main parsing out of the request handler and parse it once at module startup so failures surface on boot and you avoid per-request JSON.parse; specifically, read process.env.SST_RESOURCE_Main into a module-level constant (routerResource), validate and JSON.parse it into parsedResource, extract routerUrl and compute the base url (url) at module scope, and then update the /panel handler to call renderTrpcPanel(appRouter, { url, meta: { title: 'purple-stack tRPC API', description: 'API URL: ' + url } }) using the precomputed url; ensure the module throws the same errors if SST_RESOURCE_Main or parsedResource.url are missing so startup fails fast.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/trpc-api/src/trpcUi.ts`:
- Around line 11-30: Move SST_RESOURCE_Main parsing out of the request handler
and parse it once at module startup so failures surface on boot and you avoid
per-request JSON.parse; specifically, read process.env.SST_RESOURCE_Main into a
module-level constant (routerResource), validate and JSON.parse it into
parsedResource, extract routerUrl and compute the base url (url) at module
scope, and then update the /panel handler to call renderTrpcPanel(appRouter, {
url, meta: { title: 'purple-stack tRPC API', description: 'API URL: ' + url } })
using the precomputed url; ensure the module throws the same errors if
SST_RESOURCE_Main or parsedResource.url are missing so startup fails fast.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: purple-technology/coderabbit/.coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 2ca1241b-6456-46fb-b572-4553b26fd151
📒 Files selected for processing (6)
docs/solutions/integration-issues/sst-router-trpc-lambda-gotchas-2026-04-28.mdinfra/api.tsinfra/src/apiHandler.tspackages/trpc-api/src/trpcUi.tssst-env.d.tssst.config.ts
Summary
Introduces a single
sst.aws.Router('Main')that fronts both the Vite SPA (/) and the tRPC Lambda (/api/*) from one CloudFront distribution. The Function URL becomes Router-only (OAC-protected), the SPA hits\${router.url}/apiinstead of the bare Function URL, and downstream consumers can attach a single WAF/custom-domain to the unified edge.protection: 'oac-with-edge-signing'. Convertinfra/api.tstocreateApi(router)factory. MoveWebStaticSite under the Router (router.instance). SetVITE_tRPCAPI_url = \${router.url}/api. Update dev panel to readSST_RESOURCE_Main. ReturnUrl: router.urlfrom the deploy output./apiprefix ininfra/src/apiHandler.tsbefore delegating toawsLambdaRequestHandler. Required because CloudFront path-based behaviors don't rewrite — they route. Without stripping, every tRPC procedure 404s.docs/solutions/integration-issues/so forks inherit the institutional knowledge for both required fixes (POST signing + path stripping).sst-env.d.tsto include theMainRouter resource (consumed by the dev panel).U3 was a clean no-op audit: only consumers of the API URL remain
packages/trpc-api/src/trpcClient.ts(env var) andweb/src/vite-env.d.ts(type declaration).Why two non-obvious changes (both required)
oac-with-edge-signing, not plain"oac". Plain OAC requires every client to sendx-amz-content-sha256for POST. The tRPC HTTP client does not, so every mutation would fail with SigV4SignatureDoesNotMatch. Edge-signing adds a Lambda@Edge that body-hashes and re-signs./apiin the handler. SST/CloudFront forwards the full path to the origin. tRPC treats the path as the procedure name, so/api/transaction.depositbecomes a lookup for the literal procedureapi/transaction.depositand 404s.Both failures are silent in
sst diffand only surface on a real POST against a real deploy.Notable behavior changes
Url:(the Router) instead of separateWeb:and API URLs.tRPCAPIFunction URL is no longer publicly callable — direct hits return AWS's OAC 403. This is the protection working, not a regression.pnpm --filter @purple-stack/trpc-api start:panel) now goes through the Router viaSST_RESOURCE_Maininstead of the bare Function URL. Required because U1's OAC flip would otherwise 403 the panel.transform.cdn.webAclArnanddomaindirectly to the existing Router — no restructure.Plan
docs/plans/2026-04-28-001-feat-sst-router-unify-frontend-api-plan.md— kept local (untracked), source of truth for the design decisions.Test plan
pnpm sst diffshows one new CloudFront distribution (Router);tRPCAPIFunction URL flipsAuthTypetoAWS_IAM;WebStaticSite no longer creates its own distributionpnpm sst:deploysucceeds; deploy output shows a singleUrl:curl -I https://<router>/returns 200 with the Vite SPAcurl https://<router>/api/<existing GET procedure>returns 200 (not tRPCNOT_FOUND)curl https://<id>.lambda-url.eu-central-1.on.aws/returns AWS's OAC 403 (Function URL no longer publicly reachable)pnpm sst devautostarts Vite + tRPC panel; panel athttp://localhost:3000/panelinvokes a procedure successfully through CloudFront🤖 Generated with Claude Code