Files
music-repo/packages/common/src/Client.ts
2026-07-30 00:45:46 +02:00

118 lines
3.5 KiB
TypeScript

import { Console, Data, Effect, Option, ParseResult, pipe, Record, Schema } from "effect";
import { constant } from "effect/Function";
import * as Api from "./Api.ts";
import * as Body from "./Body.ts";
import * as Cbor from "./Cbor.ts";
import { fetch, FetchError } from "./Fetch.ts";
export class ServerDie extends Data.TaggedError("ServerDie")<{ cause: unknown }> { }
export class UnexpectedStatus extends Data.TaggedError("UnexpectedStatusCode")<{ status: number }> { }
export interface ClientOptions {
readonly baseUrl?: string | URL | undefined;
}
export const client = <const Record extends { readonly [_: string]: Api.ApiAny }>(
bundle: Api.ApiBundle<Record>,
{ baseUrl }: ClientOptions = {},
) => {
return Object.freeze(Record.map(bundle.record, (api, key) => {
const url = new URL(`/api/${key}`, baseUrl ?? location.origin);
const requestEncoder = encodeBody(api.request);
const responseDecoder = decodeBody(api.response);
const errorDecoder = decodeBody(api.error);
return Effect.fn(`call.${key}`)(function* (request: any) {
const body = yield* pipe(
request,
requestEncoder,
Effect.flatMap(({ body, headers }) => fetch(url, {
method: "POST",
credentials: "include",
body,
headers,
})),
Effect.catchTags({
CborEncodeError: Effect.die,
ParseError: Effect.die,
}),
);
switch (body.status) {
case 200: {
const result = yield* responseDecoder(body).pipe(Effect.orDie);
return result;
}
case 400: {
const result = yield* errorDecoder(body).pipe(Effect.orDie);
return yield* Effect.fail(result);
}
case 500: {
const result = yield* readBody(body).pipe(Effect.orDie);
return yield* Effect.die(new ServerDie({ cause: result }));
}
default: {
return yield* Effect.die(new UnexpectedStatus({ status: body.status }));
}
}
});
})) as { readonly [K in keyof Record]: (request: Record[K]["request"]["Type"]) => Effect.Effect<Record[K]["response"]["Type"], Record[K]["error"]["Type"] | FetchError, never> };
};
export const readBody = (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => pipe(
body,
Body.bytes,
Effect.map((bytes) => bytes.byteLength > 0 ? Option.some(bytes) : Option.none()),
Effect.flatMap((maybeBytes) => pipe(
maybeBytes,
Effect.transposeMapOption(Cbor.decode),
)),
Effect.map(Option.getOrUndefined),
);
export const decodeBody = <A>(schema: Schema.Schema<A, any>) => {
const decoder = Schema.decodeUnknown(schema);
return (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => pipe(
readBody(body),
Effect.flatMap(decoder),
Effect.tapErrorTag("ParseError", (error) => pipe(
error,
ParseResult.TreeFormatter.formatError,
Effect.flatMap(Console.error),
)),
);
};
export interface BodyData {
readonly body: null | Uint8Array<ArrayBuffer>;
readonly headers: { readonly [_: string]: string };
}
export const encodeBody = <A>(schema: Schema.Schema<A, any>) => {
const encoder = Cbor.encodeSchema(schema);
return (a: A) => pipe(
a,
Option.fromNullable,
Effect.transposeMapOption(encoder),
Effect.map(Option.match({
onNone: emptyBody,
onSome: cborBody,
}))
);
};
export const emptyBody: () => BodyData = constant(Object.freeze<BodyData>({
body: null,
headers: Object.freeze({}),
}));
export const cborBody: (bytes: Uint8Array<ArrayBuffer>) => BodyData = (bytes) => Object.freeze<BodyData>({
body: bytes,
headers: Object.freeze({
"Content-Type": "application/cbor",
"Content-Length": String(bytes.byteLength),
}),
});