feat(collab): serve preview at a dedicated subdomain root (retire /preview/ base-href)#45
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The frontend live-preview was served under a
/preview/path prefix, forcing the Angular build to usebaseHref:"/preview/"+ a matching dev-serverservePath. 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;nullin 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.tscollabMiddleware— 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 intopreviewServerResponse().cookie-auth.ts—cookieAuthorizesRequestallows when Host === preview host (same shell-trust model as/preview/*, ADR-0001).router.tssetSession— widencollab_sidtoDomain=.${COLLAB_DOMAIN}so the preview subdomain receives it; scoped to the collab subtree only. Omitted whenCOLLAB_DOMAINunset.preview-router.tsattachPreviewUpgrade— host-aware WS routing.preview-launcher.ts+ collab types + app —getPreviewState()carries a server-computedurl; the SPA links to it verbatim.preview.${domain}alb.tfpreview.${domain}→ ALBroute53.tfCOLLAB_DOMAINenv on the ECS taskecs.tf#3 is make-or-break:
COLLAB_DOMAINis currently NOT a container env var (onlyOPENCODE_BASE_URLis). Without itpreviewHost()returns null AND the cookie stays host-only → the feature is entirely inert (falls back to/preview/). AddingCOLLAB_DOMAIN = var.domain_namepowers bothpreviewHost()and the cookie widening from one var.Companion PR
Frontend: drop
baseHref/servePath"/preview/"fromangular.json+.opencode-preview.jsonso ng builds at base href/.Merge-order note
Touches
server.ts(also in PR #42) andpreview-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/healthzTLS valid (cert SAN). Anon/on preview host → 403.https://preview.collab.utils.unleashlive.com/.collab_sidvia widened Domain): SPA at root, assets at/assets/...(NOT/preview/assets/...), no MIME/404,<base href="/">.…/preview/→ 301 to subdomain.COLLAB_DOMAIN):/preview/path serving still works unchanged.🤖 Generated with Claude Code