File size: 5,107 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// 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;
}