This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/api: use knotmirror for repo git reads when set

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Aug 1, 2026, 2:19 AM +0300) commit dd0f7b8b parent 5ecf538a change-id vowxuspy
+647 -131
+193
web/src/lib/api/gitclient.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { 3 + blobRawUrl, 4 + branches, 5 + gitTarget, 6 + log, 7 + resolveDefaultBranch, 8 + tag, 9 + tags, 10 + tree 11 + } from "./gitclient"; 12 + 13 + const config = { bobbinUrl: "https://bobbin.example", knotMirrorUrl: "https://km.example" }; 14 + const repo = { 15 + uri: "at://did:plc:owner/sh.tangled.repo/core", 16 + repoDid: "did:plc:reporepo" 17 + }; 18 + 19 + const jsonResponse = (body: unknown): Response => 20 + new Response(JSON.stringify(body), { 21 + status: 200, 22 + headers: { "content-type": "application/json" } 23 + }); 24 + 25 + describe("gitTarget", () => { 26 + it("routes git ops to the knot mirror keyed by repo DID when set", () => { 27 + const target = gitTarget(config, repo, globalThis.fetch); 28 + expect(target.ctx.serviceUrl).toBe("https://km.example"); 29 + expect(target.repo).toBe("did:plc:reporepo"); 30 + expect(target.via).toBe("mirror"); 31 + }); 32 + 33 + it("uses bobbin keyed by at-uri without a mirror", () => { 34 + const target = gitTarget({ ...config, knotMirrorUrl: "" }, repo, globalThis.fetch); 35 + expect(target.ctx.serviceUrl).toBe("https://bobbin.example"); 36 + expect(target.repo).toBe(repo.uri); 37 + expect(target.via).toBe("bobbin"); 38 + }); 39 + 40 + it("falls back to bobbin when the repo DID is unknown", () => { 41 + const target = gitTarget(config, { uri: repo.uri }, globalThis.fetch); 42 + expect(target.ctx.serviceUrl).toBe("https://bobbin.example"); 43 + expect(target.repo).toBe(repo.uri); 44 + expect(target.via).toBe("bobbin"); 45 + }); 46 + }); 47 + 48 + describe("resolveDefaultBranch", () => { 49 + it("reads the is_default flag out of the mirror's branch list", async () => { 50 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 51 + jsonResponse({ 52 + branches: [ 53 + { reference: { name: "sv-fe", hash: "aaa" } }, 54 + { reference: { name: "master", hash: "bbb" }, is_default: true } 55 + ] 56 + }) 57 + ); 58 + const target = gitTarget(config, repo, fetchMock); 59 + 60 + await expect(resolveDefaultBranch(target)).resolves.toBe("master"); 61 + 62 + const url = new URL(String(fetchMock.mock.calls[0][0])); 63 + expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.listBranches"); 64 + expect(url.searchParams.get("repo")).toBe(repo.repoDid); 65 + }); 66 + 67 + it("asks the knot endpoint directly on the bobbin path", async () => { 68 + const fetchMock = vi 69 + .fn<typeof globalThis.fetch>() 70 + .mockResolvedValue(jsonResponse({ name: "main", hash: "ccc", when: "" })); 71 + const target = gitTarget({ ...config, knotMirrorUrl: "" }, repo, fetchMock); 72 + 73 + await expect(resolveDefaultBranch(target)).resolves.toBe("main"); 74 + 75 + const url = new URL(String(fetchMock.mock.calls[0][0])); 76 + expect(url.pathname).toBe("/xrpc/sh.tangled.repo.getDefaultBranch"); 77 + }); 78 + 79 + it("returns null instead of throwing when the backend is down", async () => { 80 + const fetchMock = vi 81 + .fn<typeof globalThis.fetch>() 82 + .mockRejectedValue(new TypeError("fetch failed")); 83 + const target = gitTarget(config, repo, fetchMock); 84 + 85 + await expect(resolveDefaultBranch(target)).resolves.toBeNull(); 86 + }); 87 + }); 88 + 89 + describe("endpoint routing", () => { 90 + const mirrorTarget = (fetchMock: typeof globalThis.fetch) => gitTarget(config, repo, fetchMock); 91 + const bobbinTarget = (fetchMock: typeof globalThis.fetch) => 92 + gitTarget({ ...config, knotMirrorUrl: "" }, repo, fetchMock); 93 + const calledUrl = (fetchMock: ReturnType<typeof vi.fn<typeof globalThis.fetch>>, call = 0) => 94 + new URL(String(fetchMock.mock.calls[call][0])); 95 + 96 + it("sends log to the mirror's listCommits keyed by DID, cursor intact", async () => { 97 + const fetchMock = vi 98 + .fn<typeof globalThis.fetch>() 99 + .mockResolvedValue(jsonResponse({ commits: [] })); 100 + 101 + await log(mirrorTarget(fetchMock), { ref: "main", limit: 10, cursor: "abc" }); 102 + 103 + const url = calledUrl(fetchMock); 104 + expect(url.origin).toBe("https://km.example"); 105 + expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.listCommits"); 106 + expect(url.searchParams.get("repo")).toBe(repo.repoDid); 107 + expect(url.searchParams.get("cursor")).toBe("abc"); 108 + }); 109 + 110 + it("sends log to bobbin's repo.log keyed by at-uri, cursor intact", async () => { 111 + const fetchMock = vi 112 + .fn<typeof globalThis.fetch>() 113 + .mockResolvedValue(jsonResponse({ commits: [] })); 114 + 115 + await log(bobbinTarget(fetchMock), { ref: "main", limit: 10, cursor: "abc" }); 116 + 117 + const url = calledUrl(fetchMock); 118 + expect(url.origin).toBe("https://bobbin.example"); 119 + expect(url.pathname).toBe("/xrpc/sh.tangled.repo.log"); 120 + expect(url.searchParams.get("repo")).toBe(repo.uri); 121 + expect(url.searchParams.get("cursor")).toBe("abc"); 122 + }); 123 + 124 + it("sends tree to the mirror's getTree with the path param", async () => { 125 + const fetchMock = vi 126 + .fn<typeof globalThis.fetch>() 127 + .mockResolvedValue(jsonResponse({ files: [] })); 128 + 129 + await tree(mirrorTarget(fetchMock), { ref: "main", path: "src/lib" }); 130 + 131 + const url = calledUrl(fetchMock); 132 + expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.getTree"); 133 + expect(url.searchParams.get("repo")).toBe(repo.repoDid); 134 + expect(url.searchParams.get("path")).toBe("src/lib"); 135 + }); 136 + 137 + it("sends tree to bobbin's repo.tree", async () => { 138 + const fetchMock = vi 139 + .fn<typeof globalThis.fetch>() 140 + .mockResolvedValue(jsonResponse({ files: [] })); 141 + 142 + await tree(bobbinTarget(fetchMock), { ref: "main" }); 143 + 144 + const url = calledUrl(fetchMock); 145 + expect(url.pathname).toBe("/xrpc/sh.tangled.repo.tree"); 146 + expect(url.searchParams.get("repo")).toBe(repo.uri); 147 + }); 148 + 149 + it("sends branches and tags to the legacy list endpoints on the mirror", async () => { 150 + const fetchMock = vi 151 + .fn<typeof globalThis.fetch>() 152 + .mockImplementation(() => Promise.resolve(jsonResponse({ branches: [], tags: [] }))); 153 + const target = mirrorTarget(fetchMock); 154 + 155 + await branches(target, 5); 156 + await tags(target, 5); 157 + 158 + expect(calledUrl(fetchMock).pathname).toBe("/xrpc/sh.tangled.git.temp.listBranches"); 159 + expect(calledUrl(fetchMock, 1).pathname).toBe("/xrpc/sh.tangled.git.temp.listTags"); 160 + }); 161 + 162 + it("routes single-tag reads to getTag on the mirror and repo.tag on bobbin", async () => { 163 + const fetchMock = vi 164 + .fn<typeof globalThis.fetch>() 165 + .mockImplementation(() => Promise.resolve(jsonResponse({ tag: undefined }))); 166 + 167 + await tag(mirrorTarget(fetchMock), "v1.0.0"); 168 + expect(calledUrl(fetchMock).pathname).toBe("/xrpc/sh.tangled.git.temp.getTag"); 169 + 170 + await tag(bobbinTarget(fetchMock), "v1.0.0"); 171 + expect(calledUrl(fetchMock, 1).pathname).toBe("/xrpc/sh.tangled.repo.tag"); 172 + }); 173 + 174 + it("builds the raw url against the mirror without a raw flag", () => { 175 + const url = new URL( 176 + blobRawUrl(mirrorTarget(globalThis.fetch), { ref: "main", path: "README.md" }) 177 + ); 178 + expect(url.origin).toBe("https://km.example"); 179 + expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.getBlob"); 180 + expect(url.searchParams.get("repo")).toBe(repo.repoDid); 181 + expect(url.searchParams.has("raw")).toBe(false); 182 + }); 183 + 184 + it("builds the raw url against bobbin with raw=true", () => { 185 + const url = new URL( 186 + blobRawUrl(bobbinTarget(globalThis.fetch), { ref: "main", path: "README.md" }) 187 + ); 188 + expect(url.origin).toBe("https://bobbin.example"); 189 + expect(url.pathname).toBe("/xrpc/sh.tangled.repo.blob"); 190 + expect(url.searchParams.get("repo")).toBe(repo.uri); 191 + expect(url.searchParams.get("raw")).toBe("true"); 192 + }); 193 + });
+118
web/src/lib/api/gitclient.ts
··· 1 + import { createBobbinClient, type BobbinContext } from "./client"; 2 + import { 3 + branches as knotMirrorBranches, 4 + blob as knotMirrorBlob, 5 + blobRawUrl as knotMirrorBlobRawUrl, 6 + createKnotMirrorClient, 7 + languages as knotMirrorLanguages, 8 + log as knotMirrorLog, 9 + getTag as knotMirrorGetTag, 10 + tags as knotMirrorTags, 11 + tree as knotMirrorTree 12 + } from "./knotmirror"; 13 + import { 14 + blob as knotBlob, 15 + blobRawUrl as knotBlobRawUrl, 16 + getDefaultBranch, 17 + languages as knotLanguages, 18 + tree as knotTree 19 + } from "./knot"; 20 + import { branchesFor, logFor, tagFor, tagsFor } from "./repo"; 21 + import type { Did, ResourceUri } from "@atcute/lexicons"; 22 + 23 + export interface GitServiceConfig { 24 + bobbinUrl: string; 25 + knotMirrorUrl: string; 26 + } 27 + 28 + export interface GitTarget { 29 + ctx: BobbinContext; 30 + repo: Did | ResourceUri; 31 + via: "mirror" | "bobbin"; 32 + } 33 + 34 + export const gitTarget = ( 35 + config: GitServiceConfig, 36 + repo: { uri: string; repoDid?: string }, 37 + fetch: typeof globalThis.fetch 38 + ): GitTarget => 39 + config.knotMirrorUrl && repo.repoDid 40 + ? { 41 + ctx: createKnotMirrorClient(config.knotMirrorUrl, fetch), 42 + repo: repo.repoDid as Did, 43 + via: "mirror" 44 + } 45 + : { 46 + ctx: createBobbinClient({ serviceUrl: config.bobbinUrl, fetch }), 47 + repo: repo.uri as ResourceUri, 48 + via: "bobbin" 49 + }; 50 + 51 + // the mirror has no getDefaultBranch, the default falls out of the branch 52 + // list's is_default flag instead 53 + export const resolveDefaultBranch = ( 54 + target: GitTarget, 55 + init?: { signal?: AbortSignal } 56 + ): Promise<string | null> => 57 + target.via === "mirror" 58 + ? (async () => { 59 + for (let offset = 0; ; offset += 100) { 60 + const response = await knotMirrorBranches( 61 + target.ctx, 62 + { repo: target.repo, limit: 100, cursor: offset ? `${offset}` : undefined }, 63 + init 64 + ); 65 + const list = response.branches ?? []; 66 + const found = list.find((branch) => branch.is_default)?.reference.name; 67 + if (found) return found; 68 + if (list.length < 100) return null; 69 + } 70 + })().catch(() => null) 71 + : getDefaultBranch(target.ctx, { repo: target.repo }) 72 + .then((branch) => branch.name) 73 + .catch(() => null); 74 + 75 + export const tree = (target: GitTarget, params: { ref: string; path?: string }) => 76 + target.via === "mirror" 77 + ? knotMirrorTree(target.ctx, { repo: target.repo, ...params }) 78 + : knotTree(target.ctx, { repo: target.repo, ...params }); 79 + 80 + export const log = (target: GitTarget, params: { ref: string; limit: number; cursor?: string }) => 81 + target.via === "mirror" 82 + ? knotMirrorLog(target.ctx, { repo: target.repo, ...params }) 83 + : logFor(target.ctx, target.repo, params.ref, params.limit, params.cursor); 84 + 85 + export const branches = (target: GitTarget, limit: number, cursor?: string) => 86 + target.via === "mirror" 87 + ? knotMirrorBranches(target.ctx, { repo: target.repo, limit, cursor }) 88 + : branchesFor(target.ctx, target.repo, limit, cursor); 89 + 90 + export const tags = (target: GitTarget, limit: number, cursor?: string) => 91 + target.via === "mirror" 92 + ? knotMirrorTags(target.ctx, { repo: target.repo, limit, cursor }) 93 + : tagsFor(target.ctx, target.repo, limit, cursor); 94 + 95 + export const tag = (target: GitTarget, name: string) => 96 + target.via === "mirror" 97 + ? knotMirrorGetTag(target.ctx, { repo: target.repo, tag: name }) 98 + : tagFor(target.ctx, target.repo, name); 99 + 100 + export const languages = ( 101 + target: GitTarget, 102 + ref: string 103 + ): Promise<{ languages?: { name: string; size: number }[] }> => 104 + target.via === "mirror" 105 + ? knotMirrorLanguages(target.ctx, { repo: target.repo, ref }) 106 + : knotLanguages(target.ctx, { repo: target.repo, ref }); 107 + 108 + // only the mirror needs the inline cap, the knot enforces its own and 413s 109 + // past it 110 + export const blob = (target: GitTarget, params: { ref: string; path: string }, maxBytes: number) => 111 + target.via === "mirror" 112 + ? knotMirrorBlob(target.ctx, { repo: target.repo, ...params }, maxBytes) 113 + : knotBlob(target.ctx, { repo: target.repo as ResourceUri, ...params }); 114 + 115 + export const blobRawUrl = (target: GitTarget, params: { ref: string; path: string }): string => 116 + target.via === "mirror" 117 + ? knotMirrorBlobRawUrl(target.ctx.serviceUrl, { repo: target.repo, ...params }) 118 + : knotBlobRawUrl(target.ctx.serviceUrl, { repo: target.repo, ...params });
+85 -2
web/src/lib/api/knotmirror.test.ts
··· 8 8 }); 9 9 10 10 describe("knotmirror.tree", () => { 11 - it("adapts the temporary tree response and fetches the readme blob", async () => { 11 + it("fetches the readme blob", async () => { 12 12 const fetchMock = vi.fn<typeof globalThis.fetch>().mockImplementation(async (input) => { 13 13 const url = new URL(String(input)); 14 14 if (url.pathname.endsWith("getTree")) { ··· 51 51 }); 52 52 53 53 describe("knotmirror.languages", () => { 54 - it("uses the temporary languages endpoint", async () => { 54 + it("uses the languages endpoint", async () => { 55 55 const fetchMock = vi 56 56 .fn<typeof globalThis.fetch>() 57 57 .mockResolvedValue( ··· 65 65 expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.listLanguages"); 66 66 }); 67 67 }); 68 + 69 + describe("knotmirror.blob", () => { 70 + const entryBody = { 71 + name: "hello.txt", 72 + mode: "0100644", 73 + size: 5, 74 + lastCommit: { 75 + hash: "abc", 76 + message: "hi", 77 + author: { name: "dawn", email: "dawn@tangled.org", when: "2026-07-01T00:00:00Z" } 78 + } 79 + }; 80 + 81 + const blobFetch = (content: Uint8Array, entry: unknown = entryBody) => 82 + vi.fn<typeof globalThis.fetch>().mockImplementation(async (input) => { 83 + const url = new URL(String(input)); 84 + return url.pathname.endsWith("getEntry") 85 + ? jsonResponse(entry) 86 + : new Response(content.slice().buffer, { status: 200 }); 87 + }); 88 + 89 + it("creates the repo blob shape from getEntry plus raw content", async () => { 90 + const fetchMock = blobFetch(new TextEncoder().encode("hello")); 91 + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); 92 + 93 + const output = await knotmirror.blob( 94 + ctx, 95 + { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 96 + 1024 97 + ); 98 + 99 + expect(output).toMatchObject({ 100 + path: "hello.txt", 101 + size: 5, 102 + isBinary: false, 103 + encoding: "utf-8", 104 + content: "hello", 105 + lastCommit: { hash: "abc", when: "2026-07-01T00:00:00Z" } 106 + }); 107 + }); 108 + 109 + it("skips the content fetch when the entry is over the inline cap", async () => { 110 + const fetchMock = blobFetch(new TextEncoder().encode("hello"), { ...entryBody, size: 2048 }); 111 + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); 112 + 113 + const output = await knotmirror.blob( 114 + ctx, 115 + { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 116 + 1024 117 + ); 118 + 119 + expect(output.fileTooLarge).toBe(true); 120 + expect(output.content).toBeUndefined(); 121 + expect(fetchMock).toHaveBeenCalledTimes(1); 122 + }); 123 + 124 + it("marks content with null bytes as binary", async () => { 125 + const fetchMock = blobFetch(new Uint8Array([0x50, 0x4b, 0x00, 0x00])); 126 + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); 127 + 128 + const output = await knotmirror.blob( 129 + ctx, 130 + { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 131 + 1024 132 + ); 133 + 134 + expect(output.isBinary).toBe(true); 135 + expect(output.content).toBeUndefined(); 136 + }); 137 + 138 + it("treats a gitlink mode as a submodule", async () => { 139 + const fetchMock = blobFetch(new Uint8Array(), { ...entryBody, mode: "0160000" }); 140 + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); 141 + 142 + const output = await knotmirror.blob( 143 + ctx, 144 + { repo: "did:plc:repo", ref: "main", path: "hello.txt" }, 145 + 1024 146 + ); 147 + 148 + expect(output.submodule).toEqual({ name: "hello.txt", url: "" }); 149 + }); 150 + });
+99 -4
web/src/lib/api/knotmirror.ts
··· 1 1 import type { BobbinContext, XrpcRequestInit } from "./client"; 2 2 import { createBobbinClient } from "./client"; 3 - import { jsonGet, rawGet } from "./_request"; 3 + import { buildUrl, jsonGet, rawGet } from "./_request"; 4 + import type { RepoTagResponse } from "./repo"; 5 + import type * as RepoBlob from "./lexicons/types/sh/tangled/repo/blob"; 4 6 import type * as LegacyTree from "./lexicons/types/sh/tangled/git/temp/getTree"; 5 7 import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; 6 8 import { treeEntryKind, type BranchesResponse, type LogResponse, type TagsResponse } from "./repo"; 7 9 8 10 const TREE_NSID = "sh.tangled.git.temp.getTree"; 11 + const ENTRY_NSID = "sh.tangled.git.temp.getEntry"; 9 12 const BLOB_NSID = "sh.tangled.git.temp.getBlob"; 10 13 const LOG_NSID = "sh.tangled.git.temp.listCommits"; 11 14 const BRANCHES_NSID = "sh.tangled.git.temp.listBranches"; 12 15 const TAGS_NSID = "sh.tangled.git.temp.listTags"; 13 16 const LANGUAGES_NSID = "sh.tangled.git.temp.listLanguages"; 17 + const GET_TAG_NSID = "sh.tangled.git.temp.getTag"; 18 + 19 + const MAX_README_BYTES = 1 << 20; 14 20 15 21 export interface KnotMirrorLanguagesResponse { 16 22 languages?: { name: string; size: number }[]; ··· 21 27 export const createKnotMirrorClient = (serviceUrl: string, fetch: typeof globalThis.fetch) => 22 28 createBobbinClient({ serviceUrl, fetch }); 23 29 30 + export interface KnotMirrorEntry { 31 + name: string; 32 + mode: string; 33 + oid?: string; 34 + size?: number; 35 + lastCommit?: { 36 + hash: string; 37 + message?: string; 38 + author?: { name: string; email: string; when: string }; 39 + committer?: { name: string; email: string; when: string }; 40 + }; 41 + } 42 + 43 + export const entry = ( 44 + ctx: BobbinContext, 45 + params: { repo: string; ref: string; path: string }, 46 + init?: XrpcRequestInit 47 + ) => jsonGet<KnotMirrorEntry>(ctx, ENTRY_NSID, params, init); 48 + 49 + export const blobRawUrl = ( 50 + serviceUrl: string, 51 + params: { repo: string; ref: string; path: string } 52 + ): string => buildUrl(serviceUrl, BLOB_NSID, params).toString(); 53 + 54 + // same heuristic as git: a null byte in the first chunk means binary 55 + const looksBinary = (bytes: Uint8Array): boolean => bytes.subarray(0, 8000).includes(0); 56 + 57 + // temp.getBlob answers raw bytes only, metadata comes from getEntry and 58 + // the binary flag from a content sniff 59 + export const blob = async ( 60 + ctx: BobbinContext, 61 + params: { repo: string; ref: string; path: string }, 62 + maxBytes: number, 63 + init?: XrpcRequestInit 64 + ): Promise<RepoBlob.$output> => { 65 + const meta = await entry(ctx, params, init); 66 + 67 + const lastCommit = meta.lastCommit 68 + ? { 69 + hash: meta.lastCommit.hash, 70 + message: meta.lastCommit.message ?? "", 71 + when: meta.lastCommit.committer?.when ?? meta.lastCommit.author?.when ?? "", 72 + author: meta.lastCommit.author 73 + } 74 + : undefined; 75 + 76 + // a gitlink is a submodule pointer, not a file 77 + if (treeEntryKind(meta.mode) === "submodule") { 78 + return { 79 + path: params.path, 80 + ref: params.ref, 81 + size: meta.size, 82 + submodule: { name: meta.name, url: "" } 83 + }; 84 + } 85 + 86 + if (meta.size !== undefined && meta.size > maxBytes) { 87 + return { path: params.path, ref: params.ref, size: meta.size, fileTooLarge: true, lastCommit }; 88 + } 89 + 90 + const bytes = new Uint8Array(await (await rawGet(ctx, BLOB_NSID, params, init)).arrayBuffer()); 91 + if (looksBinary(bytes)) { 92 + return { 93 + path: params.path, 94 + ref: params.ref, 95 + size: meta.size ?? bytes.length, 96 + isBinary: true, 97 + lastCommit 98 + }; 99 + } 100 + 101 + return { 102 + path: params.path, 103 + ref: params.ref, 104 + size: meta.size ?? bytes.length, 105 + isBinary: false, 106 + encoding: "utf-8", 107 + content: new TextDecoder().decode(bytes), 108 + lastCommit 109 + }; 110 + }; 111 + 24 112 const normalizeSignature = ( 25 113 signature: LegacyTree.Signature | undefined 26 114 ): Tree.Signature | undefined => ··· 64 152 const result = normalizeTree(response); 65 153 const readme = response.files.find((entry) => isReadmeFile(entry.name, entry.mode)); 66 154 if (!readme) return result; 155 + if (readme.size !== undefined && readme.size > MAX_README_BYTES) return result; 67 156 68 157 try { 69 158 const blob = await rawGet( ··· 80 169 81 170 export const log = ( 82 171 ctx: BobbinContext, 83 - params: { repo: string; ref: string; limit: number }, 172 + params: { repo: string; ref: string; limit: number; cursor?: string }, 84 173 init?: XrpcRequestInit 85 174 ) => jsonGet<LogResponse>(ctx, LOG_NSID, params, init); 86 175 87 176 export const branches = ( 88 177 ctx: BobbinContext, 89 - params: { repo: string; limit: number }, 178 + params: { repo: string; limit: number; cursor?: string }, 90 179 init?: XrpcRequestInit 91 180 ) => jsonGet<BranchesResponse>(ctx, BRANCHES_NSID, params, init); 92 181 93 182 export const tags = ( 94 183 ctx: BobbinContext, 95 - params: { repo: string; limit: number }, 184 + params: { repo: string; limit: number; cursor?: string }, 96 185 init?: XrpcRequestInit 97 186 ) => jsonGet<TagsResponse>(ctx, TAGS_NSID, params, init); 187 + 188 + export const getTag = ( 189 + ctx: BobbinContext, 190 + params: { repo: string; tag: string }, 191 + init?: XrpcRequestInit 192 + ) => jsonGet<RepoTagResponse>(ctx, GET_TAG_NSID, params, init); 98 193 99 194 export const languages = ( 100 195 ctx: BobbinContext,
+14 -3
web/src/lib/api/pagination.test.ts
··· 94 94 }); 95 95 96 96 it("maxPages caps loader invocations", async () => { 97 + let page = 0; 98 + const load = vi.fn(async () => { 99 + page += 1; 100 + return { items: [0], cursor: `p${page}` }; 101 + }); 102 + const out: number[] = []; 103 + for await (const n of paginateBy(load, { maxPages: 3 })) out.push(n); 104 + expect(load).toHaveBeenCalledTimes(3); 105 + expect(out).toHaveLength(3); 106 + }); 107 + 108 + it("stops when the cursor stops advancing", async () => { 97 109 const load = vi.fn(async () => ({ 98 110 items: [0], 99 111 cursor: "always" 100 112 })); 101 113 const out: number[] = []; 102 - for await (const n of paginateBy(load, { maxPages: 3 })) out.push(n); 103 - expect(load).toHaveBeenCalledTimes(3); 104 - expect(out).toHaveLength(3); 114 + for await (const n of paginateBy(load)) out.push(n); 115 + expect(load).toHaveBeenCalledTimes(2); 105 116 }); 106 117 });
+6 -5
web/src/lib/api/pagination.ts
··· 30 30 export type PageParams<TName extends PaginatedQuery> = Omit<QueryParams<TName>, "cursor">; 31 31 32 32 export interface PaginateOptions extends XrpcRequestInit { 33 - /** stop after this many network round-trips. */ 34 33 maxPages?: number; 35 34 } 36 35 ··· 70 69 options 71 70 ); 72 71 yield data; 73 - cursor = (data as CursorPage).cursor; 72 + const next = (data as CursorPage).cursor; 73 + if (next !== undefined && next === cursor) break; 74 + cursor = next; 74 75 if (options.maxPages && ++seen >= options.maxPages) break; 75 76 } while (cursor); 76 77 } ··· 87 88 } 88 89 89 90 export interface CollectOptions extends PaginateOptions { 90 - /** stop once this many items are gathered. */ 91 91 max?: number; 92 92 } 93 93 ··· 105 105 return out; 106 106 }; 107 107 108 - // cursor walker for schema-less list endpoints. 109 108 export async function* paginateBy<T>( 110 109 load: (cursor: string | undefined) => Promise<{ items: readonly T[]; cursor?: string | null }>, 111 110 options: { maxPages?: number } = {} ··· 115 114 do { 116 115 const page = await load(cursor); 117 116 for (const item of page.items) yield item; 118 - cursor = page.cursor ?? undefined; 117 + const next = page.cursor ?? undefined; 118 + if (next !== undefined && next === cursor) break; 119 + cursor = next; 119 120 if (options.maxPages && ++seen >= options.maxPages) break; 120 121 } while (cursor); 121 122 }
+15
web/src/lib/api/repo.test.ts
··· 1 1 import { describe, expect, it, vi } from "vitest"; 2 2 import { 3 + logFor, 3 4 repoNameOf, 4 5 resolveRepoByName, 5 6 sortTreeEntries, ··· 95 96 const summary = toCommitSummary({ ...commit, message: "one liner\n" }); 96 97 expect(summary.body).toBe(""); 97 98 expect(summary.subject).toBe("one liner"); 99 + }); 100 + }); 101 + 102 + describe("logFor", () => { 103 + it("passes the cursor through to the knot", async () => { 104 + const fetchMock = vi 105 + .fn<typeof globalThis.fetch>() 106 + .mockResolvedValue(jsonResponse({ commits: [], total: 42 })); 107 + await logFor(makeCtx(fetchMock), "at://did:plc:o/sh.tangled.repo/abc", "master", 20, "40"); 108 + const url = new URL(String(fetchMock.mock.calls[0][0])); 109 + expect(url.pathname).toBe("/xrpc/sh.tangled.repo.log"); 110 + expect(url.searchParams.get("ref")).toBe("master"); 111 + expect(url.searchParams.get("limit")).toBe("20"); 112 + expect(url.searchParams.get("cursor")).toBe("40"); 98 113 }); 99 114 }); 100 115
+22 -7
web/src/lib/api/repo.ts
··· 1 1 import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; 2 2 import { getRepoByName, type RecordView, type RepoRecord } from "./records"; 3 - import { branches as knotBranches, log as knotLog, tags as knotTags } from "./knot"; 3 + import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; 4 4 import { httpStatusFor } from "./load"; 5 5 import { rkeyFromUri } from "./uri"; 6 6 import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; ··· 62 62 tag?: { 63 63 Tagger?: GitSignature; 64 64 Message?: string; 65 - /** the commit an annotated tag points at, bytes like every go-git hash */ 65 + // the commit an annotated tag points at, bytes like every go-git hash 66 66 Target?: number[]; 67 67 }; 68 68 } ··· 70 70 export interface TagsResponse { 71 71 tags?: TagEntry[]; 72 72 total?: number; 73 + } 74 + 75 + export interface RepoTagResponse { 76 + tag?: TagEntry; 73 77 } 74 78 75 79 // newer repos get tid rkeys and keep their display name in the record ··· 158 162 export interface TagSummary { 159 163 name: string; 160 164 hash: string; 161 - /** an annotated tag has its own hash, this is the commit it points at */ 165 + // an annotated tag has its own hash, this is the commit it points at 162 166 commitHash: string; 163 167 when?: string; 164 168 message?: string; ··· 231 235 return a.name.localeCompare(b.name); 232 236 }); 233 237 238 + // the log cursor is a numeric offset encoded as a string 234 239 export const logFor = ( 235 240 ctx: BobbinContext, 236 241 repo: string, 237 242 ref: string, 238 243 limit: number, 244 + cursor?: string, 239 245 init?: XrpcRequestInit 240 - ) => knotLog<LogResponse>(ctx, { repo, ref, limit }, init); 246 + ) => knotLog<LogResponse>(ctx, { repo, ref, limit, cursor }, init); 241 247 242 248 export const branchesFor = ( 243 249 ctx: BobbinContext, 244 250 repo: string, 245 251 limit: number, 252 + cursor?: string, 246 253 init?: XrpcRequestInit 247 - ) => knotBranches<BranchesResponse>(ctx, { repo, limit }, init); 254 + ) => knotBranches<BranchesResponse>(ctx, { repo, limit, cursor }, init); 248 255 249 - export const tagsFor = (ctx: BobbinContext, repo: string, limit: number, init?: XrpcRequestInit) => 250 - knotTags<TagsResponse>(ctx, { repo, limit }, init); 256 + export const tagsFor = ( 257 + ctx: BobbinContext, 258 + repo: string, 259 + limit: number, 260 + cursor?: string, 261 + init?: XrpcRequestInit 262 + ) => knotTags<TagsResponse>(ctx, { repo, limit, cursor }, init); 263 + 264 + export const tagFor = (ctx: BobbinContext, repo: string, tag: string, init?: XrpcRequestInit) => 265 + knotTag<RepoTagResponse>(ctx, { repo, tag }, init);
+3 -3
web/src/lib/api/repoIndex.test.ts
··· 11 11 [unsupported(), unsupported(), unsupported()].map((cause) => ({ value: null, error: cause })) 12 12 ); 13 13 14 - expect(result).toEqual({ needsUpgrade: true, knotUnreachable: false }); 14 + expect(result).toBe("needs-upgrade"); 15 15 }); 16 16 17 17 it("keeps transport failures as unreachable", () => { ··· 19 19 ["tree", "log", "branches"].map(() => ({ value: null, error: new TypeError("offline") })) 20 20 ); 21 21 22 - expect(result).toEqual({ needsUpgrade: false, knotUnreachable: true }); 22 + expect(result).toBe("unreachable"); 23 23 }); 24 24 25 25 it("does not mistake a missing ref for an unavailable knot", () => { ··· 29 29 { value: { branches: [] }, error: null } 30 30 ]); 31 31 32 - expect(result).toEqual({ needsUpgrade: false, knotUnreachable: false }); 32 + expect(result).toBe("ok"); 33 33 }); 34 34 });
+29 -69
web/src/lib/api/repoIndex.ts
··· 1 1 import { error } from "@sveltejs/kit"; 2 - import { ClientResponseError, createBobbinClient } from "$lib/api/client"; 3 - import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot"; 2 + import { ClientResponseError } from "$lib/api/client"; 4 3 import { 5 - branches as knotMirrorBranches, 6 - createKnotMirrorClient, 7 - languages as knotMirrorLanguages, 8 - log as knotMirrorLog, 9 - tags as knotMirrorTags, 10 - tree as knotMirrorTree 11 - } from "$lib/api/knotmirror"; 4 + branches as gitBranches, 5 + gitTarget, 6 + languages as gitLanguages, 7 + log as gitLog, 8 + tags as gitTags, 9 + tree as gitTree 10 + } from "$lib/api/gitclient"; 12 11 import { parallel } from "$lib/api/load"; 13 12 import { 14 - branchesFor, 15 - logFor, 16 13 sortTreeEntries, 17 14 tagsByCommitHash, 18 - tagsFor, 19 15 toBranchSummary, 20 16 toCommitSummary, 21 17 toTagSummary, ··· 24 20 } from "$lib/api/repo"; 25 21 import { renderDocument } from "$lib/markup"; 26 22 import type { LanguageSlice, RepoInfo } from "$lib/components/repo/types"; 27 - import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; 28 - 29 23 // `/tree/{ref}` is this same page at another ref, so they share a load 30 24 31 25 const COMMIT_LIMIT = 10; 32 26 const BRANCH_LIMIT = 5; 33 27 const TAG_LIMIT = 5; 34 28 // a knot only ever lists 100 refs, so any total we get is really a minimum 35 - const REF_LIMIT = 100; 29 + export const REF_LIMIT = 100; 36 30 37 31 export interface RepoParent { 38 32 publicConfig: { bobbinUrl: string; knotMirrorUrl: string; camoEnabled: boolean }; ··· 44 38 url: URL; 45 39 } 46 40 47 - interface RepoDataSource { 48 - tree: (ref: string, path?: string) => Promise<Tree.$output>; 49 - log: (ref: string, limit: number) => Promise<Awaited<ReturnType<typeof logFor>>>; 50 - branches: (limit: number) => Promise<Awaited<ReturnType<typeof branchesFor>>>; 51 - tags: (limit: number) => Promise<Awaited<ReturnType<typeof tagsFor>>>; 52 - languages: (ref: string) => Promise<{ languages?: { name: string; size: number }[] }>; 53 - } 54 - 55 41 const orNull = <T>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); 56 42 57 43 interface Attempt<T> { ··· 68 54 const isUnsupported = (cause: unknown): boolean => 69 55 cause instanceof ClientResponseError && cause.status === 404; 70 56 57 + export type RepoAvailability = "ok" | "empty" | "needs-upgrade" | "unreachable"; 58 + 71 59 export const classifyRepoAvailability = ( 72 60 attempts: readonly { value: unknown | null; error: unknown | null }[] 73 - ) => { 74 - const needsUpgrade = attempts.every( 75 - (result) => result.value === null && isUnsupported(result.error) 76 - ); 77 - return { 78 - needsUpgrade, 79 - knotUnreachable: !needsUpgrade && attempts.every((result) => result.value === null) 80 - }; 61 + ): Exclude<RepoAvailability, "empty"> => { 62 + if (attempts.every((result) => result.value === null && isUnsupported(result.error))) { 63 + return "needs-upgrade"; 64 + } 65 + return attempts.every((result) => result.value === null) ? "unreachable" : "ok"; 81 66 }; 82 67 83 68 const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { ··· 107 92 capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT 108 93 }); 109 94 110 - const renderReadme = ( 95 + export const renderReadme = ( 111 96 readme: { filename: string; contents: string } | null, 112 97 parent: RepoParent, 113 98 event: RepoLoadEvent, ··· 130 115 return readme?.filename ? readme : null; 131 116 }; 132 117 133 - const repoDataSource = (event: RepoLoadEvent, parent: RepoParent): RepoDataSource => { 134 - if (parent.publicConfig.knotMirrorUrl && parent.repo.repoDid) { 135 - const ctx = createKnotMirrorClient(parent.publicConfig.knotMirrorUrl, event.fetch); 136 - const repo = parent.repo.repoDid; 137 - return { 138 - tree: (ref, path) => knotMirrorTree(ctx, { repo, ref, path }), 139 - log: (ref, limit) => knotMirrorLog(ctx, { repo, ref, limit }), 140 - branches: (limit) => knotMirrorBranches(ctx, { repo, limit }), 141 - tags: (limit) => knotMirrorTags(ctx, { repo, limit }), 142 - languages: (ref) => knotMirrorLanguages(ctx, { repo, ref }) 143 - }; 144 - } 145 - 146 - const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 147 - const repo = parent.repo.uri; 148 - return { 149 - tree: (ref, path) => knotTree(ctx, { repo, ref, path }), 150 - log: (ref, limit) => logFor(ctx, repo, ref, limit), 151 - branches: (limit) => branchesFor(ctx, repo, limit), 152 - tags: (limit) => tagsFor(ctx, repo, limit), 153 - languages: (ref) => knotLanguages(ctx, { repo, ref }) 154 - }; 155 - }; 156 - 157 118 export interface RepoIndexOptions { 158 119 requireRef?: boolean; 159 120 } ··· 166 127 // files. the default branch renders whatever the knot managed to answer 167 128 { requireRef = false }: RepoIndexOptions = {} 168 129 ) => { 169 - const source = repoDataSource(event, parent); 130 + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 170 131 171 132 // each list falls back on its own, so half a page still renders 172 133 const results = await parallel({ 173 - tree: attempt(source.tree(ref)), 174 - log: attempt(source.log(ref, COMMIT_LIMIT)), 175 - branches: attempt(source.branches(REF_LIMIT)), 176 - tags: attempt(source.tags(REF_LIMIT)), 177 - languages: attempt(source.languages(ref)) 134 + tree: attempt(gitTree(git, { ref })), 135 + log: attempt(gitLog(git, { ref, limit: COMMIT_LIMIT })), 136 + branches: attempt(gitBranches(git, REF_LIMIT)), 137 + tags: attempt(gitTags(git, REF_LIMIT)), 138 + languages: attempt(gitLanguages(git, ref)) 178 139 }); 179 140 180 141 const branches = (results.branches.value?.branches ?? []).map(toBranchSummary); ··· 188 149 const readmeHtml = await renderReadme(readme, parent, event, ref); 189 150 190 151 const contentAttempts = [results.tree, results.log, results.branches]; 191 - const { needsUpgrade, knotUnreachable } = classifyRepoAvailability(contentAttempts); 192 - const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0; 152 + const knot = classifyRepoAvailability(contentAttempts); 153 + const availability: RepoAvailability = 154 + knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; 193 155 194 156 // there are refs but not this one, so it is not a real ref. an empty repo has 195 157 // no refs at all and still gets a page ··· 199 161 200 162 return { 201 163 ref, 202 - isEmpty, 203 - needsUpgrade, 204 - knotUnreachable, 164 + availability, 205 165 files, 206 166 readme, 207 167 readmeHtml, ··· 224 184 ref: string, 225 185 path: string 226 186 ) => { 227 - const source = repoDataSource(event, parent); 187 + const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); 228 188 229 189 // the tree is the whole page here, so a miss is just a 404 230 - const tree = await orNull(source.tree(ref, path)); 190 + const tree = await orNull(gitTree(git, { ref, path })); 231 191 const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary)); 232 192 // git cannot store an empty directory. so nothing here means the path is a 233 193 // file, or was never there
+4 -6
web/src/lib/components/repo/RepoIndexView.stories.svelte
··· 18 18 19 19 const data = { 20 20 ref: "main", 21 - isEmpty: false, 22 - needsUpgrade: false, 23 - knotUnreachable: false, 21 + availability: "ok", 24 22 files: [ 25 23 { name: "src", kind: "directory" as const, size: 0 }, 26 24 { name: "README.md", kind: "file" as const, size: 512 } ··· 65 63 } satisfies Awaited<ReturnType<typeof loadRepoIndex>>; 66 64 const emptyData = { 67 65 ...data, 68 - isEmpty: true, 66 + availability: "empty", 69 67 files: [], 70 68 readme: null, 71 69 readmeHtml: null, ··· 90 88 91 89 <Story name="Populated repository" /> 92 90 <Story name="Reference counts are lower bounds" args={{ data: cappedData }} /> 93 - <Story name="Knot needs upgrade" args={{ data: { ...data, needsUpgrade: true } }} /> 94 - <Story name="Knot is unreachable" args={{ data: { ...data, knotUnreachable: true } }} /> 91 + <Story name="Knot needs upgrade" args={{ data: { ...data, availability: "needs-upgrade" } }} /> 92 + <Story name="Knot is unreachable" args={{ data: { ...data, availability: "unreachable" } }} /> 95 93 <Story name="Empty repository" asChild> 96 94 <StoryAuthProvider> 97 95 <RepoIndexView {repo} data={emptyData} bobbinUrl="https://bobbin.example.test" />
+3 -3
web/src/lib/components/repo/RepoIndexView.svelte
··· 30 30 </script> 31 31 32 32 <TabPanel> 33 - {#if data.needsUpgrade} 33 + {#if data.availability === "needs-upgrade"} 34 34 <div class="flex h-96 items-center justify-center text-center text-foreground-danger"> 35 35 <div> 36 36 <span class="flex items-center justify-center gap-2"> ··· 47 47 </p> 48 48 </div> 49 49 </div> 50 - {:else if data.knotUnreachable} 50 + {:else if data.availability === "unreachable"} 51 51 <div class="flex h-96 items-center justify-center text-center text-foreground-danger"> 52 52 <span class="flex items-center gap-2"> 53 53 <TriangleAlert class="size-5 shrink-0" aria-hidden="true" /> 54 54 The knot hosting this repository is unreachable. 55 55 </span> 56 56 </div> 57 - {:else if data.isEmpty} 57 + {:else if data.availability === "empty"} 58 58 <EmptyRepo {repo} /> 59 59 {:else} 60 60 {#if data.languages.length > 0}
+45
web/src/lib/server/repo.ts
··· 1 + import { error } from "@sveltejs/kit"; 2 + import { createBobbinClient, type BobbinContext } from "$lib/api/client"; 3 + import { gitTarget, type GitTarget } from "$lib/api/gitclient"; 4 + import { resolveMiniDoc, type MiniDoc } from "$lib/api/identity"; 5 + import { toHttpError } from "$lib/api/load"; 6 + import type { RecordView, RepoRecord } from "$lib/api/records"; 7 + import { resolveRepoByName } from "$lib/api/repo"; 8 + import { getConfig } from "$lib/server/config"; 9 + 10 + export interface ResolvedRepo { 11 + ctx: BobbinContext; 12 + git: GitTarget; 13 + doc: MiniDoc; 14 + view: RecordView<RepoRecord>; 15 + } 16 + 17 + // the handle/repo preamble shared by the raw-content routes, params arrive 18 + // already decoded 19 + export const resolveRepoFromParams = async (event: { 20 + params: { handle?: string; repo?: string }; 21 + fetch: typeof fetch; 22 + }): Promise<ResolvedRepo> => { 23 + const identifier = event.params.handle ?? ""; 24 + const name = event.params.repo ?? ""; 25 + 26 + // rejects bare words, unrelated paths must 404 instead of resolving as actors 27 + if (!identifier.startsWith("did:") && !identifier.includes(".")) { 28 + error(404, "Not found"); 29 + } 30 + 31 + const config = getConfig(); 32 + const ctx = createBobbinClient({ serviceUrl: config.bobbinUrl, fetch: event.fetch }); 33 + 34 + const doc = await resolveMiniDoc(ctx, identifier).catch((cause) => 35 + toHttpError(cause, "Could not resolve user") 36 + ); 37 + const view = await resolveRepoByName(ctx, doc.did, name).catch((cause) => 38 + toHttpError(cause, "Could not load repository") 39 + ); 40 + if (!view) error(404, `${doc.handle}/${name} does not exist`); 41 + 42 + const git = gitTarget(config, { uri: view.uri, repoDid: view.value.repoDid }, event.fetch); 43 + 44 + return { ctx, git, doc, view }; 45 + };
+5 -6
web/src/routes/[handle]/[repo]/+layout.ts
··· 1 1 import { error, redirect } from "@sveltejs/kit"; 2 2 import { createBobbinClient } from "$lib/api/client"; 3 3 import { count } from "$lib/api/count"; 4 + import { gitTarget, resolveDefaultBranch } from "$lib/api/gitclient"; 4 5 import { getStarRkey } from "$lib/api/graph"; 5 6 import { IdentityCache, resolveMiniDoc } from "$lib/api/identity"; 6 - import { getDefaultBranch } from "$lib/api/knot"; 7 7 import { parallel, toHttpError } from "$lib/api/load"; 8 8 import { getRepo } from "$lib/api/records"; 9 9 import { repoNameOf, resolveRepoByName } from "$lib/api/repo"; ··· 61 61 const record = view.value; 62 62 const repoDid = record.repoDid; 63 63 const viewerDid = parent.auth?.did; 64 + const git = gitTarget(parent.publicConfig, { uri: view.uri, repoDid }, event.fetch); 64 65 65 66 const stats = await parallel({ 66 - // only the knot knows the default branch. a knot that is down or still 67 - // syncing shouldn't take out the whole layout 68 - defaultBranch: getDefaultBranch(ctx, { repo: view.uri }) 69 - .then((branch) => branch.name) 70 - .catch(() => null), 67 + // only the git backend knows the default branch, and one that is down 68 + // or still syncing shouldn't take out the whole layout 69 + defaultBranch: resolveDefaultBranch(git), 71 70 stars: repoDid 72 71 ? count(ctx, "sh.tangled.feed.countStars", repoDid).catch(() => null) 73 72 : Promise.resolve(null),
+6 -23
web/src/routes/[handle]/[repo]/raw/[ref]/[...path]/+server.ts
··· 1 1 import { error } from "@sveltejs/kit"; 2 - import { createBobbinClient } from "$lib/api/client"; 3 - import { resolveMiniDoc } from "$lib/api/identity"; 4 - import { blobRawUrl } from "$lib/api/knot"; 5 - import { toHttpError } from "$lib/api/load"; 6 - import { resolveRepoByName } from "$lib/api/repo"; 7 - import { getConfig } from "$lib/server/config"; 2 + import { blobRawUrl } from "$lib/api/gitclient"; 3 + import { resolveRepoFromParams } from "$lib/server/repo"; 8 4 import type { RequestHandler } from "./$types"; 9 5 10 6 // redirecting instead of proxying keeps repo content off our own origin, same 11 7 // as camo and avatar 12 8 export const GET: RequestHandler = async (event) => { 13 - const identifier = decodeURIComponent(event.params.handle); 14 - const name = decodeURIComponent(event.params.repo); 15 9 const { ref, path } = event.params; 16 10 17 - // rejects bare words so unrelated paths 404 instead of resolving as actors 18 - if (!identifier.startsWith("did:") && !identifier.includes(".")) { 19 - error(404, "Not found"); 20 - } 21 11 if (path === "") error(404, "Not found"); 22 12 23 - const { bobbinUrl } = getConfig(); 24 - const ctx = createBobbinClient({ serviceUrl: bobbinUrl, fetch: event.fetch }); 13 + const { git } = await resolveRepoFromParams(event); 25 14 26 - const doc = await resolveMiniDoc(ctx, identifier).catch((cause) => 27 - toHttpError(cause, "Could not resolve user") 28 - ); 29 - const view = await resolveRepoByName(ctx, doc.did, name).catch((cause) => 30 - toHttpError(cause, "Could not load repository") 31 - ); 32 - if (!view) error(404, `${doc.handle}/${name} does not exist`); 15 + // a branch points at a new commit after every push, so this cannot cache 16 + const location = blobRawUrl(git, { ref, path }); 33 17 34 - // a branch points at a new commit after every push, so this cannot cache 35 18 return new Response(null, { 36 19 status: 302, 37 20 headers: { 38 - location: blobRawUrl(bobbinUrl, { repo: view.uri, ref, path }), 21 + location, 39 22 "cache-control": "public, no-cache" 40 23 } 41 24 });