1/**
2 * The cloud side of the cache.
3 *
4 * Reads are anonymous GETs against the bucket's public base URL: the object
5 * name is a deterministic function of the spec (src/cache/spec.ts), so a
6 * lookup is one fetch and a 404 is a miss — no index, no API.
7 *
8 * Writes go through the tmpbucket Worker
9 * (https://github.com/scratchrealm/tmpbucket): present an API key and a file name,
10 * receive a presigned R2 PUT URL, and upload directly. Only holders of the
11 * key can write; everyone can read.
12 */
13import { cacheFileName, canonicalJson, type CacheSpec } from './spec.ts';
15const PUBLIC_BASE = 'https://tempory.net/tmpbucket/';
16const WORKER_BASE = 'https://tmpbucket.figurl.workers.dev';
17const CONTENT_TYPE = 'application/x-hdf5';
19export interface CacheLookup {
20 fileName: string;
21 url: string;
22 specJson: string;
23}
25export async function lookupFor(spec: CacheSpec): Promise<CacheLookup> {
26 const fileName = await cacheFileName(spec);
27 return { fileName, url: PUBLIC_BASE + fileName, specJson: canonicalJson(spec) };
28}
30/** Fetch a cached solution; null on a miss. Throws on network failure or an
31 * unexpected status, which are reported rather than treated as misses. */
32export async function fetchCached(lookup: CacheLookup): Promise<Uint8Array | null> {
33 const res = await fetch(lookup.url, { cache: 'no-store' });
34 if (res.status === 404) return null;
35 if (!res.ok) throw new Error(`cache read: HTTP ${res.status} for ${lookup.url}`);
36 return new Uint8Array(await res.arrayBuffer());
37}
39/** Upload one cache file. Resolves to its public URL. */
40export async function uploadCacheFile(
41 apiKey: string,
42 fileName: string,
43 bytes: Uint8Array,
44): Promise<string> {
45 const res = await fetch(`${WORKER_BASE}/api/upload-url`, {
46 method: 'POST',
47 headers: {
48 Authorization: `Bearer ${apiKey}`,
49 'Content-Type': 'application/json',
50 },
51 body: JSON.stringify({ fileName, contentType: CONTENT_TYPE }),
52 });
53 if (res.status === 401 || res.status === 403) {
54 throw new Error('upload not authorized — check the API key');
55 }
56 if (!res.ok) {
57 let message = `HTTP ${res.status}`;
58 try {
59 const err = (await res.json()) as { message?: string };
60 if (err.message) message = err.message;
61 } catch {
62 // keep the status-only message
63 }
64 throw new Error(`upload-url request failed: ${message}`);
65 }
66 const grant = (await res.json()) as {
67 uploadUrl: string;
68 uploadHeaders?: Record<string, string>;
69 downloadUrl: string;
70 };
71 const put = await fetch(grant.uploadUrl, {
72 method: 'PUT',
73 headers: grant.uploadHeaders ?? { 'Content-Type': CONTENT_TYPE },
74 body: bytes as unknown as BodyInit,
75 });
76 if (!put.ok) throw new Error(`upload PUT failed: HTTP ${put.status}`);
77 return grant.downloadUrl;
78}