Skip to content

feat(infra): unify Web and tRPC API behind a single sst.aws.Router#169

Merged
zemeja merged 4 commits into
masterfrom
plt-641
Apr 30, 2026
Merged

feat(infra): unify Web and tRPC API behind a single sst.aws.Router#169
zemeja merged 4 commits into
masterfrom
plt-641

Conversation

@zemeja

@zemeja zemeja commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

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}/api instead of the bare Function URL, and downstream consumers can attach a single WAF/custom-domain to the unified edge.

  • U1 (a162711) Stand up Router with protection: 'oac-with-edge-signing'. Convert infra/api.ts to createApi(router) factory. Move Web StaticSite under the Router (router.instance). Set VITE_tRPCAPI_url = \${router.url}/api. Update dev panel to read SST_RESOURCE_Main. Return Url: router.url from the deploy output.
  • U2 (fddee6f) Strip the /api prefix in infra/src/apiHandler.ts before delegating to awsLambdaRequestHandler. Required because CloudFront path-based behaviors don't rewrite — they route. Without stripping, every tRPC procedure 404s.
  • U4 (548e79a) Mirror the SST Router + tRPC gotchas doc into docs/solutions/integration-issues/ so forks inherit the institutional knowledge for both required fixes (POST signing + path stripping).
  • f74b0d0 Regenerated sst-env.d.ts to include the Main Router 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) and web/src/vite-env.d.ts (type declaration).

Why two non-obvious changes (both required)

  1. oac-with-edge-signing, not plain "oac". Plain OAC requires every client to send x-amz-content-sha256 for POST. The tRPC HTTP client does not, so every mutation would fail with SigV4 SignatureDoesNotMatch. Edge-signing adds a Lambda@Edge that body-hashes and re-signs.
  2. Strip /api in the handler. SST/CloudFront forwards the full path to the origin. tRPC treats the path as the procedure name, so /api/transaction.deposit becomes a lookup for the literal procedure api/transaction.deposit and 404s.

Both failures are silent in sst diff and only surface on a real POST against a real deploy.

Notable behavior changes

  • The deploy output is now a single Url: (the Router) instead of separate Web: and API URLs.
  • The tRPCAPI Function URL is no longer publicly callable — direct hits return AWS's OAC 403. This is the protection working, not a regression.
  • The dev panel (pnpm --filter @purple-stack/trpc-api start:panel) now goes through the Router via SST_RESOURCE_Main instead of the bare Function URL. Required because U1's OAC flip would otherwise 403 the panel.
  • WAF / custom-domain hooks are intentionally not pre-wired. Forks attach transform.cdn.webAclArn and domain directly 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 diff shows one new CloudFront distribution (Router); tRPCAPI Function URL flips AuthType to AWS_IAM; Web StaticSite no longer creates its own distribution
  • pnpm sst:deploy succeeds; deploy output shows a single Url:
  • curl -I https://<router>/ returns 200 with the Vite SPA
  • POST mutation through the SPA succeeds end-to-end (deposit submission flow) — load-bearing assertion; this is what plain OAC silently breaks
  • curl https://<router>/api/<existing GET procedure> returns 200 (not tRPC NOT_FOUND)
  • curl https://<id>.lambda-url.eu-central-1.on.aws/ returns AWS's OAC 403 (Function URL no longer publicly reachable)
  • pnpm sst dev autostarts Vite + tRPC panel; panel at http://localhost:3000/panel invokes a procedure successfully through CloudFront
  • Browser devtools show no CORS preflight on SPA → API calls (same-origin via Router)

🤖 Generated with Claude Code

zemeja added 4 commits April 28, 2026 15:00
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.
@linear

linear Bot commented Apr 29, 2026

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • Documentation

    • Added troubleshooting guide for SST Router, tRPC, and Lambda integration scenarios with concrete fixes and configuration recommendations.
  • Bug Fixes

    • Fixed SigV4 signature mismatch errors (403) for POST requests with specific router protection modes.
    • Fixed tRPC route resolution issues caused by incorrect API path forwarding in Lambda handlers.

Walkthrough

These changes implement an architecture to fix SST Router + tRPC + Lambda integration issues. An sst.aws.Router is introduced with oac-with-edge-signing protection. The Lambda handler is refactored to remove the /api prefix from incoming request paths before delegating to tRPC. The configuration wires the tRPC API Lambda through the Router at the /api path. Type definitions and environment variable sourcing are updated to reference the Router resource instead of the Lambda directly.

Sequence Diagram

sequenceDiagram
    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
Loading
🚥 Pre-merge checks | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title is partially related to the changeset, describing the main infrastructure objective but lacks a required task ID prefix (e.g., PTT-123 or MTE-123) and exceeds the 50-character guideline. Add a task ID prefix (e.g., PTT-XXX or MTE-XXX) to the beginning of the title and condense it to stay under 50 characters, such as 'PTT-641: Unify Web and tRPC behind Router'.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch plt-641

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/trpc-api/src/trpcUi.ts (1)

11-30: Parse the router resource once at startup.

SST_RESOURCE_Main is process-static, so parsing it inside the /panel handler 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

📥 Commits

Reviewing files that changed from the base of the PR and between 45d8dfa and f74b0d0.

📒 Files selected for processing (6)
  • docs/solutions/integration-issues/sst-router-trpc-lambda-gotchas-2026-04-28.md
  • infra/api.ts
  • infra/src/apiHandler.ts
  • packages/trpc-api/src/trpcUi.ts
  • sst-env.d.ts
  • sst.config.ts

@zemeja zemeja merged commit 4ab4540 into master Apr 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants