Files
Halcyon/web/static/api.js
T
2026-10-01 22:16:48 +02:00

113 lines
4.0 KiB
JavaScript

// Client for the belt's HTTP API.
/**
* The project used to be called "wh". Carry over anything an earlier visit stored
* under the old prefix (the access key, drafts, choices), so nobody is logged out.
*/
export function migrateStorage() {
try {
for (const k of Object.keys(localStorage)) {
if (!k.startsWith('wh.')) continue;
const next = `halcyon.${k.slice(3)}`;
if (localStorage.getItem(next) === null) localStorage.setItem(next, localStorage.getItem(k));
localStorage.removeItem(k);
}
} catch { /* storage unavailable: nothing to carry over */ }
}
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
const KEY = 'halcyon.key';
export const keyStore = {
get() {
try { return localStorage.getItem(KEY); } catch { return null; }
},
set(k) {
try { localStorage.setItem(KEY, k); } catch { /* private mode: session only */ }
memoryKey = k;
},
clear() {
try { localStorage.removeItem(KEY); } catch { /* ignore */ }
memoryKey = null;
},
};
let memoryKey = null;
const currentKey = () => memoryKey ?? keyStore.get();
let unauthorized = () => {};
export function onUnauthorized(cb) { unauthorized = cb; }
async function call(method, path, { json, body, type, auth = true, raw = false, key } = {}) {
const headers = {};
// An explicit key (used by login) is tried as given and never triggers the
// global "session rejected" handler.
const k = key ?? (auth ? currentKey() : null);
if (k) headers.Authorization = `Bearer ${k}`;
let payload = body;
if (json !== undefined) {
payload = JSON.stringify(json);
headers['Content-Type'] = 'application/json';
} else if (type) {
headers['Content-Type'] = type;
}
let res;
try {
res = await fetch(path, { method, headers, body: payload });
} catch {
throw new ApiError(0, 'LINK DOWN: cannot reach the server');
}
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ }
if (res.status === 401 && auth && key === undefined) unauthorized();
throw new ApiError(res.status, msg);
}
if (raw) return res;
if ((res.headers.get('Content-Type') ?? '').includes('json')) return res.json();
return null;
}
export const api = {
info: () => call('GET', '/info', { auth: false }),
market: () => call('GET', '/market', { auth: false }),
register: (name) => call('POST', '/register', { json: { name }, auth: false }),
me: (key) => call('GET', '/me', { key }),
// Signing in with an access key: like me(), and the server records the login.
login: (key) => call('POST', '/login', { key }),
ships: () => call('GET', '/ships'),
assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }),
// Launch a docked ship, flying the program in code slot `slot`.
launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }),
scrap: (id) => call('POST', `/ships/${id}/scrap`),
slots: () => call('GET', '/slots'),
slot: (n) => call('GET', `/slots/${n}`),
saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }),
deleteSlot: (n) => call('DELETE', `/slots/${n}`),
setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }),
downlinkDays: async (id) => (await call('GET', `/ships/${id}/downlinks`)).days,
async downlink(id, day) {
const res = await call('GET', `/ships/${id}/downlink${day == null ? '' : `?day=${day}`}`, { raw: true });
return {
bytes: new Uint8Array(await res.arrayBuffer()),
day: Number(res.headers.get('X-Downlink-Day')),
};
},
async example(name) {
const res = await fetch(`/examples/${name}.s`);
if (!res.ok) throw new ApiError(res.status, `example ${name} not found`);
return res.text();
},
};
export function b64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}