diff --git a/.vscode/settings.json b/.vscode/settings.json index c022a4b..8e8f41a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.preferences.autoImportFileExcludePatterns": [ + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.preferences.autoImportFileExcludePatterns": [ "@radix-ui", ], } diff --git a/packages/backend/src/api.ts b/packages/backend/src/api.ts index d4538a9..4a4c81a 100644 --- a/packages/backend/src/api.ts +++ b/packages/backend/src/api.ts @@ -1,26 +1,55 @@ import { registerEncoder } from "cbor2/encoder"; import * as Api from "common/Api"; +import * as Body from "common/Body"; import * as Cbor from "common/Cbor"; -import * as Client from "common/Client"; -import { Cause, Effect, Either, HashMap, Inspectable, Option, pipe } from "effect"; +import { Cause, Console, Effect, Either, HashMap, Inspectable, Option, ParseResult, pipe, Schema } from "effect"; import type { HonoRequest } from "hono"; +import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status"; registerEncoder(Buffer, (buffer) => [NaN, new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)]); +export type ResponseData = + | EmptyResponseData + | (Status extends ContentfulStatusCode ? BodyResponseData : never) + ; + +export namespace ResponseData { + export type Any = ResponseData; +} + +export interface EmptyResponseData { + readonly body: null; + readonly status: Status; +} + +export const emptyResponse = (status: Status): EmptyResponseData => Object.freeze>({ + body: null, + status, +}); + +export interface BodyResponseData { + readonly body: Uint8Array; + readonly status: Status; +} + +export const bodyResponse = (status: Status) => (body: Uint8Array): BodyResponseData => Object.freeze>({ + body, + status, +}); + /* NOTE We shouldn't need to extract this to a separate type, but if we don't do * it the TypeScript parser in VS Code sort of blows up and the syntax colors * turn wrong. */ -type Return> = Effect.Effect>>; +type Return> = Effect.Effect>>; -const catchToResponse = Effect.catchAll((error: unknown) => pipe( +const catchToResponseData = Effect.catchAll((error: unknown) => pipe( error, Inspectable.toJSON, Cbor.encode, - Effect.map(Client.cborBody), Effect.matchEffect({ - onSuccess: ({ body, headers }) => Effect.fail(new Response(body, { status: 500, headers })), - onFailure: () => Effect.fail(new Response(null, { status: 500 })), + onSuccess: (body) => Effect.fail(bodyResponse(500)(body)), + onFailure: () => Effect.fail(RESPONSE_DATA_UNRECOVERABLE_ERROR), }), )); @@ -32,26 +61,26 @@ export const implement = < impl: Impl, ): (request: HonoRequest<"/api/:key">) => Return => { return (honoRequest) => { - /* Force both return types to be `Response`. We can use the error route - * for it's short-circuit capabilities. + /* Force both return types to be `ResponseData`. We can use the error + * route for it's short-circuit capabilities. */ - const effect: Effect.Effect = Effect.gen(function* () { + const effect: Effect.Effect = Effect.gen(function* () { const key = honoRequest.param("key"); const maybeApi = HashMap.get(bundle.map, key); if (Option.isNone(maybeApi)) { - return RESPONSE_API_NOT_FOUND; + return RESPONSE_DATA_API_NOT_FOUND; } const { value: api } = maybeApi; const fn = impl[key]; if (fn === undefined) { - return RESPONSE_API_NOT_IMPLEMENTED; + return RESPONSE_DATA_API_NOT_IMPLEMENTED; } const request = yield* pipe( honoRequest as { bytes(): Promise> }, - Client.decodeBody(api.request), - catchToResponse, + decodeBody(api.request), + catchToResponseData, ); const result = yield* pipe( @@ -61,51 +90,72 @@ export const implement = < Effect.either, ); - const { status, response } = pipe( + const response = yield* pipe( result, Either.match({ onLeft: (cause) => pipe( cause, Cause.failureOrCause, Either.match({ - onLeft: (error) => ({ - status: 400, - response: pipe( - error, - Client.encodeBody(api.error), - catchToResponse, - ), - }), - onRight: (die) => ({ - status: 500, - response: pipe( - die, - Inspectable.toJSON, - Cbor.encode, - Effect.map(Client.cborBody), - catchToResponse, - ), - }), + onLeft: (body) => pipe( + body, + encodeBody(api.error, 400), + catchToResponseData, + ), + onRight: (die) => pipe( + die, + Inspectable.toJSON, + Cbor.encode, + Effect.map(bodyResponse(500)), + catchToResponseData, + ), }), ), - onRight: (response) => ({ - status: 200, - response: pipe( - response, - Client.encodeBody(api.response), - catchToResponse, - ), - }), + onRight: (body) => pipe( + body, + encodeBody(api.response, 200), + catchToResponseData, + ), }), ); - const { body, headers } = yield* response; - return new Response(body, { status, headers }); + return response; }); return Effect.catchAll(effect, Effect.succeed); }; }; -const RESPONSE_API_NOT_FOUND = new Response(null, { status: 404 }); -const RESPONSE_API_NOT_IMPLEMENTED = new Response(null, { status: 501 }); +const RESPONSE_DATA_API_NOT_FOUND = emptyResponse(404); +const RESPONSE_DATA_API_NOT_IMPLEMENTED = emptyResponse(501); +const RESPONSE_DATA_UNRECOVERABLE_ERROR = emptyResponse(500); + +export const decodeBody = (schema: Schema.Schema) => { + const decoder = Schema.decodeUnknown(schema); + return (body: { bytes(): Promise> }) => pipe( + body, + Body.bytes, + Effect.flatMap((bytes) => bytes.byteLength > 0 ? Option.some(bytes) : Option.none()), + Effect.flatMap(Cbor.decode), + Effect.catchTag("NoSuchElementException", () => Effect.succeed(undefined)), + Effect.flatMap(decoder), + Effect.tapErrorTag("ParseError", (error) => pipe( + error, + ParseResult.TreeFormatter.formatError, + Effect.flatMap(Console.error), + )), + ); +}; + +export const encodeBody = (schema: Schema.Schema, status: Status) => { + const encoder = Cbor.encodeSchema(schema); + return (a: A) => pipe( + a, + Option.fromNullable, + Effect.transposeMapOption(encoder), + Effect.map(Option.match({ + onNone: () => emptyResponse(status), + onSome: bodyResponse(status), + })) + ); +}; diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 84fa29a..d63c233 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -33,17 +33,16 @@ const app = new Hono() // Callback URL with query response type if (code !== undefined || state !== undefined) { yield* Authentication.getAndProcessIdToken(code ?? null, state ?? null); - return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); + return ctx.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); } // Initial login request; redirect to identity provider - const res = yield* pipe( + const url = yield* pipe( Authentication.Authentication, Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)), - Effect.map((url) => Response.redirect(url)), ); - return res; + return ctx.redirect(url); }).pipe( Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)), Effect.runPromise, @@ -58,7 +57,7 @@ const app = new Hono() yield* Authentication.getAndProcessIdToken(code, state); - return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); + return ctx.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); }).pipe( Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)), Effect.runPromise, @@ -87,17 +86,22 @@ const app = new Hono() const authenticationLayer = Authentication.Live(ctx); const layers = Layer.provideMerge(authenticationLayer, databaseLayer); - const response = await pipe( + const { body, status } = await pipe( handle(req), Effect.provide(layers), Effect.runPromise, ); for (const [name, value] of CORS_HEADERS) { - response.headers.set(name, value); + ctx.header(name, value); } - return response; + if (body !== null) { + ctx.header("Content-Type", "application/cbor"); + return ctx.body(body, status); + } else { + return ctx.body(body, status); + } }) .use("/assets/*", serveStatic({ root: FRONTEND_ROOT })) .use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") }));