threenative-asset-mcp 0.4.0

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 (150) hide show
  1. package/README.md +441 -0
  2. package/dist/ambientcg/client.d.ts +61 -0
  3. package/dist/ambientcg/client.d.ts.map +1 -0
  4. package/dist/ambientcg/client.js +234 -0
  5. package/dist/ambientcg/client.js.map +1 -0
  6. package/dist/audio/catalog.d.ts +36 -0
  7. package/dist/audio/catalog.d.ts.map +1 -0
  8. package/dist/audio/catalog.js +169 -0
  9. package/dist/audio/catalog.js.map +1 -0
  10. package/dist/audio/client.d.ts +46 -0
  11. package/dist/audio/client.d.ts.map +1 -0
  12. package/dist/audio/client.js +196 -0
  13. package/dist/audio/client.js.map +1 -0
  14. package/dist/bundle/client.d.ts +89 -0
  15. package/dist/bundle/client.d.ts.map +1 -0
  16. package/dist/bundle/client.js +324 -0
  17. package/dist/bundle/client.js.map +1 -0
  18. package/dist/config.d.ts +20 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +130 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/download/direct-asset-downloader.d.ts +41 -0
  23. package/dist/download/direct-asset-downloader.d.ts.map +1 -0
  24. package/dist/download/direct-asset-downloader.js +232 -0
  25. package/dist/download/direct-asset-downloader.js.map +1 -0
  26. package/dist/fab/api-download.d.ts +30 -0
  27. package/dist/fab/api-download.d.ts.map +1 -0
  28. package/dist/fab/api-download.js +200 -0
  29. package/dist/fab/api-download.js.map +1 -0
  30. package/dist/fab/browser-transport.d.ts +79 -0
  31. package/dist/fab/browser-transport.d.ts.map +1 -0
  32. package/dist/fab/browser-transport.js +492 -0
  33. package/dist/fab/browser-transport.js.map +1 -0
  34. package/dist/fab/cache.d.ts +22 -0
  35. package/dist/fab/cache.d.ts.map +1 -0
  36. package/dist/fab/cache.js +55 -0
  37. package/dist/fab/cache.js.map +1 -0
  38. package/dist/fab/client.d.ts +61 -0
  39. package/dist/fab/client.d.ts.map +1 -0
  40. package/dist/fab/client.js +868 -0
  41. package/dist/fab/client.js.map +1 -0
  42. package/dist/fab/direct-transport.d.ts +68 -0
  43. package/dist/fab/direct-transport.d.ts.map +1 -0
  44. package/dist/fab/direct-transport.js +184 -0
  45. package/dist/fab/direct-transport.js.map +1 -0
  46. package/dist/fab/download-store.d.ts +21 -0
  47. package/dist/fab/download-store.d.ts.map +1 -0
  48. package/dist/fab/download-store.js +93 -0
  49. package/dist/fab/download-store.js.map +1 -0
  50. package/dist/fab/errors.d.ts +20 -0
  51. package/dist/fab/errors.d.ts.map +1 -0
  52. package/dist/fab/errors.js +65 -0
  53. package/dist/fab/errors.js.map +1 -0
  54. package/dist/fab/impersonate-fetch.d.ts +48 -0
  55. package/dist/fab/impersonate-fetch.d.ts.map +1 -0
  56. package/dist/fab/impersonate-fetch.js +228 -0
  57. package/dist/fab/impersonate-fetch.js.map +1 -0
  58. package/dist/fab/normalize.d.ts +3 -0
  59. package/dist/fab/normalize.d.ts.map +1 -0
  60. package/dist/fab/normalize.js +285 -0
  61. package/dist/fab/normalize.js.map +1 -0
  62. package/dist/fab/schemas.d.ts +10 -0
  63. package/dist/fab/schemas.d.ts.map +1 -0
  64. package/dist/fab/schemas.js +105 -0
  65. package/dist/fab/schemas.js.map +1 -0
  66. package/dist/index.d.ts +3 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +58 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/itch/catalog.d.ts +13 -0
  71. package/dist/itch/catalog.d.ts.map +1 -0
  72. package/dist/itch/catalog.js +50 -0
  73. package/dist/itch/catalog.js.map +1 -0
  74. package/dist/itch/client.d.ts +46 -0
  75. package/dist/itch/client.d.ts.map +1 -0
  76. package/dist/itch/client.js +175 -0
  77. package/dist/itch/client.js.map +1 -0
  78. package/dist/polyhaven/client.d.ts +55 -0
  79. package/dist/polyhaven/client.d.ts.map +1 -0
  80. package/dist/polyhaven/client.js +210 -0
  81. package/dist/polyhaven/client.js.map +1 -0
  82. package/dist/server.d.ts +25 -0
  83. package/dist/server.d.ts.map +1 -0
  84. package/dist/server.js +448 -0
  85. package/dist/server.js.map +1 -0
  86. package/dist/sketchfab/client.d.ts +70 -0
  87. package/dist/sketchfab/client.d.ts.map +1 -0
  88. package/dist/sketchfab/client.js +255 -0
  89. package/dist/sketchfab/client.js.map +1 -0
  90. package/dist/smithsonian/client.d.ts +32 -0
  91. package/dist/smithsonian/client.d.ts.map +1 -0
  92. package/dist/smithsonian/client.js +142 -0
  93. package/dist/smithsonian/client.js.map +1 -0
  94. package/dist/tools/ambientcg.d.ts +296 -0
  95. package/dist/tools/ambientcg.d.ts.map +1 -0
  96. package/dist/tools/ambientcg.js +183 -0
  97. package/dist/tools/ambientcg.js.map +1 -0
  98. package/dist/tools/audio.d.ts +211 -0
  99. package/dist/tools/audio.d.ts.map +1 -0
  100. package/dist/tools/audio.js +156 -0
  101. package/dist/tools/audio.js.map +1 -0
  102. package/dist/tools/bundle.d.ts +237 -0
  103. package/dist/tools/bundle.d.ts.map +1 -0
  104. package/dist/tools/bundle.js +206 -0
  105. package/dist/tools/bundle.js.map +1 -0
  106. package/dist/tools/direct-download.d.ts +53 -0
  107. package/dist/tools/direct-download.d.ts.map +1 -0
  108. package/dist/tools/direct-download.js +68 -0
  109. package/dist/tools/direct-download.js.map +1 -0
  110. package/dist/tools/download-free-asset.d.ts +66 -0
  111. package/dist/tools/download-free-asset.d.ts.map +1 -0
  112. package/dist/tools/download-free-asset.js +60 -0
  113. package/dist/tools/download-free-asset.js.map +1 -0
  114. package/dist/tools/get-asset.d.ts +161 -0
  115. package/dist/tools/get-asset.d.ts.map +1 -0
  116. package/dist/tools/get-asset.js +131 -0
  117. package/dist/tools/get-asset.js.map +1 -0
  118. package/dist/tools/itch.d.ts +120 -0
  119. package/dist/tools/itch.d.ts.map +1 -0
  120. package/dist/tools/itch.js +120 -0
  121. package/dist/tools/itch.js.map +1 -0
  122. package/dist/tools/list-filters.d.ts +127 -0
  123. package/dist/tools/list-filters.d.ts.map +1 -0
  124. package/dist/tools/list-filters.js +68 -0
  125. package/dist/tools/list-filters.js.map +1 -0
  126. package/dist/tools/list-limited-time-free.d.ts +57 -0
  127. package/dist/tools/list-limited-time-free.d.ts.map +1 -0
  128. package/dist/tools/list-limited-time-free.js +47 -0
  129. package/dist/tools/list-limited-time-free.js.map +1 -0
  130. package/dist/tools/polyhaven.d.ts +266 -0
  131. package/dist/tools/polyhaven.d.ts.map +1 -0
  132. package/dist/tools/polyhaven.js +233 -0
  133. package/dist/tools/polyhaven.js.map +1 -0
  134. package/dist/tools/search-assets.d.ts +176 -0
  135. package/dist/tools/search-assets.d.ts.map +1 -0
  136. package/dist/tools/search-assets.js +172 -0
  137. package/dist/tools/search-assets.js.map +1 -0
  138. package/dist/tools/sketchfab.d.ts +325 -0
  139. package/dist/tools/sketchfab.d.ts.map +1 -0
  140. package/dist/tools/sketchfab.js +203 -0
  141. package/dist/tools/sketchfab.js.map +1 -0
  142. package/dist/tools/smithsonian.d.ts +189 -0
  143. package/dist/tools/smithsonian.d.ts.map +1 -0
  144. package/dist/tools/smithsonian.js +189 -0
  145. package/dist/tools/smithsonian.js.map +1 -0
  146. package/dist/tools/source-directory.d.ts +226 -0
  147. package/dist/tools/source-directory.d.ts.map +1 -0
  148. package/dist/tools/source-directory.js +583 -0
  149. package/dist/tools/source-directory.js.map +1 -0
  150. package/package.json +69 -0
