Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/transport.ts
Snapshot at 34e3175
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