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