bell-reader 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +75 -2
  4. package/README.zh-TW.md +55 -0
  5. package/TRADEMARKS.md +32 -0
  6. package/bin/bell-reader.mjs +202 -0
  7. package/dist/assets/caveat-cyrillic-400-normal-9cDH9rLW.woff2 +0 -0
  8. package/dist/assets/caveat-cyrillic-400-normal-CebvvJET.woff +0 -0
  9. package/dist/assets/caveat-cyrillic-500-normal-DLSfp5IU.woff +0 -0
  10. package/dist/assets/caveat-cyrillic-500-normal-DpMZbbjM.woff2 +0 -0
  11. package/dist/assets/caveat-cyrillic-ext-400-normal-3iEGd-c5.woff2 +0 -0
  12. package/dist/assets/caveat-cyrillic-ext-400-normal-Cg0RnRQ5.woff +0 -0
  13. package/dist/assets/caveat-cyrillic-ext-500-normal-DcrB4vVx.woff2 +0 -0
  14. package/dist/assets/caveat-cyrillic-ext-500-normal-DqTXtm5N.woff +0 -0
  15. package/dist/assets/caveat-latin-400-normal-BzhAQZkN.woff +0 -0
  16. package/dist/assets/caveat-latin-400-normal-D6LQsQ_v.woff2 +0 -0
  17. package/dist/assets/caveat-latin-500-normal-B9SDL8cy.woff2 +0 -0
  18. package/dist/assets/caveat-latin-500-normal-Do9veZfZ.woff +0 -0
  19. package/dist/assets/caveat-latin-ext-400-normal-D7hBUiug.woff2 +0 -0
  20. package/dist/assets/caveat-latin-ext-400-normal-DtiRFvw0.woff +0 -0
  21. package/dist/assets/caveat-latin-ext-500-normal-jKqJW7bC.woff2 +0 -0
  22. package/dist/assets/caveat-latin-ext-500-normal-wUUXxlbS.woff +0 -0
  23. package/dist/assets/index-BO29bkMJ.js +84 -0
  24. package/dist/assets/index-DtC09ml8.css +1 -0
  25. package/dist/assets/newsreader-latin-ext-opsz-normal-BQn1nviT.woff2 +0 -0
  26. package/dist/assets/newsreader-latin-opsz-normal-s-izfB6B.woff2 +0 -0
  27. package/dist/assets/newsreader-vietnamese-opsz-normal-BjhtXyW2.woff2 +0 -0
  28. package/dist/fonts/LICENSE-geist-mono.txt +93 -0
  29. package/dist/fonts/LICENSE-geist.txt +93 -0
  30. package/dist/fonts/geist-latin-wght.woff2 +0 -0
  31. package/dist/fonts/geist-mono-latin-wght.woff2 +0 -0
  32. package/dist/fonts/iansui-tc.ttf +0 -0
  33. package/dist/fonts/naikai-tc.woff2 +0 -0
  34. package/dist/index.html +13 -0
  35. package/package.json +79 -4
  36. package/server/ai.ts +1391 -0
  37. package/server/app.ts +644 -0
  38. package/server/config.ts +140 -0
  39. package/server/errors.ts +22 -0
  40. package/server/explain-bridge.ts +320 -0
  41. package/server/extractor.ts +548 -0
  42. package/server/index.ts +28 -0
  43. package/server/security.ts +78 -0
  44. package/server/store.ts +595 -0
  45. package/shared/suite.ts +248 -0
  46. package/shared/types.ts +132 -0
