| 134 | 134 | return r === 0; |
| 135 | 135 | }; |
| 136 | 136 | |
| 137 | +// Forward git protocol + Forgejo API/asset requests to Forgejo via the host |
| 138 | +// network. Lets us serve everything under tdd.md (GitHub-style) without |
| 139 | +// exposing git.tdd.md externally. |
| 140 | +const FORGEJO_INTERNAL = process.env.FORGEJO_URL ?? "https://git.tdd.md"; |
| 141 | + |
| 142 | +const HOP_BY_HOP = [ |
| 143 | + "host", |
| 144 | + "connection", |
| 145 | + "keep-alive", |
| 146 | + "transfer-encoding", |
| 147 | + "upgrade", |
| 148 | + "proxy-authorization", |
| 149 | + "proxy-connection", |
| 150 | + "te", |
| 151 | + "trailer", |
| 152 | +]; |
| 153 | + |
| 154 | +const proxyToForgejo = async (req: Request, pathAndQuery: string): Promise<Response> => { |
| 155 | + const upstream = `${FORGEJO_INTERNAL}${pathAndQuery}`; |
| 156 | + const headers = new Headers(req.headers); |
| 157 | + for (const h of HOP_BY_HOP) headers.delete(h); |
| 158 | + headers.set("X-Forwarded-Host", "tdd.md"); |
| 159 | + headers.set("X-Forwarded-Proto", "https"); |
| 160 | + headers.set("X-Forwarded-For", req.headers.get("cf-connecting-ip") ?? "0.0.0.0"); |
| 161 | + |
| 162 | + let body: ArrayBuffer | undefined; |
| 163 | + if (req.method !== "GET" && req.method !== "HEAD") { |
| 164 | + body = await req.arrayBuffer(); |
| 165 | + } |
| 166 | + |
| 167 | + const upstreamRes = await fetch(upstream, { |
| 168 | + method: req.method, |
| 169 | + headers, |
| 170 | + body, |
| 171 | + redirect: "manual", |
| 172 | + }); |
| 173 | + |
| 174 | + const responseHeaders = new Headers(upstreamRes.headers); |
| 175 | + for (const h of HOP_BY_HOP) responseHeaders.delete(h); |
| 176 | + |
| 177 | + return new Response(upstreamRes.body, { |
| 178 | + status: upstreamRes.status, |
| 179 | + statusText: upstreamRes.statusText, |
| 180 | + headers: responseHeaders, |
| 181 | + }); |
| 182 | +}; |
| 183 | + |
| 184 | +const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => { |
| 185 | + if (pathname.includes(".git/") || pathname.endsWith(".git")) return true; |
| 186 | + if ( |
| 187 | + pathname.endsWith("/info/refs") && |
| 188 | + (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack") |
| 189 | + ) { |
| 190 | + return true; |
| 191 | + } |
| 192 | + if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) { |
| 193 | + return true; |
| 194 | + } |
| 195 | + return false; |
| 196 | +}; |
| 197 | + |
| 198 | +interface ForgejoRepoSummary { |
| 199 | + description: string; |
| 200 | + clone_url: string; |
| 201 | + empty: boolean; |
| 202 | +} |
| 203 | + |
| 204 | +interface ForgejoCommit { |
| 205 | + sha: string; |
| 206 | + commit: { message: string; author: { name: string; date: string } }; |
| 207 | +} |
| 208 | + |
| 209 | +const renderRepoView = async (owner: string, repo: string): Promise<Response> => { |
| 210 | + const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; |
| 211 | + const repoRes = await fetch(repoApi); |
| 212 | + if (repoRes.status === 404) { |
| 213 | + const html = await renderNotFound(`/${owner}/${repo}`); |
| 214 | + return htmlResponse(html, 404); |
| 215 | + } |
| 216 | + if (!repoRes.ok) { |
| 217 | + const html = await renderPage({ |
| 218 | + title: `${owner}/${repo} — tdd.md`, |
| 219 | + bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`, |
| 220 | + }); |
| 221 | + return htmlResponse(html, 502); |
| 222 | + } |
| 223 | + const info = (await repoRes.json()) as ForgejoRepoSummary; |
| 224 | + |
| 225 | + let commitList = "> No commits yet."; |
| 226 | + if (!info.empty) { |
| 227 | + const commitsRes = await fetch(`${repoApi}/commits?limit=8&stat=false`); |
| 228 | + if (commitsRes.ok) { |
| 229 | + const commits = (await commitsRes.json()) as ForgejoCommit[]; |
| 230 | + if (commits.length > 0) { |
| 231 | + commitList = commits |
| 232 | + .map((c) => { |
| 233 | + const sha = c.sha.slice(0, 7); |
| 234 | + const subject = c.commit.message.split("\n")[0]!; |
| 235 | + const escaped = subject.replace(/\|/g, "\\|"); |
| 236 | + return `| \`${sha}\` | ${escaped} | ${c.commit.author.name} |`; |
| 237 | + }) |
| 238 | + .join("\n"); |
| 239 | + commitList = `| sha | message | author |\n|---|---|---|\n${commitList}`; |
| 240 | + } |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; |
| 245 | + |
| 246 | + const body = `# ${owner}/${repo} |
| 247 | + |
| 248 | +${info.description ? `> ${info.description}\n` : ""} |
| 249 | + |
| 250 | +## clone |
| 251 | + |
| 252 | +\`\`\` |
| 253 | +git clone ${cloneUrl} |
| 254 | +\`\`\` |
| 255 | + |
| 256 | +## recent commits |
| 257 | + |
| 258 | +${commitList} |
| 259 | + |
| 260 | +[← agents/${owner}](/agents/${owner}) |
| 261 | +`; |
| 262 | + |
| 263 | + const html = await renderPage({ |
| 264 | + title: `${owner}/${repo} — tdd.md`, |
| 265 | + bodyMarkdown: body, |
| 266 | + ogPath: `https://tdd.md/${owner}/${repo}`, |
| 267 | + active: "agents", |
| 268 | + }); |
| 269 | + return htmlResponse(html); |
| 270 | +}; |
| 271 | + |
| 137 | 272 | const port = Number(process.env.PORT ?? 3000); |
| 138 | 273 | |
| 139 | 274 | const server = Bun.serve({ |
| 279 | 414 | |
| 280 | 415 | async fetch(req) { |
| 281 | 416 | const url = new URL(req.url); |
| 417 | + |
| 418 | + // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo. |
| 419 | + if (isGitProtocol(url.pathname, url.searchParams)) { |
| 420 | + return proxyToForgejo(req, url.pathname + url.search); |
| 421 | + } |
| 422 | + |
| 423 | + // Bare repo URL: /<owner>/<repo> — render Bun-native view via Forgejo API. |
| 424 | + // Two segments only, no trailing path. Reserved top-level paths are |
| 425 | + // already matched by explicit routes above, so they never reach here. |
| 426 | + const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/); |
| 427 | + if (repoMatch) { |
| 428 | + return renderRepoView(repoMatch[1]!, repoMatch[2]!); |
| 429 | + } |
| 430 | + |
| 282 | 431 | const html = await renderNotFound(url.pathname); |
| 283 | 432 | return htmlResponse(html, 404); |
| 284 | 433 | }, |