1import type { BeadSystem, Excitation, SimOptions, SimResult } from './types';
3// Shared cache of simulation results on artifact-cache (https://artifact-cache.tempory.net,
4// app "toy-instruments"). Reads are public; writes need the app's passcode, kept in
5// localStorage. A result's key is v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256 of
6// sys, exc, opt>, stored as two entries: <key>/light (audio, energy, contact force and
7// scalars: everything but the replay frames, small) and <key>/frames (the replay frames,
8// usually most of the size). The hash covers everything the solver sees, so model
9// changes miss old entries by themselves; bump CACHE_VERSION when the solver's output
10// changes for the same inputs.
12export const CACHE_VERSION = 2;
13const BASE = 'https://artifact-cache.tempory.net/api/apps/toy-instruments';
14const PASS_KEY = 'toy-instruments.cache-passcode';
15export const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
17export type Backend = 'cpu' | 'gpu';
19export async function resultKey(instrument: string, sys: BeadSystem, exc: Excitation, opt: SimOptions, backend: Backend): Promise<string> {
20 return `v${CACHE_VERSION}/${instrument}/${backend}/${await digest({ sys, exc, opt })}`;
21}
23// SHA-256 of a canonical encoding: JSON with sorted keys, typed arrays replaced by
24// their type and byte length, followed by the arrays' bytes in order.
25export async function digest(value: unknown): Promise<string> {
26 const parts: Uint8Array[] = [];
27 const head = new TextEncoder().encode(canon(value, parts));
28 const all = new Uint8Array(head.length + parts.reduce((s, p) => s + p.length, 0));
29 all.set(head);
30 let o = head.length;
31 for (const p of parts) {
32 all.set(p, o);
33 o += p.length;
34 }
35 const h = new Uint8Array(await crypto.subtle.digest('SHA-256', all));
36 return [...h].map((b) => b.toString(16).padStart(2, '0')).join('');
37}
39function canon(v: unknown, parts: Uint8Array[]): string {
40 if (ArrayBuffer.isView(v)) {
41 const b = new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
42 parts.push(b);
43 return `{"$":"${v.constructor.name}","n":${b.length}}`;
44 }
45 if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null';
46 if (Array.isArray(v)) return `[${v.map((x) => canon(x, parts)).join(',')}]`;
47 const o = v as Record<string, unknown>;
48 const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
49 return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(o[k], parts)}`).join(',')}}`;
50}
52// Binary layout of an entry: "TIR1", uint32 header length, header JSON (scalars and
53// the list of arrays), then each array's bytes, every section starting on an 8-byte boundary.
54const MAGIC = 'TIR1';
55const ARRAYS = { Float32Array, Float64Array };
56type ArrayType = keyof typeof ARRAYS;
58const pad8 = (n: number) => (n + 7) & ~7;
60export function encodeRecord(r: object): Uint8Array<ArrayBuffer> {
61 const scalars: Record<string, unknown> = {};
62 const arrays: { name: string; type: ArrayType; length: number }[] = [];
63 const data: Uint8Array[] = [];
64 for (const [name, v] of Object.entries(r)) {
65 if (v instanceof Float32Array || v instanceof Float64Array) {
66 arrays.push({ name, type: v.constructor.name as ArrayType, length: v.length });
67 data.push(new Uint8Array(v.buffer, v.byteOffset, v.byteLength));
68 } else if (v !== undefined) scalars[name] = v;
69 }
70 const head = new TextEncoder().encode(JSON.stringify({ scalars, arrays }));
71 let size = pad8(8 + head.length);
72 for (const d of data) size += pad8(d.length);
73 const out = new Uint8Array(size);
74 out.set(new TextEncoder().encode(MAGIC));
75 new DataView(out.buffer).setUint32(4, head.length, true);
76 out.set(head, 8);
77 let o = pad8(8 + head.length);
78 for (const d of data) {
79 out.set(d, o);
80 o += pad8(d.length);
81 }
82 return out;
83}
85export function decodeRecord<T>(buf: ArrayBuffer): T {
86 if (new TextDecoder().decode(new Uint8Array(buf, 0, 4)) !== MAGIC) throw new Error('not a cached result');
87 const hl = new DataView(buf).getUint32(4, true);
88 const { scalars, arrays } = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, hl)));
89 const r: Record<string, unknown> = { ...scalars };
90 let o = pad8(8 + hl);
91 for (const a of arrays as { name: string; type: ArrayType; length: number }[]) {
92 const A = ARRAYS[a.type];
93 r[a.name] = new A(buf, o, a.length);
94 o += pad8(a.length * A.BYTES_PER_ELEMENT);
95 }
96 return r as T;
97}
99// A result without its replay frames.
100export type LightResult = Omit<SimResult, 'frames'>;
102export function splitResult(r: SimResult): { light: LightResult; frames: Float32Array } {
103 const { frames, ...light } = r;
104 return { light, frames };
105}
107// Whether a result is cached (HEAD on its light entry, no download): the light entry's
108// size and when it was stored, or null.
109export async function probeResult(key: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> {
110 return probe(`${key}/light`, signal);
111}
113// Size of a cached result's replay frames, or null if they are not cached.
114export async function probeFrames(key: string, signal?: AbortSignal): Promise<number | null> {
115 return (await probe(`${key}/frames`, signal))?.bytes ?? null;
116}
118async function probe(entry: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> {
119 const res = await fetch(`${BASE}/entries/${entry}`, { method: 'HEAD', signal });
120 if (res.status === 404) return null;
121 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
122 const lm = res.headers.get('last-modified');
123 return { bytes: Number(res.headers.get('content-length')) || 0, stored: lm ? new Date(lm) : undefined };
124}
126// A cached result's light entry, or null if there is none.
127export async function loadLight(key: string, signal?: AbortSignal): Promise<{ light: LightResult; stored?: Date } | null> {
128 const got = await download(`${key}/light`, () => {}, signal);
129 return got && { light: decodeRecord<LightResult>(got.buf), stored: got.stored };
130}
132// A cached result's replay frames, or null if there are none. onProgress gets the fraction downloaded.
133export async function loadFrames(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<Float32Array | null> {
134 const got = await download(`${key}/frames`, onProgress, signal);
135 return got && decodeRecord<{ frames: Float32Array }>(got.buf).frames;
136}
138export interface CacheHit {
139 result: SimResult;
140 stored?: Date;
141}
143// A whole cached result (light entry, then frames), or null if either is missing.
144export async function loadResult(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<CacheHit | null> {
145 const hit = await loadLight(key, signal);
146 if (!hit) return null;
147 const frames = await loadFrames(key, onProgress, signal);
148 return frames && { result: { ...hit.light, frames }, stored: hit.stored };
149}
151async function download(entry: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<{ buf: ArrayBuffer; stored?: Date } | null> {
152 const res = await fetch(`${BASE}/entries/${entry}`, { signal });
153 if (res.status === 404) return null;
154 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
155 // Content-Length is used only for progress (it is the compressed size if the response is encoded)
156 const total = Number(res.headers.get('content-length')) || 0;
157 let buf: ArrayBuffer;
158 if (res.body) {
159 const chunks: Uint8Array[] = [];
160 const reader = res.body.getReader();
161 let got = 0;
162 for (;;) {
163 const { done, value } = await reader.read();
164 if (done) break;
165 chunks.push(value);
166 got += value.length;
167 if (total) onProgress(Math.min(got / total, 1));
168 }
169 const bytes = new Uint8Array(got);
170 let o = 0;
171 for (const c of chunks) {
172 bytes.set(c, o);
173 o += c.length;
174 }
175 buf = bytes.buffer;
176 } else buf = await res.arrayBuffer();
177 const lm = res.headers.get('last-modified');
178 return { buf, stored: lm ? new Date(lm) : undefined };
179}
181// Store a result: the frames first, so a light entry (what readers look for) always has its frames.
182export async function storeResult(key: string, r: SimResult, meta: Record<string, unknown>): Promise<void> {
183 const pass = getPasscode();
184 if (!pass) throw new Error('no passcode');
185 const { light, frames } = splitResult(r);
186 const framesBody = encodeRecord({ frames });
187 if (framesBody.length > MAX_ENTRY_BYTES) throw new Error(`the replay frames are ${Math.round(framesBody.length / 1e6)} MB, over the 100 MB limit`);
188 await put(`${key}/frames`, framesBody, meta, pass);
189 await put(`${key}/light`, encodeRecord(light), meta, pass);
190}
192async function put(entry: string, body: Uint8Array<ArrayBuffer>, meta: Record<string, unknown>, pass: string) {
193 const res = await fetch(`${BASE}/entries/${entry}`, {
194 method: 'PUT',
195 headers: { 'X-Passcode': pass, 'Content-Type': 'application/octet-stream', 'X-Artifact-Meta': encodeURIComponent(JSON.stringify(meta)) },
196 body,
197 });
198 if (!res.ok) throw new Error(await errorText(res));
199}
201export async function checkPasscode(pass: string): Promise<{ valid: boolean; reason?: string }> {
202 const res = await fetch(`${BASE}/check-passcode`, { method: 'POST', headers: { 'X-Passcode': pass } });
203 if (!res.ok) throw new Error(await errorText(res));
204 return res.json();
205}
207export function getPasscode(): string | null {
208 try {
209 return localStorage.getItem(PASS_KEY);
210 } catch {
211 return null;
212 }
213}
215export function setPasscode(pass: string | null) {
216 try {
217 if (pass) localStorage.setItem(PASS_KEY, pass);
218 else localStorage.removeItem(PASS_KEY);
219 } catch {
220 /* storage unavailable */
221 }
222}
224async function errorText(res: Response): Promise<string> {
225 try {
226 const b = await res.json();
227 if (b?.error) return `${res.status}: ${b.error}`;
228 } catch {
229 /* not JSON */
230 }
231 return `${res.status} ${res.statusText}`;
232}