@@ -0,0 +1,140 @@
1
+ import { SUITE_APP_IDS, SUITE_LIMITS, type SuiteAppId, type SuiteLinks } from "../shared/suite.js";
2
+
3
+ export interface RuntimeConfig {
4
+ host: string;
5
+ port: number;
6
+ dataDir: string;
7
+ allowedOrigins: string[];
8
+ suiteLinks: SuiteLinks;
9
+ suiteFetch: Partial<Record<"explain" | "pr", string>>;
10
+ }
11
+
12
+ function loopbackHostname(hostname: string): boolean {
13
+ const bare = hostname.replace(/^\[|\]$/g, "");
14
+ if (bare === "::1" || bare === "localhost") return true;
15
+ const parts = bare.split(".");
16
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
17
+ }
18
+
19
+ function loopbackIpLiteral(hostname: string): boolean {
20
+ return hostname !== "localhost" && loopbackHostname(hostname);
21
+ }
22
+
23
+ function tailnetOf(hostname: string): string | undefined {
24
+ const labels = hostname.toLowerCase().split(".");
25
+ return labels.length >= 4 && labels.at(-1) === "net" && labels.at(-2) === "ts" ? labels.slice(-3).join(".") : undefined;
26
+ }
27
+
28
+ function exactOrigin(value: string, variable: string): URL {
29
+ if (!/^https?:\/\/[^/?#\\\s@*]+$/i.test(value) || value.endsWith(":")) {
30
+ throw new Error(`${variable} 格式錯誤:必須是不含路徑、查詢或萬用字元的完整 origin。`);
31
+ }
32
+ let url: URL;
33
+ try {
34
+ url = new URL(value);
35
+ } catch {
36
+ throw new Error(`${variable} 格式錯誤:無法解析網址。`);
37
+ }
38
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || url.port === "0") {
39
+ throw new Error(`${variable} 格式錯誤:不得含帳密、路徑、查詢參數或 port 0。`);
40
+ }
41
+ return url;
42
+ }
43
+
44
+ export function parseAllowedOrigins(raw: string | undefined, variable = "BELL_READER_ALLOWED_ORIGINS"): string[] {
45
+ const text = (raw ?? "").trim();
46
+ if (!text) return [];
47
+ return text.split(",").map((entry) => {
48
+ const url = exactOrigin(entry.trim(), variable);
49
+ if (!loopbackHostname(url.hostname) && url.protocol !== "https:") {
50
+ throw new Error(`${variable} 格式錯誤:非本機來源必須使用 https。`);
51
+ }
52
+ return url.origin;
53
+ });
54
+ }
55
+
56
+ export function parseSuiteLinks(
57
+ raw: string | undefined,
58
+ ownOrigins: string[],
59
+ variable = "BELL_READER_SUITE_LINKS",
60
+ ): SuiteLinks {
61
+ const text = (raw ?? "").trim();
62
+ if (!text) return {};
63
+ const parts = text.split(",").map((part) => part.trim());
64
+ if (parts.length > SUITE_LIMITS.links) {
65
+ throw new Error(`${variable} 最多支援 ${SUITE_LIMITS.links} 個產品連結。`);
66
+ }
67
+ const ownTailnets = new Set(
68
+ ownOrigins
69
+ .map((origin) => {
70
+ try {
71
+ return tailnetOf(new URL(origin).hostname);
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ })
76
+ .filter((item): item is string => Boolean(item)),
77
+ );
78
+ const seen = new Set<string>();
79
+ const links: SuiteLinks = {};
80
+ for (const part of parts) {
81
+ const match = /^([a-z]+)=(.+)$/.exec(part);
82
+ if (!match) throw new Error(`${variable} 格式錯誤:每項須為 appId=origin。`);
83
+ const [, appId, value] = match;
84
+ if (seen.has(appId)) throw new Error(`${variable} 重複設定了 ${appId}。`);
85
+ seen.add(appId);
86
+ if (!SUITE_APP_IDS.includes(appId as SuiteAppId) || appId === "reader") {
87
+ throw new Error(`${variable} 不支援或指向自身:${appId}。`);
88
+ }
89
+ const url = exactOrigin(value, `${variable}.${appId}`);
90
+ if (!loopbackHostname(url.hostname)) {
91
+ if (url.protocol !== "https:") throw new Error(`${variable}.${appId} 非本機來源必須使用 https。`);
92
+ const tailnet = tailnetOf(url.hostname);
93
+ if (!tailnet || !ownTailnets.has(tailnet)) {
94
+ throw new Error(`${variable}.${appId} 僅允許與本服務相同的 Tailscale tailnet。`);
95
+ }
96
+ }
97
+ links[appId as SuiteAppId] = url.origin;
98
+ }
99
+ return links;
100
+ }
101
+
102
+ export function parseSuiteFetch(
103
+ raw: string | undefined,
104
+ variable = "BELL_READER_SUITE_FETCH",
105
+ ): Partial<Record<"explain" | "pr", string>> {
106
+ const text = (raw ?? "").trim();
107
+ if (!text) return {};
108
+ const parts = text.split(",").map((part) => part.trim());
109
+ const seen = new Set<string>();
110
+ const targets: Partial<Record<"explain" | "pr", string>> = {};
111
+ for (const part of parts) {
112
+ const match = /^([a-z]+)=(.+)$/.exec(part);
113
+ if (!match) throw new Error(`${variable} 格式錯誤:每項須為 appId=origin。`);
114
+ const [, appId, value] = match;
115
+ if (seen.has(appId)) throw new Error(`${variable} 重複設定了 ${appId}。`);
116
+ seen.add(appId);
117
+ if (appId !== "explain" && appId !== "pr") {
118
+ throw new Error(`${variable} 只支援讀取 explain 或 pr。`);
119
+ }
120
+ const url = exactOrigin(value, `${variable}.${appId}`);
121
+ if (url.protocol !== "http:" || !loopbackIpLiteral(url.hostname)) {
122
+ throw new Error(`${variable}.${appId} 伺服器讀取只允許 http://127.x.x.x:port 或 http://[::1]:port。`);
123
+ }
124
+ targets[appId] = url.origin;
125
+ }
126
+ return targets;
127
+ }
128
+
129
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): RuntimeConfig {
130
+ const port = Number(env.BELL_READER_PORT || env.PORT || 4630);
131
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
132
+ throw new Error("PORT 必須是 1–65535 的整數。");
133
+ }
134
+ const host = (env.BELL_READER_HOST || env.HOST || "127.0.0.1").trim();
135
+ const dataDir = (env.BELL_READER_DATA_DIR || "./data").trim();
136
+ const allowedOrigins = parseAllowedOrigins(env.BELL_READER_ALLOWED_ORIGINS);
137
+ const suiteLinks = parseSuiteLinks(env.BELL_READER_SUITE_LINKS, allowedOrigins);
138
+ const suiteFetch = parseSuiteFetch(env.BELL_READER_SUITE_FETCH);
139
+ return { host, port, dataDir, allowedOrigins, suiteLinks, suiteFetch };
140
+ }
@@ -0,0 +1,22 @@
1
+ export class AppError extends Error {
2
+ constructor(
3
+ public readonly status: number,
4
+ message: string,
5
+ ) {
6
+ super(message);
7
+ this.name = "AppError";
8
+ }
9
+ }
10
+
11
+ export function publicError(error: unknown): { status: number; message: string } {
12
+ if (error instanceof AppError) {
13
+ return { status: error.status, message: error.message };
14
+ }
15
+ if (error instanceof DOMException && error.name === "TimeoutError") {
16
+ return { status: 504, message: "AI 批註等待時間過長,請重試或在設定切換模型供應商。" };
17
+ }
18
+ if (error instanceof DOMException && error.name === "AbortError") {
19
+ return { status: 499, message: "已取消此次批註。" };
20
+ }
21
+ return { status: 500, message: "伺服器處理時發生錯誤,請稍後再試。" };
22
+ }
@@ -0,0 +1,320 @@
1
+ import http from "node:http";
2
+ import {
3
+ SUITE_LIMITS,
4
+ SUITE_OBJECT_ID,
5
+ SUITE_UNSAFE_TEXT,
6
+ allowedSuiteRoute,
7
+ } from "../shared/suite.js";
8
+ import type { ExplainBridgeStatus, ExplainTopicMatch, SavedPage } from "../shared/types.js";
9
+
10
+ // Dedicated agent so host proxy vars (NODE_USE_ENV_PROXY / http_proxy) never intercept loopback sibling reads.
11
+ const loopbackAgent = new http.Agent({ keepAlive: false });
12
+
13
+ interface ExplainEntry {
14
+ id: string;
15
+ title: string;
16
+ category?: string;
17
+ tags: string[];
18
+ url?: string;
19
+ href: string;
20
+ }
21
+
22
+ interface CacheState {
23
+ entries: ExplainEntry[];
24
+ lastAttemptAt: number;
25
+ lastSuccessAt?: number;
26
+ lastFailed: boolean;
27
+ inflight?: Promise<void>;
28
+ }
29
+
30
+ function fetchJsonBounded(url: string): Promise<unknown> {
31
+ // shortcut: 256 KB cap when reading /api/lessons fallback, tighten once /api/suite/topics is deployed everywhere
32
+ const maxBytes = url.endsWith("/api/lessons") ? 256 * 1024 : SUITE_LIMITS.fetchMaxBytes;
33
+ const target = new URL(url);
34
+ return new Promise((resolve, reject) => {
35
+ let settled = false;
36
+ let request: http.ClientRequest | undefined;
37
+ const finish = (err: Error | null, value?: unknown) => {
38
+ if (settled) return;
39
+ settled = true;
40
+ clearTimeout(timer);
41
+ if (err) {
42
+ request?.destroy();
43
+ reject(err);
44
+ } else {
45
+ resolve(value);
46
+ }
47
+ };
48
+ const timer = setTimeout(() => finish(new Error("Timeout")), SUITE_LIMITS.fetchTimeoutMs);
49
+ request = http.request(
50
+ {
51
+ agent: loopbackAgent,
52
+ protocol: "http:",
53
+ hostname: target.hostname.replace(/^\[(.*)\]$/, "$1"),
54
+ port: Number(target.port || 80),
55
+ method: "GET",
56
+ path: `${target.pathname}${target.search}`,
57
+ headers: { Accept: "application/json" },
58
+ },
59
+ (res) => {
60
+ if (res.statusCode !== 200) return finish(new Error(`HTTP ${res.statusCode}`));
61
+ const ct = String(res.headers["content-type"] ?? "");
62
+ if (!/^application\/json\b/i.test(ct)) return finish(new Error("Non-JSON response"));
63
+ if (Number(res.headers["content-length"]) > maxBytes) return finish(new Error("Response too large"));
64
+ const chunks: Buffer[] = [];
65
+ let total = 0;
66
+ res.on("data", (chunk: Buffer) => {
67
+ total += chunk.length;
68
+ if (total > maxBytes) finish(new Error("Response too large"));
69
+ else chunks.push(chunk);
70
+ });
71
+ res.on("end", () => {
72
+ try {
73
+ finish(null, JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))));
74
+ } catch {
75
+ finish(new Error("Invalid JSON"));
76
+ }
77
+ });
78
+ res.on("error", () => finish(new Error("Read failed")));
79
+ res.on("close", () => {
80
+ if (!res.complete) finish(new Error("Closed prematurely"));
81
+ });
82
+ },
83
+ );
84
+ request.on("error", () => finish(new Error("Connect failed")));
85
+ request.end();
86
+ });
87
+ }
88
+
89
+ function sanitizeEntry(raw: Record<string, unknown>): ExplainEntry | undefined {
90
+ const id = typeof raw.id === "string" ? raw.id : "";
91
+ const title = typeof raw.title === "string" ? raw.title.trim() : "";
92
+ if (!SUITE_OBJECT_ID.test(id) || id === "." || id === "..") return undefined;
93
+ if (!title || title.length > SUITE_LIMITS.title || SUITE_UNSAFE_TEXT.test(title)) return undefined;
94
+ const href = typeof raw.href === "string" && allowedSuiteRoute("explain", raw.href)
95
+ ? raw.href
96
+ : `#/note/${id}`;
97
+ if (!allowedSuiteRoute("explain", href)) return undefined;
98
+ const tags = Array.isArray(raw.tags)
99
+ ? raw.tags.filter((t): t is string => typeof t === "string" && t.length <= 40 && !SUITE_UNSAFE_TEXT.test(t)).slice(0, 8)
100
+ : [];
101
+ const category =
102
+ typeof raw.category === "string" && raw.category.length <= 40 && !SUITE_UNSAFE_TEXT.test(raw.category)
103
+ ? raw.category
104
+ : undefined;
105
+ const sourceObj = raw.source && typeof raw.source === "object" ? (raw.source as Record<string, unknown>) : undefined;
106
+ const url =
107
+ typeof raw.url === "string"
108
+ ? raw.url
109
+ : typeof sourceObj?.url === "string"
110
+ ? sourceObj.url
111
+ : undefined;
112
+ return { id, title, ...(category ? { category } : {}), tags, ...(url ? { url } : {}), href };
113
+ }
114
+
115
+ function normalizeToken(value: string): string {
116
+ return value.toLowerCase().replace(/[\s\-_·.,:;!?()[\]{}"'「」『』《》]/g, "");
117
+ }
118
+
119
+ export class ExplainBridge {
120
+ private readonly cache: CacheState = {
121
+ entries: [],
122
+ lastAttemptAt: 0,
123
+ lastFailed: false,
124
+ };
125
+
126
+ constructor(
127
+ private readonly fetchOrigin?: string,
128
+ private readonly browserOrigin?: string,
129
+ ) {}
130
+
131
+ private async refresh(force = false): Promise<void> {
132
+ if (!this.fetchOrigin) return;
133
+ const now = Date.now();
134
+ if (!force && this.cache.lastAttemptAt && now - this.cache.lastAttemptAt < SUITE_LIMITS.cacheFreshMs) {
135
+ return;
136
+ }
137
+ if (this.cache.inflight) {
138
+ await this.cache.inflight;
139
+ return;
140
+ }
141
+ this.cache.inflight = (async () => {
142
+ this.cache.lastAttemptAt = Date.now();
143
+ try {
144
+ let parsedEntries: ExplainEntry[] | undefined;
145
+ // 1. Try minimized /api/suite/topics first
146
+ try {
147
+ const data = (await fetchJsonBounded(`${this.fetchOrigin}/api/suite/topics`)) as {
148
+ topics?: unknown[];
149
+ };
150
+ if (Array.isArray(data?.topics)) {
151
+ parsedEntries = data.topics
152
+ .filter((item): item is Record<string, unknown> => Boolean(item && typeof item === "object"))
153
+ .flatMap((item) => sanitizeEntry(item) ?? []);
154
+ }
155
+ } catch {
156
+ // Fallback to /api/lessons or /api/suite/today on v1 Explain instances
157
+ }
158
+
159
+ if (!parsedEntries) {
160
+ try {
161
+ const lessons = await fetchJsonBounded(`${this.fetchOrigin}/api/lessons`);
162
+ if (Array.isArray(lessons)) {
163
+ parsedEntries = lessons
164
+ .filter((item): item is Record<string, unknown> => Boolean(item && typeof item === "object"))
165
+ .flatMap((item) => sanitizeEntry(item) ?? []);
166
+ }
167
+ } catch {
168
+ const today = (await fetchJsonBounded(`${this.fetchOrigin}/api/suite/today`)) as {
169
+ items?: unknown[];
170
+ };
171
+ if (Array.isArray(today?.items)) {
172
+ parsedEntries = today.items
173
+ .filter((item): item is Record<string, unknown> => Boolean(item && typeof item === "object"))
174
+ .flatMap((item) => sanitizeEntry(item) ?? []);
175
+ }
176
+ }
177
+ }
178
+
179
+ if (!parsedEntries) throw new Error("No valid Explain endpoint");
180
+ this.cache.entries = parsedEntries;
181
+ this.cache.lastSuccessAt = Date.now();
182
+ this.cache.lastFailed = false;
183
+ } catch {
184
+ this.cache.lastFailed = true;
185
+ if (
186
+ this.cache.lastSuccessAt &&
187
+ Date.now() - this.cache.lastSuccessAt > SUITE_LIMITS.staleDropMs
188
+ ) {
189
+ this.cache.entries = [];
190
+ }
191
+ } finally {
192
+ this.cache.inflight = undefined;
193
+ }
194
+ })();
195
+ await this.cache.inflight;
196
+ }
197
+
198
+ async matchForPage(page: SavedPage, forceRefresh = false): Promise<ExplainBridgeStatus> {
199
+ if (!this.fetchOrigin) {
200
+ return {
201
+ status: "unconfigured",
202
+ ...(this.browserOrigin ? { browserOrigin: this.browserOrigin } : {}),
203
+ matches: [],
204
+ };
205
+ }
206
+ await this.refresh(forceRefresh);
207
+
208
+ const hasRecentSuccess =
209
+ this.cache.lastSuccessAt !== undefined &&
210
+ Date.now() - this.cache.lastSuccessAt <= SUITE_LIMITS.staleDropMs;
211
+
212
+ if (this.cache.lastFailed && !hasRecentSuccess) {
213
+ return {
214
+ status: "unavailable",
215
+ ...(this.browserOrigin ? { browserOrigin: this.browserOrigin } : {}),
216
+ matches: [],
217
+ };
218
+ }
219
+
220
+ const status = this.cache.lastFailed ? "stale" : "ready";
221
+ const terms = new Map<string, string>();
222
+ const addTerm = (raw: string | undefined) => {
223
+ if (!raw || raw === "未分類") return;
224
+ const norm = normalizeToken(raw);
225
+ if (norm.length >= 2 && !terms.has(norm)) terms.set(norm, raw.trim());
226
+ };
227
+
228
+ addTerm(page.topic);
229
+ for (const tag of page.tags) addTerm(tag);
230
+ for (const ann of page.annotations) {
231
+ addTerm(ann.topic);
232
+ if (ann.quote.length <= 24) addTerm(ann.quote);
233
+ }
234
+ for (const part of page.title.split(/[\s·—\-:|/、,]+/)) {
235
+ if (part.trim().length >= 2 && part.trim().length <= 24) addTerm(part);
236
+ }
237
+
238
+ const scored: Array<{ entry: ExplainEntry; score: number; reason: string }> = [];
239
+ for (const entry of this.cache.entries) {
240
+ if (page.url && entry.url && page.url === entry.url) {
241
+ scored.push({ entry, score: 100, reason: "同一來源網址" });
242
+ continue;
243
+ }
244
+ const entryTitleNorm = normalizeToken(entry.title);
245
+ const entryCategoryNorm = entry.category ? normalizeToken(entry.category) : "";
246
+ const entryTagsNorm = entry.tags.map((t) => ({ raw: t, norm: normalizeToken(t) }));
247
+
248
+ let bestScore = 0;
249
+ let bestReason = "";
250
+ for (const [norm, display] of terms) {
251
+ if (!norm) continue;
252
+ if (entryTagsNorm.some((t) => t.norm === norm || (t.norm.length >= 2 && (t.norm.includes(norm) || norm.includes(t.norm))))) {
253
+ if (bestScore < 30) {
254
+ bestScore = 30;
255
+ bestReason = `相同主題:${display}`;
256
+ }
257
+ } else if (entryTitleNorm.includes(norm) || (entryTitleNorm.length >= 2 && norm.includes(entryTitleNorm))) {
258
+ if (bestScore < 25) {
259
+ bestScore = 25;
260
+ bestReason = `相關筆記:${display}`;
261
+ }
262
+ } else if (entryCategoryNorm && (entryCategoryNorm === norm || entryCategoryNorm.includes(norm))) {
263
+ if (bestScore < 15) {
264
+ bestScore = 15;
265
+ bestReason = `同分類:${entry.category}`;
266
+ }
267
+ }
268
+ }
269
+ if (bestScore > 0) {
270
+ scored.push({ entry, score: bestScore, reason: bestReason });
271
+ }
272
+ }
273
+
274
+ scored.sort((a, b) => b.score - a.score);
275
+ const matches: ExplainTopicMatch[] = scored.slice(0, 5).map(({ entry, reason }) => ({
276
+ id: entry.id,
277
+ title: entry.title,
278
+ ...(entry.category ? { category: entry.category } : {}),
279
+ tags: entry.tags,
280
+ href: entry.href,
281
+ reason,
282
+ }));
283
+
284
+ return {
285
+ status,
286
+ ...(this.browserOrigin ? { browserOrigin: this.browserOrigin } : {}),
287
+ matches,
288
+ };
289
+ }
290
+
291
+ async listAllTopics(forceRefresh = false): Promise<{
292
+ status: ExplainBridgeStatus["status"];
293
+ browserOrigin?: string;
294
+ entries: ExplainEntry[];
295
+ }> {
296
+ if (!this.fetchOrigin) {
297
+ return {
298
+ status: "unconfigured",
299
+ ...(this.browserOrigin ? { browserOrigin: this.browserOrigin } : {}),
300
+ entries: [],
301
+ };
302
+ }
303
+ await this.refresh(forceRefresh);
304
+ const hasRecentSuccess =
305
+ this.cache.lastSuccessAt !== undefined &&
306
+ Date.now() - this.cache.lastSuccessAt <= SUITE_LIMITS.staleDropMs;
307
+ if (this.cache.lastFailed && !hasRecentSuccess) {
308
+ return {
309
+ status: "unavailable",
310
+ ...(this.browserOrigin ? { browserOrigin: this.browserOrigin } : {}),
311
+ entries: [],
312
+ };
313
+ }
314
+ return {
315
+ status: this.cache.lastFailed ? "stale" : "ready",
316
+ ...(this.browserOrigin ? { browserOrigin: this.browserOrigin } : {}),
317
+ entries: this.cache.entries,
318
+ };
319
+ }
320
+ }