Refactor Hono API handler, fix missing headers

This commit is contained in:
2026-09-29 15:14:59 +02:00
parent 1249781204
commit 8ecc842045
3 changed files with 109 additions and 55 deletions

View File

@@ -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<Status extends StatusCode> =
| EmptyResponseData<Status>
| (Status extends ContentfulStatusCode ? BodyResponseData<Status> : never)
;
export namespace ResponseData {
export type Any = ResponseData<StatusCode>;
}
export interface EmptyResponseData<Status extends StatusCode> {
readonly body: null;
readonly status: Status;
}
export const emptyResponse = <Status extends StatusCode>(status: Status): EmptyResponseData<Status> => Object.freeze<EmptyResponseData<Status>>({
body: null,
status,
});
export interface BodyResponseData<Status extends ContentfulStatusCode> {
readonly body: Uint8Array<ArrayBuffer>;
readonly status: Status;
}
export const bodyResponse = <Status extends ContentfulStatusCode>(status: Status) => (body: Uint8Array<ArrayBuffer>): BodyResponseData<Status> => Object.freeze<BodyResponseData<Status>>({
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<Impl extends Api.ApiBundleImpl<any>> = Effect.Effect<Response, never, Effect.Effect.Context<ReturnType<Impl[keyof Impl]>>>;
type Return<Impl extends Api.ApiBundleImpl<any>> = Effect.Effect<ResponseData.Any, never, Effect.Effect.Context<ReturnType<Impl[keyof Impl]>>>;
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<ResponseData.Any>(bodyResponse(500)(body)),
onFailure: () => Effect.fail<ResponseData.Any>(RESPONSE_DATA_UNRECOVERABLE_ERROR),
}),
));
@@ -32,26 +61,26 @@ export const implement = <
impl: Impl,
): (request: HonoRequest<"/api/:key">) => Return<Impl> => {
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<Response, Response, any> = Effect.gen(function* () {
const effect: Effect.Effect<ResponseData.Any, ResponseData.Any, any> = 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<Uint8Array<ArrayBuffer>> },
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 = <A>(schema: Schema.Schema<A, any>) => {
const decoder = Schema.decodeUnknown(schema);
return (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => 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 = <A, Status extends ContentfulStatusCode>(schema: Schema.Schema<A, any>, 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),
}))
);
};

View File

@@ -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") }));