Files
radicle-explorer/explorer/http-client/index.ts
T
Daniel Norman d8f363ca99 Replace API version checks with feature detection
httpd:
- Serve the API at /api, not only under /v1
- Keep serving it at /api/v1, without a redirect, until the first
  major release after explorer and the Open Graph worker switch
  to /api
- Remove apiVersion: released explorers do not read it
- Add `httpd.releases` to `/info`, set by the artifacts feature

explorer:
- Remove the API version check, its config and the unused
  compare-versions dependency
- Keep the node version in a plain parse error message
- Add `minimumSupportedVersion`, set to 0.28.0, and warn on nodes
  whose major is older, treating 0.y as the major before 1.0
- Do not warn on builds without a release number
- Show a warning on the node page of an outdated node
- Mark an outdated node in the seed picker on every page
- Name the node version and the floor in parse errors
- Drop the `/node` fallback for httpd without `/info`, which
  predates the floor
- Check the status, not `ResponseError`, so the Open Graph worker
  can import compat without the fetcher
- Name the httpd release each shim serves, not a date
- Drop the mime shim for httpd 0.18.1 and the unused `lib/file.ts`
- Read the node from `/info` in repo views
- Pass the node and its features to repo views as one `node` param
- Show the releases tab and pages when the node supports releases,
  and the counter only when there is a count
- Read a release count as release support on older httpd
- Test the flag and the fallback

docs:
- Restructure docs/api-changes.md: the supported versions, one
  table of removals, fixed fields per change, and a link to ADR
  0001 for the rules
- Say in ADR 0001 that each compat comment names what the other
  side does
- Say in ADR 0001 when a capability needs a flag in /info

tests:
- Run e2e tests on httpd 0.30.0, above the floor, instead of 0.26.0
- Run the release tests on it too, so they cover the compat shims
- Skip only those that need release counts per scope on it
- Test a node without release support through a mocked `/info`
  flag, instead of an httpd that predates the floor
2026-10-29 18:32:03 +01:00

318 lines
6.0 KiB
TypeScript

