feeds.ts

4.4 KB · raw

1import type { ReleaseFile } from "../bucket/repo.ts";2import type { Release } from "./releases.ts";34// The machine-readable shapes of §22, written by hand rather than with JSON.stringify: clients in5// the wild were built against Kepr 2's bytes, and Kepr 2 escaped `/`, `<` and `>` (its6// `jsonEscapeStr`). URLs are assembled from validated repo names, versions and platforms, and the7// percent-encoded filename, which need no escaping, so their slashes stay bare, as Kepr 2 wrote them.89const ESCAPES: Record<string, string> = {10	'"': '\\"',11	"\\": "\\\\",12	"/": "\\/",13	"<": "\\u003c",14	">": "\\u003e",15	"\n": "\\n",16	"\r": "\\r",17	"\t": "\\t",18};1920// Kepr 2 escaped bytes, so of the control characters only ASCII's (below 0x20, and 0x7f) change.21function jsonEscape(text: string): string {22	return text.replace(/["\\/<>\p{Cc}]/gu, (ch) => {23		const code = ch.charCodeAt(0);24		if (code >= 0x80) return ch;25		return ESCAPES[ch] ?? `\\u${code.toString(16).padStart(4, "0")}`;26	});27}2829function str(text: string): string {30	return `"${jsonEscape(text)}"`;31}3233function optional(text: string | null): string {34	return text === null ? "null" : str(text);35}3637function provenance(version: string, file: ReleaseFile): string {38	return `{"version":${str(version)},"platform":${str(file.platform)},"checksum":${str(`blake3:${file.blake3}`)},"source":"koh-offer"}`;39}4041// §22.4: notes are omitted entirely when empty, never sent as null or "".42function head(release: Release): string {43	const notes = release.notes === "" ? "" : `,"notes":${str(release.notes)}`;44	return `{"version":${str(release.version)}${notes}`;45}4647/** §22.4's release JSON shape, field for field and in Kepr 2's order. */48export function releaseJson(domain: string, repo: string, release: Release): string {49	const platforms = release.files.map(50		(file) =>51			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${release.version}/${encodeURIComponent(file.name)}","blake3":${str(file.blake3)},"sha256":${str(file.sha256)},"provenance":${provenance(release.version, file)},"signature":""}`,52	);53	return `${head(release)},"platforms":{${platforms.join(",")}}}`;54}5556/** `[{"version","notes"}]`, newest first; unlike the release shape, Kepr 2 always sent notes here. */57export function historyJson(releases: Release[]): string {58	const rows = releases.map((r) => `{"version":${str(r.version)},"notes":${str(r.notes)}}`);59	return `[${rows.join(",")}]`;60}6162export type UpdateHead = { id: string; version: string; createdAt: number };6364/** `/<repo>/update-info` (§22.4). File URLs take Kepr 2's `<repo>-<platform>` form here. */65export function updateInfoJson(66	domain: string,67	repo: string,68	saveHead: UpdateHead | null,69	newest: Release | undefined,70): string {71	const headPart = saveHead72		? `{"save_id":${str(saveHead.id)},"version":${str(saveHead.version)},"timestamp":${saveHead.createdAt}}`73		: "null";74	const platforms = newest?.files.map(75		(file) =>76			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${newest.version}/${repo}-${file.platform}","blake3":${str(file.blake3)}}`,77	);78	const build = newest79		? `{"version":${str(newest.version)},"platforms":{${platforms?.join(",")}}}`80		: "null";81	return `{"head":${headPart},"latest_build":${build}}`;82}8384export type RepoMeta = {85	repo: string;86	saveCount: number;87	lineCount: number;88	lastSaveAt: number;89	latestRelease: string | null;90	description: string | null;91	language: string | null;92	category: string | null;93};9495/**96 * `/<repo>/meta` (§22.4). `latest_version` is the newest release, as is `latest_release`; Kepr 2's97 * `languages` and `categories` were single strings, so they stay strings here.98 */99export function metaJson(meta: RepoMeta): string {100	return `{"repo":${str(meta.repo)},"save_count":${meta.saveCount},"line_count":${meta.lineCount},"last_save_at":${meta.lastSaveAt},"latest_release":${optional(meta.latestRelease)},"latest_version":${optional(meta.latestRelease)},"description":${optional(meta.description)},"languages":${optional(meta.language)},"categories":${optional(meta.category)}}`;101}102103/** Kepr 2's checksums.txt: a header, the SHA-256 lines, then the Blake3 lines (§22.1). */104export function checksumsTxt(repo: string, release: Release): string {105	const lines = (algorithm: "sha256" | "blake3") =>106		release.files.map((file) => `${algorithm}:${file[algorithm]}  ${repo}-${file.platform}\n`);107	return `# ${repo} ${release.version}\n# built by kepr\n\n${[...lines("sha256"), ...lines("blake3")].join("")}`;108}