1/**
2 * In-memory PreviewManager implementation.
3 *
4 * Sessions are keyed by `(threadId, tabId)`; a single thread can host
5 * multiple tabs (browser-style). `open` always creates a new tab — tab
6 * lifecycle is owned by the renderer.
7 *
8 * Events are published via Effect's `PubSub`, so subscriber failures are
9 * isolated from the publishing call (a closed WS subscriber queue cannot
10 * fail an in-progress `navigate()`).
11 */
12import {
13 type PreviewCloseInput,
14 type PreviewEvent,
15 type PreviewError,
16 PreviewInvalidUrlError,
17 type PreviewListInput,
18 type PreviewListResult,
19 type PreviewNavigateInput,
20 type PreviewOpenInput,
21 type PreviewRefreshInput,
22 type PreviewReportStatusInput,
23 type PreviewResizeInput,
24 type PreviewAdjustInput,
25 FILL_PREVIEW_VIEWPORT,
26 PreviewSessionLookupError,
27 type PreviewSessionSnapshot,
28} from "@t3tools/contracts";
29import {
30 isPreviewUrlNormalizationError,
31 newPreviewTabId,
32 normalizePreviewUrl,
33} from "@t3tools/shared/preview";
34import * as Context from "effect/Context";
35import * as Crypto from "effect/Crypto";
36import * as DateTime from "effect/DateTime";
37import * as Effect from "effect/Effect";
38import * as Layer from "effect/Layer";
39import * as PubSub from "effect/PubSub";
40import * as Scope from "effect/Scope";
41import * as Stream from "effect/Stream";
42import * as SynchronizedRef from "effect/SynchronizedRef";
43import { PreviewControlRequiredError } from "@t3tools/contracts";
44
45export class PreviewManager extends Context.Service<
46 PreviewManager,
47 {
48 readonly open: (
49 input: PreviewOpenInput & {
50 readonly automationOwner?: string;
51 /** Runs before the `opened` event publishes, so subscribers find state keyed by the tab. */
52 readonly beforePublish?: (snapshot: PreviewSessionSnapshot) => void;
53 },
54 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
55 readonly navigate: (
56 input: PreviewNavigateInput,
57 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
58 readonly reportStatus: (
59 input: PreviewReportStatusInput & { readonly serverControlled?: boolean },
60 ) => Effect.Effect<void, PreviewError>;
61 readonly requestReveal: (
62 input: PreviewCloseInput & { readonly tabId: string; readonly force: boolean },
63 ) => Effect.Effect<void, PreviewError>;
64 readonly resize: (
65 input: PreviewResizeInput,
66 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
67 /**
68 * Records a server tab's appearance or zoom and publishes it; the server's
69 * browser applies it to the page. Any client may send it, as it changes how
70 * the page renders, not what it does.
71 */
72 readonly adjust: (
73 input: PreviewAdjustInput,
74 ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;
75 readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;
76 readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;
77 readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;
78 readonly events: Stream.Stream<PreviewEvent>;
79 readonly subscribeEvents: Effect.Effect<PubSub.Subscription<PreviewEvent>, never, Scope.Scope>;
80 }
81>()("t3/preview/Manager/PreviewManager") {}
82
83interface PreviewSessionState {
84 readonly threadId: string;
85 readonly tabId: string;
86 readonly snapshot: PreviewSessionSnapshot;
87}
88
89interface ManagerState {
90 /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */
91 readonly sessions: ReadonlyMap<string, PreviewSessionState>;
92 /** Global monotonic revision establishing list/event ordering. */
93 readonly revision: number;
94}
95
96const initialState: ManagerState = { sessions: new Map(), revision: 0 };
97
98type PreviewEventDraft = PreviewEvent extends infer Event
99 ? Event extends { readonly revision: number }
100 ? Omit<Event, "revision" | "serverEpoch">
101 : never
102 : never;
103
104const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`;
105
106const sessionsForThread = (
107 state: ManagerState,
108 threadId: string,
109): ReadonlyArray<PreviewSessionState> => {
110 const out: PreviewSessionState[] = [];
111 for (const session of state.sessions.values()) {
112 if (session.threadId === threadId) out.push(session);
113 }
114 return out;
115};
116
117const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> =>
118 Effect.try({
119 try: () => normalizePreviewUrl(rawUrl),
120 catch: (cause) => {
121 if (isPreviewUrlNormalizationError(cause)) {
122 return new PreviewInvalidUrlError({
123 inputLength: cause.inputLength,
124 reason: cause.reason,
125 protocol: cause.protocol,
126 cause,
127 });
128 }
129
130 return new PreviewInvalidUrlError({
131 inputLength: rawUrl.length,
132 reason: "unexpected",
133 cause,
134 });
135 },
136 });
137
138const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
139
140/** @public Service construction is part of the canonical Effect module API. */
141export const make = Effect.gen(function* PreviewManagerMake() {
142 const crypto = yield* Crypto.Crypto;
143 const serverEpoch = yield* crypto.randomUUIDv4().pipe(Effect.orDie);
144 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);
145 // Unbounded PubSub is fine here — events are tiny and we don't want to
146 // block publishers if a subscriber is slow. WS clients backpressure on
147 // their own queues downstream.
148 const eventsPubSub = yield* PubSub.unbounded<PreviewEvent>();
149 const events: Stream.Stream<PreviewEvent> = Stream.fromPubSub(eventsPubSub);
150
151 /**
152 * Atomic read-modify-write over the session for `(threadId, tabId)`. The
153 * mutator runs under the SynchronizedRef so concurrent writers cannot
154 * interleave. Lookup failures travel through the modify result so both
155 * branches yield the same `[A, S]` shape `modifyEffect` requires.
156 *
157 * The event is published INSIDE the lock so observers see events in the
158 * same order as the underlying state transitions. Publishing an unbounded
159 * PubSub is non-blocking, so this is cheap.
160 */
161 const mutateExistingSession = <R, E>(
162 threadId: string,
163 tabId: string,
164 mutator: (
165 session: PreviewSessionState,
166 ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>,
167 ): Effect.Effect<R, E | PreviewSessionLookupError> => {
168 type ModifyResult =
169 | { kind: "fail"; error: PreviewSessionLookupError }
170 | { kind: "ok"; result: R };
171
172 return SynchronizedRef.modifyEffect(stateRef, (state) => {
173 const session = state.sessions.get(compositeKey(threadId, tabId));
174 if (!session) {
175 return Effect.succeed([
176 { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) },
177 state,
178 ] as readonly [ModifyResult, ManagerState]);
179 }
180 return mutator(session).pipe(
181 Effect.flatMap(
182 Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) {
183 const revision = emit ? state.revision + 1 : state.revision;
184 if (emit) {
185 yield* PubSub.publish(eventsPubSub, {
186 ...emit,
187 revision,
188 serverEpoch,
189 } as PreviewEvent);
190 }
191 const sessions = new Map(state.sessions);
192 sessions.set(compositeKey(threadId, tabId), next);
193 return [{ kind: "ok", result } as ModifyResult, { sessions, revision }] as readonly [
194 ModifyResult,
195 ManagerState,
196 ];
197 }),
198 ),
199 );
200 }).pipe(
201 Effect.flatMap((modify) =>
202 modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result),
203 ),
204 );
205 };
206
207 const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")(
208 function* (input) {
209 const runtime = input.runtime;
210 // Persisted client surfaces must not bind to a different tab after a server restart.
211 const tabId = `${newPreviewTabId()}${runtime === "server" ? `_${serverEpoch}` : ""}`;
212 const updatedAt = yield* currentIsoTimestamp;
213 const snapshot: PreviewSessionSnapshot = {
214 threadId: input.threadId,
215 tabId,
216 navStatus: input.url
217 ? { _tag: "Loading", url: yield* normalizeUrl(input.url), title: "" }
218 : { _tag: "Idle" },
219 canGoBack: false,
220 canGoForward: false,
221 viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT,
222 ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
223 ...(runtime === undefined ? {} : { runtime }),
224 ...(runtime === "server" && input.automationOwner !== undefined
225 ? { automationOwner: input.automationOwner }
226 : {}),
227 ...(input.reveal === undefined ? {} : { reveal: input.reveal }),
228 updatedAt,
229 };
230 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
231 Effect.gen(function* () {
232 const revision = state.revision + 1;
233 const sessions = new Map(state.sessions);
234 sessions.set(compositeKey(input.threadId, tabId), {
235 threadId: input.threadId,
236 tabId,
237 snapshot,
238 });
239 input.beforePublish?.(snapshot);
240 yield* PubSub.publish(eventsPubSub, {
241 type: "opened",
242 threadId: input.threadId,
243 tabId,
244 createdAt: snapshot.updatedAt,
245 serverEpoch,
246 revision,
247 snapshot,
248 });
249 return [snapshot, { sessions, revision }] as const;
250 }),
251 );
252 return snapshot;
253 },
254 );
255
256 const navigate: PreviewManager["Service"]["navigate"] = Effect.fn("PreviewManager.navigate")(
257 function* (input) {
258 const url = yield* normalizeUrl(input.url);
259 return yield* mutateExistingSession(
260 input.threadId,
261 input.tabId,
262 Effect.fn("PreviewManager.navigateSession")(function* (session) {
263 if (session.snapshot.runtime === "server")
264 return yield* new PreviewControlRequiredError({ tabId: input.tabId });
265 const updatedAt = yield* currentIsoTimestamp;
266 const previousTitle =
267 session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title;
268 const resolvedTitle = input.resolvedTitle ?? previousTitle;
269 const snapshot: PreviewSessionSnapshot = {
270 ...session.snapshot,
271 navStatus: { _tag: "Success", url, title: resolvedTitle },
272 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
273 updatedAt,
274 };
275 return {
276 next: { ...session, snapshot },
277 emit: {
278 type: "navigated",
279 threadId: session.threadId,
280 tabId: session.tabId,
281 createdAt: snapshot.updatedAt,
282 snapshot,
283 },
284 result: snapshot,
285 };
286 }),
287 );
288 },
289 );
290
291 const reportStatus: PreviewManager["Service"]["reportStatus"] = Effect.fn(
292 "PreviewManager.reportStatus",
293 )(function* (input) {
294 yield* mutateExistingSession(
295 input.threadId,
296 input.tabId,
297 Effect.fn("PreviewManager.reportSessionStatus")(function* (session) {
298 if (session.snapshot.runtime === "server" && !input.serverControlled)
299 return yield* new PreviewControlRequiredError({ tabId: input.tabId });
300 const updatedAt = yield* currentIsoTimestamp;
301 const snapshot: PreviewSessionSnapshot = {
302 ...session.snapshot,
303 navStatus: input.navStatus,
304 canGoBack: input.canGoBack,
305 canGoForward: input.canGoForward,
306 viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,
307 updatedAt,
308 };
309 const emit: PreviewEventDraft =
310 input.navStatus._tag === "LoadFailed"
311 ? {
312 type: "failed",
313 threadId: session.threadId,
314 tabId: session.tabId,
315 createdAt: snapshot.updatedAt,
316 url: input.navStatus.url,
317 title: input.navStatus.title,
318 code: input.navStatus.code,
319 description: input.navStatus.description,
320 ...(input.navStatus.download === undefined
321 ? {}
322 : { download: input.navStatus.download }),
323 }
324 : {
325 type: "navigated",
326 threadId: session.threadId,
327 tabId: session.tabId,
328 createdAt: snapshot.updatedAt,
329 snapshot,
330 };
331 return {
332 next: { ...session, snapshot },
333 emit,
334 result: undefined as void,
335 };
336 }),
337 );
338 });
339
340 const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")(
341 function* (input) {
342 return yield* mutateExistingSession(
343 input.threadId,
344 input.tabId,
345 // Any client may size a tab, as the desktop has always allowed; the
346 // server's browser follows the published setting for its own tabs.
347 Effect.fn("PreviewManager.resizeSession")(function* (session) {
348 const updatedAt = yield* currentIsoTimestamp;
349 const snapshot: PreviewSessionSnapshot = {
350 ...session.snapshot,
351 viewport: input.viewport,
352 updatedAt,
353 };
354 return {
355 next: { ...session, snapshot },
356 emit: {
357 type: "resized",
358 threadId: session.threadId,
359 tabId: session.tabId,
360 createdAt: snapshot.updatedAt,
361 snapshot,
362 },
363 result: snapshot,
364 };
365 }),
366 );
367 },
368 );
369
370 const adjust: PreviewManager["Service"]["adjust"] = Effect.fn("PreviewManager.adjust")(
371 function* (input) {
372 return yield* mutateExistingSession(
373 input.threadId,
374 input.tabId,
375 Effect.fn("PreviewManager.adjustSession")(function* (session) {
376 const updatedAt = yield* currentIsoTimestamp;
377 const snapshot: PreviewSessionSnapshot = {
378 ...session.snapshot,
379 ...(input.colorScheme === undefined ? {} : { colorScheme: input.colorScheme }),
380 ...(input.zoomFactor === undefined ? {} : { zoomFactor: input.zoomFactor }),
381 updatedAt,
382 };
383 // One-off requests ride on the event for the server's browser to act on.
384 const request =
385 input.hardReload || input.clear
386 ? {
387 request: {
388 ...(input.hardReload ? { hardReload: true } : {}),
389 ...(input.clear ? { clear: input.clear } : {}),
390 },
391 }
392 : {};
393 return {
394 next: { ...session, snapshot },
395 emit: {
396 ...request,
397 type: "resized",
398 threadId: session.threadId,
399 tabId: session.tabId,
400 createdAt: snapshot.updatedAt,
401 snapshot,
402 },
403 result: snapshot,
404 };
405 }),
406 );
407 },
408 );
409
410 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(
411 function* (input) {
412 // Verify the session exists; the desktop bridge handles the actual reload
413 // and will report progress back via `reportStatus`. No event emitted.
414 yield* mutateExistingSession(input.threadId, input.tabId, (session) =>
415 Effect.succeed({ next: session, emit: null, result: undefined as void }),
416 );
417 },
418 );
419
420 const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")(
421 function* (input) {
422 const createdAt = yield* currentIsoTimestamp;
423 yield* SynchronizedRef.modifyEffect(stateRef, (state) => {
424 const eventsToEmit: PreviewEvent[] = [];
425 const sessions = new Map(state.sessions);
426 const targets = input.tabId
427 ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter(
428 (entry): entry is PreviewSessionState => entry !== undefined,
429 )
430 : sessionsForThread(state, input.threadId);
431 let revision = state.revision;
432 for (const target of targets) {
433 revision += 1;
434 sessions.delete(compositeKey(target.threadId, target.tabId));
435 eventsToEmit.push({
436 type: "closed",
437 threadId: target.threadId,
438 tabId: target.tabId,
439 createdAt,
440 serverEpoch,
441 revision,
442 });
443 }
444 if (eventsToEmit.length === 0) {
445 return Effect.succeed([undefined, state] as const);
446 }
447 return Effect.as(
448 Effect.forEach(eventsToEmit, (event) => PubSub.publish(eventsPubSub, event), {
449 discard: true,
450 }),
451 [undefined, { sessions, revision }] as const,
452 );
453 });
454 },
455 );
456
457 const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")(
458 function* (input) {
459 return yield* SynchronizedRef.get(stateRef).pipe(
460 Effect.map((state): PreviewListResult => ({
461 sessions: sessionsForThread(state, input.threadId)
462 .map((s) => s.snapshot)
463 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)),
464 serverEpoch,
465 revision: state.revision,
466 })),
467 );
468 },
469 );
470
471 const requestReveal: PreviewManager["Service"]["requestReveal"] = Effect.fn(
472 "PreviewManager.requestReveal",
473 )(function* (input) {
474 yield* mutateExistingSession(
475 input.threadId,
476 input.tabId,
477 Effect.fn("PreviewManager.revealSession")(function* (session) {
478 const snapshot = {
479 ...session.snapshot,
480 reveal: true,
481 revealRequest: {
482 id: yield* crypto.randomUUIDv4().pipe(Effect.orDie),
483 force: input.force,
484 },
485 updatedAt: yield* currentIsoTimestamp,
486 };
487 return {
488 next: { ...session, snapshot },
489 emit: {
490 type: "navigated" as const,
491 threadId: session.threadId,
492 tabId: session.tabId,
493 createdAt: snapshot.updatedAt,
494 snapshot,
495 },
496 result: undefined,
497 };
498 }),
499 );
500 });
501
502 return PreviewManager.of({
503 open,
504 requestReveal,
505 navigate,
506 reportStatus,
507 resize,
508 adjust,
509 refresh,
510 close,
511 list,
512 events,
513 subscribeEvents: PubSub.subscribe(eventsPubSub),
514 });
515}).pipe(Effect.withSpan("PreviewManager.make"));
516
517export const layer = Layer.effect(PreviewManager, make);