Skip to content

feat(collab): serve preview at a dedicated subdomain root (retire /preview/ base-href)#45

Merged
hblanken merged 3 commits into
collabfrom
feat/collab-preview-subdomain-root
Jun 14, 2026
Merged

feat(collab): serve preview at a dedicated subdomain root (retire /preview/ base-href)#45
hblanken merged 3 commits into
collabfrom
feat/collab-preview-subdomain-root

Conversation

@hblanken

Copy link
Copy Markdown

Why

The frontend live-preview was served under a /preview/ path prefix, forcing the Angular build to use baseHref:"/preview/" + a matching dev-server servePath. That prefix machinery has been the recurring source of preview breakage — asset resolution, MIME errors, 502s, base-href drift between branches. The collab control-plane SPA owns / on the main host, so the preview can't take that root — it gets its own host and is served there at root, byte-for-byte like a develop-style serve.

Host model: a single fixed preview subdomain preview.${COLLAB_DOMAIN}, matching the one-preview-per-container cap. Config-driven; null in local dev → legacy /preview/ serving stays as the fallback. (Per-id wildcards are out of scope — they only matter with a multi-preview engine the single-launcher cap forbids today.)

Code changes (this PR)

  • preview-host.ts (new leaf module) — previewHost() + previewUrl(), imported everywhere without cycles.
  • server.ts collabMiddleware — Host === preview host → root-serve the active preview for all paths (cookie-gated; friendly 200 when nothing's running). /preview/* on the main host → 301 to the subdomain root (deep path preserved). Path-based proxy kept only as the local-dev fallback. Stream passthrough factored into previewServerResponse().
  • cookie-auth.tscookieAuthorizesRequest allows when Host === preview host (same shell-trust model as /preview/*, ADR-0001).
  • router.ts setSession — widen collab_sid to Domain=.${COLLAB_DOMAIN} so the preview subdomain receives it; scoped to the collab subtree only. Omitted when COLLAB_DOMAIN unset.
  • preview-router.ts attachPreviewUpgrade — host-aware WS routing.
  • preview-launcher.ts + collab types + appgetPreviewState() carries a server-computed url; the SPA links to it verbatim.

⚠️ REQUIRES operator infra (handed over separately, see hand-over notes)

# Change Repo/file
1 ACM cert SAN preview.${domain} devops alb.tf
2 Route53 A-alias preview.${domain} → ALB devops route53.tf
3 COLLAB_DOMAIN env on the ECS task devops ecs.tf

#3 is make-or-break: COLLAB_DOMAIN is currently NOT a container env var (only OPENCODE_BASE_URL is). Without it previewHost() returns null AND the cookie stays host-only → the feature is entirely inert (falls back to /preview/). Adding COLLAB_DOMAIN = var.domain_name powers both previewHost() and the cookie widening from one var.

Companion PR

Frontend: drop baseHref/servePath "/preview/" from angular.json + .opencode-preview.json so ng builds at base href /.

Merge-order note

Touches server.ts (also in PR #42) and preview-launcher.ts (also in PR #40) in different regions — should auto-merge, trivial rebase otherwise.

Test plan

After infra applied + deploy:

  • https://preview.collab.utils.unleashlive.com/healthz TLS valid (cert SAN). Anon / on preview host → 403.
  • Launch a preview; pill links to https://preview.collab.utils.unleashlive.com/.
  • Open it (new tab carries collab_sid via widened Domain): SPA at root, assets at /assets/... (NOT /preview/assets/...), no MIME/404, <base href="/">.
  • Old …/preview/ → 301 to subdomain.
  • WS/HMR upgrades on the preview host succeed.
  • Local dev (no COLLAB_DOMAIN): /preview/ path serving still works unchanged.

🤖 Generated with Claude Code

hblanken and others added 3 commits June 14, 2026 19:27
…eview/ base-href)

The frontend live-preview was served under a /preview/ PATH prefix, forcing
the Angular build to use baseHref:"/preview/" + a matching dev-server
servePath.  That prefix machinery has been the recurring source of preview
breakage (asset resolution, MIME errors, 502s, base-href drift between
branches).  The collab control-plane SPA owns / on the main host, so the
preview can't take that root — it gets its own host and is served there at
ROOT, byte-for-byte like a develop-style serve.

Host model: a SINGLE fixed preview subdomain (preview.${COLLAB_DOMAIN}),
matching the one-preview-per-container cap.  Config-driven via
COLLAB_PREVIEW_HOST (explicit) or derived from COLLAB_DOMAIN; null in local
dev → legacy /preview/ path serving stays as the fallback.

Changes:

- preview-host.ts (NEW, leaf module): previewHost() + previewUrl() resolution,
  imported by server/cookie-auth/preview-router/preview-launcher without
  import cycles.
- server.ts collabMiddleware: when Host === preview host, root-serve the
  active preview for ALL paths (gate on collab cookie, 200 "no preview" page
  when nothing's running).  When a preview host is configured, /preview/* on
  the MAIN host 301s to the subdomain root (deep path preserved, optional
  <port> segment stripped) so old links keep working.  Path-based proxy
  remains only as the local-dev fallback.  Stream passthrough factored into
  previewServerResponse().
- cookie-auth.ts: cookieAuthorizesRequest allows when Host === preview host
  (whole host is the preview; same shell-trust model as /preview/*, ADR-0001).
- router.ts setSession: widen the collab_sid cookie to
  Domain=.${COLLAB_DOMAIN} so the preview subdomain receives it — scoped to
  the collab subtree only (not all of utils.unleashlive.com).  Omitted when
  COLLAB_DOMAIN unset (local dev → host-only, unchanged).  SameSite=Lax is
  fine: preview opens as a top-level new-tab nav, subdomains are same-site.
- preview-router.ts attachPreviewUpgrade: host-aware WS routing mirroring the
  HTTP path (root path on the preview host; legacy path-based otherwise).
- preview-launcher.ts + collab types + app: getPreviewState() now carries a
  server-computed `url`; the SPA pill/Open-preview link uses it verbatim
  instead of hard-coding /preview/.

REQUIRES (operator, separate infra steps — handed over with this PR):
  1. ACM cert SAN for preview.${domain} (devops alb.tf)
  2. Route53 A-alias preview.${domain} → ALB (devops route53.tf)
  3. COLLAB_DOMAIN env on the ECS task (devops ecs.tf) — powers BOTH
     previewHost() and the cookie Domain widening; without it the feature is
     inert (host-only cookie, null preview host → legacy /preview/).

Frontend side (separate PR): drop baseHref/servePath "/preview/" so ng builds
at base href "/".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PreviewStateSnapshot.url was made required in this PR; ActiveState extends
it, so the launchPreview literal must provide it.  parse-smoke (syntax) is
the only CI check that runs, and Bun erases types at build time, so this
slipped through — but `bun run typecheck` (tsgo) flags it, and it's plainly
incorrect.  Set url: previewUrl() on the literal (same stable env-derived
value getPreviewState re-derives).

Found in /code-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…S2741)

Second PreviewStateSnapshot literal that predated the now-required url
field — the `installing` placeholder in restartPreview.  Same one-line
fix as the launch literal.

Found in /code-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hblanken hblanken merged commit 768fbdf into collab Jun 14, 2026
1 check passed
hblanken added a commit that referenced this pull request Jun 14, 2026
parse-smoke only catches Bun *parse* failures, not type errors — which is
how the TS2741 in PR #45 (required `url` missing on two PreviewStateSnapshot
literals) reached a green PR.  Add two PR gates:

typecheck-ratchet — runs `tsgo --noEmit` (via `bun turbo typecheck`) on BOTH
  the PR head and the PR base, and fails only on type-error signatures the PR
  INTRODUCES.  A plain typecheck gate is impossible on this fork: it carries
  ~20 pre-existing TS errors (Drizzle drift, date/number mixups — documented
  in parse-smoke.yml) that the runtime tolerates but tsgo rejects, so it would
  be red on day one.  The base-vs-head ratchet lets that debt through while
  catching anything new.  No committed baseline to maintain — the base branch
  IS the baseline.  Signatures strip (line,col) so unrelated edits don't churn
  them; base typecheck reuses head's installed node_modules via symlink to
  avoid a second install.

lint-changed — oxlint on the PR's changed .ts/.tsx files only, so a new lint
  error fails without being blocked by (or needing a baseline for) lint debt
  in untouched files.

Both run on GitHub-hosted ubuntu-latest and reuse the existing setup-bun
action.  To actually BLOCK merges, mark `typecheck-ratchet` + `lint-changed`
as required status checks in the collab branch protection.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant