Skip to the content

Capsid menu

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

The functions of client.ts
FunctionLinesComplexityCoveredCRAPMutation
constructor53 to 592100%OkCRAP 2Critical0%
createSiteClient62 to 1592100%OkCRAP 2Watch66.7%
call67 to 888100%WatchCRAP 8Watch68.2%
get90 to 921100%OkCRAP 1Ok100%
query94 to 994100%OkCRAP 4Ok100%
meta102 to 1021100%OkCRAP 1Ok100%
list103 to 1031100%OkCRAP 1Ok100%
get104 to 1041100%OkCRAP 1Ok100%
saveDraft105 to 1051100%OkCRAP 1Ok100%
publish106 to 1061100%OkCRAP 1Ok100%
schedule107 to 1071100%OkCRAP 1Ok100%
unpublish108 to 1081100%OkCRAP 1Ok100%
revisions109 to 1091100%OkCRAP 1Ok100%
revision111 to 1111100%OkCRAP 1Ok100%
delete113 to 1141100%OkCRAP 1Ok100%
setTags116 to 1161100%OkCRAP 1Ok100%
diff117 to 1171100%OkCRAP 1Ok100%
preview118 to 1181100%OkCRAP 1Ok100%
list121 to 1211100%OkCRAP 1Ok100%
get122 to 1221100%OkCRAP 1Ok100%
upload123 to 1311100%OkCRAP 1Ok100%
delete133 to 1342100%OkCRAP 2Ok100%
setAlt139 to 1391100%OkCRAP 1Ok100%
setTags140 to 1401100%OkCRAP 1Ok100%
trash141 to 1411100%OkCRAP 1Ok100%
restore142 to 1421100%OkCRAP 1Ok100%
emptyTrash144 to 1441100%OkCRAP 1Ok100%
bulk146 to 1461100%OkCRAP 1Ok100%
list150 to 1501100%OkCRAP 1Ok100%
decide151 to 1511100%OkCRAP 1Ok100%
delete152 to 1531100%OkCRAP 1Ok100%
sweep155 to 1562100%OkCRAP 2Ok100%

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";