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,6 +1,6 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "js/ts.tsdk.path": "node_modules/typescript/lib",
"typescript.preferences.autoImportFileExcludePatterns": [ "js/ts.preferences.autoImportFileExcludePatterns": [
"@radix-ui", "@radix-ui",
], ],
} }

View File

@@ -1,26 +1,55 @@
import { registerEncoder } from "cbor2/encoder"; import { registerEncoder } from "cbor2/encoder";
import * as Api from "common/Api"; import * as Api from "common/Api";
import * as Body from "common/Body";
import * as Cbor from "common/Cbor"; import * as Cbor from "common/Cbor";
import * as Client from "common/Client"; import { Cause, Console, Effect, Either, HashMap, Inspectable, Option, ParseResult, pipe, Schema } from "effect";
import { Cause, Effect, Either, HashMap, Inspectable, Option, pipe } from "effect";
import type { HonoRequest } from "hono"; 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)]); 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 /* 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 * it the TypeScript parser in VS Code sort of blows up and the syntax colors
* turn wrong. * 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, error,
Inspectable.toJSON, Inspectable.toJSON,
Cbor.encode, Cbor.encode,
Effect.map(Client.cborBody),
Effect.matchEffect({ Effect.matchEffect({
onSuccess: ({ body, headers }) => Effect.fail(new Response(body, { status: 500, headers })), onSuccess: (body) => Effect.fail<ResponseData.Any>(bodyResponse(500)(body)),
onFailure: () => Effect.fail(new Response(null, { status: 500 })), onFailure: () => Effect.fail<ResponseData.Any>(RESPONSE_DATA_UNRECOVERABLE_ERROR),
}), }),
)); ));
@@ -32,26 +61,26 @@ export const implement = <
impl: Impl, impl: Impl,
): (request: HonoRequest<"/api/:key">) => Return<Impl> => { ): (request: HonoRequest<"/api/:key">) => Return<Impl> => {
return (honoRequest) => { return (honoRequest) => {
/* Force both return types to be `Response`. We can use the error route /* Force both return types to be `ResponseData`. We can use the error
* for it's short-circuit capabilities. * 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 key = honoRequest.param("key");
const maybeApi = HashMap.get(bundle.map, key); const maybeApi = HashMap.get(bundle.map, key);
if (Option.isNone(maybeApi)) { if (Option.isNone(maybeApi)) {
return RESPONSE_API_NOT_FOUND; return RESPONSE_DATA_API_NOT_FOUND;
} }
const { value: api } = maybeApi; const { value: api } = maybeApi;
const fn = impl[key]; const fn = impl[key];
if (fn === undefined) { if (fn === undefined) {
return RESPONSE_API_NOT_IMPLEMENTED; return RESPONSE_DATA_API_NOT_IMPLEMENTED;
} }
const request = yield* pipe( const request = yield* pipe(
honoRequest as { bytes(): Promise<Uint8Array<ArrayBuffer>> }, honoRequest as { bytes(): Promise<Uint8Array<ArrayBuffer>> },
Client.decodeBody(api.request), decodeBody(api.request),
catchToResponse, catchToResponseData,
); );
const result = yield* pipe( const result = yield* pipe(
@@ -61,51 +90,72 @@ export const implement = <
Effect.either, Effect.either,
); );
const { status, response } = pipe( const response = yield* pipe(
result, result,
Either.match({ Either.match({
onLeft: (cause) => pipe( onLeft: (cause) => pipe(
cause, cause,
Cause.failureOrCause, Cause.failureOrCause,
Either.match({ Either.match({
onLeft: (error) => ({ onLeft: (body) => pipe(
status: 400, body,
response: pipe( encodeBody(api.error, 400),
error, catchToResponseData,
Client.encodeBody(api.error),
catchToResponse,
), ),
}), onRight: (die) => pipe(
onRight: (die) => ({
status: 500,
response: pipe(
die, die,
Inspectable.toJSON, Inspectable.toJSON,
Cbor.encode, Cbor.encode,
Effect.map(Client.cborBody), Effect.map(bodyResponse(500)),
catchToResponse, catchToResponseData,
), ),
}), }),
}),
), ),
onRight: (response) => ({ onRight: (body) => pipe(
status: 200, body,
response: pipe( encodeBody(api.response, 200),
response, catchToResponseData,
Client.encodeBody(api.response),
catchToResponse,
), ),
}), }),
}),
); );
const { body, headers } = yield* response; return response;
return new Response(body, { status, headers });
}); });
return Effect.catchAll(effect, Effect.succeed); return Effect.catchAll(effect, Effect.succeed);
}; };
}; };
const RESPONSE_API_NOT_FOUND = new Response(null, { status: 404 }); const RESPONSE_DATA_API_NOT_FOUND = emptyResponse(404);
const RESPONSE_API_NOT_IMPLEMENTED = new Response(null, { status: 501 }); 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 // Callback URL with query response type
if (code !== undefined || state !== undefined) { if (code !== undefined || state !== undefined) {
yield* Authentication.getAndProcessIdToken(code ?? null, state ?? null); 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 // Initial login request; redirect to identity provider
const res = yield* pipe( const url = yield* pipe(
Authentication.Authentication, Authentication.Authentication,
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)), Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
Effect.map((url) => Response.redirect(url)),
); );
return res; return ctx.redirect(url);
}).pipe( }).pipe(
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)), Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
Effect.runPromise, Effect.runPromise,
@@ -58,7 +57,7 @@ const app = new Hono()
yield* Authentication.getAndProcessIdToken(code, state); 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( }).pipe(
Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)), Effect.provide(Layer.provideMerge(Authentication.Live(ctx), databaseLayer)),
Effect.runPromise, Effect.runPromise,
@@ -87,17 +86,22 @@ const app = new Hono()
const authenticationLayer = Authentication.Live(ctx); const authenticationLayer = Authentication.Live(ctx);
const layers = Layer.provideMerge(authenticationLayer, databaseLayer); const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
const response = await pipe( const { body, status } = await pipe(
handle(req), handle(req),
Effect.provide(layers), Effect.provide(layers),
Effect.runPromise, Effect.runPromise,
); );
for (const [name, value] of CORS_HEADERS) { 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("/assets/*", serveStatic({ root: FRONTEND_ROOT }))
.use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") })); .use("*", serveStatic({ path: path.join(FRONTEND_ROOT, "index.html") }));