| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- import { useState, useEffect, useCallback } from "react";
- const BASE = "/api/v1";
- const API_INVALIDATE_EVENT = "inkos:api-invalidate";
- interface ApiInvalidateDetail {
- readonly paths: ReadonlyArray<string>;
- }
- export function buildApiUrl(path: string): string | null {
- const normalized = String(path ?? "").trim();
- if (!normalized) return null;
- if (normalized.startsWith(`${BASE}/`) || normalized === BASE) {
- return normalized;
- }
- return normalized.startsWith("/") ? `${BASE}${normalized}` : `${BASE}/${normalized}`;
- }
- export function deriveInvalidationPaths(path: string): ReadonlyArray<string> {
- const normalized = buildApiUrl(path);
- if (!normalized) return [];
- if (normalized === "/api/v1/books/create") {
- return ["/api/v1/books"];
- }
- if (normalized === "/api/v1/project") {
- return ["/api/v1/project"];
- }
- if (normalized.startsWith("/api/v1/project/")) {
- return ["/api/v1/project", normalized];
- }
- const bookAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/(write-next|draft)$/);
- if (bookAction) {
- return ["/api/v1/books", `/api/v1/books/${bookAction[1]}`];
- }
- const chapterAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/chapters\/\d+\/(approve|reject)$/);
- if (chapterAction) {
- return ["/api/v1/books", `/api/v1/books/${chapterAction[1]}`];
- }
- if (/^\/api\/v1\/daemon\/(start|stop)$/.test(normalized)) {
- return ["/api/v1/daemon"];
- }
- return [];
- }
- export function invalidateApiPaths(paths: ReadonlyArray<string>): void {
- if (!paths.length || typeof window === "undefined") {
- return;
- }
- window.dispatchEvent(new CustomEvent<ApiInvalidateDetail>(API_INVALIDATE_EVENT, {
- detail: { paths: [...new Set(paths)] },
- }));
- }
- async function readErrorMessage(res: Response): Promise<string> {
- const contentType = res.headers.get("content-type") ?? "";
- if (contentType.includes("application/json")) {
- try {
- const json = await res.json() as { error?: unknown };
- if (typeof json.error === "string" && json.error.trim()) {
- return json.error;
- }
- if (
- json.error &&
- typeof json.error === "object" &&
- "message" in json.error &&
- typeof (json.error as { message?: unknown }).message === "string" &&
- (json.error as { message: string }).message.trim()
- ) {
- return (json.error as { message: string }).message;
- }
- } catch {
- // fall through
- }
- }
- return `${res.status} ${res.statusText}`.trim();
- }
- export async function fetchJson<T>(
- path: string,
- init: RequestInit = {},
- deps?: { readonly fetchImpl?: typeof fetch },
- ): Promise<T> {
- const url = buildApiUrl(path);
- if (!url) {
- throw new Error("API path is required");
- }
- const fetchImpl = deps?.fetchImpl ?? fetch;
- const res = await fetchImpl(url, init);
- if (!res.ok) {
- throw new Error(await readErrorMessage(res));
- }
- if (res.status === 204) {
- return undefined as T;
- }
- const contentType = res.headers.get("content-type") ?? "";
- if (!contentType.includes("application/json")) {
- const text = await res.text();
- if (!text.trim()) {
- return undefined as T;
- }
- return JSON.parse(text) as T;
- }
- return await res.json() as T;
- }
- export function useApi<T>(path: string) {
- const [data, setData] = useState<T | null>(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const refetch = useCallback(async () => {
- const url = buildApiUrl(path);
- if (!url) {
- setData(null);
- setError(null);
- setLoading(false);
- return;
- }
- setLoading(true);
- setError(null);
- try {
- const json = await fetchJson<T>(url);
- setData(json);
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- } finally {
- setLoading(false);
- }
- }, [path]);
- useEffect(() => {
- refetch();
- }, [refetch]);
- useEffect(() => {
- const url = buildApiUrl(path);
- if (!url || typeof window === "undefined") {
- return;
- }
- const handleInvalidate = (event: Event) => {
- const detail = (event as CustomEvent<ApiInvalidateDetail>).detail;
- if (!detail?.paths.includes(url)) return;
- void refetch();
- };
- window.addEventListener(API_INVALIDATE_EVENT, handleInvalidate);
- return () => {
- window.removeEventListener(API_INVALIDATE_EVENT, handleInvalidate);
- };
- }, [path, refetch]);
- return { data, loading, error, refetch, mutate: setData };
- }
- export async function postApi<T>(path: string, body?: unknown): Promise<T> {
- const result = await fetchJson<T>(path, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: body ? JSON.stringify(body) : undefined,
- });
- invalidateApiPaths(deriveInvalidationPaths(path));
- return result;
- }
- export async function putApi<T>(path: string, body?: unknown): Promise<T> {
- const result = await fetchJson<T>(path, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: body ? JSON.stringify(body) : undefined,
- });
- invalidateApiPaths(deriveInvalidationPaths(path));
- return result;
- }
|