import { type ControlKind, type TunnelState, type UAClass } from "@vincentt-xr/harness/tunnel"; import type { DiagEvent } from "@vincentt-xr/harness/events"; import { type EventTags } from "../relay/server.js"; import type { ResolvedConfig } from "../shared/config.js"; /** Why a preview ended. Each maps to exactly one screen (Flow L20/L21). */ export type PreviewEndReason = "stopped" /** * `stopped` reported by the API, not observed locally. A separate member from * `stopped` because this window was not the actor: it holds no timer and no * viewer count, so it must not print the stats the local screen prints. */ | "stopped_elsewhere" /** * The connection closed and the session did not come back. Deliberately * distinct from `grace_exhausted`, which is the CLI's own observation after * it watched the link go and tried; this one is reported to a process that * may never have witnessed the drop. */ | "disconnected" | "hard_max" | "superseded" | "grace_exhausted" /** * The drop PAST the cap — the edge refused the reconnect with a 409 because * `graceUsed` is spent. Distinct from `grace_exhausted` (the grace window * elapsed without the client getting back): same remedy, different diagnosis, * and the copy explains the bound only at the moment it bites. */ | "grace_cap" | "authz_revoked" | "authz_unconfirmed" /** * The 30-minute heartbeat-silence flip. NEVER reported as `hard_max`: a * session ended at minute 30 has not reached its 12-hour ceiling, and saying * so is a false statement about the product's own behaviour. */ | "abandoned" | "egress_cap"; export interface StartPreviewOptions { /** Project directory — its .vincentt binding + dev script drive the preview. */ projectCwd: string; /** * The directory holding the binding — the tree's IDENTITY, as distinct from * the directory the dev server runs in. * * These differ whenever a creator runs `vincentt preview` from a subdirectory * of a bound repo: `locateProjectBinding` walks up to find the binding, so the * same project resolves from `apps/web/` and from the repo root. Keying the * one-session-per-tree rule off the raw cwd made those two spellings two * trees, which is precisely what `workingTreeKey` normalizes symlinks and * relative paths to prevent. Defaults to projectCwd when the caller has not * located a binding. */ projectRoot?: string; /** The API host + PAT. Resolved by the caller so the runner makes no policy. */ config: ResolvedConfig; projectId: string; /** App dev-serve port (default 5173). A server already here is REUSED. */ appPort?: number; relayPort?: number; frontPort?: number; /** Path routed to the relay instead of the app (default /__harness). */ harnessPath?: string; devCommand?: string[]; appStdio?: "ignore" | "inherit"; appReadyTimeoutMs?: number; /** Progress sink. Never write app stdout to an MCP server's stdout. */ onLog?: (message: string) => void; /** * A viewer label appearing on a stream. Fires per OPEN, so the roster's * per-label idempotence is what keeps one device from announcing itself once * per asset. * * A label is per CONNECTION (the edge binds it in `ViewerForConn` and holds * it for the connection's life), so a page plus nine assets is ONE label and * the dedupe above is what keeps it to one line. It is NOT per device: under * HTTP/1.1 a browser cannot multiplex, so one phone opens several. */ onViewer?: (label: string, uaClass: UAClass) => void; /** * The other half of `onViewer`: a viewer's connection closed. Fires once, * carrying the label to drop from the roster. */ onViewerGone?: (label: string) => void; /** * Diagnostics landing at the relay, tagged with the connection that produced * them and the tester that connection resolved to. Either tag can be absent: * the tester is unknown until the app mounts and the harness introduces * itself, so the first events of a load are routinely un-attributed. */ onEvents?: (events: DiagEvent[], tags: EventTags) => void; /** * A connection introduced its tester on the `hello`, at connect, before any * event. This is what makes `tester-N connected` print for a SILENT app and * for one that crashes during mount — the key introduced itself before the * crash. Fires once per connection; a client with no hello (an old harness) * still resolves its tester on the first batch via `onEvents`. */ onAttribute?: (tags: EventTags) => void; /** * The tester cap was reached, once per refused session. The CLI prints one * line; the connection's events are still stored, just un-attributed. */ onCapReached?: () => void; /** The tunnel's own state — what drives the L19 reconnect advisories. */ onTunnelState?: (state: TunnelState) => void; /** The creator's dev server is not answering (L18). */ onOriginError?: (label: string, code: string) => void; /** Their dev server answered again. Re-arms the advisory; prints NOTHING. */ onOriginRecovered?: () => void; /** The session ended for a reason the creator did not cause. */ onEnded?: (reason: PreviewEndReason) => void; /** The hard max is approaching. A WARNING, never an ending (L20b). */ onExpiring?: () => void; /** Injected for tests. */ fetchImpl?: typeof fetch; } export interface RunningPreview { /** Public https URL to open on the device. SERVER-COMPUTED, never assembled. */ url: string; sessionId: string; /** ISO8601, the hard max. Never extended by a heartbeat. */ expiresAt: string; relayPort: number; appPort: number; /** Whether the app was responding when we returned. */ appReady: boolean; /** Tear down the tunnel, the app dev serve, the front proxy and the relay. */ stop: () => Promise; } export declare function startPreview(opts: StartPreviewOptions): Promise; /** * Two of the three CONTROL kinds are endings. `expiring` is NOT: it is the * hard-max warning, and it arrives on the same channel. Mapping it to an ending * would take away a link that has half an hour left, at exactly the moment the * creator is being warned to plan around it — so it returns null and the caller * renders the warning instead. */ export declare function controlToReason(kind: ControlKind): PreviewEndReason | null; /** * A refused RECONNECT dial → the ending screen it means. * * The edge answers a reattach with three distinguishable statuses and they are * deliberately not collapsed (`ingress.go:299-310`): the consume is what * separates them, and collapsing would cost the CLI its ability to say "another * tunnel holds this preview" versus "your preview ended" — different creator * actions, and the debugging creator is the common case. * * A 409 is the grace cap being spent, which is the one ending that explains the * bound. A 401 (the token was refused) and a 410 (the session is gone) both mean * the link is not coming back, and `grace_exhausted` is that screen. * * Anything else — 502, 503, a transient edge fault — returns null and keeps the * backoff. Ending a preview over a momentarily unavailable edge would take away * a link that is about to work again. */ export declare function refusalToReason(status: number): PreviewEndReason | null; /** * One heartbeat. A 410 is the session ending on the server's terms; a 404/403 * is authorization lost. They are DELIBERATELY NOT MERGED, because the remedies * differ: one is "find out who you are", the other is "try again". * * A transport failure is neither — the API being briefly unreachable must not * end a healthy preview, because the edge already fails closed on its own * 60-second deadline and will drain if the outage is real. * * THE 410 IS NOT REPORTED AS `hard_max`. It previously was, unconditionally, and * that is a FALSE STATEMENT about the product's own behaviour for every terminal * cause that is not the ceiling: a session flipped `abandoned` at minute 30, or * superseded, or stopped elsewhere, all answer 410, and the hard-max screen is a * `✓` telling the creator the preview did exactly what it promised over 12 hours. * * The API's 410 body carries ONE opaque code (`preview_session_ended`) for every * terminal reason — verified against the running stack: an `abandoned` row and an * `expired` row return byte-identical bodies — so the CLI CANNOT tell them apart * and must not guess. `session_ended` is the honest, cause-free rendering until * the wire carries the discriminator. See the note on PreviewEndedError. */ export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason) => void): Promise; /** * The API's `endReason` → the CLI's ending screen. * * `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 — * which is every 410 the API sends today — maps to `grace_exhausted`, the one * ending whose copy states only what is actually known: the preview has ended * and the link no longer works. It asserts no cause. * * That default is the conservative choice and it is deliberate. `hard_max` is a * `✓` claiming the preview ran its full 12 hours; rendering it for a session the * platform reaped at minute 30 tells the creator something untrue about their own * session. `grace_exhausted` is a `✗` that says the link is gone and gives the * next command, which is true of EVERY terminal cause. Under-claiming a cause * costs the creator one sentence of diagnosis; over-claiming one states a fact * only the product can see, and states it wrong. * * EVERY endReason the domain defines must have a case here. The `default` is for * a reason a NEWER api invented, not for one this client forgot: a fall-through * renders "your network dropped" for a cause that may be nothing of the kind. * That is not hypothetical — `stopped` and `disconnected` fell through for a * build, so a creator who stopped their own preview from another terminal was * told their connection failed. The set is enumerated from the domain by * QA-F3-C9(f), which fails when a new member is added rather than mapping it * silently to a wrong screen. */ export declare function endReasonToPreviewEnd(reason: string | undefined): PreviewEndReason;