From 60b8155085a1c3c931972a89abf2a7c0227c7b84 Mon Sep 17 00:00:00 2001 From: Hanno Blankenstein Date: Sun, 14 Jun 2026 19:27:30 +1000 Subject: [PATCH 1/3] feat(collab): serve preview at a dedicated subdomain root (retire /preview/ base-href) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../src/components/collab/PreviewLauncher.tsx | 17 ++- packages/app/src/context/collab.tsx | 4 + packages/app/src/pages/collab/session.tsx | 4 +- packages/collab/src/types.ts | 4 + packages/opencode/src/collab/cookie-auth.ts | 11 ++ packages/opencode/src/collab/preview-host.ts | 42 +++++++ .../opencode/src/collab/preview-launcher.ts | 7 ++ .../opencode/src/collab/preview-router.ts | 18 ++- packages/opencode/src/collab/router.ts | 14 ++- packages/opencode/src/server/server.ts | 112 ++++++++++++++---- 10 files changed, 199 insertions(+), 34 deletions(-) create mode 100644 packages/opencode/src/collab/preview-host.ts diff --git a/packages/app/src/components/collab/PreviewLauncher.tsx b/packages/app/src/components/collab/PreviewLauncher.tsx index b4ed37459f30..7c7dbf95f694 100644 --- a/packages/app/src/components/collab/PreviewLauncher.tsx +++ b/packages/app/src/components/collab/PreviewLauncher.tsx @@ -205,15 +205,14 @@ function RunningBanner(props: { onRestart: () => void busy: boolean }) { - // Use the portless `/preview/` URL form — the proxy's parsePreviewPath - // routes it to the active preview's port automatically (commit b313df846 - // on opencode-collab). Frontend repos that ship `.opencode-preview.json` - // with a matching `` (e.g. Angular CLI with the - // `preview` build configuration) rely on this URL form so their internal - // routing + asset URLs resolve correctly. Falling back to the explicit - // `/preview//` form would still work for legacy clients but is - // less consistent with what the SPA's index.html expects. - const url = () => `/preview/` + // Server-authoritative preview URL. The server computes it + // (preview-host.ts → previewUrl()): an absolute + // `https://preview.collab…/` when a dedicated preview host is configured + // (root serve, base href "/"), else the legacy portless `/preview/`. We + // link to it verbatim so the SPA never hard-codes the host. Fallback to + // `/preview/` only for older servers whose snapshot predates the `url` + // field. + const url = () => props.state.url ?? `/preview/` return (
diff --git a/packages/app/src/context/collab.tsx b/packages/app/src/context/collab.tsx index 4ff3d28ecf53..646c80281b9c 100644 --- a/packages/app/src/context/collab.tsx +++ b/packages/app/src/context/collab.tsx @@ -88,6 +88,10 @@ export interface PreviewStateSnapshot { lastTraffic: number recentLog: ReadonlyArray<{ stream: "stdout" | "stderr"; line: string; ts: number }> errorMessage?: string + /** Absolute URL to open the preview (server-computed): the dedicated + * preview-host root when configured, else the legacy `/preview/`. + * Optional for resilience against older servers. */ + url?: string } const CollabContext = createContext() diff --git a/packages/app/src/pages/collab/session.tsx b/packages/app/src/pages/collab/session.tsx index 2d9f24aee081..eff8448e7934 100644 --- a/packages/app/src/pages/collab/session.tsx +++ b/packages/app/src/pages/collab/session.tsx @@ -756,10 +756,10 @@ function CollabSessionInner(props: { me: Me }) { {(port) => ( diff --git a/packages/collab/src/types.ts b/packages/collab/src/types.ts index b3830cb16c57..85557b166734 100644 --- a/packages/collab/src/types.ts +++ b/packages/collab/src/types.ts @@ -215,6 +215,10 @@ export type CollabEvent = lastTraffic: number recentLog: ReadonlyArray<{ stream: "stdout" | "stderr"; line: string; ts: number }> errorMessage?: string + /** Absolute URL to open the preview — dedicated preview-host root + * (`https://preview.…/`) when configured, else legacy `/preview/`. + * Computed server-side; the SPA links to it verbatim. */ + url: string } } | { type: "collab:preview_stopped"; collabSessionId: string } diff --git a/packages/opencode/src/collab/cookie-auth.ts b/packages/opencode/src/collab/cookie-auth.ts index 109a36d69974..d317d27bbfa8 100644 --- a/packages/opencode/src/collab/cookie-auth.ts +++ b/packages/opencode/src/collab/cookie-auth.ts @@ -28,6 +28,7 @@ import { CollabRepoTable, } from "./schema.sql" import { decryptToken, isEncrypted } from "./crypto" +import { previewHost } from "./preview-host" // NOTE: the auth gate only needs the cookie holder's identity // (github_id, github_login) for the participation check. The encrypted @@ -319,6 +320,16 @@ export function cookieAuthorizesRequest(req: Request): CookieAuthDecision { const url = new URL(req.url, "http://localhost") + // Rule (a0): the dedicated preview host. When the request arrived on + // `preview.collab…`, the ENTIRE host is the single active preview served + // at root — every path is a preview asset. Same shell-trust model as the + // legacy `/preview/*` path (ADR-0001): a valid cookie alone is enough, no + // scope check. Host comes off the Host header (url above is normalised to + // localhost and can't carry it). + const reqHost = (req.headers.get("host") ?? "").toLowerCase().split(":")[0] + const ph = previewHost() + if (ph && reqHost === ph) return "allow" + // Rule (a): cookie-only paths (no scope check needed). if (cookieAllowedWithoutScope(url.pathname)) return "allow" diff --git a/packages/opencode/src/collab/preview-host.ts b/packages/opencode/src/collab/preview-host.ts new file mode 100644 index 000000000000..58e239c9e257 --- /dev/null +++ b/packages/opencode/src/collab/preview-host.ts @@ -0,0 +1,42 @@ +/** + * Resolution of the dedicated host the single active preview is served at. + * + * The collab control-plane SPA owns `/` on the main collab host, so the + * frontend preview can't take that root — it gets its own host + * (e.g. `preview.collab.utils.unleashlive.com`) and is served there at root + * (base href "/"), byte-for-byte like a develop-style serve, with no + * `/preview/` prefix rewriting. + * + * Leaf module — imports nothing else in collab/, so server.ts, cookie-auth.ts, + * preview-router.ts and preview-launcher.ts can all import it without the + * import cycles that would arise from hanging this off preview-router (which + * imports from cookie-auth) or preview-launcher. + */ + +/** + * The preview host, lower-cased + port-stripped for direct comparison against + * an incoming request's `Host` header. Resolution order: + * + * 1. `COLLAB_PREVIEW_HOST` env — explicit override. + * 2. `preview.${COLLAB_DOMAIN}` — derived from the main collab domain. + * 3. `null` — no preview host configured (local dev): callers fall back to + * the legacy path-based `/preview/` serving. + */ +export function previewHost(): string | null { + const explicit = process.env["COLLAB_PREVIEW_HOST"] + if (explicit && explicit.trim()) return explicit.trim().toLowerCase().split(":")[0]! + const domain = process.env["COLLAB_DOMAIN"] + if (domain && domain.trim()) return `preview.${domain.trim().toLowerCase().split(":")[0]!}` + return null +} + +/** + * Absolute URL the SPA links to for opening the preview. Root of the + * dedicated preview host when one is configured, else the legacy portless + * `/preview/` path (relative — resolved against the current origin by the + * browser). + */ +export function previewUrl(): string { + const host = previewHost() + return host ? `https://${host}/` : "/preview/" +} diff --git a/packages/opencode/src/collab/preview-launcher.ts b/packages/opencode/src/collab/preview-launcher.ts index f9b348e41aa8..51dc2f7c8a6d 100644 --- a/packages/opencode/src/collab/preview-launcher.ts +++ b/packages/opencode/src/collab/preview-launcher.ts @@ -37,6 +37,7 @@ import { spawn, type ChildProcess } from "child_process" import { existsSync, readFileSync } from "fs" import { join } from "path" import { repoWorkspacePath } from "./workspace" +import { previewUrl } from "./preview-host" import type { CollabEvent } from "@opencode-ai/collab" // ── Configuration ────────────────────────────────────────────────────────── @@ -160,6 +161,11 @@ export interface PreviewStateSnapshot { /** Last N lines of combined stdout+stderr — for the install/run UI. */ readonly recentLog: ReadonlyArray<{ stream: "stdout" | "stderr"; line: string; ts: number }> readonly errorMessage?: string + /** Absolute URL the SPA should link to for opening this preview. + * `https://${previewHost()}/` when a dedicated preview host is configured + * (root serve), else the legacy portless `/preview/` path. Computed + * server-side so the SPA never hard-codes the host. */ + readonly url: string } interface ActiveState extends PreviewStateSnapshot { @@ -342,6 +348,7 @@ export function getPreviewState(): PreviewStateSnapshot | null { lastTraffic: active.lastTraffic, recentLog: active._log.slice(-LOG_LINES_RETAINED), errorMessage: active.errorMessage, + url: previewUrl(), } } diff --git a/packages/opencode/src/collab/preview-router.ts b/packages/opencode/src/collab/preview-router.ts index 62d2ca9dcec3..2e23d278ef9d 100644 --- a/packages/opencode/src/collab/preview-router.ts +++ b/packages/opencode/src/collab/preview-router.ts @@ -27,6 +27,7 @@ import type { IncomingMessage } from "node:http" import type { Socket } from "node:net" import { lookupCookieIdentityFromHeaders } from "./cookie-auth" import { getActiveUpstreamScheme, getActivePreviewPort, getActiveServePath } from "./preview-launcher" +import { previewHost } from "./preview-host" const PREVIEW_PREFIX = "/preview/" @@ -295,7 +296,22 @@ export function attachPreviewUpgrade(server: { server.on("upgrade", (req, clientSocket, head) => { const url = req.url ?? "/" const pathname = url.split("?", 1)[0]! - const parsed = parsePreviewPath(pathname) + + // Two ways an upgrade is "ours": + // 1. Host-based — the request arrived on the dedicated preview host + // (preview.collab…). The WHOLE path is the preview; route the full + // pathname to the active preview's port at root. + // 2. Path-based — legacy `/preview//…` on the main host (local + // dev / fallback when no preview host is configured). + const reqHost = ((req.headers["host"] as string | undefined) ?? "").toLowerCase().split(":")[0] + const ph = previewHost() + let parsed: { port: number; rest: string } | null + if (ph && reqHost === ph) { + const activePort = getActivePreviewPort() + parsed = activePort === null ? null : { port: activePort, rest: pathname || "/" } + } else { + parsed = parsePreviewPath(pathname) + } if (!parsed) { // Not ours — leave the socket alone so other upgrade listeners (e.g. // opencode's own WebSocket routes) can claim it. diff --git a/packages/opencode/src/collab/router.ts b/packages/opencode/src/collab/router.ts index 0cc8bea4a8e2..6676079f819c 100644 --- a/packages/opencode/src/collab/router.ts +++ b/packages/opencode/src/collab/router.ts @@ -604,9 +604,21 @@ function setSession(session: CookieSession): { token: string; header: string } { expires_at: expiresAt, }).run() }) + // Scope the cookie to the collab subdomain tree so the dedicated preview + // host (preview.${COLLAB_DOMAIN}) receives it and the preview's root serve + // is authenticated by the same session — WITHOUT widening to the whole + // utils.unleashlive.com (which would leak the session token to unrelated + // sibling services). `Domain=.collab.utils.unleashlive.com` covers the + // apex collab host AND every *.collab.utils.unleashlive.com. Omit the + // attribute entirely when COLLAB_DOMAIN is unset (local dev) → host-only, + // unchanged behaviour. SameSite=Lax is fine: the preview opens as a + // top-level new-tab navigation and subdomains of unleashlive.com are + // same-site regardless. + const collabDomain = process.env["COLLAB_DOMAIN"]?.trim().toLowerCase().split(":")[0] + const domainAttr = collabDomain ? `Domain=.${collabDomain}; ` : "" return { token, - header: `collab_sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_TTL_SECONDS}`, + header: `collab_sid=${token}; ${domainAttr}Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_TTL_SECONDS}`, } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 169182ea222c..87d2fab11aff 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -3,7 +3,8 @@ import "./init-projectors" import { handleCollabRequest } from "@/collab/router" import { parsePreviewPath, handlePreviewHttp, attachPreviewUpgrade } from "@/collab/preview-router" import { cookieAuthorizesRequest, lookupCookieIdentity } from "@/collab/cookie-auth" -import { markPreviewTraffic } from "@/collab/preview-launcher" +import { markPreviewTraffic, getActivePreviewPort } from "@/collab/preview-launcher" +import { previewHost } from "@/collab/preview-host" import { Database } from "@/storage/db" import { NodeHttpServer } from "@effect/platform-node" import * as Log from "@opencode-ai/core/util/log" @@ -28,10 +29,45 @@ const serverStartedAt = Date.now() // route can serve index.html for them. Bridges the standard Web Request/Response // API used by the collab router into Effect's HttpServerRequest/HttpServerResponse. +// Friendly page shown at the preview host when nothing is running yet. +const NO_PREVIEW_HTML = + `No preview running` + + `` + + `

No preview is currently running

` + + `

A collab Driver needs to launch the dev-server preview from the session sidebar. ` + + `Once it's up, this host serves it at the root.

` + +/** + * Convert the proxied upstream `Response` from handlePreviewHttp into an + * Effect HttpServerResponse, streaming the body without buffering (video, + * large downloads, SSE all work). Shared by the dedicated-preview-host + * branch and the legacy `/preview/` path branch. + */ +function previewServerResponse(webResponse: Response) { + const previewHeaders = new Headers(webResponse.headers) + if (webResponse.body) { + const previewStream = Stream.fromAsyncIterable( + webResponse.body as AsyncIterable, + () => new Error("Preview stream error"), + ) + return HttpServerResponse.stream(previewStream, { + status: webResponse.status, + headers: previewHeaders, + }) + } + return HttpServerResponse.raw(new Uint8Array(), { + status: webResponse.status, + headers: previewHeaders, + }) +} + const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) => Effect.gen(function* () { const req = yield* HttpServerRequest.HttpServerRequest const pathname = new URL(req.url, "http://localhost").pathname + // Lower-cased, port-stripped Host for the dedicated-preview-host routing + // below. Effect's HttpServerRequest exposes the raw header map. + const host = (req.headers["host"] ?? "").toLowerCase().split(":")[0] // /healthz — ALB / ECS health probe. Sits ahead of every other route so // a degraded collab/preview path can't fail the liveness check on its own. @@ -41,6 +77,39 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) => return yield* serveHealthz() } + // Dedicated preview host — root serve. When the request arrived on + // `preview.collab…` the ENTIRE host is the single active preview served + // at root (base href "/"), byte-for-byte like a develop serve. This + // MUST come before the `/`→/collab/new landing below, since on the + // preview host `/` is the preview's index.html, not the collab landing. + // (/healthz is handled above this, so ALB health checks are unaffected + // regardless of Host.) + const ph = previewHost() + if (ph && host === ph) { + const webRequest = yield* HttpServerRequest.toWeb(req) + // Same shell-trust gate as the legacy path (ADR-0001): a valid collab + // cookie is enough. cookieAuthorizesRequest now allows when the Host + // is the preview host (see cookie-auth.ts rule a0). + if (cookieAuthorizesRequest(webRequest) !== "allow") { + return HttpServerResponse.raw(new TextEncoder().encode("Forbidden"), { + status: 403, + headers: new Headers({ "content-type": "text/plain" }), + }) + } + markPreviewTraffic() + const port = getActivePreviewPort() + if (port === null) { + return HttpServerResponse.raw(new TextEncoder().encode(NO_PREVIEW_HTML), { + status: 200, + headers: new Headers({ "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }), + }) + } + // Root serve: forward the WHOLE pathname (servePath is null now that the + // frontend builds at base href "/"), so /assets/x.js → /assets/x.js. + const webResponse = yield* Effect.promise(() => handlePreviewHttp(webRequest, port, pathname || "/")) + return previewServerResponse(webResponse) + } + // GET / and GET /collab — collab landing. Authenticated users are // bounced to /collab/new (which lists their existing sessions in the // sidebar + shows the create form). Unauthenticated users get a small @@ -53,9 +122,26 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) => return yield* Effect.sync(() => HttpServerResponse.fromWeb(serveCollabLanding(webRequest))) } - // /preview// — HTTP reverse proxy to a dev server running - // inside this container. WebSocket upgrades for the same path are - // handled separately by attachPreviewUpgrade on the http.Server. + // Legacy `/preview/...` on the MAIN host, when a dedicated preview host + // IS configured → 301 to the subdomain root so old links + bookmarks keep + // working and there's one canonical preview origin. Done by prefix (not + // parsePreviewPath) so the redirect fires even when no preview is active + // (parsePreviewPath's portless form returns null then). Strip an optional + // leading `/` segment so the redirect preserves the deep path. + if (ph && pathname.startsWith("/preview/")) { + const afterPrefix = pathname.slice("/preview/".length) + const segs = afterPrefix.split("/") + const rest = /^\d+$/.test(segs[0] ?? "") ? "/" + segs.slice(1).join("/") : "/" + afterPrefix + const location = `https://${ph}${rest}${new URL(req.url, "http://localhost").search}` + return HttpServerResponse.raw(new Uint8Array(), { + status: 301, + headers: new Headers({ location, "cache-control": "no-store" }), + }) + } + + // Legacy `/preview//` path-based proxy — only the local-dev + // fallback now (no preview host configured). WebSocket upgrades are + // handled separately by attachPreviewUpgrade. const previewParsed = parsePreviewPath(pathname) if (previewParsed) { const webRequest = yield* HttpServerRequest.toWeb(req) @@ -77,23 +163,7 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) => const webResponse = yield* Effect.promise(() => handlePreviewHttp(webRequest, previewParsed.port, previewParsed.rest), ) - const previewHeaders = new Headers(webResponse.headers) - // The upstream body is a stream — let Effect pipe it through without - // buffering, so video / large downloads / SSE all work. - if (webResponse.body) { - const previewStream = Stream.fromAsyncIterable( - webResponse.body as AsyncIterable, - () => new Error("Preview stream error"), - ) - return HttpServerResponse.stream(previewStream, { - status: webResponse.status, - headers: previewHeaders, - }) - } - return HttpServerResponse.raw(new Uint8Array(), { - status: webResponse.status, - headers: previewHeaders, - }) + return previewServerResponse(webResponse) } // Only intercept collab API/auth/invite paths — let UI routes fall through to index.html From d9051378dcac435735c14a9e346834ca52aad567 Mon Sep 17 00:00:00 2001 From: Hanno Blankenstein Date: Sun, 14 Jun 2026 20:50:51 +1000 Subject: [PATCH 2/3] =?UTF-8?q?fix(collab):=20set=20url=20on=20ActiveState?= =?UTF-8?q?=20literal=20(TS2741=20=E2=80=94=20required=20field)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/opencode/src/collab/preview-launcher.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/collab/preview-launcher.ts b/packages/opencode/src/collab/preview-launcher.ts index 51dc2f7c8a6d..4428e37ccbe1 100644 --- a/packages/opencode/src/collab/preview-launcher.ts +++ b/packages/opencode/src/collab/preview-launcher.ts @@ -560,6 +560,10 @@ export function launchPreview( _log: [], recentLog: [], errorMessage: undefined, + // Snapshot URL (server-computed). Required on PreviewStateSnapshot, so + // ActiveState (which extends it) must carry it too; getPreviewState() + // re-derives the same stable, env-based value when it builds a snapshot. + url: previewUrl(), child, config, _stopRequested: false, From 6863837eb90ca576fa9783f73bf9bc9c833fcece Mon Sep 17 00:00:00 2001 From: Hanno Blankenstein Date: Sun, 14 Jun 2026 20:52:10 +1000 Subject: [PATCH 3/3] fix(collab): set url on the restartPreview installing snapshot too (TS2741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/opencode/src/collab/preview-launcher.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/collab/preview-launcher.ts b/packages/opencode/src/collab/preview-launcher.ts index 4428e37ccbe1..6332c30cb734 100644 --- a/packages/opencode/src/collab/preview-launcher.ts +++ b/packages/opencode/src/collab/preview-launcher.ts @@ -698,6 +698,7 @@ export function restartPreview(): LaunchResult { startedAt: Date.now(), lastTraffic: Date.now(), recentLog: [], + url: previewUrl(), } stopPreview("restart")