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 = ( bundle: Api.ApiBundle, { 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 }; }; export const readBody = (body: { bytes(): Promise> }) => 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 = (schema: Schema.Schema) => { const decoder = Schema.decodeUnknown(schema); return (body: { bytes(): Promise> }) => 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; readonly headers: { readonly [_: string]: string }; } export const encodeBody = (schema: Schema.Schema) => { 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({ body: null, headers: Object.freeze({}), })); export const cborBody: (bytes: Uint8Array) => BodyData = (bytes) => Object.freeze({ body: bytes, headers: Object.freeze({ "Content-Type": "application/cbor", "Content-Length": String(bytes.byteLength), }), });