| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 3 | import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream"; |
| 4 | import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; |
| 5 | import { resolveDeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; |
| 6 | import type { |
| 7 | EnvironmentId, |
| 8 | PreviewEvent, |
| 9 | PreviewListResult, |
| 10 | PreviewSessionSnapshot, |
| 11 | ThreadId, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Option from "effect/Option"; |
| 15 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 16 | import { useMemo } from "react"; |
| 17 | |
| 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { useEnvironmentQuery } from "./query"; |
| 20 | import { environmentSession, usePreparedConnection } from "./session"; |
| 21 | |
| 22 | export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); |
| 23 | |
| 24 | interface ThreadPreviewTabs { |
| 25 | readonly serverEpoch: string | null; |
| 26 | readonly revision: number; |
| 27 | readonly sessions: ReadonlyArray<PreviewSessionSnapshot>; |
| 28 | /** A list result has arrived, so `sessions` covers tabs opened before this view. */ |
| 29 | readonly listed: boolean; |
| 30 | } |
| 31 | |
| 32 | const EMPTY_TABS: ThreadPreviewTabs = { |
| 33 | serverEpoch: null, |
| 34 | revision: 0, |
| 35 | sessions: [], |
| 36 | listed: false, |
| 37 | }; |
| 38 | const emptyTabsAtom = Atom.make(EMPTY_TABS).pipe(Atom.withLabel("mobile-preview-tabs:empty")); |
| 39 | |
| 40 | const MAX_REPLAY_EVENTS = 200; |
| 41 | |
| 42 | function applyEvent(current: ThreadPreviewTabs, event: PreviewEvent): ThreadPreviewTabs { |
| 43 | if (event.revision <= current.revision) return current; |
| 44 | const index = current.sessions.findIndex((session) => session.tabId === event.tabId); |
| 45 | const sessions = [...current.sessions]; |
| 46 | if (event.type === "closed") { |
| 47 | if (index !== -1) sessions.splice(index, 1); |
| 48 | } else if (event.type === "failed") { |
| 49 | const existing = sessions[index]; |
| 50 | if (existing) { |
| 51 | sessions[index] = { |
| 52 | ...existing, |
| 53 | navStatus: { |
| 54 | _tag: "LoadFailed", |
| 55 | url: event.url, |
| 56 | title: event.title, |
| 57 | code: event.code, |
| 58 | description: event.description, |
| 59 | }, |
| 60 | updatedAt: event.createdAt, |
| 61 | }; |
| 62 | } |
| 63 | } else { |
| 64 | // Keep a tab's place so the picker order is stable. |
| 65 | sessions[index === -1 ? sessions.length : index] = event.snapshot; |
| 66 | } |
| 67 | return { ...current, serverEpoch: event.serverEpoch, revision: event.revision, sessions }; |
| 68 | } |
| 69 | |
| 70 | const threadPreviewTabsAtom = Atom.family((threadKey: string) => { |
| 71 | const ref = parseScopedThreadKey(threadKey); |
| 72 | if (ref === null) return emptyTabsAtom; |
| 73 | const listAtom = previewEnvironment.list({ |
| 74 | environmentId: ref.environmentId, |
| 75 | input: { threadId: ref.threadId }, |
| 76 | }); |
| 77 | const eventsAtom = previewEnvironment.events({ environmentId: ref.environmentId, input: {} }); |
| 78 | return Atom.make((get) => { |
| 79 | let disposed = false; |
| 80 | let state = EMPTY_TABS; |
| 81 | // Lists can land after events they predate; replay newer events on each list. |
| 82 | let list: PreviewListResult | null = null; |
| 83 | let events: ReadonlyArray<PreviewEvent> = []; |
| 84 | const publish = (next: ThreadPreviewTabs) => { |
| 85 | if (next === state) return; |
| 86 | state = next; |
| 87 | get.setSelf(next); |
| 88 | }; |
| 89 | const applyList = (result: PreviewListResult) => { |
| 90 | if (list?.serverEpoch === result.serverEpoch && result.revision < list.revision) return; |
| 91 | list = result; |
| 92 | events = events.filter( |
| 93 | (event) => event.serverEpoch === result.serverEpoch && event.revision > result.revision, |
| 94 | ); |
| 95 | return events.reduce<ThreadPreviewTabs>(applyEvent, { ...result, listed: true }); |
| 96 | }; |
| 97 | get.addFinalizer(() => { |
| 98 | disposed = true; |
| 99 | }); |
| 100 | get.subscribe(listAtom, (result) => { |
| 101 | if (!AsyncResult.isSuccess(result)) return; |
| 102 | const next = applyList(result.value); |
| 103 | if (next) publish(next); |
| 104 | }); |
| 105 | get.subscribe(eventsAtom, (result) => { |
| 106 | if (!AsyncResult.isSuccess(result) || result.value.threadId !== ref.threadId) return; |
| 107 | const event = result.value; |
| 108 | if (list?.serverEpoch === event.serverEpoch && event.revision <= list.revision) return; |
| 109 | events = [...events.slice(1 - MAX_REPLAY_EVENTS), event]; |
| 110 | // A restarted server resets revisions; only a fresh list is authoritative. |
| 111 | if (state.serverEpoch !== null && event.serverEpoch !== state.serverEpoch) { |
| 112 | get.refresh(listAtom); |
| 113 | return; |
| 114 | } |
| 115 | publish(applyEvent(state, event)); |
| 116 | }); |
| 117 | get.mount(listAtom); |
| 118 | get.mount(eventsAtom); |
| 119 | const cached = get.once(listAtom); |
| 120 | if (AsyncResult.isSuccess(cached)) state = applyList(cached.value) ?? state; |
| 121 | // The cached list can predate an agent-opened tab. |
| 122 | queueMicrotask(() => { |
| 123 | if (!disposed) get.refresh(listAtom); |
| 124 | }); |
| 125 | return state; |
| 126 | }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`mobile-preview-tabs:${threadKey}`)); |
| 127 | }); |
| 128 | |
| 129 | export function useThreadServerBrowserTabs(input: { |
| 130 | readonly environmentId: EnvironmentId; |
| 131 | readonly threadId: ThreadId; |
| 132 | readonly enabled: boolean; |
| 133 | }) { |
| 134 | const tabs = useAtomValue( |
| 135 | input.enabled |
| 136 | ? threadPreviewTabsAtom( |
| 137 | scopedThreadKey({ environmentId: input.environmentId, threadId: input.threadId }), |
| 138 | ) |
| 139 | : emptyTabsAtom, |
| 140 | ); |
| 141 | const sessions = useMemo( |
| 142 | () => tabs.sessions.filter((session) => session.runtime === "server"), |
| 143 | [tabs.sessions], |
| 144 | ); |
| 145 | return { tabs: sessions, loaded: tabs.listed }; |
| 146 | } |
| 147 | |
| 148 | const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) => |
| 149 | connectionAtomRuntime |
| 150 | .atom((get) => { |
| 151 | const prepared = Option.getOrNull( |
| 152 | get(environmentSession.preparedConnectionValueAtom(environmentId)), |
| 153 | ); |
| 154 | return prepared === null |
| 155 | ? Effect.never |
| 156 | : resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH }); |
| 157 | }) |
| 158 | .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)), |
| 159 | ); |
| 160 | |
| 161 | export function usePreviewStreamAccess(environmentId: EnvironmentId) { |
| 162 | const prepared = usePreparedConnection(environmentId); |
| 163 | const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId)); |
| 164 | const access = query.data && query.error === null && Option.isSome(prepared) ? query.data : null; |
| 165 | return { access, error: query.error, refresh: query.refresh }; |
| 166 | } |