461 lines · 15.8 KB · TypeScript
RawDownload
/** * In-memory PreviewManager implementation. * * Sessions are keyed by `(threadId, tabId)`; a single thread can host * multiple tabs (browser-style). `open` always creates a new tab — tab * lifecycle is owned by the renderer. * * Events are published via Effect's `PubSub`, so subscriber failures are * isolated from the publishing call (a closed WS subscriber queue cannot * fail an in-progress `navigate()`). */import {  type PreviewCloseInput,  type PreviewEvent,  type PreviewError,  PreviewInvalidUrlError,  type PreviewListInput,  type PreviewListResult,  type PreviewNavigateInput,  type PreviewOpenInput,  type PreviewRefreshInput,  type PreviewReportStatusInput,  type PreviewResizeInput,  FILL_PREVIEW_VIEWPORT,  PreviewSessionLookupError,  type PreviewSessionSnapshot,  type PreviewViewportSetting,} from "@t3tools/contracts";import {  isPreviewUrlNormalizationError,  newPreviewTabId,  normalizePreviewUrl,} from "@t3tools/shared/preview";import * as NodeCrypto from "node:crypto";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as PubSub from "effect/PubSub";import * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import * as SynchronizedRef from "effect/SynchronizedRef"; export class PreviewManager extends Context.Service<  PreviewManager,  {    readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;    readonly navigate: (      input: PreviewNavigateInput,    ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;    readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>;    readonly resize: (      input: PreviewResizeInput,    ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>;    readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>;    readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>;    readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>;    readonly events: Stream.Stream<PreviewEvent>;    readonly subscribeEvents: Effect.Effect<PubSub.Subscription<PreviewEvent>, never, Scope.Scope>;  }>()("t3/preview/Manager/PreviewManager") {} interface PreviewSessionState {  readonly threadId: string;  readonly tabId: string;  readonly snapshot: PreviewSessionSnapshot;} interface ManagerState {  /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */  readonly sessions: ReadonlyMap<string, PreviewSessionState>;  /** Global monotonic revision establishing list/event ordering. */  readonly revision: number;} const initialState: ManagerState = { sessions: new Map(), revision: 0 }; type PreviewEventDraft = PreviewEvent extends infer Event  ? Event extends { readonly revision: number }    ? Omit<Event, "revision" | "serverEpoch">    : never  : never; const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; const sessionsForThread = (  state: ManagerState,  threadId: string,): ReadonlyArray<PreviewSessionState> => {  const out: PreviewSessionState[] = [];  for (const session of state.sessions.values()) {    if (session.threadId === threadId) out.push(session);  }  return out;}; const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> =>  Effect.try({    try: () => normalizePreviewUrl(rawUrl),    catch: (cause) => {      if (isPreviewUrlNormalizationError(cause)) {        return new PreviewInvalidUrlError({          inputLength: cause.inputLength,          reason: cause.reason,          protocol: cause.protocol,          cause,        });      }       return new PreviewInvalidUrlError({        inputLength: rawUrl.length,        reason: "unexpected",        cause,      });    },  }); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); const buildLoadingSnapshot = (input: {  readonly threadId: string;  readonly tabId: string;  readonly url: string;  readonly title: string;  readonly viewport: PreviewViewportSetting;  readonly profileId?: string | undefined;  readonly updatedAt: string;}): PreviewSessionSnapshot => ({  threadId: input.threadId,  tabId: input.tabId,  navStatus: { _tag: "Loading", url: input.url, title: input.title },  canGoBack: false,  canGoForward: false,  viewport: input.viewport,  ...(input.profileId === undefined ? {} : { profileId: input.profileId }),  updatedAt: input.updatedAt,}); const buildIdleSnapshot = (input: {  readonly threadId: string;  readonly tabId: string;  readonly viewport: PreviewViewportSetting;  readonly profileId?: string | undefined;  readonly updatedAt: string;}): PreviewSessionSnapshot => ({  threadId: input.threadId,  tabId: input.tabId,  navStatus: { _tag: "Idle" },  canGoBack: false,  canGoForward: false,  viewport: input.viewport,  ...(input.profileId === undefined ? {} : { profileId: input.profileId }),  updatedAt: input.updatedAt,}); /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* PreviewManagerMake() {  const serverEpoch = NodeCrypto.randomUUID();  const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);  // Unbounded PubSub is fine here — events are tiny and we don't want to  // block publishers if a subscriber is slow. WS clients backpressure on  // their own queues downstream.  const eventsPubSub = yield* PubSub.unbounded<PreviewEvent>();  const events: Stream.Stream<PreviewEvent> = Stream.fromPubSub(eventsPubSub);   /**   * Atomic read-modify-write over the session for `(threadId, tabId)`. The   * mutator runs under the SynchronizedRef so concurrent writers cannot   * interleave. Lookup failures travel through the modify result so both   * branches yield the same `[A, S]` shape `modifyEffect` requires.   *   * The event is published INSIDE the lock so observers see events in the   * same order as the underlying state transitions. Publishing an unbounded   * PubSub is non-blocking, so this is cheap.   */  const mutateExistingSession = <R, E>(    threadId: string,    tabId: string,    mutator: (      session: PreviewSessionState,    ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEventDraft | null; result: R }, E>,  ): Effect.Effect<R, E | PreviewSessionLookupError> => {    type ModifyResult =      | { kind: "fail"; error: PreviewSessionLookupError }      | { kind: "ok"; result: R };     return SynchronizedRef.modifyEffect(stateRef, (state) => {      const session = state.sessions.get(compositeKey(threadId, tabId));      if (!session) {        return Effect.succeed([          { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) },          state,        ] as readonly [ModifyResult, ManagerState]);      }      return mutator(session).pipe(        Effect.flatMap(          Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) {            const revision = emit ? state.revision + 1 : state.revision;            if (emit) {              yield* PubSub.publish(eventsPubSub, {                ...emit,                revision,                serverEpoch,              } as PreviewEvent);            }            const sessions = new Map(state.sessions);            sessions.set(compositeKey(threadId, tabId), next);            return [{ kind: "ok", result } as ModifyResult, { sessions, revision }] as readonly [              ModifyResult,              ManagerState,            ];          }),        ),      );    }).pipe(      Effect.flatMap((modify) =>        modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result),      ),    );  };   const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")(    function* (input) {      const tabId = newPreviewTabId();      const updatedAt = yield* currentIsoTimestamp;      // Clients with a configured default send the viewport up front so the      // session is born at the right size; older clients omit it and keep the      // historical fill-panel behaviour.      const viewport = input.viewport ?? FILL_PREVIEW_VIEWPORT;      const snapshot = input.url        ? buildLoadingSnapshot({            threadId: input.threadId,            tabId,            url: yield* normalizeUrl(input.url),            title: "",            viewport,            profileId: input.profileId,            updatedAt,          })        : buildIdleSnapshot({            threadId: input.threadId,            tabId,            viewport,            profileId: input.profileId,            updatedAt,          });      yield* SynchronizedRef.modifyEffect(stateRef, (state) =>        Effect.gen(function* () {          const revision = state.revision + 1;          const sessions = new Map(state.sessions);          sessions.set(compositeKey(input.threadId, tabId), {            threadId: input.threadId,            tabId,            snapshot,          });          yield* PubSub.publish(eventsPubSub, {            type: "opened",            threadId: input.threadId,            tabId,            createdAt: snapshot.updatedAt,            serverEpoch,            revision,            snapshot,          });          return [snapshot, { sessions, revision }] as const;        }),      );      return snapshot;    },  );   const navigate: PreviewManager["Service"]["navigate"] = Effect.fn("PreviewManager.navigate")(    function* (input) {      const url = yield* normalizeUrl(input.url);      return yield* mutateExistingSession(        input.threadId,        input.tabId,        Effect.fn("PreviewManager.navigateSession")(function* (session) {          const updatedAt = yield* currentIsoTimestamp;          const previousTitle =            session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title;          const resolvedTitle = input.resolvedTitle ?? previousTitle;          const snapshot: PreviewSessionSnapshot = {            threadId: session.threadId,            tabId: session.tabId,            navStatus: { _tag: "Success", url, title: resolvedTitle },            canGoBack: session.snapshot.canGoBack,            canGoForward: session.snapshot.canGoForward,            viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,            ...(session.snapshot.profileId === undefined              ? {}              : { profileId: session.snapshot.profileId }),            updatedAt,          };          return {            next: { ...session, snapshot },            emit: {              type: "navigated",              threadId: session.threadId,              tabId: session.tabId,              createdAt: snapshot.updatedAt,              snapshot,            },            result: snapshot,          };        }),      );    },  );   const reportStatus: PreviewManager["Service"]["reportStatus"] = Effect.fn(    "PreviewManager.reportStatus",  )(function* (input) {    yield* mutateExistingSession(      input.threadId,      input.tabId,      Effect.fn("PreviewManager.reportSessionStatus")(function* (session) {        const updatedAt = yield* currentIsoTimestamp;        const snapshot: PreviewSessionSnapshot = {          threadId: session.threadId,          tabId: session.tabId,          navStatus: input.navStatus,          canGoBack: input.canGoBack,          canGoForward: input.canGoForward,          viewport: session.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT,          ...(session.snapshot.profileId === undefined            ? {}            : { profileId: session.snapshot.profileId }),          updatedAt,        };        const emit: PreviewEventDraft =          input.navStatus._tag === "LoadFailed"            ? {                type: "failed",                threadId: session.threadId,                tabId: session.tabId,                createdAt: snapshot.updatedAt,                url: input.navStatus.url,                title: input.navStatus.title,                code: input.navStatus.code,                description: input.navStatus.description,              }            : {                type: "navigated",                threadId: session.threadId,                tabId: session.tabId,                createdAt: snapshot.updatedAt,                snapshot,              };        return {          next: { ...session, snapshot },          emit,          result: undefined as void,        };      }),    );  });   const resize: PreviewManager["Service"]["resize"] = Effect.fn("PreviewManager.resize")(    function* (input) {      return yield* mutateExistingSession(        input.threadId,        input.tabId,        Effect.fn("PreviewManager.resizeSession")(function* (session) {          const updatedAt = yield* currentIsoTimestamp;          const snapshot: PreviewSessionSnapshot = {            ...session.snapshot,            viewport: input.viewport,            updatedAt,          };          return {            next: { ...session, snapshot },            emit: {              type: "resized",              threadId: session.threadId,              tabId: session.tabId,              createdAt: snapshot.updatedAt,              snapshot,            },            result: snapshot,          };        }),      );    },  );   const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(    function* (input) {      // Verify the session exists; the desktop bridge handles the actual reload      // and will report progress back via `reportStatus`. No event emitted.      yield* mutateExistingSession(input.threadId, input.tabId, (session) =>        Effect.succeed({ next: session, emit: null, result: undefined as void }),      );    },  );   const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")(    function* (input) {      const createdAt = yield* currentIsoTimestamp;      yield* SynchronizedRef.modifyEffect(stateRef, (state) => {        const eventsToEmit: PreviewEvent[] = [];        const sessions = new Map(state.sessions);        const targets = input.tabId          ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter(              (entry): entry is PreviewSessionState => entry !== undefined,            )          : sessionsForThread(state, input.threadId);        let revision = state.revision;        for (const target of targets) {          revision += 1;          sessions.delete(compositeKey(target.threadId, target.tabId));          eventsToEmit.push({            type: "closed",            threadId: target.threadId,            tabId: target.tabId,            createdAt,            serverEpoch,            revision,          });        }        if (eventsToEmit.length === 0) {          return Effect.succeed([undefined, state] as const);        }        return Effect.as(          Effect.forEach(eventsToEmit, (event) => PubSub.publish(eventsPubSub, event), {            discard: true,          }),          [undefined, { sessions, revision }] as const,        );      });    },  );   const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")(    function* (input) {      return yield* SynchronizedRef.get(stateRef).pipe(        Effect.map((state): PreviewListResult => ({          sessions: sessionsForThread(state, input.threadId)            .map((s) => s.snapshot)            .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)),          serverEpoch,          revision: state.revision,        })),      );    },  );   return PreviewManager.of({    open,    navigate,    reportStatus,    resize,    refresh,    close,    list,    events,    subscribeEvents: PubSub.subscribe(eventsPubSub),  });}).pipe(Effect.withSpan("PreviewManager.make")); export const layer = Layer.effect(PreviewManager, make);