src/client.ts
154 lines and 32 functions in DrDustinEdwards/site-api, at commit d3b340f on 2026-10-24. It is in an import cycle. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| constructor | 53 to 59 | 2 | 100% | OkCRAP 2 | Critical0% |
| createSiteClient | 62 to 159 | 2 | 100% | OkCRAP 2 | Watch66.7% |
| call | 67 to 88 | 8 | 100% | WatchCRAP 8 | Watch68.2% |
| get | 90 to 92 | 1 | 100% | OkCRAP 1 | Ok100% |
| query | 94 to 99 | 4 | 100% | OkCRAP 4 | Ok100% |
| meta | 102 to 102 | 1 | 100% | OkCRAP 1 | Ok100% |
| list | 103 to 103 | 1 | 100% | OkCRAP 1 | Ok100% |
| get | 104 to 104 | 1 | 100% | OkCRAP 1 | Ok100% |
| saveDraft | 105 to 105 | 1 | 100% | OkCRAP 1 | Ok100% |
| publish | 106 to 106 | 1 | 100% | OkCRAP 1 | Ok100% |
| schedule | 107 to 107 | 1 | 100% | OkCRAP 1 | Ok100% |
| unpublish | 108 to 108 | 1 | 100% | OkCRAP 1 | Ok100% |
| revisions | 109 to 109 | 1 | 100% | OkCRAP 1 | Ok100% |
| revision | 111 to 111 | 1 | 100% | OkCRAP 1 | Ok100% |
| delete | 113 to 114 | 1 | 100% | OkCRAP 1 | Ok100% |
| setTags | 116 to 116 | 1 | 100% | OkCRAP 1 | Ok100% |
| diff | 117 to 117 | 1 | 100% | OkCRAP 1 | Ok100% |
| preview | 118 to 118 | 1 | 100% | OkCRAP 1 | Ok100% |
| list | 121 to 121 | 1 | 100% | OkCRAP 1 | Ok100% |
| get | 122 to 122 | 1 | 100% | OkCRAP 1 | Ok100% |
| upload | 123 to 131 | 1 | 100% | OkCRAP 1 | Ok100% |
| delete | 133 to 134 | 2 | 100% | OkCRAP 2 | Ok100% |
| setAlt | 139 to 139 | 1 | 100% | OkCRAP 1 | Ok100% |
| setTags | 140 to 140 | 1 | 100% | OkCRAP 1 | Ok100% |
| trash | 141 to 141 | 1 | 100% | OkCRAP 1 | Ok100% |
| restore | 142 to 142 | 1 | 100% | OkCRAP 1 | Ok100% |
| emptyTrash | 144 to 144 | 1 | 100% | OkCRAP 1 | Ok100% |
| bulk | 146 to 146 | 1 | 100% | OkCRAP 1 | Ok100% |
| list | 150 to 150 | 1 | 100% | OkCRAP 1 | Ok100% |
| decide | 151 to 151 | 1 | 100% | OkCRAP 1 | Ok100% |
| delete | 152 to 153 | 1 | 100% | OkCRAP 1 | Ok100% |
| sweep | 155 to 156 | 2 | 100% | OkCRAP 2 | Ok100% |
Imports
It imports src/contract.ts, src/local.ts. Imported by src/admin/kit.ts, src/admin/media.ts, src/admin/posts.ts, src/local.ts.
Source
// The Carrel side: a typed client for one site. Every response is checked against the contract, so
// a site that drifts fails here by name instead of somewhere later in Carrel.
import type { z } from "zod";
import {
ContentDeleteResult,
ContentDoc,
ContentList,
Diff,
ErrorBody,
MediaBulkResult,
MediaDeleteResult,
MediaDetail,
MediaItem,
MediaList,
MediaTrashEmptyResult,
MediaWriteResult,
MentionDecideInput,
MentionDeleteResult,
MentionList,
MentionSweepResult,
MentionWriteResult,
Meta,
PREFIX,
RevisionList,
RevisionSource,
WriteResult,
type ContentTagsInput,
type ListQuery,
type MediaAltInput,
type MediaBulkInput,
type MediaListQuery,
type MediaTagsInput,
type MediaTrashEmptyInput,
type MediaTrashInput,
type MentionListQuery,
type PreviewInput,
type PublishInput,
type SaveDraftInput,
type ScheduleInput,
type UnpublishInput,
} from "./contract.js";
export interface SiteClientConfig {
/** The site's origin, such as https://dustinedwards.info. */
baseUrl: string;
key: string;
fetch?: typeof fetch;
}
/** A refusal or failure from the site, with the contract's error body when the site sent one. */
export class SiteApiError extends Error {
constructor(
readonly status: number,
readonly body: ErrorBody | null,
) {
super(body ? `${status} ${body.error}: ${body.message}` : `site answered ${status}`);
this.name = "SiteApiError";
}
}
export function createSiteClient(config: SiteClientConfig) {
const doFetch = config.fetch ?? fetch;
const base = `${config.baseUrl.replace(/\/+$/, "")}${PREFIX}`;
const enc = encodeURIComponent;
async function call(method: string, path: string, body?: unknown, raw?: { bytes: Uint8Array; contentType: string }): Promise<Response> {
const response = await doFetch(`${base}${path}`, {
method,
headers: {
authorization: `Bearer ${config.key}`,
...(raw ? { "content-type": raw.contentType } : body === undefined ? {} : { "content-type": "application/json" }),
},
// A Blob, which every fetch takes as a body whatever its buffer type.
body: raw ? new Blob([raw.bytes as Uint8Array<ArrayBuffer>]) : body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
let parsed: ErrorBody | null = null;
try {
const result = ErrorBody.safeParse(await response.json());
if (result.success) parsed = result.data;
} catch {
// Not JSON: the site's own error page, not ours.
}
throw new SiteApiError(response.status, parsed);
}
return response;
}
async function get<T extends z.ZodType>(schema: T, method: string, path: string, body?: unknown) {
return schema.parse(await (await call(method, path, body)).json()) as z.infer<T>;
}
function query(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined) search.set(k, String(v));
const text = search.toString();
return text ? `?${text}` : "";
}
return {
meta: () => get(Meta, "GET", "/meta"),
list: (q: Partial<ListQuery> = {}) => get(ContentList, "GET", `/content${query(q)}`),
get: (id: string) => get(ContentDoc, "GET", `/content/${enc(id)}`),
saveDraft: (id: string, input: SaveDraftInput) => get(WriteResult, "PUT", `/content/${enc(id)}/draft`, input),
publish: (id: string, input: PublishInput) => get(WriteResult, "POST", `/content/${enc(id)}/publish`, input),
schedule: (id: string, input: ScheduleInput) => get(WriteResult, "POST", `/content/${enc(id)}/schedule`, input),
unpublish: (id: string, input: UnpublishInput) => get(WriteResult, "POST", `/content/${enc(id)}/unpublish`, input),
revisions: (id: string) => get(RevisionList, "GET", `/content/${enc(id)}/revisions`),
/** One revision's source (v0.3.0), so each revision in the list opens. */
revision: (id: string, version: string) => get(RevisionSource, "GET", `/content/${enc(id)}/revisions/${enc(version)}`),
/** Optional per site (v0.3.0): a site with no delete answers SiteApiError with status 501. */
delete: (id: string, input: { expectedVersion: string; changeId: string }) =>
get(ContentDeleteResult, "DELETE", `/content/${enc(id)}${query(input)}`),
/** Optional per site (v0.6.0): sets a post's whole tag set. A site without it answers SiteApiError with status 501. */
setTags: (id: string, input: ContentTagsInput) => get(WriteResult, "PUT", `/content/${enc(id)}/tags`, input),
diff: (id: string, from: string, to?: string) => get(Diff, "GET", `/content/${enc(id)}/diff${query({ from, to })}`),
preview: async (input: PreviewInput) => (await call("POST", "/preview", input)).text(),
/** The media group (v0.2.0). A refused delete throws SiteApiError whose body carries `usedBy`. */
media: {
list: (q: Partial<MediaListQuery> = {}) => get(MediaList, "GET", `/media${query(q)}`),
get: (id: string) => get(MediaDetail, "GET", `/media/${enc(id)}`),
upload: async (input: { bytes: Uint8Array; contentType: string; filename: string; alt?: string; changeId: string }) =>
MediaItem.parse(
await (
await call("POST", `/media${query({ filename: input.filename, alt: input.alt, changeId: input.changeId })}`, undefined, {
bytes: input.bytes,
contentType: input.contentType,
})
).json(),
),
/** Takes `{ changeId }` (v0.6.0), like every other write; the change id alone still works. */
delete: (id: string, input: { changeId: string } | string) =>
get(MediaDeleteResult, "DELETE", `/media/${enc(id)}${query({ changeId: typeof input === "string" ? input : input.changeId })}`),
/**
* The writes of v0.4.0, each optional per site: a site without it answers SiteApiError with
* status 501 (see meta().capabilities). A stale version is status 409 with the current version.
*/
setAlt: (id: string, input: MediaAltInput) => get(MediaWriteResult, "PUT", `/media/${enc(id)}/alt`, input),
setTags: (id: string, input: MediaTagsInput) => get(MediaWriteResult, "PUT", `/media/${enc(id)}/tags`, input),
trash: (id: string, input: MediaTrashInput) => get(MediaWriteResult, "POST", `/media/${enc(id)}/trash`, input),
restore: (id: string, input: MediaTrashInput) => get(MediaWriteResult, "POST", `/media/${enc(id)}/restore`, input),
/** Deletes for good every trashed file the site allows, up to MAX_TRASH_EMPTY; `more` says to send it again. */
emptyTrash: (input: MediaTrashEmptyInput) => get(MediaTrashEmptyResult, "POST", "/media/trash/empty", input),
/** One request for many files; every file's outcome is its own, so check each `ok`. */
bulk: (input: MediaBulkInput) => get(MediaBulkResult, "POST", "/media/bulk", input),
},
/** The mentions group (v0.5.0), optional per site: a site without it answers SiteApiError with status 501. */
mentions: {
list: (q: Partial<MentionListQuery> = {}) => get(MentionList, "GET", `/mentions${query(q)}`),
decide: (id: string, input: MentionDecideInput) => get(MentionWriteResult, "POST", `/mentions/${enc(id)}/decide`, input),
delete: (id: string, input: { expectedVersion: string; changeId: string }) =>
get(MentionDeleteResult, "DELETE", `/mentions/${enc(id)}${query(input)}`),
/** Takes `{ changeId }` (v0.6.0), like every other write; the change id alone still works. */
sweep: (input: { changeId: string } | string) =>
get(MentionSweepResult, "POST", "/mentions/sweep", { changeId: typeof input === "string" ? input : input.changeId }),
},
};
}
export type SiteClient = ReturnType<typeof createSiteClient>;
// A site's own admin: the same client, answered in process by its own adapter (./local.ts).
export { localClient, type LocalClientOptions } from "./local.js";