Skip to content

Commit ed1b834

Browse files
committed
refactor(gp): Hide the GP source fallback behind fetch-shaped functions
gpSource exported six functions whose interface was a direct copy of the probe/resolve/fallback procedure, and SatelliteCatalog composed them itself — resolving the base, building both URL schemes, and implementing the per-request API→static retry inline, so the worker-vs-static decision leaked into catalog loading. The module now exposes three fetch-shaped functions — fetchGpIndex, fetchGpGroup, fetchGpMetadata — that hide the probe, its memoization, both URL schemes, and the fallback. The catalog no longer knows two deployment flavors exist. The probe and fallback paths gain their first direct tests (HTML probe answer, mid-session retry, explicit-URL passthrough, single probe per session). Assisted-by: ClaudeCode:claude-fable-5
1 parent f418b9b commit ed1b834

5 files changed

Lines changed: 208 additions & 64 deletions

File tree

CONTEXT.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ discussion; sharpen them here when they drift.
2424
- **Group store**: the persistence seam of the GP refresh pipeline —
2525
readIndex/writeGroup/writeIndex — with a Workers KV adapter (cron/API) and
2626
a disk adapter (static `data/gp/` snapshot) (`worker/src/gp/store.ts`).
27+
- **GP source**: where the frontend gets GP data — the worker API when the
28+
probe succeeds, the static `data/gp/` snapshot otherwise, with a
29+
per-request API→static fallback mid-session (`src/modules/util/gpSource.ts`).

src/modules/SatelliteCatalog.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
22

33
import { SatelliteCatalog } from "./SatelliteCatalog";
44
import { parseGpPayload, type GpRecord } from "./util/gp";
5-
import { resetGpBase } from "./util/gpSource";
5+
import { resetGpSource } from "./util/gpSource";
66

