Skip to content
File

Blob: src/worker/routes/hono.ts

typescript121 lines
1import { createMiddleware } from "hono/factory";
2import type { Context, Hono } from "hono";
3import type { Viewer } from "@/client/server/viewer";
4import type { CacheContext } from "@/worker/cache/cache";
5import type { ActiveSession } from "@/worker/auth/session";
6import type { Db } from "@/worker/db/d1/client";
7import type { Limiter } from "@/worker/git/operations/limits";
8import type { Logger, LoggerContext } from "@/worker/common/logger";
9 
10import { createLogger } from "@/worker/common/logger";
11import { createDb } from "@/worker/db/d1/client";
12import { getLimiter } from "@/worker/git/operations/limits";
13import { emitD1Bookmark, readInboundD1Bookmark } from "./d1Bookmark";
14 
15export type RequestLogContext = Omit<LoggerContext, "requestId">;
16export type RequestLogFactory = (context: RequestLogContext) => Logger;
17 
18export type AppBindings = {
19 Bindings: Env;
20 Variables: {
21 db: Db;
22 cacheCtx: CacheContext;
23 limiter: Limiter;
24 requestId: string;
25 logFor: RequestLogFactory;
26 activeSessionPromise?: Promise<ActiveSession | null>;
27 viewerPromise?: Promise<Viewer | null>;
28 };
29};
30 
31export type AppRouter = Hono<AppBindings>;
32// Route handlers should accept AppContext directly. Adapter wrappers hide Hono's
33// request state, middleware variables, response helpers, and executionCtx.
34export type AppContext<Path extends string = string> = Context<AppBindings, Path>;
35 
36type SessionConstraintDecision =
37 | { kind: "primary" }
38 | { kind: "read"; anchor: D1SessionBookmark | D1SessionConstraint };
39 
40// Centralized read/write classification. The selector runs once before
41// `next()` and the chosen anchor cannot be changed mid-route: every route
42// is classified purely by `(method, path)`.
43function selectSessionConstraint(
44 method: string,
45 path: string,
46 inboundBookmark: string | null
47): SessionConstraintDecision {
48 // Hono with `strict: false` already strips a trailing slash before our
49 // middleware sees `c.req.path`, but we normalize defensively so the
50 // classification keeps working if that option is ever flipped.
51 const normalizedPath = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
52 
53 // Write-shaped GET: OIDC callback writes user/membership rows and then
54 // reads them back in the same request.
55 if (method === "GET" && normalizedPath === "/auth/callback") return { kind: "primary" };
56 
57 // Git protocol routes lie about read/write through the HTTP verb:
58 // upload-pack POST is the fetch path (read-only); receive-pack POST is
59 // the push path. The discovery GETs on `info/refs` fall through to the
60 // default-method rule below as read-like.
61 if (method === "POST") {
62 if (normalizedPath.endsWith("/git-upload-pack")) {
63 return { kind: "read", anchor: inboundBookmark ?? "first-unconstrained" };
64 }
65 if (normalizedPath.endsWith("/git-receive-pack")) return { kind: "primary" };
66 }
67 
68 if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
69 return { kind: "read", anchor: inboundBookmark ?? "first-unconstrained" };
70 }
71 return { kind: "primary" };
72}
73 
74export function workerExecutionContext(c: AppContext): ExecutionContext {
75 // Hono 4.12.x still types `executionCtx.exports` as optional while Wrangler's
76 // generated Workers runtime types require it. At runtime Hono is carrying the
77 // real Cloudflare Workers context; keep the cast at this framework boundary.
78 // See https://github.com/honojs/hono/issues/4493.
79 return c.executionCtx as ExecutionContext;
80}
81 
82function requestIdFrom(request: Request): string {
83 return request.headers.get("Cf-Ray")?.trim() || crypto.randomUUID();
84}
85 
86export const requestServicesMiddleware = createMiddleware<AppBindings>(async (c, next) => {
87 const requestId = requestIdFrom(c.req.raw);
88 const cacheCtx: CacheContext = {
89 req: c.req.raw,
90 ctx: workerExecutionContext(c),
91 };
92 const limiter = getLimiter(cacheCtx);
93 
94 // Open exactly one D1 session per request. The anchor is fixed before
95 // any route runs and `c.var.db` is not allowed to be re-anchored from
96 // inside a route. `emitD1Bookmark` runs in `finally` so the bookmark is
97 // shipped even when a handler throws and `onError` rewrites `c.res`.
98 const inboundBookmark = readInboundD1Bookmark(c);
99 const decision = selectSessionConstraint(c.req.method, c.req.path, inboundBookmark);
100 const anchor: D1SessionBookmark | D1SessionConstraint =
101 decision.kind === "primary" ? "first-primary" : decision.anchor;
102 const session = c.env.DB.withSession(anchor);
103 
104 c.set("requestId", requestId);
105 c.set("db", createDb(session));
106 c.set("cacheCtx", cacheCtx);
107 c.set("limiter", limiter);
108 c.set("logFor", (context) =>
109 createLogger(c.env.LOG_LEVEL, {
110 ...context,
111 requestId,
112 })
113 );
114 
115 try {
116 await next();
117 } finally {
118 emitD1Bookmark(c, session, inboundBookmark);
119 }
120});