feat: generate typed frontend API contracts

This commit is contained in:
sjg
2026-08-01 11:58:09 +02:00
parent 888f793eb8
commit bbc53d56b0
16 changed files with 504 additions and 38 deletions
@@ -0,0 +1,71 @@
"use strict";
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isRigSnapshot(value) {
if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) {
return false;
}
const { info, status } = value;
return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean";
}
export function isRigListResponse(value) {
return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every(
(rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean"
);
}
export function isDecoderRegistry(value) {
return Array.isArray(value) && value.every(
(decoder) => isRecord(decoder) && typeof decoder.id === "string" && typeof decoder.label === "string" && (decoder.activation === "mode_bound" || decoder.activation === "toggle") && Array.isArray(decoder.active_modes) && decoder.active_modes.every((mode) => typeof mode === "string") && typeof decoder.background_decode === "boolean" && typeof decoder.bookmark_selectable === "boolean"
);
}
export class TrxApi {
constructor(baseUrl = "") {
this.baseUrl = baseUrl;
}
async get(path, validate) {
return this.request(path, { cache: "no-store" }, validate);
}
async post(path, body, validate) {
return this.request(
path,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
},
validate
);
}
async request(path, init, validate) {
const response = await fetch(`${this.baseUrl}${path}`, init);
if (!response.ok) {
const detail = await response.text();
throw new ApiError(response.status, detail || response.statusText);
}
const value = await response.json();
if (!validate(value)) {
throw new ApiError(response.status, `Malformed response from ${path}`);
}
return value;
}
}
export function decodeServerEvent(event, validate) {
let value;
try {
value = JSON.parse(event.data);
} catch (error) {
throw new TypeError("Server event is not valid JSON", { cause: error });
}
if (!validate(value)) {
throw new TypeError("Server event has an unexpected shape");
}
return value;
}