77
// Two OMM records; ALPHA appears in both groups (same satnum + name) to
88
// exercise cross-group dedup and tag union.
@@ -139,7 +139,7 @@ const PROBE_ROUTES: RouteMap = {
139139

140140
describe("SatelliteCatalog lazy loading", () => {
141141
afterEach(() => {
142-
resetGpBase();
142+
resetGpSource();
143143
vi.unstubAllGlobals();
144144
});
145145

src/modules/SatelliteCatalog.ts

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { appMetadataConfig, type MetadataRule, type ResolvedMetadata } from "../config/satelliteMetadata";
88
import { parseGpPayload, recordName, recordSatnum, type GpRecord } from "./util/gp";
9-
import { resolveGpBase, resolveGpSource, resolveGroupUrl, resolveMetadataUrl, staticGroupUrl } from "./util/gpSource";
9+
import { fetchGpGroup, fetchGpIndex, fetchGpMetadata } from "./util/gpSource";
1010

1111
// A single known satellite. Created by SatelliteCatalog.addRecords, which owns
1212
// the identity/tag indices; the entry keeps a back-reference to its catalog so
@@ -142,8 +142,8 @@ export class SatelliteCatalog {
142142
// an estimated count in the UI. Best-effort: an unavailable index just leaves
143143
// the estimates at 0.
144144
ensureIndex(): Promise<void> {
145-
this.#indexLoad ??= resolveGpSource().then((info) => {
146-
for (const group of info.index) {
145+
this.#indexLoad ??= fetchGpIndex().then((index) => {
146+
for (const group of index) {
147147
if (typeof group.count === "number") {
148148
this.#indexCounts.set(group.name, group.count);
149149
}
@@ -184,12 +184,11 @@ export class SatelliteCatalog {
184184
}
185185

186186
async #loadRegisteredGroup(group: RegisteredGroup): Promise<void> {
187-
const base = await resolveGpBase();
188187
// Fetch metadata (once) BEFORE the first group file so ground tracks /
189188
// cones aren't created with stale widths.
190-
await (this.#metadataLoad ??= this.#loadMetadata(base));
189+
await (this.#metadataLoad ??= this.#loadMetadata());
191190
try {
192-
const text = await this.#fetchGroupPayload(group.source, base);
191+
const text = await fetchGpGroup(group.source);
193192
const records = parseGpPayload(text);
194193
const changed = this.addRecords(records, group.tags);
195194
group.loaded = true;
@@ -201,44 +200,12 @@ export class SatelliteCatalog {
201200
}
202201
}
203202

204-
// Fetch a group payload from the resolved base; when the worker API fails
205-
// mid-session (network error or non-2xx), retry once against the static
206-
// snapshot bundled with the build.
207-
async #fetchGroupPayload(source: string, base: string): Promise<string> {
208-
const url = resolveGroupUrl(source, base);
209-
try {
210-
// Plain fetch (NOT mode:"no-cors") — the API is same-origin; an opaque
211-
// response would have an unreadable body and break parsing.
212-
const response = await fetch(url);
213-
if (!response.ok) {
214-
throw new Error(response.statusText);
215-
}
216-
return await response.text();
217-
} catch (error) {
218-
const fallback = staticGroupUrl(source);
219-
if (fallback === undefined || fallback === url) {
220-
throw error;
221-
}
222-
console.log(`GP fetch failed for ${url}, retrying static snapshot ${fallback}`, error);
223-
const response = await fetch(fallback);
224-
if (!response.ok) {
225-
throw new Error(response.statusText, { cause: error });
226-
}
227-
return await response.text();
228-
}
229-
}
230-
231203
// Fetch and merge remote metadata rules. Tolerant of worker-less / older
232204
// deployments: any failure (404, network, invalid body) logs and continues
233205
// with the built-in app defaults.
234-
async #loadMetadata(base: string): Promise<void> {
235-
const url = resolveMetadataUrl(base);
206+
async #loadMetadata(): Promise<void> {
236207
try {
237-
const response = await fetch(url);
238-
if (!response.ok) {
239-
throw new Error(response.statusText);
240-
}
241-
const remote = (await response.json()) as unknown;
208+
const remote = await fetchGpMetadata();
242209
if (!Array.isArray(remote)) {
243210
throw new Error("metadata payload is not an array");
244211
}

src/modules/util/gpSource.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { afterEach, describe, expect, test, vi } from "vitest";
2+
3+
import { fetchGpGroup, fetchGpIndex, fetchGpMetadata, resetGpSource } from "./gpSource";
4+
5+
type RouteMap = Record<string, () => Response>;
6+
7+
function json(body: unknown): () => Response {
8+
return () => new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } });
9+
}
10+
11+
function installFetch(routes: RouteMap): string[] {
12+
const requested: string[] = [];
13+
vi.stubGlobal(
14+
"fetch",
15+
vi.fn(async (input: RequestInfo | URL) => {
16+
const url = String(input);
17+
requested.push(url);
18+
const route = routes[url];
19+
if (!route) {
20+
return new Response("Not Found", { status: 404 });
21+
}
22+
return route();
23+
}),
24+
);
25+
return requested;
26+
}
27+
28+
const WORKER_ROUTES: RouteMap = {
29+
"/api/groups.json": json({ updated: "", groups: [{ name: "weather", count: 2 }] }),
30+
};
31+
32+
const STATIC_ROUTES: RouteMap = {
33+
"data/gp/index.json": json({ updated: "", groups: [{ name: "weather", count: 1 }] }),
34+
};
35+
36+
afterEach(() => {
37+
resetGpSource();
38+
vi.unstubAllGlobals();
39+
});
40+
41+
describe("GpSource probe", () => {
42+
test("uses the worker API when the probe answers with JSON", async () => {
43+
const requested = installFetch({
44+
...WORKER_ROUTES,
45+
"/api/gp/weather.json": () => new Response("[]"),
46+
});
47+
expect(await fetchGpIndex()).toEqual([{ name: "weather", count: 2 }]);
48+
await fetchGpGroup("weather");
49+
expect(requested).toContain("/api/gp/weather.json");
50+
});
51+
52+
test("falls back to the static snapshot when the probe fails", async () => {
53+
const requested = installFetch({
54+
...STATIC_ROUTES,
55+
"data/gp/weather.json": () => new Response("[]"),
56+
});
57+
expect(await fetchGpIndex()).toEqual([{ name: "weather", count: 1 }]);
58+
await fetchGpGroup("weather");
59+
expect(requested).toContain("data/gp/weather.json");
60+
expect(requested).not.toContain("/api/gp/weather.json");
61+
});
62+
63+
test("treats an HTML probe answer (worker-less SPA fallthrough) as no worker", async () => {
64+
installFetch({
65+
"/api/groups.json": () => new Response("<!doctype html><html></html>", { status: 200, headers: { "Content-Type": "text/html" } }),
66+
...STATIC_ROUTES,
67+
});
68+
expect(await fetchGpIndex()).toEqual([{ name: "weather", count: 1 }]);
69+
});
70+
71+
test("probes only once per session", async () => {
72+
const requested = installFetch(WORKER_ROUTES);
73+
await fetchGpIndex();
74+
await fetchGpMetadata().catch(() => undefined);
75+
await fetchGpGroup("weather").catch(() => undefined);
76+
expect(requested.filter((url) => url === "/api/groups.json")).toHaveLength(1);
77+
});
78+
});
79+
80+
describe("fetchGpGroup", () => {
81+
test("retries a failed API group against the static snapshot", async () => {
82+
installFetch({
83+
...WORKER_ROUTES,
84+
"/api/gp/weather.json": () => new Response("KV miss", { status: 404 }),
85+
"data/gp/weather.json": () => new Response('[{"OBJECT_NAME":"METEO-1","NORAD_CAT_ID":1}]'),
86+
});
87+
const payload = await fetchGpGroup("weather");
88+
expect(JSON.parse(payload)).toHaveLength(1);
89+
});
90+
91+
test("passes explicit URL sources through without a fallback", async () => {
92+
const requested = installFetch({
93+
...WORKER_ROUTES,
94+
"data/tle/custom.txt": () => new Response("CUSTOM\n1 ...\n2 ..."),
95+
});
96+
const payload = await fetchGpGroup("data/tle/custom.txt");
97+
expect(payload).toContain("CUSTOM");
98+
expect(requested).not.toContain("/api/gp/data/tle/custom.txt.json");
99+
});
100+
101+
test("rethrows when both the API and the static snapshot fail", async () => {
102+
installFetch({
103+
...WORKER_ROUTES,
104+
"/api/gp/weather.json": () => new Response("boom", { status: 500 }),
105+
"data/gp/weather.json": () => new Response("missing", { status: 404 }),
106+
});
107+
await expect(fetchGpGroup("weather")).rejects.toThrow();
108+
});
109+
});
110+
111+
describe("fetchGpMetadata", () => {
112+
test("uses the API endpoint when the worker answered the probe", async () => {
113+
const requested = installFetch({
114+
...WORKER_ROUTES,
115+
"/api/metadata.json": json([{ match: { satnums: ["1"] }, metadata: { swathKm: 290 } }]),
116+
});
117+
const rules = await fetchGpMetadata();
118+
expect(Array.isArray(rules)).toBe(true);
119+
expect(requested).toContain("/api/metadata.json");
120+
});
121+
122+
test("uses the static endpoint in worker-less deployments", async () => {
123+
const requested = installFetch({
124+
...STATIC_ROUTES,
125+
"data/gp/metadata.json": json([]),
126+
});
127+
await fetchGpMetadata();
128+
expect(requested).toContain("data/gp/metadata.json");
129+
});
130+
131+
test("throws on failure so the caller decides tolerance", async () => {
132+
installFetch(WORKER_ROUTES);
133+
await expect(fetchGpMetadata()).rejects.toThrow();
134+
});
135+
});

src/modules/util/gpSource.ts

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
// GP base resolution for worker vs. worker-less (static snapshot) deployments.
1+
// GpSource — the single place that knows where GP data comes from. Hides the
2+
// worker probe, its memoization, the two URL schemes, and the API→static
3+
// fallback behind three fetch-shaped functions: fetchGpIndex, fetchGpGroup,
4+
// and fetchGpMetadata.
25
//
3-
// On the first call we probe `/api/groups.json`. If a worker answers with a
6+
// On first use we probe `/api/groups.json`. If a worker answers with a
47
// parseable JSON body we use the API base `/api/gp/` and keep the returned
58
// group index (names + counts); otherwise we fall back to the static snapshot
69
// under `data/gp/` (written by `pnpm update-gp`, served as part of the static
710
// build) and read its `index.json` instead. The probe runs once per session
8-
// (memoized promise).
11+
// (memoized promise). When the worker API fails for a single group
12+
// mid-session, that request is retried once against the static snapshot.
913

1014
const API_BASE = "/api/gp/";
1115
const STATIC_BASE = "data/gp/";
@@ -23,7 +27,7 @@ export interface GpIndexEntry {
2327
count?: number;
2428
}
2529

26-
export interface GpSourceInfo {
30+
interface GpSourceInfo {
2731
base: string;
2832
index: GpIndexEntry[];
2933
}
@@ -68,26 +72,16 @@ async function probeGpSource(): Promise<GpSourceInfo> {
6872
}
6973
}
7074

71-
// Base plus the group index (names + counts) from the same probe request.
72-
export function resolveGpSource(): Promise<GpSourceInfo> {
75+
function resolveGpSource(): Promise<GpSourceInfo> {
7376
infoPromise ??= probeGpSource();
7477
return infoPromise;
7578
}
7679

77-
export async function resolveGpBase(): Promise<string> {
78-
return (await resolveGpSource()).base;
79-
}
80-
81-
// For tests: reset the memoized probe.
82-
export function resetGpBase(): void {
83-
infoPromise = undefined;
84-
}
85-
8680
// Resolve a preset source into a fetchable URL. Bare group names
8781
// (^[a-zA-Z0-9_-]+$) are resolved against the probed base; anything containing
8882
// "/" or "." (legacy .txt URLs, absolute/relative paths) passes through
8983
// unchanged so it can still be parsed via payload sniffing.
90-
export function resolveGroupUrl(source: string, base: string): string {
84+
function resolveGroupUrl(source: string, base: string): string {
9185
if (/^[a-zA-Z0-9_-]+$/.test(source)) {
9286
return `${base}${source}.json`;
9387
}
@@ -97,17 +91,62 @@ export function resolveGroupUrl(source: string, base: string): string {
9791
// Static-snapshot URL for a bare group name, used as a per-request fallback
9892
// when the worker API fails mid-session. Explicit URL sources have no static
9993
// counterpart and return undefined.
100-
export function staticGroupUrl(source: string): string | undefined {
94+
function staticGroupUrl(source: string): string | undefined {
10195
if (/^[a-zA-Z0-9_-]+$/.test(source)) {
10296
return `${STATIC_BASE}${source}.json`;
10397
}
10498
return undefined;
10599
}
106100

107-
// The metadata endpoint for the resolved base: the worker serves it at
108-
// `/api/metadata.json`; the static snapshot writes `data/gp/metadata.json`.
109-
export function resolveMetadataUrl(base: string): string {
110-
return base === API_BASE ? API_METADATA_URL : STATIC_METADATA_URL;
101+
// The group index (names + counts) from the probe. Best-effort: empty when
102+
// neither the worker nor the static snapshot answers; never rejects.
103+
export async function fetchGpIndex(): Promise<GpIndexEntry[]> {
104+
return (await resolveGpSource()).index;
111105
}
112106

113-
export { API_BASE, STATIC_BASE };
107+
// The payload text for a preset source. Bare group names resolve against the
108+
// probed base; when the worker API fails mid-session (network error or
109+
// non-2xx), the request is retried once against the static snapshot bundled
110+
// with the build. Explicit URL sources pass through without a fallback.
111+
export async function fetchGpGroup(source: string): Promise<string> {
112+
const { base } = await resolveGpSource();
113+
const url = resolveGroupUrl(source, base);
114+
try {
115+
// Plain fetch (NOT mode:"no-cors") — the API is same-origin; an opaque
116+
// response would have an unreadable body and break parsing.
117+
const response = await fetch(url);
118+
if (!response.ok) {
119+
throw new Error(response.statusText);
120+
}
121+
return await response.text();
122+
} catch (error) {
123+
const fallback = staticGroupUrl(source);
124+
if (fallback === undefined || fallback === url) {
125+
throw error;
126+
}
127+
console.log(`GP fetch failed for ${url}, retrying static snapshot ${fallback}`, error);
128+
const response = await fetch(fallback);
129+
if (!response.ok) {
130+
throw new Error(response.statusText, { cause: error });
131+
}
132+
return await response.text();
133+
}
134+
}
135+
136+
// Parsed remote metadata rules from the endpoint matching the probed base:
137+
// the worker serves `/api/metadata.json`; the static snapshot writes
138+
// `data/gp/metadata.json`. Throws on failure — the caller decides tolerance.
139+
export async function fetchGpMetadata(): Promise<unknown> {
140+
const { base } = await resolveGpSource();
141+
const url = base === API_BASE ? API_METADATA_URL : STATIC_METADATA_URL;
142+
const response = await fetch(url);
143+
if (!response.ok) {
144+
throw new Error(response.statusText);
145+
}
146+
return await response.json();
147+
}
148+
149+
// For tests: reset the memoized probe.
150+
export function resetGpSource(): void {
151+
infoPromise = undefined;
152+
}

0 commit comments

Comments
 (0)