import type { BaseUrl } from "./lib/fetcher.js";
import type {
Blob,
DiffResponse,
Job,
PeerRefs,
ReleaseScope,
ReleaseCounts,
Remote,
Repo,
RepoListQuery,
SearchResult,
TagInfo,
Tree,
TreeStats,
} from "./lib/repo.js";
import type {
Author,
Config,
SeedingPolicy,
DefaultSeedingPolicy,
} from "./lib/shared.js";
import type { Comment, Embed, Reaction } from "./lib/repo/comment.js";
import type {
Commit,
CommitBlob,
CommitHeader,
ChangesetWithDiff,
ChangesetWithoutDiff,
Diff,
DiffBlob,
DiffContent,
DiffFile,
HunkLine,
} from "./lib/repo/commit.js";
import type { Issue, IssueState } from "./lib/repo/issue.js";
import type {
LifecycleState,
Merge,
MergeTarget,
Patch,
PatchState,
Review,
Revision,
Verdict,
} from "./lib/repo/patch.js";
import type {
Artifact,
Location,
Redaction,
Release,
} from "./lib/repo/release.js";
import type { RequestOptions } from "./lib/fetcher.js";
import type { ZodSchema } from "zod";
import * as z from "zod";
import * as repo from "./lib/repo.js";
import { Fetcher, ResponseError } from "./lib/fetcher.js";
export { ResponseError };
import {
nodeConfigSchema,
scopeSchema,
seedingPolicySchema,
} from "./lib/shared.js";
export type {
Artifact,
Author,
BaseUrl,
Blob,
ChangesetWithDiff,
ChangesetWithoutDiff,
Comment,
Commit,
CommitBlob,
CommitHeader,
Config,
DefaultSeedingPolicy,
Diff,
DiffBlob,
DiffContent,
DiffFile,
DiffResponse,
Embed,
HunkLine,
Issue,
IssueState,
Job,
LifecycleState,
Location,
Merge,
MergeTarget,
Patch,
PatchState,
PeerRefs,
Reaction,
Redaction,
Release,
ReleaseScope,
ReleaseCounts,
Remote,
Repo,
RepoListQuery,
Review,
Revision,
SearchResult,
SeedingPolicy,
TagInfo,
Tree,
TreeStats,
Verdict,
};
export type Node = z.infer<typeof nodeSchema>;
const nodeSchema = z.object({
id: z.string(),
agent: z.string().nullable(),
config: nodeConfigSchema.nullable(),
state: z.union([z.literal("running"), z.literal("stopped")]),
avatarUrl: z.string().optional(),
bannerUrl: z.string().optional(),
description: z.string().optional(),
});
export type Info = z.infer<typeof infoSchema>;
const infoSchema = z.object({
node: nodeSchema,
httpd: z.object({
searchAvailable: z.boolean(),
// Absent on older httpd, see `releasesSupported`.
releases: z.boolean().optional(),
}),
});
export type NodeIdentity = z.infer<typeof nodeIdentitySchema>;
const nodeIdentitySchema = z.object({
alias: z.string().nullable(),
did: z.string(),
ssh: z.object({
full: z.string(),
hash: z.string(),
}),
});
export type NodeInfo = z.infer<typeof nodeInfoSchema>;
const nodeInfoSchema = z.object({
message: z.string(),
service: z.string(),
version: z.string(),
nid: z.string(),
path: z.string(),
links: z.array(
z.object({
href: z.string(),
rel: z.string(),
type: z.union([
z.literal("GET"),
z.literal("POST"),
z.literal("PUT"),
z.literal("DELETE"),
]),
}),
),
});
export type NodePolicies = z.infer<typeof nodePoliciesSchema>;
const nodePoliciesSchema = z.object({
rid: z.string(),
policy: z.union([
z.object({ policy: z.literal("block") }),
z.object({
policy: z.literal("allow"),
scope: scopeSchema,
}),
]),
});
export interface NodeStats {
repos: { total: number };
}
const nodeStatsSchema = z.object({
repos: z.object({ total: z.number() }),
}) satisfies ZodSchema<NodeStats>;
export class HttpdClient {
#fetcher: Fetcher;
public baseUrl: BaseUrl;
public repo: repo.Client;
public constructor(baseUrl: BaseUrl) {
this.baseUrl = baseUrl;
this.#fetcher = new Fetcher(this.baseUrl);
this.repo = new repo.Client(this.#fetcher);
}
public changePort(port: number): void {
this.baseUrl.port = port;
}
public get url(): string {
return `${this.baseUrl.scheme}://${this.baseUrl.hostname}:${this.baseUrl.port}`;
}
public get hostname(): string {
return this.baseUrl.hostname;
}
public get port(): string {
return this.baseUrl.port.toString();
}
public async getNodeInfo(options?: RequestOptions): Promise<NodeInfo> {
return this.#fetcher.fetchOk(
{
method: "GET",
options,
},
nodeInfoSchema,
);
}
public async getStats(options?: RequestOptions): Promise<NodeStats> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: "stats",
options,
},
nodeStatsSchema,
);
}
public async getPolicies(options?: RequestOptions): Promise<NodePolicies[]> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: "node/policies/repos",
options,
},
z.array(nodePoliciesSchema),
);
}
public async getPolicyByRid(
rid: string,
options?: RequestOptions,
): Promise<SeedingPolicy> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `node/policies/repos/${rid}`,
options,
},
seedingPolicySchema,
);
}
public async getNode(options?: RequestOptions): Promise<Node> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: "node",
options,
},
nodeSchema,
);
}
public async getInfo(options?: RequestOptions): Promise<Info> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: "info",
options,
},
infoSchema,
);
}
public async getNodeIdentity(
nid: string,
options?: RequestOptions,
): Promise<NodeIdentity> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `nodes/${nid}`,
options,
},
nodeIdentitySchema,
);
}
public async getNodeInventory(
nid: string,
options?: RequestOptions,
): Promise<string[]> {
return this.#fetcher.fetchOk(
{
method: "GET",
path: `nodes/${nid}/inventory`,
options,
},
z.array(z.string()),
);
}
}