Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/transport.ts

Snapshot at 34e3175

Containing directory

5,636 bytes · typescriptView raw
1import { stateView } from "./model.ts";
2import type {
3  ActivityPage,
4  DashboardTransport,
5  StateResponse,
6  OwnerReceipt,
7  ReviewDetail,
8  ReviewDiff,
9} from "./contracts.ts";
10
11export class ApiError extends Error {
12  readonly status: number;
13  readonly diagnosticId?: string;
14  constructor(status: number, message: string, diagnosticId?: string) {
15    super(message);
16    this.name = "ApiError";
17    this.status = status;
18    // Only the core's bounded UUID is safe to display; never extract IDs from error copy.
19    if (diagnosticId && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(diagnosticId))
20      this.diagnosticId = diagnosticId;
21  }
22}
23
24export interface HttpTransportOptions {
25  /** Origin is the embedding page's origin. Cross-origin owner calls are refused. */
26  origin: string;
27  fetch?: typeof fetch;
28  /** Optional existing same-origin socket route supplied by the host; no route is invented. */
29  socketUrl?: (project: string) => string;
30  openSocket?: (url: string) => WebSocket;
31}
32
33const projectPath = (id: string) => `/projects/${encodeURIComponent(id)}`;
34
35/** Cookie/Access session only: there is deliberately no token or custom-header option. */
36export function createHttpTransport(options: HttpTransportOptions): DashboardTransport {
37  const origin = new URL(options.origin).origin;
38  const request = options.fetch ?? globalThis.fetch.bind(globalThis);
39  async function call<T>(path: string, method = "GET", body = {}): Promise<T> {
40    const response = await request(new URL(path, origin), {
41      method,
42      credentials: "same-origin",
43      cache: "no-store",
44      redirect: "error",
45      ...(method === "POST" || method === "PATCH"
46        ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
47        : {}),
48    });
49    if (!response.ok) {
50      let message = `Request failed (${response.status}).`;
51      let diagnosticId: string | undefined;
52      // Only the core's sanitized {error} convention is displayed; HTML login pages stay out.
53      try {
54        const data = (await response.json()) as { error?: unknown; diagnosticId?: unknown };
55        if (typeof data.error === "string") message = data.error;
56        if (typeof data.diagnosticId === "string") diagnosticId = data.diagnosticId;
57      } catch {
58        /* Non-JSON errors use the status message above. */
59      }
60      if (response.status === 401 || response.status === 403) {
61        message =
62          "Owner session unavailable. Sign in through the configured Access application, then refresh.";
63      }
64      throw new ApiError(response.status, message, diagnosticId);
65    }
66    if (!response.headers.get("content-type")?.includes("application/json")) {
67      throw new ApiError(
68        502,
69        "Expected a JSON API response. Check the host's Access and API configuration.",
70      );
71    }
72    return (await response.json()) as T;
73  }
74  return {
75    archivePacket: (id, packet) => call(`${projectPath(id)}/packets/${encodeURIComponent(packet)}/archive`, "POST"),
76    unarchivePacket: (id, packet) => call(`${projectPath(id)}/packets/${encodeURIComponent(packet)}/unarchive`, "POST"),
77    getReview: (id, review) =>
78      call<ReviewDetail>(`${projectPath(id)}/reviews/${encodeURIComponent(review)}`),
79    getDiff: (id, review, path) =>
80      call<ReviewDiff>(
81        `${projectPath(id)}/reviews/${encodeURIComponent(review)}/diff?path=${encodeURIComponent(path)}`,
82      ),
83    returnReview: (id, review, reason) =>
84      call(`${projectPath(id)}/reviews/${encodeURIComponent(review)}/return`, "POST", { reason }),
85    resolveBlocker: (id, blocker, reason) =>
86      call(`${projectPath(id)}/blockers/${encodeURIComponent(blocker)}/resolve`, "POST", { reason }),
87    getState: async (id) => stateView(await call<StateResponse>(`${projectPath(id)}/state`)),
88    getActivity: (id, after = 0) =>
89      call<ActivityPage>(`${projectPath(id)}/activity?after=${after}&limit=100`),
90    approve: (id, review) =>
91      call(`${projectPath(id)}/reviews/${encodeURIComponent(review)}/approve`, "POST"),
92    getReceipt: (id, review) =>
93      call<OwnerReceipt>(`${projectPath(id)}/reviews/${encodeURIComponent(review)}/receipt`),
94    setSettings: (id, settings) => call(`${projectPath(id)}/settings`, "PATCH", settings),
95    promote: (id, review) =>
96      call<{ sha: string }>(
97        `${projectPath(id)}/reviews/${encodeURIComponent(review)}/promote`,
98        "POST",
99      ),
100    ...(options.socketUrl
101      ? {
102          subscribe: (id: string, invalidate: () => void) => {
103            const expected = new URL(origin);
104            expected.protocol = expected.protocol === "https:" ? "wss:" : "ws:";
105            const url = new URL(options.socketUrl!(id), expected);
106            if (
107              url.origin !== expected.origin ||
108              url.username ||
109              url.password ||
110              url.search ||
111              url.hash
112            ) {
113              throw new Error(
114                "Activity socket must use the page's origin without credentials or query parameters.",
115              );
116            }
117            const socket = (options.openSocket ?? ((address) => new WebSocket(address)))(url.href);
118            socket.addEventListener("message", invalidate);
119            socket.addEventListener("open", invalidate);
120            // Socket loss is surfaced by the next explicit refresh; writes are never retried.
121            return () => {
122              socket.removeEventListener("message", invalidate);
123              socket.removeEventListener("open", invalidate);
124              socket.close();
125            };
126          },
127        }
128      : {}),
129  };
130}
131