Commit05b973eunknown_key
perf: LRU cache, response compression, query parallelization, session caching
perf: LRU cache, response compression, query parallelization, session caching - Add in-memory LRU cache (src/lib/cache.ts) with TTL expiration for git operations (trees, branches, commits, README) — eliminates redundant subprocess spawns on repeated page loads - Add gzip/brotli response compression via Hono compress middleware - Parallelize git operations: defaultBranch + branches fetched together, tree + star info fetched together, commit + fullMessage + diff in parallel - Cache session->user mapping in auth middleware (2 min TTL) — eliminates 2 DB queries per authenticated request - Skip logger on git protocol routes (reduces overhead on clone/push) - Set Cache-Control headers on raw file downloads (5 min browser cache) - Invalidate repo cache on git push (ensures freshness after mutations) - Skip caching empty results to avoid stale data on new repos https://claude.ai/code/session_01M6EgoTaZ6HAZiVu77xp11g
6 files changed+284−11905b973eaf21259584775db69d868890abca76ae6
6 changed files+284−119
Modifiedsrc/app.tsx+8−2View fileUnifiedSplit
@@ -1,6 +1,7 @@
11import { Hono } from "hono";
22import { logger } from "hono/logger";
33import { cors } from "hono/cors";
4import { compress } from "hono/compress";
45import { Layout } from "./views/layout";
56import gitRoutes from "./routes/git";
67import apiRoutes from "./routes/api";
@@ -20,8 +21,13 @@ import webRoutes from "./routes/web";
2021
2122const app = new Hono();
2223
23// Middleware
24app.use("*", logger());
24// Middleware — compression first (wraps all responses)
25app.use("*", compress());
26// Logger only on non-git routes to avoid overhead on clone/push
27app.use("*", async (c, next) => {
28 if (c.req.path.includes(".git/")) return next();
29 return logger()(c, next);
30});
2531app.use("/api/*", cors());
2632
2733// Git Smart HTTP protocol routes (must be before web routes)
Modifiedsrc/git/repository.ts+95−84View fileUnifiedSplit
@@ -1,6 +1,7 @@
11import { join } from "path";
22import { mkdir } from "fs/promises";
33import { config } from "../lib/config";
4import { gitCache, cached } from "../lib/cache";
45
56export interface GitCommit {
67 sha: string;
@@ -87,26 +88,30 @@ export async function listBranches(
8788 owner: string,
8889 name: string
8990): Promise<string[]> {
90 const path = repoPath(owner, name);
91 const { stdout, exitCode } = await exec(
92 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
93 { cwd: path }
94 );
95 if (exitCode !== 0) return [];
96 return stdout.trim().split("\n").filter(Boolean);
91 return cached(gitCache as any, `${owner}/${name}:branches`, async () => {
92 const path = repoPath(owner, name);
93 const { stdout, exitCode } = await exec(
94 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
95 { cwd: path }
96 );
97 if (exitCode !== 0) return [];
98 return stdout.trim().split("\n").filter(Boolean);
99 });
97100}
98101
99102export async function getDefaultBranch(
100103 owner: string,
101104 name: string
102105): Promise<string | null> {
103 const path = repoPath(owner, name);
104 const { stdout, exitCode } = await exec(
105 ["git", "symbolic-ref", "--short", "HEAD"],
106 { cwd: path }
107 );
108 if (exitCode !== 0) return null;
109 return stdout.trim() || null;
106 return cached(gitCache as any, `${owner}/${name}:defaultBranch`, async () => {
107 const path = repoPath(owner, name);
108 const { stdout, exitCode } = await exec(
109 ["git", "symbolic-ref", "--short", "HEAD"],
110 { cwd: path }
111 );
112 if (exitCode !== 0) return null;
113 return stdout.trim() || null;
114 });
110115}
111116
112117export async function resolveRef(
@@ -167,36 +172,38 @@ export async function listCommits(
167172 limit = 30,
168173 offset = 0
169174): Promise<GitCommit[]> {
170 const path = repoPath(owner, name);
171 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
172 const { stdout, exitCode } = await exec(
173 [
174 "git",
175 "log",
176 `--format=${format}`,
177 `--skip=${offset}`,
178 `-${limit}`,
179 ref,
180 ],
181 { cwd: path }
182 );
183 if (exitCode !== 0) return [];
184 return stdout
185 .trim()
186 .split("\n")
187 .filter(Boolean)
188 .map((line) => {
189 const [sha, message, author, authorEmail, date, parents] =
190 line.split("\0");
191 return {
192 sha,
193 message,
194 author,
195 authorEmail,
196 date,
197 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
198 };
199 });
175 return cached(gitCache as any, `${owner}/${name}:commits:${ref}:${limit}:${offset}`, async () => {
176 const path = repoPath(owner, name);
177 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
178 const { stdout, exitCode } = await exec(
179 [
180 "git",
181 "log",
182 `--format=${format}`,
183 `--skip=${offset}`,
184 `-${limit}`,
185 ref,
186 ],
187 { cwd: path }
188 );
189 if (exitCode !== 0) return [];
190 return stdout
191 .trim()
192 .split("\n")
193 .filter(Boolean)
194 .map((line) => {
195 const [sha, message, author, authorEmail, date, parents] =
196 line.split("\0");
197 return {
198 sha,
199 message,
200 author,
201 authorEmail,
202 date,
203 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
204 };
205 });
206 });
200207}
201208
202209export async function getTree(
@@ -205,39 +212,41 @@ export async function getTree(
205212 ref: string,
206213 treePath = ""
207214): Promise<GitTreeEntry[]> {
208 const path = repoPath(owner, name);
209 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
210 const { stdout, exitCode } = await exec(
211 ["git", "ls-tree", "-l", treeish],
212 { cwd: path }
213 );
214 if (exitCode !== 0) return [];
215 return stdout
216 .trim()
217 .split("\n")
218 .filter(Boolean)
219 .map((line) => {
220 // format: <mode> <type> <sha>\t<size>\t<name>
221 // Actually: <mode> SP <type> SP <sha> SP <size> TAB <name>
222 const match = line.match(
223 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
224 );
225 if (!match) return null;
226 return {
227 mode: match[1],
228 type: match[2] as "blob" | "tree" | "commit",
229 sha: match[3],
230 size: match[4] === "-" ? undefined : parseInt(match[4], 10),
231 name: match[5],
232 };
233 })
234 .filter((e): e is GitTreeEntry => e !== null)
235 .sort((a, b) => {
236 // directories first, then files
237 if (a.type === "tree" && b.type !== "tree") return -1;
238 if (a.type !== "tree" && b.type === "tree") return 1;
239 return a.name.localeCompare(b.name);
240 });
215 return cached(gitCache as any, `${owner}/${name}:tree:${ref}:${treePath}`, async () => {
216 const path = repoPath(owner, name);
217 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
218 const { stdout, exitCode } = await exec(
219 ["git", "ls-tree", "-l", treeish],
220 { cwd: path }
221 );
222 if (exitCode !== 0) return [];
223 return stdout
224 .trim()
225 .split("\n")
226 .filter(Boolean)
227 .map((line) => {
228 // format: <mode> <type> <sha>\t<size>\t<name>
229 // Actually: <mode> SP <type> SP <sha> SP <size> TAB <name>
230 const match = line.match(
231 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
232 );
233 if (!match) return null;
234 return {
235 mode: match[1],
236 type: match[2] as "blob" | "tree" | "commit",
237 sha: match[3],
238 size: match[4] === "-" ? undefined : parseInt(match[4], 10),
239 name: match[5],
240 };
241 })
242 .filter((e): e is GitTreeEntry => e !== null)
243 .sort((a, b) => {
244 // directories first, then files
245 if (a.type === "tree" && b.type !== "tree") return -1;
246 if (a.type !== "tree" && b.type === "tree") return 1;
247 return a.name.localeCompare(b.name);
248 });
249 });
241250}
242251
243252export async function getBlob(
@@ -431,11 +440,13 @@ export async function getReadme(
431440 name: string,
432441 ref: string
433442): Promise<string | null> {
434 const tree = await getTree(owner, name, ref);
435 const readme = tree.find((e) =>
436 /^readme(\.(md|txt|rst))?$/i.test(e.name)
437 );
438 if (!readme) return null;
439 const blob = await getBlob(owner, name, ref, readme.name);
440 return blob?.content || null;
443 return cached(gitCache as any, `${owner}/${name}:readme:${ref}`, async () => {
444 const tree = await getTree(owner, name, ref);
445 const readme = tree.find((e) =>
446 /^readme(\.(md|txt|rst))?$/i.test(e.name)
447 );
448 if (!readme) return null;
449 const blob = await getBlob(owner, name, ref, readme.name);
450 return blob?.content || null;
451 });
441452}
Addedsrc/lib/cache.ts+123−0View fileUnifiedSplit
@@ -0,0 +1,123 @@
1/**
2 * In-memory LRU cache with TTL expiration.
3 *
4 * Used for caching git operations, session lookups,
5 * and other hot-path data to avoid redundant subprocess
6 * spawns and database roundtrips.
7 */
8
9interface CacheEntry<T> {
10 value: T;
11 expiresAt: number;
12}
13
14export class LRUCache<T> {
15 private cache = new Map<string, CacheEntry<T>>();
16 private readonly maxSize: number;
17 private readonly ttlMs: number;
18
19 constructor(maxSize: number, ttlMs: number) {
20 this.maxSize = maxSize;
21 this.ttlMs = ttlMs;
22 }
23
24 get(key: string): T | undefined {
25 const entry = this.cache.get(key);
26 if (!entry) return undefined;
27
28 if (Date.now() > entry.expiresAt) {
29 this.cache.delete(key);
30 return undefined;
31 }
32
33 // Move to end (most recently used)
34 this.cache.delete(key);
35 this.cache.set(key, entry);
36 return entry.value;
37 }
38
39 set(key: string, value: T): void {
40 // Delete first to update position
41 this.cache.delete(key);
42
43 // Evict oldest if at capacity
44 if (this.cache.size >= this.maxSize) {
45 const firstKey = this.cache.keys().next().value;
46 if (firstKey !== undefined) this.cache.delete(firstKey);
47 }
48
49 this.cache.set(key, {
50 value,
51 expiresAt: Date.now() + this.ttlMs,
52 });
53 }
54
55 invalidate(key: string): void {
56 this.cache.delete(key);
57 }
58
59 /**
60 * Invalidate all keys matching a prefix.
61 * Useful for clearing all cached data for a repo after a push.
62 */
63 invalidatePrefix(prefix: string): void {
64 for (const key of this.cache.keys()) {
65 if (key.startsWith(prefix)) {
66 this.cache.delete(key);
67 }
68 }
69 }
70
71 clear(): void {
72 this.cache.clear();
73 }
74
75 get size(): number {
76 return this.cache.size;
77 }
78}
79
80// --- Shared cache instances ---
81
82/** Git operation cache — trees, branches, commits, blobs (5 min TTL, 2000 entries) */
83export const gitCache = new LRUCache<unknown>(2000, 5 * 60 * 1000);
84
85/** Session cache — maps session tokens to user objects (2 min TTL, 500 entries) */
86export const sessionCache = new LRUCache<unknown>(500, 2 * 60 * 1000);
87
88/**
89 * Cache-through helper — returns cached value or runs the factory,
90 * caches the result, and returns it.
91 *
92 * Does NOT cache empty arrays or null — avoids stale empty results
93 * when a repo is freshly created or still receiving its first push.
94 */
95export async function cached<T>(
96 cache: LRUCache<T>,
97 key: string,
98 factory: () => Promise<T>
99): Promise<T> {
100 const existing = cache.get(key);
101 if (existing !== undefined) return existing;
102
103 const value = await factory();
104
105 // Only cache non-empty results
106 if (value !== null && value !== undefined) {
107 if (Array.isArray(value) && value.length === 0) {
108 // Don't cache empty arrays — repo may just be initializing
109 } else {
110 cache.set(key, value);
111 }
112 }
113
114 return value;
115}
116
117/**
118 * Invalidate all cached data for a repository.
119 * Call this after pushes, merges, or any repo-mutating operation.
120 */
121export function invalidateRepoCache(owner: string, repo: string): void {
122 gitCache.invalidatePrefix(`${owner}/${repo}:`);
123}
Modifiedsrc/middleware/auth.ts+13−0View fileUnifiedSplit
@@ -1,5 +1,6 @@
11/**
22 * Auth middleware — reads session cookie, injects user into context.
3 * Uses in-memory session cache to avoid DB roundtrip on every request.
34 */
45
56import { createMiddleware } from "hono/factory";
@@ -8,6 +9,7 @@ import { eq, gt } from "drizzle-orm";
89import { db } from "../db";
910import { sessions, users } from "../db/schema";
1011import type { User } from "../db/schema";
12import { sessionCache } from "../lib/cache";
1113
1214export type AuthEnv = {
1315 Variables: {
@@ -18,6 +20,7 @@ export type AuthEnv = {
1820/**
1921 * Soft auth — sets c.get("user") to the current user or null.
2022 * Does NOT block unauthenticated requests.
23 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
2124 */
2225export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
2326 const token = getCookie(c, "session");
@@ -26,6 +29,13 @@ export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
2629 return next();
2730 }
2831
32 // Check session cache first
33 const cachedUser = sessionCache.get(token) as User | null | undefined;
34 if (cachedUser !== undefined) {
35 c.set("user", cachedUser);
36 return next();
37 }
38
2939 try {
3040 const [session] = await db
3141 .select()
@@ -34,6 +44,7 @@ export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
3444 .limit(1);
3545
3646 if (!session || new Date(session.expiresAt) < new Date()) {
47 sessionCache.set(token, null as any);
3748 c.set("user", null);
3849 return next();
3950 }
@@ -44,6 +55,8 @@ export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
4455 .where(eq(users.id, session.userId))
4556 .limit(1);
4657
58 // Cache the result (user or null)
59 sessionCache.set(token, (user || null) as any);
4760 c.set("user", user || null);
4861 } catch {
4962 c.set("user", null);
Modifiedsrc/routes/git.ts+4−0View fileUnifiedSplit
@@ -8,6 +8,7 @@ import { Hono } from "hono";
88import { getInfoRefs, serviceRpc } from "../git/protocol";
99import { repoExists } from "../git/repository";
1010import { onPostReceive } from "../hooks/post-receive";
11import { invalidateRepoCache } from "../lib/cache";
1112
1213const git = new Hono();
1314
@@ -64,6 +65,9 @@ git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
6465 bodyBuffer
6566 );
6667
68 // Invalidate cached git data for this repo immediately
69 invalidateRepoCache(owner, repo);
70
6771 // Fire post-receive hooks asynchronously (don't block response)
6872 // We parse updated refs from the pkt-line protocol in the request
6973 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
Modifiedsrc/routes/web.tsx+41−33View fileUnifiedSplit
@@ -342,32 +342,34 @@ web.get("/:owner/:repo", async (c) => {
342342 );
343343 }
344344
345 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
346 const branches = await listBranches(owner, repo);
347 const tree = await getTree(owner, repo, defaultBranch);
348
349 // Get star info if user logged in
350 let starCount = 0;
351 let starred = false;
352 try {
353 const [ownerUser] = await db
354 .select()
355 .from(users)
356 .where(eq(users.username, owner))
357 .limit(1);
358 if (ownerUser) {
359 const [repoRow] = await db
360 .select()
361 .from(repositories)
362 .where(
363 and(
364 eq(repositories.ownerId, ownerUser.id),
365 eq(repositories.name, repo)
345 // Parallelize all independent operations
346 const [defaultBranch, branches] = await Promise.all([
347 getDefaultBranch(owner, repo).then((b) => b || "main"),
348 listBranches(owner, repo),
349 ]);
350 const [tree, starInfo] = await Promise.all([
351 getTree(owner, repo, defaultBranch),
352 // Star info fetched in parallel with tree
353 (async () => {
354 try {
355 const [ownerUser] = await db
356 .select()
357 .from(users)
358 .where(eq(users.username, owner))
359 .limit(1);
360 if (!ownerUser) return { starCount: 0, starred: false };
361 const [repoRow] = await db
362 .select()
363 .from(repositories)
364 .where(
365 and(
366 eq(repositories.ownerId, ownerUser.id),
367 eq(repositories.name, repo)
368 )
366369 )
367 )
368 .limit(1);
369 if (repoRow) {
370 starCount = repoRow.starCount;
370 .limit(1);
371 if (!repoRow) return { starCount: 0, starred: false };
372 let starred = false;
371373 if (user) {
372374 const [star] = await db
373375 .select()
@@ -381,11 +383,13 @@ web.get("/:owner/:repo", async (c) => {
381383 .limit(1);
382384 starred = !!star;
383385 }
386 return { starCount: repoRow.starCount, starred };
387 } catch {
388 return { starCount: 0, starred: false };
384389 }
385 }
386 } catch {
387 // DB not available
388 }
390 })(),
391 ]);
392 const { starCount, starred } = starInfo;
389393
390394 if (tree.length === 0) {
391395 return c.html(
@@ -641,7 +645,12 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
641645 const { owner, repo, sha } = c.req.param();
642646 const user = c.get("user");
643647
644 const commit = await getCommit(owner, repo, sha);
648 // Fetch commit, full message, and diff in parallel
649 const [commit, fullMessage, diffResult] = await Promise.all([
650 getCommit(owner, repo, sha),
651 getCommitFullMessage(owner, repo, sha),
652 getDiff(owner, repo, sha),
653 ]);
645654 if (!commit) {
646655 return c.html(
647656 <Layout title="Not Found" user={user}>
@@ -653,8 +662,7 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
653662 );
654663 }
655664
656 const fullMessage = await getCommitFullMessage(owner, repo, sha);
657 const { files, raw } = await getDiff(owner, repo, sha);
665 const { files, raw } = diffResult;
658666
659667 return c.html(
660668 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
@@ -734,7 +742,7 @@ web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
734742 headers: {
735743 "Content-Type": "application/octet-stream",
736744 "Content-Disposition": `attachment; filename="${fileName}"`,
737 "Cache-Control": "no-cache",
745 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
738746 },
739747 });
740748});
741749