diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 648fcbc..47a0a67 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,3 +16,4 @@ jobs: - run: bun install --frozen-lockfile - run: bun run check + - run: bun test diff --git a/worker/index.test.ts b/worker/index.test.ts new file mode 100644 index 0000000..549e71d --- /dev/null +++ b/worker/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import worker from "./index"; + +/** A stand-in for the static assets, recording what the Worker asked it for. */ +const assets = () => { + const seen: string[] = []; + return { + seen, + ASSETS: { + fetch: async (request: Request) => { + const { pathname } = new URL(request.url); + seen.push(pathname); + return new Response(pathname); + }, + }, + }; +}; + +type Env = Parameters[1]; + +const send = (url: string, env: ReturnType, init?: RequestInit) => + worker.fetch(new Request(url, init), env as unknown as Env); + +describe("fetch", () => { + test("serves a vanity import path", async () => { + const env = assets(); + const res = await send("https://moq.dev/moq?go-get=1", env); + expect(await res.text()).toContain('content="moq.dev/moq git https://github.com/moq-dev/moq-go"'); + expect(env.seen).toEqual([]); + }); + + test("leaves de.moq.dev its own namespace", async () => { + // The rewrite has to win over the vanity paths, or DEMOQED loses every URL + // starting with /moq to a pkg.go.dev redirect. + const env = assets(); + const res = await send("https://de.moq.dev/moq", env); + expect(res.status).toBe(200); + expect(env.seen).toEqual(["/de/moq"]); + }); + + test("passes anything else to the assets", async () => { + const env = assets(); + await send("https://moq.dev/blog/", env); + expect(env.seen).toEqual(["/blog/"]); + }); + + test("rejects a non-POST subscribe", async () => { + const env = assets(); + expect((await send("https://moq.dev/api/subscribe", env)).status).toBe(405); + expect(env.seen).toEqual([]); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index 4f302e6..b636e2e 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,5 +1,12 @@ // Cloudflare Worker entry. Static asset requests fall through to the ASSETS -// binding (Workers-with-Static-Assets). Only /api/* is handled here. +// binding (Workers-with-Static-Assets). Only /api/*, the de.moq.dev rewrite, +// and the Go vanity import paths are handled here. +// +// Note that the Worker only sees a request if wrangler.jsonc says so: an asset +// miss is answered with the 404 page rather than falling through, so every path +// below has to be listed in `run_worker_first`. + +import { vanity } from "./vanity"; interface Env { ASSETS: { fetch: (request: Request) => Promise }; @@ -28,6 +35,12 @@ export default { return env.ASSETS.fetch(new Request(rewritten, request)); } + // `go get moq.dev/moq` and friends, served from a mirror repo rather than + // by this site. After the rewrite above, so it only ever answers for the + // host the import paths are published under. + const module = vanity(url); + if (module) return module; + return env.ASSETS.fetch(request); }, }; diff --git a/worker/vanity.test.ts b/worker/vanity.test.ts new file mode 100644 index 0000000..d7bf07f --- /dev/null +++ b/worker/vanity.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { vanity } from "./vanity"; + +const goGet = (path: string) => vanity(new URL(`https://moq.dev${path}?go-get=1`)); + +describe("vanity", () => { + test("points the go command at the mirror", async () => { + const res = goGet("/moq"); + expect(res?.status).toBe(200); + expect(await res?.text()).toContain( + ``, + ); + }); + + test("claims a package directory for its module", async () => { + // The go command asks about the full import path, so the prefix in the tag + // has to stay the module path rather than what was requested. + const res = goGet("/moq-ffi/moq"); + expect(await res?.text()).toContain( + ``, + ); + }); + + test("keeps modules with a shared prefix apart", async () => { + expect(await goGet("/moq-ffi")?.text()).toContain("moq-go-ffi"); + expect(await goGet("/moq")?.text()).not.toContain("moq-go-ffi"); + }); + + test("sends people to the docs", () => { + const res = vanity(new URL("https://moq.dev/moq/subpkg")); + expect(res?.status).toBe(302); + expect(res?.headers.get("location")).toBe("https://pkg.go.dev/moq.dev/moq/subpkg"); + }); + + test("ignores everything else", () => { + for (const path of ["/", "/blog", "/moqadillo", "/de/moq"]) { + expect(vanity(new URL(`https://moq.dev${path}?go-get=1`))).toBeUndefined(); + } + }); +}); diff --git a/worker/vanity.ts b/worker/vanity.ts new file mode 100644 index 0000000..28ecfb4 --- /dev/null +++ b/worker/vanity.ts @@ -0,0 +1,77 @@ +/** + * Vanity import paths for the Go modules, so a program imports `moq.dev/moq` + * rather than whichever mirror repo the code happens to live in. + * + * The go command fetches `?go-get=1` and reads the repo to clone + * out of a `` tag, so this hostname owns the published + * import path. Moving or renaming a mirror then costs a line in the table below + * instead of a breaking change for everyone who imported it. + */ + +/** A published module: the path it answers to, and the repo behind it. */ +interface Module { + /** Path on this host, which is also the module path in its `go.mod`. */ + dir: string; + /** Repo the go command clones. */ + repo: string; +} + +/** + * The host the modules are published under. + * + * Hard-coded rather than read off the request: the tag has to name the module + * path in `go.mod`, so staging serves the same `moq.dev/...` prefix it will + * serve in production (and `go get new.moq.dev/moq` correctly refuses). + */ +const HOST = "moq.dev"; + +/** + * Every module published under HOST. + * + * Paths have to start with `/moq` to match the `run_worker_first` wildcard in + * wrangler.jsonc. Without that, an asset miss answers with the 404 page and the + * go command never reaches this Worker at all. + */ +const MODULES: Module[] = [ + { dir: "/moq", repo: "https://github.com/moq-dev/moq-go" }, + { dir: "/moq-ffi", repo: "https://github.com/moq-dev/moq-go-ffi" }, +]; + +/** The go-get response for a vanity path, or undefined to serve the request normally. */ +export function vanity(url: URL): Response | undefined { + // The go command asks about the full import path, package directory and all, + // so a module owns everything beneath it. + const module = MODULES.find((m) => url.pathname === m.dir || url.pathname.startsWith(`${m.dir}/`)); + if (!module) return undefined; + + // Only the go command wants the meta tag; send people to the docs. + if (url.searchParams.get("go-get") !== "1") { + return Response.redirect(`https://pkg.go.dev/${HOST}${url.pathname}`, 302); + } + + return new Response(page(module), { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "public, max-age=3600", + }, + }); +} + +function page(module: Module): string { + const path = HOST + module.dir; + + // go-import is the one the go command needs. go-source is what pkg.go.dev + // follows to link a symbol back to the line that declares it. + return ` + + + + + + + + ${path} is served from ${module.repo}. + + +`; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index bd5e2e9..92b58c0 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -15,10 +15,12 @@ "binding": "ASSETS", // Static assets are normally served before the Worker ever runs, which - // would skip the de.moq.dev -> /de rewrite for "/" and swallow POSTs to - // /api/* with a bare 405. Route those through the Worker; everything else - // still short-circuits to assets. - "run_worker_first": ["/", "/api/*"] + // would skip the de.moq.dev -> /de rewrite for "/", swallow POSTs to + // /api/* with a bare 405, and answer the Go vanity paths (worker/vanity.ts) + // with the 404 page, since a miss lands there rather than falling through. + // The /moq* wildcard covers every module, so adding one to that table + // doesn't mean editing this list. Everything else short-circuits to assets. + "run_worker_first": ["/", "/api/*", "/moq*"] }, // Environment-specific configurations