mcp-recipes 1.0.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.
@@ -0,0 +1,697 @@
1
+ // src/version.ts
2
+ var PKG_VERSION = "1.0.0";
3
+ var REPO_URL = "https://github.com/smeet666/mcp-recipes";
4
+ var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
5
+
6
+ // src/config.ts
7
+ var LOG_LEVELS = ["silent", "error", "info", "debug"];
8
+ var DEFAULT_INTERVAL_MS = 1e3;
9
+ var MIN_ALLOWED_INTERVAL_MS = 500;
10
+ var MAX_ALLOWED_INTERVAL_MS = 6e4;
11
+ var DEFAULT_USER_AGENT = `mcp-recipes/${PKG_VERSION} (+${REPO_URL})`;
12
+ function createLogger(level) {
13
+ const rank = LOG_LEVELS.indexOf(level);
14
+ const write = (at, message) => {
15
+ if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
16
+ process.stderr.write(`[mcp-recipes] ${at}: ${message}
17
+ `);
18
+ };
19
+ return {
20
+ debug: (m) => write("debug", m),
21
+ info: (m) => write("info", m),
22
+ // A warning goes out at the error level so it survives the default
23
+ // setting: a caller has to know that a site was left out of an answer.
24
+ warn: (m) => write("error", m),
25
+ error: (m) => write("error", m)
26
+ };
27
+ }
28
+ function readInteger(env, name, fallback, min, max) {
29
+ const raw = env[name];
30
+ if (raw === void 0 || raw.trim() === "") return fallback;
31
+ const value = Number(raw);
32
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
33
+ process.stderr.write(
34
+ `[mcp-recipes] error: ${name}="${raw}" is not a whole number; using ${fallback}.
35
+ `
36
+ );
37
+ return fallback;
38
+ }
39
+ if (value < min || value > max) {
40
+ process.stderr.write(
41
+ `[mcp-recipes] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.
42
+ `
43
+ );
44
+ return fallback;
45
+ }
46
+ return value;
47
+ }
48
+ function loadConfig(env = process.env) {
49
+ const level = env.RECIPES_LOG_LEVEL;
50
+ const logLevel = level && LOG_LEVELS.includes(level) ? level : "error";
51
+ if (level && !LOG_LEVELS.includes(level)) {
52
+ process.stderr.write(
53
+ `[mcp-recipes] error: RECIPES_LOG_LEVEL="${level}" is not one of ${LOG_LEVELS.join(", ")}; using error.
54
+ `
55
+ );
56
+ }
57
+ const custom = env.RECIPES_USER_AGENT?.trim();
58
+ return {
59
+ // A caller who wants to be recognised may say who they are, and the
60
+ // contact address stays attached: a site has to be able to reach a human
61
+ // about traffic it did not expect.
62
+ userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,
63
+ minIntervalMs: readInteger(
64
+ env,
65
+ "RECIPES_MIN_INTERVAL_MS",
66
+ DEFAULT_INTERVAL_MS,
67
+ MIN_ALLOWED_INTERVAL_MS,
68
+ MAX_ALLOWED_INTERVAL_MS
69
+ ),
70
+ timeoutMs: readInteger(env, "RECIPES_TIMEOUT_MS", 2e4, 1e3, 12e4),
71
+ maxRetries: readInteger(env, "RECIPES_MAX_RETRIES", 3, 0, 8),
72
+ cacheTtlMs: readInteger(env, "RECIPES_CACHE_TTL_MS", 9e5, 0, 864e5),
73
+ cacheMaxEntries: readInteger(env, "RECIPES_CACHE_MAX_ENTRIES", 200, 1, 5e3),
74
+ logLevel
75
+ };
76
+ }
77
+
78
+ // src/errors.ts
79
+ var ERROR_CODES = [
80
+ "not_found",
81
+ "invalid_input",
82
+ "rate_limited",
83
+ "parse_failure",
84
+ "network_error",
85
+ "timeout"
86
+ ];
87
+ var RecipesError = class extends Error {
88
+ code;
89
+ details;
90
+ constructor(code, message, details = {}) {
91
+ super(message);
92
+ this.name = "RecipesError";
93
+ this.code = code;
94
+ this.details = details;
95
+ }
96
+ };
97
+ var invalidInput = (message, hint) => new RecipesError("invalid_input", message, hint ? { hint } : {});
98
+ var parseFailure = (message, details) => new RecipesError("parse_failure", message, {
99
+ hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,
100
+ ...details
101
+ });
102
+ var timeout = (message, details) => new RecipesError("timeout", message, details ?? {});
103
+ function toRecipesError(error) {
104
+ if (error instanceof RecipesError) return error;
105
+ const message = error instanceof Error ? error.message : String(error);
106
+ const raw = error;
107
+ const code = typeof raw?.code === "string" ? raw.code : "";
108
+ const known = ERROR_CODES.find((candidate) => candidate === code);
109
+ const details = raw && typeof raw.details === "object" && raw.details !== null ? raw.details : {};
110
+ return new RecipesError(known ?? "network_error", message, details);
111
+ }
112
+
113
+ // src/sources/adapter.ts
114
+ function text(value) {
115
+ return typeof value === "string" && value.trim() !== "" ? value : null;
116
+ }
117
+ function textList(value) {
118
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
119
+ }
120
+ function count(value) {
121
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
122
+ }
123
+ function required(value, field, source) {
124
+ const found = text(value);
125
+ if (found === null) {
126
+ throw parseFailure(
127
+ `${source.name} answered without a readable "${field}", so there is no recipe to return.`
128
+ );
129
+ }
130
+ return found;
131
+ }
132
+ function rowsOf(value, source) {
133
+ if (!Array.isArray(value)) {
134
+ throw parseFailure(
135
+ `${source.name} answered in a shape this server could not read: the list of results was missing.`
136
+ );
137
+ }
138
+ return value;
139
+ }
140
+ function namespacedId(source, reference) {
141
+ return `${source}:${reference}`;
142
+ }
143
+ function publishedFigures(panel) {
144
+ if (typeof panel !== "object" || panel === null) return null;
145
+ const kept = {};
146
+ for (const [key, value] of Object.entries(panel)) {
147
+ if (typeof value === "string" && value.trim() !== "") kept[key] = value.trim();
148
+ }
149
+ return Object.keys(kept).length > 0 ? kept : null;
150
+ }
151
+ function readYieldSpan(published) {
152
+ if (published === null) return { count: null, max: null, unit: null };
153
+ const span = /^\s*(\d+(?:[.,]\d+)?)\s*(?:à|a|to|-|–|—|ou|or)\s*(\d+(?:[.,]\d+)?)\s*(.*)$/i.exec(
154
+ published
155
+ );
156
+ if (span) {
157
+ const low = Number(span[1].replace(",", "."));
158
+ const high = Number(span[2].replace(",", "."));
159
+ if (Number.isFinite(low) && Number.isFinite(high) && high > low) {
160
+ return { count: low, max: high, unit: text(span[3]) };
161
+ }
162
+ }
163
+ const single = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/.exec(published);
164
+ if (single) {
165
+ const value = Number(single[1].replace(",", "."));
166
+ if (Number.isFinite(value)) return { count: value, max: null, unit: text(single[2]) };
167
+ }
168
+ return { count: null, max: null, unit: null };
169
+ }
170
+ function detailBase(source) {
171
+ return {
172
+ source: source.id,
173
+ sourceName: source.name,
174
+ language: source.language,
175
+ attribution: source.attribution
176
+ };
177
+ }
178
+
179
+ // src/sources/ids.ts
180
+ var SEPARATOR = ":";
181
+ function resolveId(rawId, sources) {
182
+ const trimmed = rawId.trim();
183
+ if (trimmed === "") {
184
+ throw invalidInput(
185
+ "A recipe identifier is required.",
186
+ `Use one of the ids search_recipes returned, such as ${example(sources)}.`
187
+ );
188
+ }
189
+ for (const source of sources) {
190
+ const prefix = `${source.id}${SEPARATOR}`;
191
+ if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) continue;
192
+ const reference = trimmed.slice(prefix.length).trim();
193
+ if (reference === "") {
194
+ throw invalidInput(
195
+ `"${trimmed}" names a source and no recipe.`,
196
+ "Put the source's own identifier after the colon."
197
+ );
198
+ }
199
+ return { source, reference, inferred: null };
200
+ }
201
+ const claimed = sources.map((source) => ({ source, claim: source.claims(trimmed) })).filter(
202
+ (entry) => Boolean(entry.claim)
203
+ );
204
+ if (claimed.length > 1) {
205
+ throw invalidInput(
206
+ `"${trimmed}" could be an identifier on ${names(claimed.map((entry) => entry.source))}, so it names no one recipe.`,
207
+ `Spell it with its source, as in ${claimed.map((entry) => `${entry.source.id}:${trimmed}`).join(" or ")}.`
208
+ );
209
+ }
210
+ const only = claimed[0];
211
+ if (!only) {
212
+ throw invalidInput(
213
+ `"${trimmed}" is not an identifier any of the sources this server reads would mint.`,
214
+ `Call search_recipes with "${trimmed}" as the query and pass an id from a row, which reads like ${example(sources)}.`
215
+ );
216
+ }
217
+ return {
218
+ source: only.source,
219
+ reference: only.claim.reference,
220
+ inferred: only.claim.guess ? `${only.claim.why}, which is a guess: check the recipe that comes back is the one you meant` : only.claim.why
221
+ };
222
+ }
223
+ function names(sources) {
224
+ const all = sources.map((source) => source.name);
225
+ if (all.length <= 1) return all.join("");
226
+ return `${all.slice(0, -1).join(", ")} and ${all[all.length - 1]}`;
227
+ }
228
+ function example(sources) {
229
+ const first = sources[0];
230
+ return first ? `${first.id}${SEPARATOR}<the source's own id>` : "<source>:<id>";
231
+ }
232
+
233
+ // src/sources/registry.ts
234
+ import { MarmitonClient } from "mcp-marmiton/client";
235
+ import { CookbookClient } from "mcp-wikibooks-cookbook/client";
236
+
237
+ // src/sources/cookbook.ts
238
+ var COOKBOOK_PROFILE = {
239
+ id: "cookbook",
240
+ name: "Wikibooks Cookbook",
241
+ homeUrl: "https://en.wikibooks.org/wiki/Cookbook:Table_of_Contents",
242
+ language: "en",
243
+ attribution: "Source: Wikibooks Cookbook",
244
+ // The namespace holds pages about salt and about a dish made with it alike.
245
+ mixesReferencePages: true
246
+ };
247
+ var SITE_URL = /^https?:\/\/en\.wikibooks\.org\/wiki\//i;
248
+ var NAMESPACE = /^cookbook\s*:/i;
249
+ function dishName(title) {
250
+ return title.replace(NAMESPACE, "").trim() || title;
251
+ }
252
+ function cookbookAdapter(reader) {
253
+ return {
254
+ ...COOKBOOK_PROFILE,
255
+ claims(raw) {
256
+ if (SITE_URL.test(raw)) {
257
+ let key;
258
+ try {
259
+ key = decodeURIComponent(raw.replace(SITE_URL, "")).replace(/_/g, " ");
260
+ } catch {
261
+ throw invalidInput(
262
+ `"${raw}" carries a percent sign that is not a valid escape, so the page name cannot be read.`,
263
+ "Pass the id search_recipes returned instead of the address."
264
+ );
265
+ }
266
+ return {
267
+ reference: key,
268
+ why: "the address is a page on the English Wikibooks",
269
+ guess: false
270
+ };
271
+ }
272
+ if (NAMESPACE.test(raw)) {
273
+ return {
274
+ reference: raw,
275
+ why: "the name opens with the namespace every Cookbook page lives under",
276
+ guess: false
277
+ };
278
+ }
279
+ return null;
280
+ },
281
+ async search(query, limit) {
282
+ const outcome = await reader.search(query, limit);
283
+ const list = rowsOf(
284
+ outcome.data?.results,
285
+ COOKBOOK_PROFILE
286
+ );
287
+ const rows = [];
288
+ let skipped = 0;
289
+ for (const summary of list) {
290
+ const key = text(summary?.key);
291
+ const title = text(summary?.title) ?? key;
292
+ const url = text(summary?.sourceUrl);
293
+ if (!key || !title || !url) {
294
+ skipped += 1;
295
+ continue;
296
+ }
297
+ rows.push({
298
+ id: namespacedId(COOKBOOK_PROFILE.id, key),
299
+ source: COOKBOOK_PROFILE.id,
300
+ sourceName: COOKBOOK_PROFILE.name,
301
+ title: dishName(title),
302
+ url,
303
+ imageUrl: text(summary?.imageUrl),
304
+ excerpt: text(summary?.excerpt) ?? text(summary?.description)
305
+ });
306
+ }
307
+ return {
308
+ rows,
309
+ skipped,
310
+ // The gateway states no total and offers no second page, so there is no
311
+ // number to report. Counting the rows returned and calling it a total
312
+ // would invent one.
313
+ reportedTotal: null,
314
+ reportedTotalMeans: null,
315
+ cached: outcome.cached
316
+ };
317
+ },
318
+ async getRecipe(reference) {
319
+ const outcome = await reader.getRecipe(reference);
320
+ return { recipe: cookbookDetail(outcome.data), cached: outcome.cached };
321
+ }
322
+ };
323
+ }
324
+ function cookbookDetail(payload) {
325
+ const recipe = payload ?? {};
326
+ const license = recipe.license;
327
+ const licenseTitle = text(license?.title);
328
+ const licenseUrl = text(license?.url);
329
+ const published = text(recipe.yieldText);
330
+ const span = readYieldSpan(published);
331
+ return {
332
+ ...detailBase(COOKBOOK_PROFILE),
333
+ id: namespacedId(COOKBOOK_PROFILE.id, required(recipe.key, "key", COOKBOOK_PROFILE)),
334
+ title: dishName(required(recipe.title ?? recipe.key, "title", COOKBOOK_PROFILE)),
335
+ url: required(recipe.sourceUrl, "url", COOKBOOK_PROFILE),
336
+ // The page source carries no picture this server can address.
337
+ imageUrl: null,
338
+ yieldCount: count(recipe.servings) ?? span.count,
339
+ yieldMax: span.max,
340
+ yieldText: published,
341
+ yieldUnit: text(recipe.yieldUnit) ?? span.unit,
342
+ ingredients: textList(recipe.ingredients),
343
+ steps: textList(recipe.steps),
344
+ // The Cookbook publishes one time for a recipe rather than splitting it.
345
+ prepMinutes: null,
346
+ cookMinutes: null,
347
+ totalMinutes: count(recipe.totalMinutes),
348
+ category: text(recipe.category),
349
+ // The page is written by everyone who edited it, so it credits nobody in
350
+ // particular and carries no reader rating.
351
+ author: null,
352
+ rating: null,
353
+ nutrition: publishedFigures(recipe.nutrition),
354
+ equipment: textList(recipe.equipment),
355
+ tips: textList(recipe.tips),
356
+ // A licence is only stated when both halves of it arrived: a name with no
357
+ // address, or an address with no name, is not terms anyone can follow.
358
+ license: licenseTitle && licenseUrl ? { title: licenseTitle, url: licenseUrl } : null
359
+ };
360
+ }
361
+
362
+ // src/sources/marmiton.ts
363
+ var MARMITON_PROFILE = {
364
+ id: "marmiton",
365
+ name: "Marmiton",
366
+ homeUrl: "https://www.marmiton.org",
367
+ language: "fr",
368
+ attribution: "Source: Marmiton",
369
+ // Every page the search returns is a recipe.
370
+ mixesReferencePages: false
371
+ };
372
+ var SITE_URL2 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
373
+ var REFERENCE = /^\d+$/;
374
+ function marmitonAdapter(reader) {
375
+ return {
376
+ ...MARMITON_PROFILE,
377
+ claims(raw) {
378
+ if (SITE_URL2.test(raw)) {
379
+ return { reference: raw, why: "the address is a Marmiton page", guess: false };
380
+ }
381
+ if (REFERENCE.test(raw)) {
382
+ return {
383
+ reference: raw,
384
+ why: "a bare number is the shape Marmiton mints",
385
+ guess: false
386
+ };
387
+ }
388
+ return null;
389
+ },
390
+ async search(query) {
391
+ const outcome = await reader.search(query);
392
+ const list = rowsOf(outcome.data, MARMITON_PROFILE);
393
+ const rows = [];
394
+ let skipped = 0;
395
+ for (const summary of list) {
396
+ const id = text(summary?.id);
397
+ const title = text(summary?.title);
398
+ const url = text(summary?.url);
399
+ if (!id || !title || !url) {
400
+ skipped += 1;
401
+ continue;
402
+ }
403
+ rows.push({
404
+ id: namespacedId(MARMITON_PROFILE.id, id),
405
+ source: MARMITON_PROFILE.id,
406
+ sourceName: MARMITON_PROFILE.name,
407
+ title,
408
+ url,
409
+ imageUrl: text(summary?.imageUrl),
410
+ // The result list carries a title and a picture and no prose, so
411
+ // there is nothing to quote here.
412
+ excerpt: null
413
+ });
414
+ }
415
+ return {
416
+ rows,
417
+ skipped,
418
+ // Rows on the page, including the ones this server could not read: the
419
+ // site saw them, and reporting only the readable ones would understate
420
+ // what it holds.
421
+ reportedTotal: rows.length + skipped,
422
+ reportedTotalMeans: "rows on the single page of results Marmiton serves; the site disallows paging, so this is not a catalogue count",
423
+ cached: outcome.cached
424
+ };
425
+ },
426
+ async getRecipe(reference) {
427
+ const ref = /^https?:\/\//i.test(reference) ? { url: reference } : { id: reference };
428
+ const outcome = await reader.getRecipe(ref);
429
+ return { recipe: marmitonDetail(outcome.data), cached: outcome.cached };
430
+ }
431
+ };
432
+ }
433
+ function marmitonDetail(payload) {
434
+ const recipe = payload ?? {};
435
+ const yields = recipe.recipeYield ?? {};
436
+ const rating = recipe.rating;
437
+ const value = count(rating?.value);
438
+ const published = text(yields.text);
439
+ const span = readYieldSpan(published);
440
+ return {
441
+ ...detailBase(MARMITON_PROFILE),
442
+ id: namespacedId(MARMITON_PROFILE.id, required(recipe.id, "id", MARMITON_PROFILE)),
443
+ title: required(recipe.title, "title", MARMITON_PROFILE),
444
+ url: required(recipe.url, "url", MARMITON_PROFILE),
445
+ imageUrl: text(recipe.imageUrl),
446
+ yieldCount: span.count ?? count(yields.count),
447
+ yieldMax: span.max,
448
+ yieldText: published,
449
+ yieldUnit: span.unit ?? text(yields.unit),
450
+ ingredients: textList(recipe.ingredients),
451
+ steps: textList(recipe.steps),
452
+ prepMinutes: count(recipe.prepMinutes),
453
+ cookMinutes: count(recipe.cookMinutes),
454
+ totalMinutes: count(recipe.totalMinutes),
455
+ category: text(recipe.category),
456
+ author: text(recipe.author),
457
+ rating: value === null ? null : { value, count: count(rating?.count), max: count(rating?.best) },
458
+ nutrition: publishedFigures(recipe.nutrition),
459
+ equipment: [],
460
+ tips: [],
461
+ // Marmiton states no licence on a recipe page, and silence is not a grant.
462
+ license: null
463
+ };
464
+ }
465
+
466
+ // src/sources/registry.ts
467
+ var SOURCE_IDS = [MARMITON_PROFILE.id, COOKBOOK_PROFILE.id];
468
+ function buildSources(config, readers = {}) {
469
+ const shared = {
470
+ userAgent: config.userAgent,
471
+ minIntervalMs: config.minIntervalMs,
472
+ timeoutMs: config.timeoutMs,
473
+ maxRetries: config.maxRetries,
474
+ cacheTtlMs: config.cacheTtlMs,
475
+ cacheMaxEntries: config.cacheMaxEntries,
476
+ logLevel: config.logLevel
477
+ };
478
+ const marmitonOptions = { config: shared };
479
+ const cookbookOptions = { config: shared };
480
+ return [
481
+ marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),
482
+ cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions))
483
+ ];
484
+ }
485
+ function selectSources(sources, wanted) {
486
+ if (!wanted) return sources;
487
+ const unknown = wanted.filter((id) => !sources.some((source) => source.id === id));
488
+ if (unknown.length > 0) {
489
+ throw invalidInput(
490
+ `This server reads no source called ${unknown.map((id) => `"${id}"`).join(", ")}.`,
491
+ `It reads ${sources.map((source) => source.id).join(", ")}.`
492
+ );
493
+ }
494
+ const chosen = sources.filter((source) => wanted.includes(source.id));
495
+ if (chosen.length === 0) {
496
+ throw invalidInput(
497
+ "A search needs at least one source.",
498
+ `Name one of ${sources.map((source) => source.id).join(", ")}, or leave the argument out to ask them all.`
499
+ );
500
+ }
501
+ return chosen;
502
+ }
503
+
504
+ // src/sources/client.ts
505
+ var MAX_LIMIT_PER_SOURCE = 25;
506
+ function withGuarantees(config) {
507
+ const defaults = loadConfig({});
508
+ const bounded = (value, fallback, min, max) => {
509
+ const parsed = Number(value);
510
+ if (!Number.isFinite(parsed)) return fallback;
511
+ return Math.min(max, Math.max(min, parsed));
512
+ };
513
+ const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
514
+ const identifier = defaults.userAgent;
515
+ return {
516
+ ...config,
517
+ userAgent: claimed === "" || claimed.includes(identifier) ? identifier : `${claimed} ${identifier}`,
518
+ minIntervalMs: bounded(
519
+ config.minIntervalMs,
520
+ defaults.minIntervalMs,
521
+ MIN_ALLOWED_INTERVAL_MS,
522
+ MAX_ALLOWED_INTERVAL_MS
523
+ ),
524
+ timeoutMs: bounded(config.timeoutMs, defaults.timeoutMs, 1e3, 12e4),
525
+ maxRetries: bounded(config.maxRetries, defaults.maxRetries, 0, 8),
526
+ cacheTtlMs: bounded(config.cacheTtlMs, defaults.cacheTtlMs, 0, 864e5),
527
+ cacheMaxEntries: bounded(config.cacheMaxEntries, defaults.cacheMaxEntries, 1, 5e3),
528
+ logLevel: config.logLevel ?? defaults.logLevel
529
+ };
530
+ }
531
+ function withDeadline(work, ms, source) {
532
+ let timer;
533
+ const alarm = new Promise((_resolve, reject) => {
534
+ timer = setTimeout(
535
+ () => reject(
536
+ timeout(
537
+ `${source.name} did not answer within ${ms}ms and was left out of this answer.`
538
+ )
539
+ ),
540
+ ms
541
+ );
542
+ timer.unref?.();
543
+ });
544
+ return Promise.race([work, alarm]).finally(() => clearTimeout(timer));
545
+ }
546
+ function reportOf(attempt, count2) {
547
+ return {
548
+ source: attempt.source.id,
549
+ name: attempt.source.name,
550
+ status: attempt.error ? "failed" : "answered",
551
+ count: count2,
552
+ reportedTotal: attempt.reportedTotal,
553
+ reportedTotalMeans: attempt.reportedTotalMeans,
554
+ skipped: attempt.skipped,
555
+ mixesReferencePages: attempt.source.mixesReferencePages,
556
+ cached: attempt.cached,
557
+ error: attempt.error
558
+ };
559
+ }
560
+ function interleave(groups) {
561
+ const merged = [];
562
+ const longest = Math.max(0, ...groups.map((group) => group.length));
563
+ for (let index = 0; index < longest; index += 1) {
564
+ for (const group of groups) {
565
+ const row = group[index];
566
+ if (row) merged.push(row);
567
+ }
568
+ }
569
+ return merged;
570
+ }
571
+ var RecipesClient = class {
572
+ config;
573
+ logger;
574
+ sources;
575
+ constructor(options = {}) {
576
+ this.config = withGuarantees({ ...loadConfig(), ...options.config });
577
+ this.logger = options.logger ?? createLogger(this.config.logLevel);
578
+ this.sources = options.sources ?? buildSources(this.config, options.readers ?? {});
579
+ }
580
+ /** What every source sees this client call itself. */
581
+ get userAgent() {
582
+ return this.config.userAgent;
583
+ }
584
+ get intervalMs() {
585
+ return this.config.minIntervalMs;
586
+ }
587
+ get timeoutMs() {
588
+ return this.config.timeoutMs;
589
+ }
590
+ get maxRetries() {
591
+ return this.config.maxRetries;
592
+ }
593
+ /** The sources this server reads, in the order an answer takes them. */
594
+ get profiles() {
595
+ return this.sources.map((source) => ({
596
+ id: source.id,
597
+ name: source.name,
598
+ language: source.language,
599
+ homeUrl: source.homeUrl
600
+ }));
601
+ }
602
+ /**
603
+ * The backstop deadline over one source, covering every attempt it is
604
+ * entitled to make and the pacing between them.
605
+ */
606
+ get deadlineMs() {
607
+ const attempts = this.config.maxRetries + 1;
608
+ return this.config.timeoutMs * attempts + this.config.minIntervalMs * this.config.maxRetries;
609
+ }
610
+ /**
611
+ * Ask every source the same question and merge the rows.
612
+ *
613
+ * The calls go out together, so the answer takes as long as the slowest
614
+ * source rather than as long as all of them. Whichever of them fail, the rest
615
+ * still come back, and the report says which was which.
616
+ */
617
+ async searchRecipes(query, limitPerSource, wanted) {
618
+ const trimmed = query.trim();
619
+ if (trimmed === "") {
620
+ throw invalidInput("A search needs something to look for.", "Name a dish or an ingredient.");
621
+ }
622
+ const chosen = selectSources(this.sources, wanted);
623
+ const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));
624
+ const attempts = await Promise.all(
625
+ chosen.map((source) => this.searchOne(source, trimmed, limit))
626
+ );
627
+ const groups = attempts.map((attempt) => attempt.rows.slice(0, limit));
628
+ return {
629
+ rows: interleave(groups),
630
+ reports: attempts.map((attempt, index) => reportOf(attempt, groups[index].length))
631
+ };
632
+ }
633
+ async searchOne(source, query, limit) {
634
+ try {
635
+ const read = await withDeadline(source.search(query, limit), this.deadlineMs, source);
636
+ if (read.skipped > 0) {
637
+ this.logger.warn(
638
+ `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
639
+ );
640
+ }
641
+ return {
642
+ source,
643
+ rows: read.rows,
644
+ cached: read.cached,
645
+ reportedTotal: read.reportedTotal,
646
+ reportedTotalMeans: read.reportedTotalMeans,
647
+ skipped: read.skipped,
648
+ error: null
649
+ };
650
+ } catch (error) {
651
+ const known = toRecipesError(error);
652
+ this.logger.warn(`${source.name} did not answer: [${known.code}] ${known.message}`);
653
+ return {
654
+ source,
655
+ rows: [],
656
+ cached: false,
657
+ reportedTotal: null,
658
+ reportedTotalMeans: null,
659
+ skipped: 0,
660
+ error: {
661
+ code: known.code,
662
+ message: known.message,
663
+ ...known.details.hint ? { hint: known.details.hint } : {}
664
+ }
665
+ };
666
+ }
667
+ }
668
+ /**
669
+ * Read one recipe, from whichever source the identifier names.
670
+ *
671
+ * Only that source is called. Trying another after a miss would turn "this
672
+ * source has no page by this name" into somebody else's recipe with a
673
+ * different title, which is a worse answer than the absence it replaced.
674
+ */
675
+ async getRecipe(id) {
676
+ const read = resolveId(id, this.sources);
677
+ try {
678
+ const outcome = await withDeadline(
679
+ read.source.getRecipe(read.reference),
680
+ this.deadlineMs,
681
+ read.source
682
+ );
683
+ return { recipe: outcome.recipe, cached: outcome.cached, read };
684
+ } catch (error) {
685
+ throw toRecipesError(error);
686
+ }
687
+ }
688
+ };
689
+ export {
690
+ MAX_LIMIT_PER_SOURCE,
691
+ RecipesClient,
692
+ SOURCE_IDS,
693
+ interleave,
694
+ namespacedId,
695
+ resolveId
696
+ };
697
+ //# sourceMappingURL=client.js.map