@nanmicoder/dsh-skills-hub 0.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 (67) hide show
  1. package/AGENTS.md +9 -0
  2. package/CONTRIBUTING.md +11 -0
  3. package/LICENSE +21 -0
  4. package/README.md +120 -0
  5. package/README_EN.md +120 -0
  6. package/README_ZH.md +5 -0
  7. package/assets/readme/hero-en.png +0 -0
  8. package/assets/readme/hero-zh.png +0 -0
  9. package/assets/readme/marketplace.png +0 -0
  10. package/compatibility.json +10 -0
  11. package/cordis.patch.yml +5 -0
  12. package/docs/COMPATIBILITY.md +31 -0
  13. package/docs/CONTRACT.md +125 -0
  14. package/docs/RELEASE.md +24 -0
  15. package/lib/client.js +25115 -0
  16. package/lib/client.js.map +1 -0
  17. package/lib/config.js +51 -0
  18. package/lib/index.js +176 -0
  19. package/lib/market/cache.js +113 -0
  20. package/lib/market/clawhub-provider.js +598 -0
  21. package/lib/market/install-service.js +394 -0
  22. package/lib/market/market-service.js +312 -0
  23. package/lib/market/provider-fetch.js +216 -0
  24. package/lib/market/skillhub-provider.js +277 -0
  25. package/lib/market/types.js +82 -0
  26. package/lib/skills/installed.js +426 -0
  27. package/lib/skills/management.js +36 -0
  28. package/lib/skills/read-text.js +23 -0
  29. package/lib/skills/root.js +112 -0
  30. package/lib/types/client/api.d.ts +106 -0
  31. package/lib/types/client/components/FilterBar.d.ts +20 -0
  32. package/lib/types/client/components/FrontmatterPanel.d.ts +15 -0
  33. package/lib/types/client/components/InstallConfirmDialog.d.ts +31 -0
  34. package/lib/types/client/components/InstallStateBadge.d.ts +11 -0
  35. package/lib/types/client/components/InstalledSkills.d.ts +3 -0
  36. package/lib/types/client/components/MarkdownView.d.ts +4 -0
  37. package/lib/types/client/components/MarketDisclaimer.d.ts +10 -0
  38. package/lib/types/client/components/MarketHome.d.ts +12 -0
  39. package/lib/types/client/components/SecurityBadge.d.ts +16 -0
  40. package/lib/types/client/components/SkillAvatar.d.ts +14 -0
  41. package/lib/types/client/components/SkillCard.d.ts +17 -0
  42. package/lib/types/client/components/SkillDetailView.d.ts +21 -0
  43. package/lib/types/client/components/SourceStatusBar.d.ts +13 -0
  44. package/lib/types/client/icons.d.ts +71 -0
  45. package/lib/types/client/index.d.ts +56 -0
  46. package/lib/types/client/locale-context.d.ts +31 -0
  47. package/lib/types/client/locales.d.ts +117 -0
  48. package/lib/types/client/page/MarketPage.d.ts +21 -0
  49. package/lib/types/client/skill-markdown.d.ts +2 -0
  50. package/lib/types/client/state.d.ts +119 -0
  51. package/lib/types/config.d.ts +51 -0
  52. package/lib/types/index.d.ts +42 -0
  53. package/lib/types/market/cache.d.ts +49 -0
  54. package/lib/types/market/clawhub-provider.d.ts +28 -0
  55. package/lib/types/market/install-service.d.ts +75 -0
  56. package/lib/types/market/market-service.d.ts +74 -0
  57. package/lib/types/market/provider-fetch.d.ts +61 -0
  58. package/lib/types/market/skillhub-provider.d.ts +13 -0
  59. package/lib/types/market/types.d.ts +163 -0
  60. package/lib/types/skills/installed.d.ts +117 -0
  61. package/lib/types/skills/management.d.ts +7 -0
  62. package/lib/types/skills/read-text.d.ts +5 -0
  63. package/lib/types/skills/root.d.ts +48 -0
  64. package/lib/types/web-routes.d.ts +74 -0
  65. package/lib/web-routes.js +427 -0
  66. package/package.json +160 -0
  67. package/release-notes/v0.0.1.md +11 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Skills Market — aggregation service (host half).