@@ -0,0 +1,868 @@
1
+ import { dirname, join } from "node:path";
2
+ import { loadFabConfig } from "../config.js";
3
+ import { downloadFreeAssetViaApi, nodeDownloadToFile } from "./api-download.js";
4
+ import { BrowserFabTransport } from "./browser-transport.js";
5
+ import { TtlLruCache } from "./cache.js";
6
+ import { DirectFabTransport, FabClientError, } from "./direct-transport.js";
7
+ import { createStderrLogger, nullFabLogger } from "./errors.js";
8
+ import { createImpersonateFetch, impersonateDownloadToFile, resolveImpersonateCommand, } from "./impersonate-fetch.js";
9
+ const FAB_ORIGIN = "https://www.fab.com";
10
+ const SEARCH_PATH = "/i/listings/search";
11
+ const TAXONOMY_TTL_MS = 6 * 60 * 60 * 1_000;
12
+ const SEARCH_TTL_MS = 5 * 60 * 1_000;
13
+ const LISTING_TTL_MS = 15 * 60 * 1_000;
14
+ const LIMITED_TIME_FREE_TTL_MS = 10 * 60 * 1_000;
15
+ const MAX_SEARCH_PAYLOAD_BYTES = 256 * 1_024;
16
+ const TAXONOMY_CACHE_KEY = "taxonomy:public-filter-groups";
17
+ const TAXONOMY_CONTRACT_VERSION = "fab-taxonomy-fallback-2026-07-28";
18
+ const TAXONOMY_CAPTURED_AT = "2026-07-28T00:00:00.000Z";
19
+ const FILTER_KINDS = [
20
+ "channels",
21
+ "listing_types",
22
+ "formats",
23
+ "categories",
24
+ "licenses",
25
+ ];
26
+ const ALLOWED_DISCOVERY_PATHS = new Set([
27
+ "/i/public/taxonomy",
28
+ "/i/public/limited-time-free",
29
+ ]);
30
+ const INTERNAL_FILTER_PATTERN = /(^|[-_\s])(admin|internal|private|staff|hidden|test)([-_\s]|$)/i;
31
+ const FALLBACK_FILTERS = {
32
+ channels: [
33
+ { label: "Unreal Engine", slug: "unreal-engine" },
34
+ { label: "Unity", slug: "unity" },
35
+ ],
36
+ listing_types: [{ label: "3D Model", slug: "3d-model" }],
37
+ formats: [
38
+ { label: "FBX", slug: "fbx" },
39
+ { label: "glTF", slug: "gltf" },
40
+ ],
41
+ categories: [{ label: "Environments", slug: "environments" }],
42
+ licenses: [
43
+ { label: "Personal", slug: "personal" },
44
+ { label: "Professional", slug: "professional" },
45
+ ],
46
+ };
47
+ const FALLBACK_WARNING = "Fab has no confirmed anonymous taxonomy JSON contract; returning versioned fallback values captured on 2026-07-28.";
48
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
49
+ export { DirectFabTransport, FabClientError, };
50
+ const sortTokens = {
51
+ relevance: "-relevance",
52
+ rating: "-ratings.averageRating",
53
+ newest: "-firstPublishedAt",
54
+ oldest: "firstPublishedAt",
55
+ price_asc: "price",
56
+ price_desc: "-price",
57
+ discount_desc: "-min_discount_percentage",
58
+ recently_updated: "-publishedAt",
59
+ };
60
+ function asRecord(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value)
62
+ ? value
63
+ : undefined;
64
+ }
65
+ function asString(value) {
66
+ return typeof value === "string" && value.length > 0 ? value : undefined;
67
+ }
68
+ function asNumber(value) {
69
+ return typeof value === "number" && Number.isFinite(value)
70
+ ? value
71
+ : undefined;
72
+ }
73
+ function firstString(record, ...keys) {
74
+ for (const key of keys) {
75
+ const value = asString(record?.[key]);
76
+ if (value)
77
+ return value;
78
+ }
79
+ return undefined;
80
+ }
81
+ function firstNumber(record, ...keys) {
82
+ for (const key of keys) {
83
+ const value = asNumber(record?.[key]);
84
+ if (value !== undefined)
85
+ return value;
86
+ }
87
+ return undefined;
88
+ }
89
+ function safeHttpsUrl(value) {
90
+ const text = asString(value);
91
+ if (!text || text.length > 1_024)
92
+ return undefined;
93
+ try {
94
+ const url = new URL(text);
95
+ return url.protocol === "https:" ? url.toString() : undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ function publicOperationUrl(path) {
102
+ const url = new URL(path, FAB_ORIGIN);
103
+ const segments = url.pathname.toLowerCase().split("/").filter(Boolean);
104
+ if (url.origin !== FAB_ORIGIN ||
105
+ url.username.length > 0 ||
106
+ url.password.length > 0 ||
107
+ !ALLOWED_DISCOVERY_PATHS.has(url.pathname) ||
108
+ segments.includes("admin")) {
109
+ throw new FabClientError("FAB_INTERNAL", "Discovery operations must use an explicitly approved public Fab route.");
110
+ }
111
+ return url;
112
+ }
113
+ function normalizeFilterValue(value) {
114
+ if (typeof value === "string") {
115
+ const normalized = value.trim();
116
+ if (!normalized ||
117
+ normalized.length > 200 ||
118
+ INTERNAL_FILTER_PATTERN.test(normalized)) {
119
+ return undefined;
120
+ }
121
+ return { label: normalized, slug: normalized };
122
+ }
123
+ const record = asRecord(value);
124
+ if (!record)
125
+ return undefined;
126
+ if (record.hidden === true ||
127
+ record.internal === true ||
128
+ record.isInternal === true ||
129
+ record.adminOnly === true) {
130
+ return undefined;
131
+ }
132
+ const label = firstString(record, "label", "name", "title");
133
+ const slug = firstString(record, "slug", "code", "value", "id");
134
+ if (!label || !slug || label.length > 200 || slug.length > 200) {
135
+ return undefined;
136
+ }
137
+ if (INTERNAL_FILTER_PATTERN.test(slug) ||
138
+ INTERNAL_FILTER_PATTERN.test(label)) {
139
+ return undefined;
140
+ }
141
+ return { label, slug };
142
+ }
143
+ function normalizeFilterGroup(value) {
144
+ if (!Array.isArray(value))
145
+ return [];
146
+ const seen = new Set();
147
+ const normalized = [];
148
+ for (const entry of value) {
149
+ const item = normalizeFilterValue(entry);
150
+ if (!item || seen.has(item.slug))
151
+ continue;
152
+ seen.add(item.slug);
153
+ normalized.push(item);
154
+ if (normalized.length === 200)
155
+ break;
156
+ }
157
+ return normalized;
158
+ }
159
+ function normalizeTaxonomy(value) {
160
+ const envelope = asRecord(value);
161
+ const root = asRecord(envelope?.filters) ??
162
+ asRecord(envelope?.taxonomy) ??
163
+ asRecord(envelope?.data) ??
164
+ envelope;
165
+ if (!root) {
166
+ throw new FabClientError("FAB_UPSTREAM_CHANGED", "Fab's taxonomy response no longer matches the expected contract.");
167
+ }
168
+ const filters = {
169
+ channels: normalizeFilterGroup(root.channels),
170
+ listing_types: normalizeFilterGroup(root.listing_types ?? root.listingTypes),
171
+ formats: normalizeFilterGroup(root.formats ?? root.asset_formats ?? root.assetFormats),
172
+ categories: normalizeFilterGroup(root.categories),
173
+ licenses: normalizeFilterGroup(root.licenses),
174
+ };
175
+ if (FILTER_KINDS.some((kind) => filters[kind].length === 0)) {
176
+ throw new FabClientError("FAB_UPSTREAM_CHANGED", "Fab's taxonomy response is missing a required public filter group.");
177
+ }
178
+ return filters;
179
+ }
180
+ function selectFilterKinds(filters, kinds) {
181
+ const selected = {};
182
+ for (const kind of kinds ?? FILTER_KINDS)
183
+ selected[kind] = filters[kind];
184
+ return selected;
185
+ }
186
+ function explicitPromotionEnd(value) {
187
+ const text = asString(value);
188
+ if (!text ||
189
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(text) ||
190
+ !Number.isFinite(Date.parse(text))) {
191
+ return undefined;
192
+ }
193
+ return text;
194
+ }
195
+ function promotionEntries(value) {
196
+ if (Array.isArray(value))
197
+ return value;
198
+ const record = asRecord(value);
199
+ const entries = record?.results ?? record?.items ?? record?.listings;
200
+ return Array.isArray(entries) ? entries : undefined;
201
+ }
202
+ function priceFrom(value) {
203
+ const record = asRecord(value);
204
+ if (!record)
205
+ return undefined;
206
+ const amount = firstNumber(record, "amount", "price");
207
+ if (amount === undefined)
208
+ return undefined;
209
+ const currency = (firstString(record, "currency", "currencyCode") ?? "USD").slice(0, 10);
210
+ const effectiveAmount = firstNumber(record, "effectiveAmount", "discountedAmount", "discountPrice");
211
+ return {
212
+ amount,
213
+ currency,
214
+ ...(effectiveAmount === undefined ? {} : { effectiveAmount }),
215
+ };
216
+ }
217
+ function normalizeFormats(record) {
218
+ const raw = record.assetFormats ?? record.asset_formats ?? record.formats;
219
+ if (!Array.isArray(raw))
220
+ return [];
221
+ const values = raw.flatMap((entry) => {
222
+ if (typeof entry === "string")
223
+ return [entry.slice(0, 100)];
224
+ const item = asRecord(entry);
225
+ const value = firstString(item, "slug", "name", "code");
226
+ return value ? [value.slice(0, 100)] : [];
227
+ });
228
+ return [...new Set(values)].slice(0, 20);
229
+ }
230
+ function normalizeTags(record) {
231
+ if (!Array.isArray(record.tags))
232
+ return [];
233
+ return record.tags
234
+ .flatMap((entry) => {
235
+ if (typeof entry === "string") {
236
+ return [{ name: entry.slice(0, 100) }];
237
+ }
238
+ const tag = asRecord(entry);
239
+ const name = firstString(tag, "name", "label", "slug");
240
+ if (!name)
241
+ return [];
242
+ const slug = firstString(tag, "slug");
243
+ return [
244
+ {
245
+ name: name.slice(0, 100),
246
+ ...(slug ? { slug: slug.slice(0, 100) } : {}),
247
+ },
248
+ ];
249
+ })
250
+ .slice(0, 20);
251
+ }
252
+ function licenseEffectiveAmount(value) {
253
+ const license = asRecord(value);
254
+ if (!license)
255
+ return undefined;
256
+ const price = priceFrom(license.price) ??
257
+ priceFrom(license.pricing) ??
258
+ priceFrom(license.basePrice);
259
+ const discounted = priceFrom(license.discountedPrice) ??
260
+ priceFrom(license.effectivePrice) ??
261
+ priceFrom(license.salePrice);
262
+ return (discounted?.effectiveAmount ??
263
+ discounted?.amount ??
264
+ price?.effectiveAmount ??
265
+ price?.amount);
266
+ }
267
+ function normalizeSearchItem(value) {
268
+ const record = asRecord(value);
269
+ if (!record)
270
+ return null;
271
+ const rawId = firstString(record, "uid", "id", "uuid");
272
+ const rawTitle = firstString(record, "title", "name");
273
+ if (!rawId || !rawTitle)
274
+ return null;
275
+ const id = rawId.slice(0, 100);
276
+ const title = rawTitle.slice(0, 500);
277
+ const seller = asRecord(record.seller ?? record.publisher);
278
+ const sellerName = firstString(seller, "name", "displayName", "username")?.slice(0, 200);
279
+ const categoryValue = asRecord(record.category) ??
280
+ (Array.isArray(record.categories)
281
+ ? asRecord(record.categories[0])
282
+ : undefined);
283
+ const categoryName = firstString(categoryValue, "name", "label");
284
+ const categorySlug = firstString(categoryValue, "slug", "path");
285
+ const ratingValue = asRecord(record.rating ?? record.ratings);
286
+ const average = firstNumber(ratingValue, "average", "averageRating");
287
+ const count = firstNumber(ratingValue, "count", "ratingCount", "total");
288
+ const thumbnailRecord = asRecord(record.thumbnail);
289
+ const startingPrice = priceFrom(record.startingPrice) ??
290
+ priceFrom(record.starting_price) ??
291
+ priceFrom(record.price);
292
+ const licenses = Array.isArray(record.licenses) ? record.licenses : [];
293
+ const licenseAmounts = licenses
294
+ .map(licenseEffectiveAmount)
295
+ .filter((amount) => amount !== undefined);
296
+ const isFree = licenseAmounts.length > 0
297
+ ? licenseAmounts.some((amount) => amount === 0)
298
+ : startingPrice?.effectiveAmount === 0 || startingPrice?.amount === 0;
299
+ return {
300
+ id,
301
+ title,
302
+ url: `${FAB_ORIGIN}/listings/${id}`,
303
+ ...(sellerName
304
+ ? {
305
+ publisher: {
306
+ ...(firstString(seller, "uid", "id")
307
+ ? { id: firstString(seller, "uid", "id")?.slice(0, 200) }
308
+ : {}),
309
+ name: sellerName,
310
+ ...(safeHttpsUrl(seller?.url)
311
+ ? { url: safeHttpsUrl(seller?.url) }
312
+ : {}),
313
+ },
314
+ }
315
+ : {}),
316
+ ...(firstString(record, "listingType", "listing_type", "type")
317
+ ? {
318
+ listingType: firstString(record, "listingType", "listing_type", "type")?.slice(0, 200),
319
+ }
320
+ : {}),
321
+ ...(categoryName || categorySlug
322
+ ? {
323
+ category: {
324
+ ...(categoryName ? { name: categoryName.slice(0, 200) } : {}),
325
+ ...(categorySlug ? { slug: categorySlug.slice(0, 200) } : {}),
326
+ },
327
+ }
328
+ : {}),
329
+ formats: normalizeFormats(record),
330
+ tags: normalizeTags(record),
331
+ ...(safeHttpsUrl(record.thumbnailUrl ?? thumbnailRecord?.url ?? record.thumbnail)
332
+ ? {
333
+ thumbnailUrl: safeHttpsUrl(record.thumbnailUrl ?? thumbnailRecord?.url ?? record.thumbnail),
334
+ }
335
+ : {}),
336
+ ...(average !== undefined && count !== undefined
337
+ ? { rating: { average, count } }
338
+ : {}),
339
+ ...(startingPrice ? { startingPrice } : {}),
340
+ isFree,
341
+ ...(typeof record.isDiscounted === "boolean"
342
+ ? { isDiscounted: record.isDiscounted }
343
+ : {}),
344
+ ...(typeof record.isAiGenerated === "boolean"
345
+ ? { isAiGenerated: record.isAiGenerated }
346
+ : {}),
347
+ ...(typeof record.is_ai_generated === "boolean"
348
+ ? { isAiGenerated: record.is_ai_generated }
349
+ : {}),
350
+ ...(typeof record.isAiForbidden === "boolean"
351
+ ? { allowsAiUse: !record.isAiForbidden }
352
+ : {}),
353
+ ...(typeof record.is_ai_forbidden === "boolean"
354
+ ? { allowsAiUse: !record.is_ai_forbidden }
355
+ : {}),
356
+ ...(typeof record.isMature === "boolean"
357
+ ? { isMature: record.isMature }
358
+ : {}),
359
+ ...(firstString(record, "firstPublishedAt", "publishedAt")
360
+ ? {
361
+ publishedAt: firstString(record, "firstPublishedAt", "publishedAt")?.slice(0, 100),
362
+ }
363
+ : {}),
364
+ ...(firstString(record, "publishedAt", "updatedAt")
365
+ ? {
366
+ updatedAt: firstString(record, "publishedAt", "updatedAt")?.slice(0, 100),
367
+ }
368
+ : {}),
369
+ };
370
+ }
371
+ export function buildSearchUrl(input) {
372
+ const url = new URL(SEARCH_PATH, FAB_ORIGIN);
373
+ const params = url.searchParams;
374
+ if (input.query)
375
+ params.set("q", input.query);
376
+ if (input.priceMode === "free")
377
+ params.set("is_free", "1");
378
+ if (input.priceMode === "range") {
379
+ params.set("price", `${input.minPrice ?? ""}..${input.maxPrice ?? ""}`);
380
+ }
381
+ const repeated = [
382
+ ["channels", input.channels],
383
+ ["listing_types", input.listingTypes],
384
+ ["categories", input.categories],
385
+ ["asset_formats", input.formats],
386
+ ["tags", input.tags],
387
+ ["licenses", input.licenses],
388
+ ];
389
+ for (const [key, values] of repeated) {
390
+ for (const value of values ?? [])
391
+ params.append(key, value);
392
+ }
393
+ if (input.publisher)
394
+ params.set("seller", input.publisher);
395
+ if (input.minimumRating !== undefined) {
396
+ params.set("average_rating", `${input.minimumRating}..5`);
397
+ }
398
+ if (input.publishedSince) {
399
+ params.set("published_since", input.publishedSince);
400
+ }
401
+ if (input.aiGenerated !== undefined) {
402
+ params.set("is_ai_generated", input.aiGenerated ? "1" : "0");
403
+ }
404
+ if (input.allowsAiUse !== undefined) {
405
+ params.set("is_ai_forbidden", input.allowsAiUse ? "0" : "1");
406
+ }
407
+ params.set("sort_by", sortTokens[input.sort]);
408
+ params.set("count", String(input.limit));
409
+ params.set("currency", input.currency);
410
+ if (input.cursor)
411
+ params.set("cursor", input.cursor);
412
+ return url;
413
+ }
414
+ export class FabClient {
415
+ directTransport;
416
+ browserTransport;
417
+ discovery;
418
+ cache;
419
+ logger;
420
+ now;
421
+ apiDownload;
422
+ constructor(directTransport, browserTransport, discovery = {}) {
423
+ this.discovery = discovery;
424
+ this.now = discovery.now ?? Date.now;
425
+ this.cache = discovery.cache ?? new TtlLruCache({ now: this.now });
426
+ if (directTransport) {
427
+ this.directTransport = directTransport;
428
+ this.browserTransport = browserTransport;
429
+ this.logger = discovery.logger ?? nullFabLogger;
430
+ this.apiDownload = undefined;
431
+ return;
432
+ }
433
+ const config = loadFabConfig();
434
+ this.logger = createStderrLogger({
435
+ level: config.logLevel,
436
+ logQueries: config.logQueries,
437
+ });
438
+ const impersonateCommand = resolveImpersonateCommand(process.env);
439
+ const cookieJarPath = join(dirname(config.browserProfileDir), "curl-cookie-jar.txt");
440
+ this.directTransport = new DirectFabTransport({
441
+ timeoutMs: config.directTimeoutMs,
442
+ minimumIntervalMs: config.minRequestIntervalMs,
443
+ ...(impersonateCommand
444
+ ? {
445
+ fetch: createImpersonateFetch({
446
+ command: impersonateCommand,
447
+ cookieJarPath,
448
+ timeoutMs: config.directTimeoutMs,
449
+ logger: this.logger,
450
+ }),
451
+ }
452
+ : {}),
453
+ logger: this.logger,
454
+ });
455
+ this.browserTransport = BrowserFabTransport.fromConfig(config);
456
+ this.apiDownload = {
457
+ downloadDir: config.downloadDir,
458
+ maxBytes: config.maxDownloadBytes,
459
+ timeoutMs: config.downloadTimeoutMs,
460
+ ...(impersonateCommand ? { impersonateCommand } : {}),
461
+ cookieJarPath,
462
+ };
463
+ if (impersonateCommand) {
464
+ this.logger.log("info", "fab_impersonate_enabled", {
465
+ command: impersonateCommand,
466
+ });
467
+ }
468
+ }
469
+ async withChallengeFallback(operation) {
470
+ try {
471
+ return {
472
+ payload: await operation(this.directTransport),
473
+ transport: this.directTransport.name,
474
+ };
475
+ }
476
+ catch (error) {
477
+ if (!(error instanceof FabClientError) ||
478
+ error.code !== "FAB_CHALLENGE" ||
479
+ !this.browserTransport) {
480
+ throw error;
481
+ }
482
+ return {
483
+ payload: await operation(this.browserTransport),
484
+ transport: this.browserTransport.name,
485
+ };
486
+ }
487
+ }
488
+ async search(input) {
489
+ const startedAt = this.now();
490
+ const searchUrl = buildSearchUrl(input);
491
+ const cacheKey = `search:${searchUrl.toString()}`;
492
+ const cached = this.cache.get(cacheKey);
493
+ if (cached) {
494
+ this.logger.log("debug", "fab_tool_complete", {
495
+ tool: "fab_search_assets",
496
+ durationMs: this.now() - startedAt,
497
+ transport: cached.transport,
498
+ resultCount: cached.items.length,
499
+ cacheHit: true,
500
+ ...this.logger.queryFields(input.query),
501
+ });
502
+ return cached;
503
+ }
504
+ try {
505
+ const upstream = await this.withChallengeFallback((transport) => transport.search(searchUrl));
506
+ const payload = asRecord(upstream.payload);
507
+ if (!payload || !Array.isArray(payload.results)) {
508
+ throw new FabClientError("FAB_UPSTREAM_CHANGED", "Fab's search response no longer matches the expected contract.");
509
+ }
510
+ const normalizedItems = payload.results
511
+ .map(normalizeSearchItem)
512
+ .filter((item) => item !== null)
513
+ .slice(0, input.limit);
514
+ const cursors = asRecord(payload.cursors);
515
+ const nextCursor = asString(cursors?.next) ?? asString(payload.next) ?? undefined;
516
+ const warnings = [];
517
+ const output = {
518
+ items: [...normalizedItems],
519
+ ...(nextCursor && nextCursor.length <= 2_048
520
+ ? { nextCursor }
521
+ : {}),
522
+ appliedFilters: {
523
+ query: input.query ?? "",
524
+ priceMode: input.priceMode,
525
+ currency: input.currency,
526
+ channels: input.channels ?? [],
527
+ listingTypes: input.listingTypes ?? [],
528
+ categories: input.categories ?? [],
529
+ formats: input.formats ?? [],
530
+ tags: input.tags ?? [],
531
+ licenses: input.licenses ?? [],
532
+ sort: input.sort,
533
+ limit: input.limit,
534
+ },
535
+ transport: upstream.transport,
536
+ warnings,
537
+ };
538
+ if (nextCursor && nextCursor.length > 2_048) {
539
+ warnings.push("Fab returned an oversized cursor, so pagination was omitted.");
540
+ }
541
+ while (output.items.length > 0 &&
542
+ Buffer.byteLength(JSON.stringify(output), "utf8") >
543
+ MAX_SEARCH_PAYLOAD_BYTES) {
544
+ output.items.pop();
545
+ }
546
+ if (output.items.length < normalizedItems.length) {
547
+ warnings.push("Some results were omitted to keep the MCP response under 256 KiB.");
548
+ while (output.items.length > 0 &&
549
+ Buffer.byteLength(JSON.stringify(output), "utf8") >
550
+ MAX_SEARCH_PAYLOAD_BYTES) {
551
+ output.items.pop();
552
+ }
553
+ }
554
+ this.cache.set(cacheKey, output, SEARCH_TTL_MS);
555
+ this.logger.log("debug", "fab_tool_complete", {
556
+ tool: "fab_search_assets",
557
+ durationMs: this.now() - startedAt,
558
+ transport: upstream.transport,
559
+ resultCount: output.items.length,
560
+ cacheHit: false,
561
+ ...this.logger.queryFields(input.query),
562
+ });
563
+ return output;
564
+ }
565
+ catch (error) {
566
+ this.logger.log("warn", "fab_tool_error", {
567
+ tool: "fab_search_assets",
568
+ durationMs: this.now() - startedAt,
569
+ errorCode: error instanceof FabClientError ? error.code : "FAB_INTERNAL",
570
+ ...this.logger.queryFields(input.query),
571
+ });
572
+ throw error;
573
+ }
574
+ }
575
+ async getListing(id, currency) {
576
+ const cacheKey = `listing:${id.toLowerCase()}:${currency.toUpperCase()}`;
577
+ const cached = this.cache.get(cacheKey);
578
+ if (cached) {
579
+ this.logger.log("debug", "fab_tool_complete", {
580
+ tool: "fab_get_asset",
581
+ transport: cached.transport,
582
+ cacheHit: true,
583
+ });
584
+ return cached;
585
+ }
586
+ const result = await this.withChallengeFallback((transport) => {
587
+ if (!transport.getListing) {
588
+ throw new FabClientError("FAB_INTERNAL", "The configured Fab transport cannot load listing details.");
589
+ }
590
+ return transport.getListing(id, currency);
591
+ });
592
+ this.cache.set(cacheKey, result, LISTING_TTL_MS);
593
+ this.logger.log("debug", "fab_tool_complete", {
594
+ tool: "fab_get_asset",
595
+ transport: result.transport,
596
+ cacheHit: false,
597
+ });
598
+ return result;
599
+ }
600
+ async downloadFreeAsset(request) {
601
+ const startedAt = this.now();
602
+ try {
603
+ const result = await this.downloadApiFirst(request);
604
+ this.logger.log("info", "fab_tool_complete", {
605
+ tool: "fab_download_free_asset",
606
+ durationMs: this.now() - startedAt,
607
+ transport: result.transport,
608
+ format: request.format,
609
+ sizeBytes: result.sizeBytes,
610
+ alreadyExisted: result.alreadyExisted,
611
+ });
612
+ return result;
613
+ }
614
+ catch (error) {
615
+ this.logger.log("warn", "fab_tool_error", {
616
+ tool: "fab_download_free_asset",
617
+ durationMs: this.now() - startedAt,
618
+ errorCode: error instanceof FabClientError ? error.code : "FAB_INTERNAL",
619
+ format: request.format,
620
+ });
621
+ throw error;
622
+ }
623
+ }
624
+ /**
625
+ * Resolves the file through Fab's anonymous JSON contract first (no
626
+ * browser needed when the host has a browser-fingerprint TLS client) and
627
+ * falls back to the guarded browser click flow when Cloudflare challenges
628
+ * the direct path.
629
+ */
630
+ async downloadApiFirst(request) {
631
+ if (this.apiDownload) {
632
+ try {
633
+ const result = await downloadFreeAssetViaApi({
634
+ request,
635
+ fetchJson: (url) => this.directJson(url),
636
+ downloader: (url, destinationPath) => this.downloadFromDistribution(url, destinationPath),
637
+ downloadDir: this.apiDownload.downloadDir,
638
+ maxBytes: this.apiDownload.maxBytes,
639
+ logger: this.logger,
640
+ });
641
+ return { ...result, transport: this.directTransport.name };
642
+ }
643
+ catch (error) {
644
+ if (!(error instanceof FabClientError) ||
645
+ error.code !== "FAB_CHALLENGE") {
646
+ throw error;
647
+ }
648
+ this.logger.log("debug", "fab_api_download_challenge_fallback", {
649
+ listingId: request.listingId,
650
+ format: request.format,
651
+ });
652
+ }
653
+ }
654
+ if (!this.browserTransport?.downloadFreeAsset) {
655
+ throw new FabClientError("FAB_UPSTREAM_UNAVAILABLE", "The configured Fab browser cannot download public files.");
656
+ }
657
+ const result = await this.browserTransport.downloadFreeAsset(request);
658
+ return { ...result, transport: this.browserTransport.name };
659
+ }
660
+ async directJson(url) {
661
+ if (!this.directTransport.request) {
662
+ throw new FabClientError("FAB_INTERNAL", "The configured Fab transport cannot load this public resource.");
663
+ }
664
+ return this.directTransport.request(url);
665
+ }
666
+ async downloadFromDistribution(url, destinationPath) {
667
+ if (!this.apiDownload) {
668
+ throw new FabClientError("FAB_INTERNAL", "The Fab download path is not configured.");
669
+ }
670
+ if (this.apiDownload.impersonateCommand) {
671
+ await impersonateDownloadToFile({
672
+ command: this.apiDownload.impersonateCommand,
673
+ cookieJarPath: this.apiDownload.cookieJarPath,
674
+ timeoutMs: this.apiDownload.timeoutMs,
675
+ url: url.toString(),
676
+ destinationPath,
677
+ maxBytes: this.apiDownload.maxBytes,
678
+ });
679
+ return;
680
+ }
681
+ await nodeDownloadToFile(url, destinationPath, {
682
+ timeoutMs: this.apiDownload.timeoutMs,
683
+ maxBytes: this.apiDownload.maxBytes,
684
+ });
685
+ }
686
+ async request(url) {
687
+ const upstream = await this.withChallengeFallback((transport) => {
688
+ if (!transport.request) {
689
+ throw new FabClientError("FAB_INTERNAL", "The configured Fab transport cannot load this public resource.");
690
+ }
691
+ return transport.request(url);
692
+ });
693
+ return upstream.payload;
694
+ }
695
+ async prepareBrowser() {
696
+ if (!this.browserTransport?.prepare)
697
+ return;
698
+ try {
699
+ await this.browserTransport.prepare();
700
+ this.logger.log("info", "fab_browser_ready", {
701
+ transport: this.browserTransport.name,
702
+ });
703
+ }
704
+ catch (error) {
705
+ this.logger.log("warn", "fab_browser_prepare_failed", {
706
+ errorCode: error instanceof FabClientError ? error.code : "FAB_INTERNAL",
707
+ });
708
+ }
709
+ }
710
+ async listFilters(input) {
711
+ const now = this.discovery.now?.() ?? Date.now();
712
+ const ttl = this.discovery.taxonomyTtlMs ?? TAXONOMY_TTL_MS;
713
+ const lastGood = this.cache.peek(TAXONOMY_CACHE_KEY);
714
+ const cached = input.refresh
715
+ ? undefined
716
+ : this.cache.get(TAXONOMY_CACHE_KEY);
717
+ if (cached && now - cached.cachedAtMs < ttl) {
718
+ return {
719
+ filters: selectFilterKinds(cached.filters, input.kinds),
720
+ source: "cache",
721
+ capturedAt: cached.capturedAt,
722
+ contractVersion: cached.contractVersion,
723
+ warnings: cached.warnings,
724
+ };
725
+ }
726
+ if (this.discovery.taxonomyPath) {
727
+ try {
728
+ const filters = normalizeTaxonomy(await this.request(publicOperationUrl(this.discovery.taxonomyPath)));
729
+ const taxonomyEntry = {
730
+ filters,
731
+ capturedAt: new Date(now).toISOString(),
732
+ cachedAtMs: now,
733
+ contractVersion: "fab-taxonomy-live-v1",
734
+ warnings: [],
735
+ };
736
+ this.cache.set(TAXONOMY_CACHE_KEY, taxonomyEntry, ttl);
737
+ return {
738
+ filters: selectFilterKinds(filters, input.kinds),
739
+ source: "live",
740
+ capturedAt: taxonomyEntry.capturedAt,
741
+ contractVersion: taxonomyEntry.contractVersion,
742
+ warnings: [],
743
+ };
744
+ }
745
+ catch (error) {
746
+ if (lastGood) {
747
+ return {
748
+ filters: selectFilterKinds(lastGood.filters, input.kinds),
749
+ source: "stale-cache",
750
+ capturedAt: lastGood.capturedAt,
751
+ contractVersion: lastGood.contractVersion,
752
+ warnings: [
753
+ ...lastGood.warnings,
754
+ "Fab taxonomy refresh failed; returning the last-good cached values.",
755
+ ],
756
+ };
757
+ }
758
+ if (error instanceof FabClientError && error.code === "FAB_INTERNAL") {
759
+ throw error;
760
+ }
761
+ }
762
+ }
763
+ const fallbackEntry = {
764
+ filters: FALLBACK_FILTERS,
765
+ capturedAt: TAXONOMY_CAPTURED_AT,
766
+ cachedAtMs: now,
767
+ contractVersion: TAXONOMY_CONTRACT_VERSION,
768
+ warnings: [FALLBACK_WARNING],
769
+ };
770
+ this.cache.set(TAXONOMY_CACHE_KEY, fallbackEntry, ttl);
771
+ return {
772
+ filters: selectFilterKinds(FALLBACK_FILTERS, input.kinds),
773
+ source: "fallback",
774
+ capturedAt: TAXONOMY_CAPTURED_AT,
775
+ contractVersion: TAXONOMY_CONTRACT_VERSION,
776
+ warnings: [FALLBACK_WARNING],
777
+ };
778
+ }
779
+ async listLimitedTimeFree(input) {
780
+ const cacheKey = `limited-time-free:${input.limit}`;
781
+ const cached = this.cache.get(cacheKey);
782
+ if (cached) {
783
+ this.logger.log("debug", "fab_tool_complete", {
784
+ tool: "fab_list_limited_time_free",
785
+ resultCount: cached.items.length,
786
+ cacheHit: true,
787
+ });
788
+ return cached;
789
+ }
790
+ let payload;
791
+ let source;
792
+ if (this.discovery.limitedTimeFreeProvider) {
793
+ payload = await this.discovery.limitedTimeFreeProvider();
794
+ source = "configured-curated";
795
+ }
796
+ else if (this.discovery.limitedTimeFreePath) {
797
+ payload = await this.request(publicOperationUrl(this.discovery.limitedTimeFreePath));
798
+ source = "live-curated";
799
+ }
800
+ else if (this.browserTransport?.getLimitedTimeFreeIds) {
801
+ payload = await this.browserTransport.getLimitedTimeFreeIds(input.limit);
802
+ source = "browser-curated";
803
+ }
804
+ else {
805
+ throw new FabClientError("FAB_UPSTREAM_UNAVAILABLE", "Fab's curated limited-time-free feed is unavailable because no stable anonymous contract is configured.");
806
+ }
807
+ const entries = promotionEntries(payload);
808
+ if (!entries) {
809
+ throw new FabClientError("FAB_UPSTREAM_CHANGED", "Fab's curated promotion response no longer matches the expected contract.");
810
+ }
811
+ const seen = new Set();
812
+ const items = [];
813
+ for (const value of entries) {
814
+ if (items.length >= input.limit)
815
+ break;
816
+ const record = asRecord(value);
817
+ const id = (typeof value === "string"
818
+ ? value
819
+ : firstString(record, "uid", "id", "uuid"))?.toLowerCase();
820
+ if (!id || !UUID_PATTERN.test(id) || seen.has(id))
821
+ continue;
822
+ seen.add(id);
823
+ let title = firstString(record, "title", "name");
824
+ let thumbnailUrl = safeHttpsUrl(record?.thumbnailUrl ?? asRecord(record?.thumbnail)?.url);
825
+ if (!title) {
826
+ const detail = await this.getListing(id, "USD");
827
+ const listing = asRecord(detail.payload);
828
+ title = firstString(listing, "title", "name");
829
+ thumbnailUrl ??= safeHttpsUrl(listing?.thumbnailUrl ?? asRecord(listing?.thumbnail)?.url);
830
+ }
831
+ if (!title) {
832
+ throw new FabClientError("FAB_UPSTREAM_CHANGED", "A curated promotion no longer resolves to a public Fab listing.");
833
+ }
834
+ const promotion = asRecord(record?.promotion);
835
+ const promotionEndsAt = explicitPromotionEnd(record?.promotionEndsAt ??
836
+ record?.promotion_ends_at ??
837
+ promotion?.endsAt ??
838
+ promotion?.ends_at);
839
+ items.push({
840
+ id,
841
+ title,
842
+ url: `${FAB_ORIGIN}/listings/${id}`,
843
+ ...(thumbnailUrl ? { thumbnailUrl } : {}),
844
+ ...(promotionEndsAt ? { promotionEndsAt } : {}),
845
+ });
846
+ }
847
+ const output = {
848
+ items,
849
+ source,
850
+ warnings: [],
851
+ };
852
+ this.cache.set(cacheKey, output, LIMITED_TIME_FREE_TTL_MS);
853
+ this.logger.log("debug", "fab_tool_complete", {
854
+ tool: "fab_list_limited_time_free",
855
+ resultCount: items.length,
856
+ cacheHit: false,
857
+ });
858
+ return output;
859
+ }
860
+ async close() {
861
+ this.cache.clear();
862
+ await Promise.allSettled([
863
+ this.directTransport.close(),
864
+ this.browserTransport?.close(),
865
+ ]);
866
+ }
867
+ }
868
+ //# sourceMappingURL=client.js.map