diff options
| author | Joe Mou <dev@mou.fo> | 2026-07-15 02:08:17 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-07-15 02:08:29 -0400 |
| commit | 9b556ffe6c0c0528697e15f7f4f35107de212ce1 (patch) | |
| tree | 7f87bd7d58c4bc9a640a669fa314fec6c49c82df /cgithub/src | |
| parent | 9fbb5f0d68a131b55bc172d46b75705f2842af04 (diff) | |
Add Cloudflare Workers deployment target
Split routes into a shared createApp() (src/app.ts) used by both the
Node entry (src/index.ts) and a new Workers entry (src/worker.ts).
Workers disallow runtime code generation, so scripts/compile-views.ts
precompiles the Eta templates into src/views.generated.ts and rendering
switches from renderAsync to the sync render. Static files move to
public/static so Workers Static Assets can serve them; the Node build
and serveStatic root are updated to match.
Deploy with pnpm run deploy:cf, local dev with pnpm run dev:cf.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'cgithub/src')
| -rw-r--r-- | cgithub/src/app.ts | 132 | ||||
| -rw-r--r-- | cgithub/src/index.ts | 132 | ||||
| -rw-r--r-- | cgithub/src/worker.ts | 27 |
3 files changed, 164 insertions, 127 deletions
diff --git a/cgithub/src/app.ts b/cgithub/src/app.ts new file mode 100644 index 0000000..78b59ae --- /dev/null +++ b/cgithub/src/app.ts @@ -0,0 +1,132 @@ +import type { Eta } from "eta"; +import { type Context, Hono } from "hono"; +import type { StatusCode } from "hono/utils/http-status"; +import { + getGitHubBlob, + getGitHubCommits, + getGitHubIssues, + getGitHubOwner, + getGitHubPulls, + getGitHubRelease, + getGitHubReleases, + getGitHubRepo, + getGitHubTree, + GitHubHTTPError, + RedirectError, +} from "./scraper.ts"; + +export function createApp(eta: Eta) { + const app = new Hono(); + + app.get("/", async (c) => { + return c.html(eta.render("home.eta", {})); + }); + + async function tryRender<T extends object>(c: Context, template: string, promise: Promise<T>) { + let data: T; + try { + data = await promise; + } catch (e) { + if (e instanceof RedirectError) { + if (e.location.startsWith("https://")) { + c.header("Referrer-Policy", "no-referrer"); + } + return c.redirect(e.location); + } else if (e instanceof GitHubHTTPError) { + c.status(e.status as StatusCode); + const message = `GitHub responded with HTTP ${e.status} ${e.message}`; + return c.html(eta.render("error.eta", { title: e.message, message })); + } else { + c.status(500); + return c.html(eta.render("error.eta", { message: "" + e })); + } + } + return c.html(eta.render(template, data)); + } + + app.get("/:owner", async (c) => { + const { owner } = c.req.param(); + return tryRender(c, "owner.eta", getGitHubOwner(owner)); + }); + + app.get("/:owner/:repo", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "repo.eta", getGitHubRepo(owner, repo)); + }); + + app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRender(c, "tree.eta", getGitHubTree(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => { + const { owner, repo, branch, path } = c.req.param(); + return tryRender(c, "blob.eta", getGitHubBlob(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { + const { owner, repo, branch, path } = c.req.param(); + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); + }); + + app.get("/:owner/:repo/issues", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q"); + + // GitHub supports combined issue/PR search, apparently for legacy. We just + // redirect to issue-only search. + if (q && !/\bis:issue\b/.test(q)) { + return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`); + } + + return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q })); + }); + + app.get("/:owner/:repo/pulls", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q"); + return tryRender(c, "pulls.eta", getGitHubPulls(owner, repo, { q })); + }); + + app.get("/:owner/:repo/search", async (c) => { + const { owner, repo } = c.req.param(); + const q = c.req.query("q") ?? ""; + const type = c.req.query("type") ?? "issues"; + + if (type === "issues") { + return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`); + } else if (type === "pullrequests") { + return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`); + } + + // Redirect unhandled search types (like code, which requires sign in anyway). + c.header("Referrer-Policy", "no-referrer"); + return c.redirect( + `https://github.com/${owner}/${repo}/search?${new URLSearchParams(c.req.query()).toString()}`, + ); + }); + + app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { + const { owner, repo, branch, path = "" } = c.req.param(); + return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); + }); + + app.get("/:owner/:repo/releases/tag/:tag", async (c) => { + const { owner, repo, tag } = c.req.param(); + return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); + }); + + app.get("/:owner/:repo/releases", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); + }); + + app.all("*", async (c) => { + const path = c.req.path; + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(`https://github.com${path}`); + }); + + return app; +} diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index f676bd6..65c46d9 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -1,140 +1,18 @@ import { createAdaptorServer, serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Eta } from "eta"; -import { type Context, Hono } from "hono"; -import type { StatusCode } from "hono/utils/http-status"; +import { Hono } from "hono"; import path from "node:path"; import url from "node:url"; -import { - getGitHubBlob, - getGitHubCommits, - getGitHubIssues, - getGitHubOwner, - getGitHubPulls, - getGitHubRelease, - getGitHubReleases, - getGitHubRepo, - getGitHubTree, - GitHubHTTPError, - RedirectError, -} from "./scraper.ts"; +import { createApp } from "./app.ts"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); -const app = new Hono(); const eta = new Eta({ views: path.join(__dirname, "..", "views") }); -app.use("/static/*", serveStatic({ root: path.join(__dirname, "..") })); - -app.get("/", async (c) => { - return c.html(await eta.renderAsync("home.eta", {})); -}); - -async function tryRender<T extends object>(c: Context, template: string, promise: Promise<T>) { - let data: T; - try { - data = await promise; - } catch (e) { - if (e instanceof RedirectError) { - if (e.location.startsWith("https://")) { - c.header("Referrer-Policy", "no-referrer"); - } - return c.redirect(e.location); - } else if (e instanceof GitHubHTTPError) { - c.status(e.status as StatusCode); - const message = `GitHub responded with HTTP ${e.status} ${e.message}`; - return c.html(await eta.renderAsync("error.eta", { title: e.message, message })); - } else { - c.status(500); - return c.html(await eta.renderAsync("error.eta", { message: "" + e })); - } - } - return c.html(await eta.renderAsync(template, data)); -} - -app.get("/:owner", async (c) => { - const { owner } = c.req.param(); - return tryRender(c, "owner.eta", getGitHubOwner(owner)); -}); - -app.get("/:owner/:repo", async (c) => { - const { owner, repo } = c.req.param(); - return tryRender(c, "repo.eta", getGitHubRepo(owner, repo)); -}); - -app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => { - const { owner, repo, branch, path = "" } = c.req.param(); - return tryRender(c, "tree.eta", getGitHubTree(owner, repo, branch, path)); -}); - -app.get("/:owner/:repo/blob/:branch/:path{.*}", async (c) => { - const { owner, repo, branch, path } = c.req.param(); - return tryRender(c, "blob.eta", getGitHubBlob(owner, repo, branch, path)); -}); - -app.get("/:owner/:repo/raw/:branch/:path{.*}", async (c) => { - const { owner, repo, branch, path } = c.req.param(); - c.header("Referrer-Policy", "no-referrer"); - return c.redirect(`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`); -}); - -app.get("/:owner/:repo/issues", async (c) => { - const { owner, repo } = c.req.param(); - const q = c.req.query("q"); - - // GitHub supports combined issue/PR search, apparently for legacy. We just - // redirect to issue-only search. - if (q && !/\bis:issue\b/.test(q)) { - return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(`is:issue ${q}`)}`); - } - - return tryRender(c, "issues.eta", getGitHubIssues(owner, repo, { q })); -}); - -app.get("/:owner/:repo/pulls", async (c) => { - const { owner, repo } = c.req.param(); - const q = c.req.query("q"); - return tryRender(c, "pulls.eta", getGitHubPulls(owner, repo, { q })); -}); - -app.get("/:owner/:repo/search", async (c) => { - const { owner, repo } = c.req.param(); - const q = c.req.query("q") ?? ""; - const type = c.req.query("type") ?? "issues"; - - if (type === "issues") { - return c.redirect(`/${owner}/${repo}/issues?q=${encodeURIComponent(q)}`); - } else if (type === "pullrequests") { - return c.redirect(`/${owner}/${repo}/pulls?q=${encodeURIComponent(q)}`); - } - - // Redirect unhandled search types (like code, which requires sign in anyway). - c.header("Referrer-Policy", "no-referrer"); - return c.redirect( - `https://github.com/${owner}/${repo}/search?${new URLSearchParams(c.req.query()).toString()}`, - ); -}); - -app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { - const { owner, repo, branch, path = "" } = c.req.param(); - return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); -}); - -app.get("/:owner/:repo/releases/tag/:tag", async (c) => { - const { owner, repo, tag } = c.req.param(); - return tryRender(c, "release.eta", getGitHubRelease(owner, repo, tag)); -}); - -app.get("/:owner/:repo/releases", async (c) => { - const { owner, repo } = c.req.param(); - return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); -}); - -app.all("*", async (c) => { - const path = c.req.path; - c.header("Referrer-Policy", "no-referrer"); - return c.redirect(`https://github.com${path}`); -}); +const app = new Hono(); +app.use("/static/*", serveStatic({ root: path.join(__dirname, "..", "public") })); +app.route("/", createApp(eta)); if (process.env.LISTEN_FDS === "1") { const server = createAdaptorServer(app); diff --git a/cgithub/src/worker.ts b/cgithub/src/worker.ts new file mode 100644 index 0000000..024b01a --- /dev/null +++ b/cgithub/src/worker.ts @@ -0,0 +1,27 @@ +// Cloudflare Workers entry point. Static files under public/ are served by +// Workers Static Assets before the worker runs (see wrangler.jsonc). +import { Eta } from "eta"; +import { createApp } from "./app.ts"; +import { views } from "./views.generated.ts"; + +// Workers disallow runtime code generation, so templates are precompiled by +// scripts/compile-views.ts and preloaded into Eta's cache here. With +// cache: true, Eta serves them from the cache and never compiles. +const eta = new Eta({ cache: true }); + +eta.resolvePath = (templatePath: string) => { + let name = templatePath.replace(/^\.\//, ""); + if (!name.endsWith(".eta")) name += ".eta"; + return name; +}; + +// Only reached on a cache miss, i.e. a template missing from the build. +eta.readFile = (path: string) => { + throw new Error(`Unknown template: ${path}`); +}; + +for (const [name, fn] of Object.entries(views)) { + eta.loadTemplate(name, fn); +} + +export default createApp(eta); |
