mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
- Remove custom-environment-variables.json: nothing sets its variables, and PREFERRED_SEEDS mapped to a key the app never reads - Remove deploymentId: only Vercel set it, and we don't deploy there - Always show the self-hosting hint in parse errors, as before - Document local.json as the way to configure a build
177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
// This module provides low-level capabilities to interact with a typed
|
||
// JSON HTTP API.
|
||
|
||
import type { ZodIssue, ZodType, TypeOf } from "zod";
|
||
|
||
import {
|
||
isOutdated,
|
||
minimumSupportedVersion,
|
||
releaseNumber,
|
||
} from "./compat.js";
|
||
|
||
export interface BaseUrl {
|
||
hostname: string;
|
||
port: number;
|
||
scheme: string;
|
||
}
|
||
|
||
// Error that is thrown by `Fetcher` methods.
|
||
export class ResponseError extends Error {
|
||
public method: string;
|
||
public url: string;
|
||
public status: number;
|
||
public body: unknown;
|
||
|
||
public constructor(method: string, response: Response, body_: unknown) {
|
||
const body: unknown = body_;
|
||
if (
|
||
typeof body === "object" &&
|
||
body !== null &&
|
||
"message" in body &&
|
||
typeof body.message === "string"
|
||
) {
|
||
super(body.message);
|
||
} else {
|
||
super("Response error");
|
||
}
|
||
|
||
this.method = method;
|
||
this.body = body_;
|
||
this.status = response.status;
|
||
this.url = response.url;
|
||
}
|
||
}
|
||
|
||
// Error that is thrown by `Fetcher` methods when parsing the response
|
||
// body fails.
|
||
export class ResponseParseError extends Error {
|
||
public method: string;
|
||
public body: unknown;
|
||
public description: string;
|
||
public nodeVersion: string | undefined;
|
||
public zodIssues: ZodIssue[];
|
||
public path?: string;
|
||
|
||
public constructor(
|
||
method: string,
|
||
body: unknown,
|
||
nodeVersion: string | undefined,
|
||
zodIssues: ZodIssue[],
|
||
path?: string,
|
||
) {
|
||
super("Failed to parse response body");
|
||
const node = nodeVersion ? `The node (v${nodeVersion})` : "The node";
|
||
this.nodeVersion = nodeVersion;
|
||
const outdated =
|
||
nodeVersion !== undefined && isOutdated(nodeVersion)
|
||
? ` It runs radicle-httpd ${releaseNumber(nodeVersion)}, but explorer needs ${minimumSupportedVersion} or newer. Its operator should upgrade it.`
|
||
: "";
|
||
this.description =
|
||
`${node} sent a response that radicle-explorer can’t parse.${outdated}` +
|
||
" If you are self-hosting radicle-explorer, try to clear the browser’s localStorage and the cache.";
|
||
|
||
this.method = method;
|
||
this.path = path;
|
||
this.body = body;
|
||
this.zodIssues = zodIssues;
|
||
}
|
||
}
|
||
|
||
export interface RequestOptions {
|
||
abort?: AbortSignal;
|
||
}
|
||
|
||
export interface FetchParams {
|
||
method: Method;
|
||
// Path to append to the `Fetcher`s base URL to get the final URL.
|
||
path?: string;
|
||
// Object that is serialized into JSON and sent as the data.
|
||
body?: unknown;
|
||
// Query parameters to be serialized with URLSearchParams.
|
||
query?: Record<string, string | number | boolean>;
|
||
options?: RequestOptions;
|
||
headers?: Record<string, string>;
|
||
}
|
||
|
||
type Method = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
|
||
|
||
export class Fetcher {
|
||
#baseUrl: BaseUrl;
|
||
|
||
public constructor(baseUrl: BaseUrl) {
|
||
this.#baseUrl = baseUrl;
|
||
}
|
||
|
||
// Execute a fetch and parse the result with the provided schema.
|
||
// Return the parsed payload.
|
||
//
|
||
// Throws `ResponseError` if the response status code is not `200`.
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
public async fetchOk<T extends ZodType<any>>(
|
||
params: FetchParams,
|
||
schema: T,
|
||
): Promise<TypeOf<T>> {
|
||
const response = await this.fetch(params);
|
||
|
||
if (!response.ok) {
|
||
let responseBody = await response.text();
|
||
try {
|
||
responseBody = JSON.parse(responseBody);
|
||
} catch {
|
||
// We keep the original text response body.
|
||
}
|
||
throw new ResponseError(params.method, response, responseBody);
|
||
}
|
||
|
||
const responseBody = await response.json();
|
||
const result = schema.safeParse(responseBody);
|
||
if (result.success) {
|
||
return result.data;
|
||
} else {
|
||
const info = await this.fetch({ method: "GET" })
|
||
.then(response => response.json())
|
||
.catch(() => undefined);
|
||
const nodeVersion =
|
||
typeof info?.version === "string" ? info.version : undefined;
|
||
throw new ResponseParseError(
|
||
params.method,
|
||
responseBody,
|
||
nodeVersion,
|
||
result.error.issues,
|
||
params.path,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async fetch({
|
||
method,
|
||
path,
|
||
body,
|
||
options = {},
|
||
query,
|
||
headers = {},
|
||
}: FetchParams): Promise<Response> {
|
||
if (body !== undefined && headers["content-type"] === undefined) {
|
||
headers["content-type"] = "application/json";
|
||
}
|
||
|
||
const pathSegment = path === undefined ? "" : `/${path}`;
|
||
|
||
// Compat: older httpd serves the API only at `/api/v1`, newer httpd
|
||
// serves it at both `/api` and `/api/v1`. Client: switch to `/api` when
|
||
// explorer drops httpd 0.30.
|
||
let url = `${this.#baseUrl.scheme}://${this.#baseUrl.hostname}:${this.#baseUrl.port}/api/v1${pathSegment}`;
|
||
|
||
if (query) {
|
||
const searchparams = new URLSearchParams(query as Record<string, string>);
|
||
url = `${url}?${searchparams.toString()}`;
|
||
}
|
||
return globalThis.fetch(url, {
|
||
method,
|
||
headers,
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: options.abort,
|
||
});
|
||
}
|
||
}
|