3
+ *
4
+ * Merges the two upstream providers into a single paginated feed with
5
+ * cross-source dedupe, per-source health/degradation reporting, TTL caching
6
+ * (stale-while-error) and locally-computed install state.
7
+ *
8
+ * The module is deliberately free of DSH services and of the filesystem: the
9
+ * "what is installed locally" question is answered by an injected
10
+ * `InstalledLookup` (see `setInstalledLookup`), so this file can be unit-tested
11
+ * by pointing the providers at a stub server.
12
+ */
13
+ import { getSourceHealth, markSourceHealth, MARKET_TTL, marketCache } from "./cache.js";
14
+ import { clawhubProvider } from "./clawhub-provider.js";
15
+ import { skillhubProvider } from "./skillhub-provider.js";
16
+ import { MARKET_LIMITS, MARKET_SOURCES, detectMarketLanguage, sanitizeDirName, skillId, } from "./types.js";
17
+ const providers = {
18
+ clawhub: clawhubProvider,
19
+ skillhub: skillhubProvider,
20
+ };
21
+ /** Nothing is installed until the host installs its real lookup. */
22
+ const EMPTY_INSTALLED_LOOKUP = {
23
+ has: () => false,
24
+ info: () => undefined,
25
+ };
26
+ let installedLookup = EMPTY_INSTALLED_LOOKUP;
27
+ export function setInstalledLookup(lookup) {
28
+ installedLookup = lookup;
29
+ }
30
+ /** Test hook: fall back to "nothing installed" (also used when a plugin unloads). */
31
+ export function resetInstalledLookup() {
32
+ installedLookup = EMPTY_INSTALLED_LOOKUP;
33
+ }
34
+ export function encodeCursor(cursor) {
35
+ const keys = Object.keys(cursor);
36
+ if (keys.length === 0)
37
+ return null;
38
+ return Buffer.from(JSON.stringify(cursor), 'utf-8').toString('base64url');
39
+ }
40
+ export function decodeCursor(raw) {
41
+ if (!raw)
42
+ return undefined;
43
+ try {
44
+ const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf-8'));
45
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
46
+ return undefined;
47
+ const cursor = {};
48
+ const record = parsed;
49
+ for (const source of MARKET_SOURCES) {
50
+ const value = record[source];
51
+ if (typeof value === 'string' && value)
52
+ cursor[source] = value;
53
+ }
54
+ return cursor;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ // ─── Install-state annotation ────────────────────────────────────────────────
61
+ /**
62
+ * Stamp a market item with its local install state.
63
+ *
64
+ * Computed on every request (never cached): a skill installed a second ago must
65
+ * show up as installed on the next list call. The reference version also probed
66
+ * the skills directory for a name conflict; that check needs a filesystem, so it
67
+ * now belongs to the install path, which refuses to overwrite a directory it did
68
+ * not create.
69
+ */
70
+ export function annotateInstallState(skill) {
71
+ // A non-string slug is untrusted upstream data: degrade it to "not
72
+ // installable" instead of throwing the whole list request away.
73
+ const dirName = typeof skill.slug === 'string' ? sanitizeDirName(skill.slug) : null;
74
+ if (!dirName) {
75
+ return { ...skill, installState: 'not-installable', notInstallableReason: 'invalid-name' };
76
+ }
77
+ const id = skillId(skill.source, skill.slug);
78
+ if (!installedLookup.has(id)) {
79
+ return { ...skill, installState: 'installable', notInstallableReason: undefined, installedInfo: undefined };
80
+ }
81
+ const info = installedLookup.info(id);
82
+ return {
83
+ ...skill,
84
+ installState: 'installed',
85
+ notInstallableReason: undefined,
86
+ installedInfo: {
87
+ dirName: info?.dirName ?? dirName,
88
+ version: info?.version,
89
+ installedAt: info?.installedAt,
90
+ },
91
+ };
92
+ }
93
+ /** File-level installability checks — only possible once the file list is known. */
94
+ export function applyFileLimits(detail) {
95
+ const files = detail.files.map((file) => ({ ...file, tooBig: file.size > MARKET_LIMITS.maxFileSize }));
96
+ const result = { ...detail, files };
97
+ if (result.installState !== 'installable')
98
+ return result;
99
+ if (files.length === 0 || !files.some((file) => file.path === 'SKILL.md')) {
100
+ return { ...result, installState: 'not-installable', notInstallableReason: 'empty-file-list' };
101
+ }
102
+ if (files.length > MARKET_LIMITS.maxFileCount) {
103
+ return { ...result, installState: 'not-installable', notInstallableReason: 'too-many-files' };
104
+ }
105
+ if (files.some((file) => file.tooBig) || result.totalSize > MARKET_LIMITS.maxTotalSize) {
106
+ return { ...result, installState: 'not-installable', notInstallableReason: 'file-too-large' };
107
+ }
108
+ return result;
109
+ }
110
+ // ─── Cross-source dedupe ─────────────────────────────────────────────────────
111
+ /**
112
+ * SkillHub mirrors ClawHub skills (source='clawhub' + upstream_url). When a
113
+ * page contains both the mirror and the ClawHub original, merge them: the
114
+ * ClawHub entry wins (fresher data), enriched with SkillHub-only fields.
115
+ *
116
+ * Unlike the reference this does not mutate its input: those items come from
117
+ * the shared response cache, and mutating them made `mirrors` grow on every
118
+ * repeat request.
119
+ */
120
+ export function dedupeSkills(items) {
121
+ const copies = items.map((item) => ({ ...item }));
122
+ const byClawhubSlug = new Map();
123
+ for (const item of copies) {
124
+ if (item.source === 'clawhub')
125
+ byClawhubSlug.set(item.slug, item);
126
+ }
127
+ const result = [];
128
+ for (const item of copies) {
129
+ if (item.source === 'skillhub' && item.upstream?.slug) {
130
+ const original = byClawhubSlug.get(item.upstream.slug);
131
+ if (original) {
132
+ original.mirrors = [...(original.mirrors ?? []), item.id];
133
+ // Enrich the original with SkillHub-only data.
134
+ if (!original.iconUrl && item.iconUrl)
135
+ original.iconUrl = item.iconUrl;
136
+ if (original.securityStatus === 'unknown' && item.securityStatus !== 'unknown') {
137
+ original.securityStatus = item.securityStatus;
138
+ }
139
+ if (item.tags.length && original.tags.length === 0)
140
+ original.tags = item.tags;
141
+ continue;
142
+ }
143
+ }
144
+ result.push(item);
145
+ }
146
+ return result;
147
+ }
148
+ /**
149
+ * Cache key for one provider page.
150
+ *
151
+ * The fields are JSON-encoded rather than `:`-joined: with a joined key,
152
+ * `q="alpha"` + `cursor="x:y"` and `q="alpha:x"` + `cursor="y"` produced the same
153
+ * string, so one request was served the other's page. `source` stays a leading
154
+ * segment so every key is obviously source-scoped.
155
+ */
156
+ function providerPageCacheKey(source, kind, params) {
157
+ return `${kind}:${source}:${JSON.stringify([params.q ?? null, params.cursor ?? null, params.limit])}`;
158
+ }
159
+ async function fetchProviderPage(source, params) {
160
+ const isSearch = Boolean(params.q);
161
+ const cacheKey = providerPageCacheKey(source, isSearch ? 'search' : 'list', params);
162
+ const ttl = isSearch ? MARKET_TTL.search : MARKET_TTL.list;
163
+ const cached = marketCache.get(cacheKey);
164
+ if (cached) {
165
+ return { page: cached, status: { status: 'ok', fetchedAt: Date.now(), fromCache: true } };
166
+ }
167
+ try {
168
+ const q = params.q;
169
+ const page = isSearch && q !== undefined
170
+ ? await providers[source].search({ q, cursor: params.cursor, limit: params.limit })
171
+ : await providers[source].list({ cursor: params.cursor, limit: params.limit });
172
+ marketCache.set(cacheKey, page, ttl);
173
+ return { page, status: { status: 'ok', fetchedAt: Date.now(), fromCache: false } };
174
+ }
175
+ catch (error) {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ // A provider that rejects a well-formed HTTP 200 (missing `items`, a
178
+ // non-zero SkillHub envelope code) never reaches the fetch layer's failure
179
+ // bookkeeping, so record it here — otherwise the source bar keeps claiming
180
+ // the registry is healthy while every request fails.
181
+ markSourceHealth(source, 'failed', message);
182
+ // Stale-while-error: a degraded source still serves its last good page, so a
183
+ // temporary upstream outage does not empty the panel.
184
+ const stale = marketCache.getStale(cacheKey);
185
+ if (stale) {
186
+ return {
187
+ page: stale.value,
188
+ status: { status: 'cached', fetchedAt: stale.storedAt, fromCache: true, error: message },
189
+ };
190
+ }
191
+ return { page: null, status: { ...getSourceHealth(source), fromCache: false, error: message } };
192
+ }
193
+ }
194
+ export async function listMarketSkills(params) {
195
+ const cursor = decodeCursor(params.cursor);
196
+ const isFirstPage = !params.cursor;
197
+ const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source];
198
+ const q = params.q;
199
+ const outcomes = new Map();
200
+ await Promise.all(activeSources.map(async (source) => {
201
+ // A source absent from a non-first-page cursor is exhausted.
202
+ const providerCursor = cursor?.[source];
203
+ if (!isFirstPage && !providerCursor) {
204
+ outcomes.set(source, { page: { items: [] }, status: { status: 'ok', fromCache: true } });
205
+ return;
206
+ }
207
+ // ClawHub search has no pagination: ask it for one big page and let the
208
+ // aggregate cap apply, instead of pretending a second page exists.
209
+ const limit = q && source === 'clawhub' ? MARKET_LIMITS.searchResultCap : params.limit;
210
+ outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }));
211
+ }));
212
+ let merged = [];
213
+ const nextCursor = {};
214
+ const sources = {};
215
+ for (const source of MARKET_SOURCES) {
216
+ const outcome = outcomes.get(source);
217
+ if (!outcome) {
218
+ sources[source] = { status: 'ok', fromCache: false };
219
+ continue;
220
+ }
221
+ sources[source] = outcome.status;
222
+ if (outcome.page) {
223
+ merged.push(...outcome.page.items);
224
+ if (outcome.page.nextCursor)
225
+ nextCursor[source] = outcome.page.nextCursor;
226
+ }
227
+ }
228
+ merged = dedupeSkills(merged);
229
+ merged.sort((a, b) => b.stats.downloads - a.stats.downloads);
230
+ merged = merged.map((item) => annotateInstallState(item));
231
+ if (params.security !== 'all') {
232
+ merged = merged.filter((item) => item.securityStatus === params.security);
233
+ }
234
+ if (params.installed !== 'all') {
235
+ merged = merged.filter((item) => params.installed === 'installed'
236
+ ? item.installState === 'installed'
237
+ : item.installState !== 'installed');
238
+ }
239
+ return { items: merged, nextCursor: encodeCursor(nextCursor), sources };
240
+ }
241
+ // ─── Detail / file content ───────────────────────────────────────────────────
242
+ export async function getMarketSkillDetail(source, slug) {
243
+ const cacheKey = `detail:${source}:${slug}`;
244
+ let detail = marketCache.get(cacheKey);
245
+ let sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: true };
246
+ if (!detail) {
247
+ try {
248
+ detail = await providers[source].detail(slug);
249
+ marketCache.set(cacheKey, detail, MARKET_TTL.detail);
250
+ sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: false };
251
+ }
252
+ catch (error) {
253
+ const stale = marketCache.getStale(cacheKey);
254
+ if (!stale)
255
+ throw error;
256
+ detail = stale.value;
257
+ sourceStatus = {
258
+ status: 'cached',
259
+ fetchedAt: stale.storedAt,
260
+ fromCache: true,
261
+ error: error instanceof Error ? error.message : String(error),
262
+ };
263
+ }
264
+ }
265
+ const annotated = applyFileLimits(annotateInstallState(detail));
266
+ return { skill: annotated, sourceStatus };
267
+ }
268
+ export function isValidMarketFilePath(filePath) {
269
+ if (!filePath || filePath.length > 512)
270
+ return false;
271
+ if (filePath.startsWith('/') || filePath.startsWith('\\'))
272
+ return false;
273
+ if (filePath.includes('..') || filePath.includes('\0'))
274
+ return false;
275
+ return true;
276
+ }
277
+ export async function getMarketFileContent(source, slug, filePath) {
278
+ // JSON-encoded for the same reason as the list keys: slug and filePath are
279
+ // both caller-controlled, so a `:`-joined key is ambiguous.
280
+ const cacheKey = `file:${source}:${JSON.stringify([slug, filePath])}`;
281
+ const cached = marketCache.get(cacheKey);
282
+ if (cached)
283
+ return cached;
284
+ const fetched = await providers[source].fetchFile(slug, filePath);
285
+ let content = fetched.content;
286
+ let truncated = false;
287
+ if (Buffer.byteLength(content, 'utf-8') > MARKET_LIMITS.previewTruncateBytes) {
288
+ content = Buffer.from(content, 'utf-8').subarray(0, MARKET_LIMITS.previewTruncateBytes).toString('utf-8');
289
+ truncated = true;
290
+ }
291
+ const result = {
292
+ path: filePath,
293
+ content,
294
+ language: detectMarketLanguage(filePath),
295
+ size: fetched.size,
296
+ truncated,
297
+ };
298
+ marketCache.set(cacheKey, result, MARKET_TTL.fileContent);
299
+ return result;
300
+ }
301
+ // ─── Status ──────────────────────────────────────────────────────────────────
302
+ export function getMarketStatus() {
303
+ return {
304
+ clawhub: getSourceHealth('clawhub'),
305
+ skillhub: getSourceHealth('skillhub'),
306
+ };
307
+ }
308
+ /** Look up a single skill (used by install) — the detail path, bypassing list. */
309
+ export async function resolveMarketSkill(source, slug) {
310
+ const { skill } = await getMarketSkillDetail(source, slug);
311
+ return skill;
312
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Skills Market — shared upstream fetch helper.
3
+ *
4
+ * Every upstream request goes through `providerFetch` so timeouts, retries,
5
+ * redirect following (SkillHub file downloads 302 to COS) and per-source health
6
+ * bookkeeping stay in one place.
7
+ *
8
+ * Base URLs, timeout and retry budget are injected with `configureProviderFetch`
9
+ * — the plugin passes its resolved Config in `apply()`. Nothing is read from
10
+ * `process.env` at module scope, so the module (and every provider built on it)
11
+ * is directly unit-testable by pointing the bases at a local stub.
12
+ */
13
+ import { markSourceHealth } from "./cache.js";
14
+ import { MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError } from "./types.js";
15
+ const DEFAULT_CONFIG = {
16
+ clawhubBaseUrl: 'https://clawhub.ai',
17
+ skillhubBaseUrl: 'https://api.skillhub.cn',
18
+ timeoutMs: 15_000,
19
+ retries: 1,
20
+ userAgent: 'dsh-skills-hub',
21
+ };
22
+ let currentConfig = { ...DEFAULT_CONFIG };
23
+ /** Strip trailing slashes so `new URL('/api/...', base)` never doubles a separator. */
24
+ function normalizeBaseUrl(value) {
25
+ return value.replace(/\/+$/, '');
26
+ }
27
+ /**
28
+ * Point the providers at different hosts / limits. Called by the plugin with the
29
+ * resolved Config; tests call it to aim the providers at a local stub server.
30
+ * Unspecified fields keep their current value, so a partial override can never
31
+ * silently blank the other source.
32
+ */
33
+ export function configureProviderFetch(config) {
34
+ const next = { ...currentConfig };
35
+ if (typeof config.clawhubBaseUrl === 'string' && config.clawhubBaseUrl) {
36
+ next.clawhubBaseUrl = normalizeBaseUrl(config.clawhubBaseUrl);
37
+ }
38
+ if (typeof config.skillhubBaseUrl === 'string' && config.skillhubBaseUrl) {
39
+ next.skillhubBaseUrl = normalizeBaseUrl(config.skillhubBaseUrl);
40
+ }
41
+ if (typeof config.timeoutMs === 'number' && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0) {
42
+ next.timeoutMs = config.timeoutMs;
43
+ }
44
+ if (typeof config.retries === 'number' && Number.isFinite(config.retries) && config.retries >= 0) {
45
+ next.retries = Math.floor(config.retries);
46
+ }
47
+ if (typeof config.userAgent === 'string' && config.userAgent) {
48
+ next.userAgent = config.userAgent;
49
+ }
50
+ currentConfig = next;
51
+ }
52
+ /** Base URL of one upstream, as resolved from the active configuration. */
53
+ export function getProviderBase(source) {
54
+ return source === 'clawhub' ? currentConfig.clawhubBaseUrl : currentConfig.skillhubBaseUrl;
55
+ }
56
+ /**
57
+ * An upstream answered with a non-2xx status (or a retryable 429/5xx).
58
+ *
59
+ * Extends `MarketUpstreamError` so the existing `instanceof MarketUpstreamError`
60
+ * handling (and its `code`) keeps working, and adds the HTTP `status` so the
61
+ * route layer can tell "skill not found" apart from "upstream is broken".
62
+ */
63
+ export class MarketHttpError extends MarketUpstreamError {
64
+ status;
65
+ constructor(source, status, code, message) {
66
+ super(source, code, message);
67
+ this.name = 'MarketHttpError';
68
+ this.status = status;
69
+ }
70
+ }
71
+ function errorFrom(source, message) {
72
+ return new MarketUpstreamError(source, MARKET_ERROR_CODES.upstreamError, message);
73
+ }
74
+ /** Merge the caller's headers over our defaults without losing either. */
75
+ function requestHeaders(init, userAgent) {
76
+ const headers = {
77
+ Accept: 'application/json, text/plain, */*',
78
+ 'User-Agent': userAgent,
79
+ };
80
+ if (!init?.headers)
81
+ return headers;
82
+ new Headers(init.headers).forEach((value, key) => {
83
+ headers[key] = value;
84
+ });
85
+ return headers;
86
+ }
87
+ /**
88
+ * Fetch an upstream URL with timeout and retries.
89
+ *
90
+ * `retries` is the number of *extra* attempts (the contract's default of 1 means
91
+ * two attempts), matching the reference server. A 429 or 5xx counts as a failed
92
+ * attempt; every other status — including 404 — is returned to the caller so the
93
+ * provider can map it to a meaningful error.
94
+ */
95
+ export async function providerFetch(source, url, init) {
96
+ const config = currentConfig;
97
+ const attempts = Math.max(1, config.retries + 1);
98
+ let lastError;
99
+ for (let attempt = 0; attempt < attempts; attempt++) {
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
102
+ try {
103
+ // Respect a caller-supplied signal (request cancellation) alongside ours.
104
+ const signal = init?.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
105
+ const res = await fetch(url, {
106
+ ...init,
107
+ headers: requestHeaders(init, config.userAgent),
108
+ redirect: 'follow',
109
+ signal,
110
+ });
111
+ if (res.status === 429 || res.status >= 500) {
112
+ lastError = new MarketHttpError(source, res.status, MARKET_ERROR_CODES.upstreamError, `${source} responded ${res.status}`);
113
+ continue;
114
+ }
115
+ markSourceHealth(source, 'ok');
116
+ return res;
117
+ }
118
+ catch (error) {
119
+ if (error instanceof MarketUpstreamError) {
120
+ lastError = error;
121
+ continue;
122
+ }
123
+ const isAbort = error instanceof Error && error.name === 'AbortError';
124
+ lastError = new MarketUpstreamError(source, isAbort ? MARKET_ERROR_CODES.upstreamTimeout : MARKET_ERROR_CODES.upstreamError, isAbort
125
+ ? `${source} request timed out`
126
+ : `${source} request failed: ${error instanceof Error ? error.message : String(error)}`);
127
+ }
128
+ finally {
129
+ clearTimeout(timer);
130
+ }
131
+ }
132
+ const finalError = lastError ?? errorFrom(source, `${source} request failed`);
133
+ markSourceHealth(source, 'failed', finalError.message);
134
+ throw finalError;
135
+ }
136
+ /** `providerFetch` plus JSON decoding and non-2xx/parse-failure classification. */
137
+ export async function providerFetchJson(source, url, init) {
138
+ const res = await providerFetch(source, url, init);
139
+ if (!res.ok) {
140
+ const error = new MarketHttpError(source, res.status, MARKET_ERROR_CODES.upstreamError, `${source} responded ${res.status} for ${safePathname(url)}`);
141
+ markSourceHealth(source, 'failed', error.message);
142
+ throw error;
143
+ }
144
+ // A JSON body is capped exactly like a file body: `res.text()` would buffer
145
+ // whatever the upstream (or an attacker in front of it) sends, and a chunked
146
+ // response without `content-length` can be arbitrarily large.
147
+ let text;
148
+ try {
149
+ ({ content: text } = await readResponseTextWithLimit(source, res, MARKET_LIMITS.maxTotalSize, 'JSON response body'));
150
+ }
151
+ catch (error) {
152
+ const failure = error instanceof MarketUpstreamError
153
+ ? error
154
+ : new MarketUpstreamError(source, MARKET_ERROR_CODES.upstreamBadResponse, `${source} response could not be read`);
155
+ markSourceHealth(source, 'failed', failure.message);
156
+ throw failure;
157
+ }
158
+ try {
159
+ return JSON.parse(text);
160
+ }
161
+ catch {
162
+ const error = new MarketUpstreamError(source, MARKET_ERROR_CODES.upstreamBadResponse, `${source} returned invalid JSON`);
163
+ markSourceHealth(source, 'failed', error.message);
164
+ throw error;
165
+ }
166
+ }
167
+ /** `new URL()` throws on relative input; error messages must never mask the real cause. */
168
+ function safePathname(url) {
169
+ try {
170
+ return new URL(url).pathname;
171
+ }
172
+ catch {
173
+ return url;
174
+ }
175
+ }
176
+ function responseTooLarge(source, label, maxBytes) {
177
+ return new MarketUpstreamError(source, MARKET_ERROR_CODES.upstreamBadResponse, `${source} ${label} exceeds the actual size limit (${maxBytes} bytes)`);
178
+ }
179
+ /**
180
+ * Read a response body as text, aborting as soon as it exceeds `maxBytes`.
181
+ *
182
+ * `content-length` is only a hint (redirects and chunked responses omit it), so
183
+ * the body is streamed and counted rather than trusted.
184
+ */
185
+ export async function readResponseTextWithLimit(source, response, maxBytes, label) {
186
+ const contentLength = Number(response.headers.get('content-length'));
187
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
188
+ throw responseTooLarge(source, label, maxBytes);
189
+ }
190
+ if (!response.body)
191
+ return { content: '', size: 0 };
192
+ const reader = response.body.getReader();
193
+ const decoder = new TextDecoder();
194
+ const chunks = [];
195
+ let total = 0;
196
+ try {
197
+ for (;;) {
198
+ const { done, value } = await reader.read();
199
+ if (done)
200
+ break;
201
+ if (value) {
202
+ total += value.byteLength;
203
+ if (total > maxBytes) {
204
+ await reader.cancel().catch(() => { });
205
+ throw responseTooLarge(source, label, maxBytes);
206
+ }
207
+ chunks.push(decoder.decode(value, { stream: true }));
208
+ }
209
+ }
210
+ chunks.push(decoder.decode());
211
+ return { content: chunks.join(''), size: total };
212
+ }
213
+ finally {
214
+ reader.releaseLock();
215
+ }
216
+ }