concept-collection / turing-surface-cache
turing-surface-cache / src / cache / client.ts
117 lines · 4.4 KBBlameHistoryRaw
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 { APP_NAME, FORMAT_VERSION, 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;
25export async function lookupFor(spec: CacheSpec): Promise<CacheLookup> {
26 const fileName = await cacheFileName(spec);
27 return { fileName, url: PUBLIC_BASE + fileName, specJson: canonicalJson(spec) };
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());
39/**
40 * Is this solution in the cloud? A HEAD is enough. Three answers, not two:
41 * null is "could not tell", which a note may show as nothing at all and a
42 * walk treats as absence — a network hiccup is not evidence that a file is
43 * there, and computing it anyway only costs time and ends in an upload that
44 * overwrites an identical object.
45 */
46export async function headCached(lookup: CacheLookup): Promise<boolean | null> {
47 try {
48 const res = await fetch(lookup.url, { method: 'HEAD', cache: 'no-store' });
49 return res.ok ? true : res.status === 404 ? false : null;
50 } catch {
51 return null;
52 }
55/** headCached, for a caller with nothing to say about "could not tell". */
56export async function isCached(lookup: CacheLookup): Promise<boolean> {
57 return (await headCached(lookup)) === true;
60/** Ask the Worker for a presigned PUT. Nothing is written until it is used. */
61function requestUploadUrl(apiKey: string, fileName: string): Promise<Response> {
62 return fetch(`${WORKER_BASE}/api/upload-url`, {
63 method: 'POST',
64 headers: {
65 Authorization: `Bearer ${apiKey}`,
66 'Content-Type': 'application/json',
67 },
68 body: JSON.stringify({ fileName, contentType: CONTENT_TYPE }),
69 });
72/**
73 * Would this key be allowed to upload? Asking for a grant and not using it
74 * writes nothing, and answers in one request — worth doing when a key is
75 * entered, since the alternative is finding out after the first run.
76 * Throws if the Worker cannot be reached at all, which is not the key's fault.
77 */
78export async function verifyApiKey(apiKey: string): Promise<boolean> {
79 const res = await requestUploadUrl(apiKey, `${APP_NAME}/v${FORMAT_VERSION}/.keycheck`);
80 if (res.status === 401 || res.status === 403) return false;
81 if (!res.ok) throw new Error(`upload-url request failed: HTTP ${res.status}`);
82 return true;
85/** Upload one cache file. Resolves to its public URL. */
86export async function uploadCacheFile(
87 apiKey: string,
88 fileName: string,
89 bytes: Uint8Array,
90): Promise<string> {
91 const res = await requestUploadUrl(apiKey, fileName);
92 if (res.status === 401 || res.status === 403) {
93 throw new Error('upload not authorized — check the API key');
94 }
95 if (!res.ok) {
96 let message = `HTTP ${res.status}`;
97 try {
98 const err = (await res.json()) as { message?: string };
99 if (err.message) message = err.message;
100 } catch {
101 // keep the status-only message
102 }
103 throw new Error(`upload-url request failed: ${message}`);
104 }
105 const grant = (await res.json()) as {
106 uploadUrl: string;
107 uploadHeaders?: Record<string, string>;
108 downloadUrl: string;
109 };
110 const put = await fetch(grant.uploadUrl, {
111 method: 'PUT',
112 headers: grant.uploadHeaders ?? { 'Content-Type': CONTENT_TYPE },
113 body: bytes as unknown as BodyInit,
114 });
115 if (!put.ok) throw new Error(`upload PUT failed: HTTP ${put.status}`);
116 return grant.downloadUrl;