feeds.ts

4.9 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, which need7// 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}/${file.platform}","blake3":${str(file.blake3)},"sha256":${str(file.sha256)},"provenance":${provenance(release.version, file)},"signature":""}`,52	);53	return `${head(release)},"platforms":{${platforms.join(",")}}}`;54}5556/** Kepr 2's legacy per-platform shape, which only the frozen Koh 2 route serves (§22.4). */57export function legacyReleaseJson(domain: string, repo: string, release: Release): string {58	const platforms = release.files.map(59		(file) =>60			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/api/${repo}/releases/${release.version}/${file.platform}","checksum":${str(`blake3:${file.blake3}`)},"provenance":${provenance(release.version, file)},"signature":""}`,61	);62	return `${head(release)},"platforms":{${platforms.join(",")}}}`;63}6465/** `[{"version","notes"}]`, newest first; unlike the release shape, Kepr 2 always sent notes here. */66export function historyJson(releases: Release[]): string {67	const rows = releases.map((r) => `{"version":${str(r.version)},"notes":${str(r.notes)}}`);68	return `[${rows.join(",")}]`;69}7071export type UpdateHead = { id: string; version: string; createdAt: number };7273/** `/<repo>/update-info` (§22.4). File URLs take Kepr 2's `<repo>-<platform>` form here. */74export function updateInfoJson(75	domain: string,76	repo: string,77	saveHead: UpdateHead | null,78	newest: Release | undefined,79): string {80	const headPart = saveHead81		? `{"save_id":${str(saveHead.id)},"version":${str(saveHead.version)},"timestamp":${saveHead.createdAt}}`82		: "null";83	const platforms = newest?.files.map(84		(file) =>85			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${newest.version}/${repo}-${file.platform}","blake3":${str(file.blake3)}}`,86	);87	const build = newest88		? `{"version":${str(newest.version)},"platforms":{${platforms?.join(",")}}}`89		: "null";90	return `{"head":${headPart},"latest_build":${build}}`;91}9293export type RepoMeta = {94	repo: string;95	saveCount: number;96	lineCount: number;97	lastSaveAt: number;98	latestRelease: string | null;99	description: string | null;100	language: string | null;101	category: string | null;102};103104/**105 * `/<repo>/meta` (§22.4). `latest_version` is the newest release, as is `latest_release`; Kepr 2's106 * `languages` and `categories` were single strings, so they stay strings here.107 */108export function metaJson(meta: RepoMeta): string {109	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)}}`;110}111112/** Kepr 2's checksums.txt: a header, the SHA-256 lines, then the Blake3 lines (§22.1). */113export function checksumsTxt(repo: string, release: Release): string {114	const lines = (algorithm: "sha256" | "blake3") =>115		release.files.map((file) => `${algorithm}:${file[algorithm]}  ${repo}-${file.platform}\n`);116	return `# ${repo} ${release.version}\n# built by kepr\n\n${[...lines("sha256"), ...lines("blake3")].join("")}`;117}