orbit-studio / src /modules /util /gpSource.ts
moncefem's picture
Deploy Orbit Studio propagator
9f21d0a
Raw
History Blame Contribute Delete
5.11 kB
// GpSource — the single place that knows where GP data comes from. Hides the
// worker probe, its memoization, the two URL schemes, and the API→static
// fallback behind two fetch-shaped functions: fetchGpIndex and fetchGpGroup.
//
// On first use we probe `/api/groups.json`. If a worker answers with a
// parseable JSON body we use the API base `/api/gp/` and keep the returned
// group index (names + counts); otherwise we fall back to the static snapshot
// under `data/gp/` (written by `pnpm update-gp`, served as part of the static
// build) and read its `index.json` instead. The probe runs once per session
// (memoized promise). When the worker API fails for a single group
// mid-session, that request is retried once against the static snapshot.
const API_BASE = "/api/gp/";
const STATIC_BASE = "data/gp/";
const PROBE_URL = "/api/groups.json";
const STATIC_INDEX_URL = "data/gp/index.json";
const PROBE_TIMEOUT_MS = 3000;
// One entry of the group index (`/api/groups.json` or `data/gp/index.json`,
// same shape): the group name plus its record count for UI display.
export interface GpIndexEntry {
name: string;
updated?: string;
count?: number;
}
interface GpSourceInfo {
base: string;
index: GpIndexEntry[];
}
let infoPromise: Promise<GpSourceInfo> | undefined;
function parseIndex(payload: unknown): GpIndexEntry[] {
if (typeof payload !== "object" || payload === null || !Array.isArray((payload as { groups?: unknown }).groups)) {
return [];
}
return ((payload as { groups: unknown[] }).groups as GpIndexEntry[]).filter((group) => typeof group?.name === "string");
}
async function fetchStaticIndex(): Promise<GpIndexEntry[]> {
try {
const response = await fetch(STATIC_INDEX_URL);
if (!response.ok) {
return [];
}
return parseIndex(await response.json());
} catch {
return [];
}
}
async function probeGpSource(): Promise<GpSourceInfo> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const response = await fetch(PROBE_URL, { signal: controller.signal });
if (!response.ok) {
return { base: STATIC_BASE, index: await fetchStaticIndex() };
}
// A worker-less deployment may answer the probe with the SPA index HTML
// (200 + text/html). Require a parseable JSON body to accept the API base.
const payload = (await response.json()) as unknown;
return { base: API_BASE, index: parseIndex(payload) };
} catch {
return { base: STATIC_BASE, index: await fetchStaticIndex() };
} finally {
clearTimeout(timer);
}
}
function resolveGpSource(): Promise<GpSourceInfo> {
infoPromise ??= probeGpSource();
return infoPromise;
}
// Resolve a preset source into a fetchable URL. Bare group names
// (^[a-zA-Z0-9_-]+$) are resolved against the probed base; anything containing
// "/" or "." (legacy .txt URLs, absolute/relative paths) passes through
// unchanged so it can still be parsed via payload sniffing.
function resolveGroupUrl(source: string, base: string): string {
if (/^[a-zA-Z0-9_-]+$/.test(source)) {
return `${base}${source}.json`;
}
return source;
}
// Static-snapshot URL for a bare group name, used as a per-request fallback
// when the worker API fails mid-session. Explicit URL sources have no static
// counterpart and return undefined.
function staticGroupUrl(source: string): string | undefined {
if (/^[a-zA-Z0-9_-]+$/.test(source)) {
return `${STATIC_BASE}${source}.json`;
}
return undefined;
}
// The group index (names + counts) from the probe. Best-effort: empty when
// neither the worker nor the static snapshot answers; never rejects.
export async function fetchGpIndex(): Promise<GpIndexEntry[]> {
return (await resolveGpSource()).index;
}
// The payload text for a preset source. Bare group names resolve against the
// probed base; when the worker API fails mid-session (network error or
// non-2xx), the request is retried once against the static snapshot bundled
// with the build. Explicit URL sources pass through without a fallback.
export async function fetchGpGroup(source: string): Promise<string> {
const { base } = await resolveGpSource();
const url = resolveGroupUrl(source, base);
try {
// Plain fetch (NOT mode:"no-cors") — the API is same-origin; an opaque
// response would have an unreadable body and break parsing.
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
return await response.text();
} catch (error) {
const fallback = staticGroupUrl(source);
if (fallback === undefined || fallback === url) {
throw error;
}
console.log(`GP fetch failed for ${url}, retrying static snapshot ${fallback}`, error);
const response = await fetch(fallback);
if (!response.ok) {
throw new Error(response.statusText, { cause: error });
}
return await response.text();
}
}
// Test seam: the probe is otherwise memoized for the life of the module.
export function resetGpSource(): void {
infoPromise = undefined;
}