@flightbyte/mcp 0.0.0-stage → 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.
package/dist/docs.js ADDED
@@ -0,0 +1,414 @@
1
+ import { USER_AGENT } from './config.js';
2
+ /** Split at "---" lines followed by a blank line and a "# " title, ignoring any inside code fences (e.g. a SKILL.md example). */
3
+ function splitPages(text) {
4
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
5
+ const chunks = [];
6
+ let start = 0;
7
+ let fence = null;
8
+ for (let i = 0; i < lines.length; i++) {
9
+ const line = lines[i];
10
+ fence = trackFence(line, fence);
11
+ if (fence === null && line === '---' && lines[i + 1] === '' && lines[i + 2]?.startsWith('# ')) {
12
+ chunks.push(lines.slice(start, i).join('\n'));
13
+ start = i + 2;
14
+ }
15
+ }
16
+ chunks.push(lines.slice(start).join('\n'));
17
+ return chunks;
18
+ }
19
+ /** The fence that is open after this line: null outside code blocks. */
20
+ function trackFence(line, open) {
21
+ const marker = line.match(/^\s*(`{3,}|~{3,})/)?.[1];
22
+ if (!marker)
23
+ return open;
24
+ if (open === null)
25
+ return marker;
26
+ return marker[0] === open[0] && marker.length >= open.length ? null : open;
27
+ }
28
+ export function parseLlmsFull(text) {
29
+ const pages = [];
30
+ for (const chunk of splitPages(text)) {
31
+ const markdown = chunk.trim();
32
+ const lines = markdown.split('\n');
33
+ const title = lines[0]?.match(/^# (.+)$/)?.[1]?.trim();
34
+ // Only real pages carry a Source line in their header; the file's own preamble does not.
35
+ const head = lines.slice(0, 8);
36
+ const url = head.find((l) => l.startsWith('Source: '))?.slice('Source: '.length).trim();
37
+ if (!title || !url)
38
+ continue;
39
+ const description = head.find((l) => l.startsWith('> '))?.slice(2).trim() ?? '';
40
+ const page = { title, description, url, markdown, sections: [] };
41
+ page.sections = splitSections(page, lines.slice(1).filter((l) => !l.startsWith('Source: ') && l !== `> ${description}`));
42
+ pages.push(page);
43
+ }
44
+ return pages;
45
+ }
46
+ function splitSections(page, lines) {
47
+ const sections = [];
48
+ const seen = new Map();
49
+ let current = { page, heading: null, anchor: null, text: '' };
50
+ let buffer = [];
51
+ let fence = null;
52
+ const flush = () => {
53
+ current.text = buffer.join('\n').trim();
54
+ if (current.text || current.heading)
55
+ sections.push(current);
56
+ buffer = [];
57
+ };
58
+ for (const line of lines) {
59
+ const wasInFence = fence !== null;
60
+ fence = trackFence(line, fence);
61
+ const heading = !wasInFence && fence === null ? line.match(/^(#{2,3}) (.+)$/) : null;
62
+ if (heading) {
63
+ flush();
64
+ const text = heading[2].trim();
65
+ current = { page, heading: stripInline(text), anchor: uniqueSlug(text, seen), text: '' };
66
+ continue;
67
+ }
68
+ buffer.push(line);
69
+ }
70
+ flush();
71
+ return sections;
72
+ }
73
+ /** github-slugger compatible: what the docs site uses for heading anchors. */
74
+ export function slugify(heading) {
75
+ return stripInline(heading)
76
+ .toLowerCase()
77
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
78
+ .replace(/\s/g, '-');
79
+ }
80
+ function uniqueSlug(heading, seen) {
81
+ const base = slugify(heading);
82
+ const count = seen.get(base) ?? 0;
83
+ seen.set(base, count + 1);
84
+ return count === 0 ? base : `${base}-${count}`;
85
+ }
86
+ function stripInline(text) {
87
+ return text
88
+ .replace(/`([^`]*)`/g, '$1')
89
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
90
+ .replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, '$1')
91
+ .trim();
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Ranking: BM25 over sections, with the section heading and the page title and description counted as
95
+ // extra, heavier occurrences, light stemming, and prefix matches at a discount.
96
+ const STOPWORDS = new Set('a an and are as at be by can do does for from get how i if in into is it its me my of on or the this that to use what when where which with you your'.split(' '));
97
+ export function tokenize(text) {
98
+ const out = [];
99
+ for (const raw of text.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
100
+ if (!raw || STOPWORDS.has(raw))
101
+ continue;
102
+ out.push(stem(raw));
103
+ }
104
+ return out;
105
+ }
106
+ function stem(word) {
107
+ if (word.length > 4 && word.endsWith('ies'))
108
+ return `${word.slice(0, -3)}y`;
109
+ if (word.length > 5 && word.endsWith('ing'))
110
+ return word.slice(0, -3);
111
+ if (word.length > 4 && word.endsWith('ed'))
112
+ return word.slice(0, -2);
113
+ if (word.length > 3 && word.endsWith('s') && !word.endsWith('ss') && !word.endsWith('us'))
114
+ return word.slice(0, -1);
115
+ return word;
116
+ }
117
+ const WEIGHT_HEADING = 3;
118
+ const WEIGHT_TITLE = 2;
119
+ const WEIGHT_DESCRIPTION = 0.5;
120
+ const WEIGHT_CODE = 0.3;
121
+ const MIN_LENGTH = 30;
122
+ const K1 = 1.2;
123
+ const B = 0.75;
124
+ export function buildIndex(pages) {
125
+ const sections = [];
126
+ const df = new Map();
127
+ for (const page of pages) {
128
+ const titleTokens = tokenize(page.title);
129
+ const descriptionTokens = tokenize(page.description);
130
+ for (const section of page.sections) {
131
+ const tf = new Map();
132
+ const add = (tokens, weight) => {
133
+ for (const t of tokens)
134
+ tf.set(t, (tf.get(t) ?? 0) + weight);
135
+ };
136
+ // Code is mostly identifiers and punctuation: it counts, but lightly, and does not make a section "long".
137
+ const { prose, code } = splitCode(section.text);
138
+ const bodyTokens = tokenize(prose);
139
+ add(bodyTokens, 1);
140
+ add(tokenize(code), WEIGHT_CODE);
141
+ add(tokenize(section.heading ?? ''), WEIGHT_HEADING);
142
+ add(titleTokens, WEIGHT_TITLE);
143
+ add(descriptionTokens, WEIGHT_DESCRIPTION);
144
+ for (const t of tf.keys())
145
+ df.set(t, (df.get(t) ?? 0) + 1);
146
+ sections.push({
147
+ section,
148
+ tf,
149
+ // A floor, so a one-line section does not outrank the section that actually explains the topic.
150
+ length: Math.max(bodyTokens.length, MIN_LENGTH),
151
+ lowerText: `${page.title} ${section.heading ?? ''} ${section.text}`.toLowerCase(),
152
+ });
153
+ }
154
+ }
155
+ const avgLength = sections.reduce((sum, s) => sum + s.length, 0) / Math.max(1, sections.length);
156
+ return { pages, sections, df, avgLength };
157
+ }
158
+ function splitCode(text) {
159
+ const prose = [];
160
+ const code = [];
161
+ let fence = null;
162
+ for (const line of text.split('\n')) {
163
+ const wasInFence = fence !== null;
164
+ fence = trackFence(line, fence);
165
+ (wasInFence || fence !== null ? code : prose).push(line);
166
+ }
167
+ return { prose: prose.join('\n'), code: code.join('\n') };
168
+ }
169
+ export function search(index, query, limit = 5) {
170
+ const terms = [...new Set(tokenize(query))];
171
+ if (terms.length === 0)
172
+ return [];
173
+ const n = index.sections.length;
174
+ const idf = (t) => {
175
+ const df = index.df.get(t) ?? 0;
176
+ return Math.log(1 + (n - df + 0.5) / (df + 0.5));
177
+ };
178
+ // Each query term matches itself exactly, and vocabulary words it is a prefix of (or that are a prefix of it) at a discount.
179
+ const vocabulary = [...index.df.keys()];
180
+ const expansions = terms.map((q) => {
181
+ const matches = [];
182
+ if (index.df.has(q))
183
+ matches.push([q, 1]);
184
+ if (q.length >= 4) {
185
+ for (const t of vocabulary) {
186
+ if (t === q)
187
+ continue;
188
+ if (t.startsWith(q) || (t.length >= 5 && q.startsWith(t)))
189
+ matches.push([t, 0.5]);
190
+ }
191
+ }
192
+ return matches;
193
+ });
194
+ const phrase = query.trim().toLowerCase().replace(/\s+/g, ' ');
195
+ const scored = [];
196
+ for (const entry of index.sections) {
197
+ let score = 0;
198
+ let matchedTerms = 0;
199
+ for (const matches of expansions) {
200
+ let best = 0;
201
+ for (const [t, weight] of matches) {
202
+ const tf = entry.tf.get(t);
203
+ if (!tf)
204
+ continue;
205
+ const norm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * entry.length) / index.avgLength));
206
+ best = Math.max(best, weight * idf(t) * norm);
207
+ }
208
+ if (best > 0)
209
+ matchedTerms++;
210
+ score += best;
211
+ }
212
+ if (score === 0)
213
+ continue;
214
+ // Favour sections that cover more of the query, and exact phrases.
215
+ score *= 0.5 + (0.5 * matchedTerms) / expansions.length;
216
+ if (terms.length > 1 && phrase.length > 3 && entry.lowerText.includes(phrase))
217
+ score *= 1.5;
218
+ // The reference pages are generated and long; the hand-written docs explain things.
219
+ const url = entry.section.page.url;
220
+ if (/\/docs(\/|$)/.test(url) && !url.includes('/docs/reference/'))
221
+ score *= 1.25;
222
+ scored.push({ entry, score });
223
+ }
224
+ scored.sort((a, b) => b.score - a.score);
225
+ const hits = [];
226
+ const perPage = new Map();
227
+ const snippets = new Set();
228
+ for (const { entry, score } of scored) {
229
+ const { page, heading, anchor } = entry.section;
230
+ const count = perPage.get(page.url) ?? 0;
231
+ if (count >= 2)
232
+ continue;
233
+ // Generated reference pages repeat the same boilerplate sections; show each text once.
234
+ const text = snippet(entry.section.text || page.description, terms);
235
+ if (snippets.has(text))
236
+ continue;
237
+ snippets.add(text);
238
+ perPage.set(page.url, count + 1);
239
+ hits.push({
240
+ title: heading ? `${page.title} › ${heading}` : page.title,
241
+ page: page.title,
242
+ section: heading,
243
+ url: anchor ? `${page.url}#${anchor}` : page.url,
244
+ snippet: text,
245
+ score: Math.round(score * 100) / 100,
246
+ });
247
+ if (hits.length >= limit)
248
+ break;
249
+ }
250
+ return hits;
251
+ }
252
+ function snippet(text, terms, width = 320) {
253
+ const clean = text
254
+ .replace(/^\s*(`{3,}|~{3,}).*$/gm, ' ')
255
+ .replace(/\|?\s*:?-{3,}:?\s*/g, ' ')
256
+ .replace(/\s*\|\s*/g, ' | ')
257
+ .replace(/\s+/g, ' ')
258
+ .trim();
259
+ if (clean.length <= width)
260
+ return clean;
261
+ const lower = clean.toLowerCase();
262
+ let at = -1;
263
+ for (const t of terms) {
264
+ const i = lower.search(new RegExp(`\\b${escapeRegExp(t)}`));
265
+ if (i !== -1 && (at === -1 || i < at))
266
+ at = i;
267
+ }
268
+ const start = Math.max(0, at === -1 ? 0 : at - Math.floor(width / 3));
269
+ const end = Math.min(clean.length, start + width);
270
+ return `${start > 0 ? '…' : ''}${clean.slice(start, end).trim()}${end < clean.length ? '…' : ''}`;
271
+ }
272
+ function escapeRegExp(s) {
273
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
274
+ }
275
+ /** Canonical docs host; links in the docs use it even when FLIGHTBYTE_DOCS_URL points somewhere else. */
276
+ const CANONICAL_ORIGINS = new Set(['https://flightbyte.dev', 'https://www.flightbyte.dev']);
277
+ const PAGE_ROOTS = ['/docs', '/guides', '/glossary'];
278
+ const MAX_PAGE_CHARS = 120_000;
279
+ export class DocsStore {
280
+ docsUrl;
281
+ #origin;
282
+ #ttlMs;
283
+ #timeoutMs;
284
+ #fetch;
285
+ #now;
286
+ #index;
287
+ #loading;
288
+ #pages = new Map();
289
+ constructor(options) {
290
+ this.docsUrl = options.docsUrl.replace(/\/+$/, '');
291
+ this.#origin = new URL(this.docsUrl).origin;
292
+ this.#ttlMs = options.ttlMs ?? 15 * 60_000;
293
+ this.#timeoutMs = options.timeoutMs ?? 20_000;
294
+ this.#fetch = options.fetch ?? fetch;
295
+ this.#now = options.now ?? Date.now;
296
+ }
297
+ /** The parsed docs, refreshed after the TTL. A failed refresh keeps serving the previous copy. */
298
+ async index() {
299
+ const cached = this.#index;
300
+ if (cached && cached.expires > this.#now())
301
+ return cached.value;
302
+ this.#loading ??= this.#load().finally(() => {
303
+ this.#loading = undefined;
304
+ });
305
+ try {
306
+ return await this.#loading;
307
+ }
308
+ catch (err) {
309
+ if (cached)
310
+ return cached.value;
311
+ throw err;
312
+ }
313
+ }
314
+ async #load() {
315
+ const url = `${this.docsUrl}/llms-full.txt`;
316
+ const res = await this.#get(url, 'text/plain');
317
+ if (!res.ok)
318
+ throw new Error(`GET ${url} answered ${res.status}`);
319
+ const pages = parseLlmsFull(await res.text());
320
+ if (pages.length === 0)
321
+ throw new Error(`${url} contained no pages`);
322
+ const value = buildIndex(pages);
323
+ this.#index = { value, expires: this.#now() + this.#ttlMs };
324
+ return value;
325
+ }
326
+ async search(query, limit) {
327
+ return search(await this.index(), query, limit);
328
+ }
329
+ /** Map a docs path or URL to the page's canonical URL on the configured docs site. */
330
+ resolve(input) {
331
+ const value = input.trim();
332
+ if (!value)
333
+ return { ok: false, message: 'Pass a docs path such as "/docs/webhooks" or a URL from search_docs.' };
334
+ let url;
335
+ try {
336
+ url = /^[a-z][a-z\d+.-]*:/i.test(value) ? new URL(value) : new URL(value.startsWith('/') ? value : `/${value}`, this.docsUrl);
337
+ }
338
+ catch {
339
+ return { ok: false, message: `"${value}" is not a docs path or URL.` };
340
+ }
341
+ if (url.origin !== this.#origin && !CANONICAL_ORIGINS.has(url.origin)) {
342
+ return { ok: false, message: `Only FlightByte docs pages can be fetched (${this.docsUrl}/docs, /guides, /glossary), not ${url.origin}.` };
343
+ }
344
+ let path = decodeURIComponent(url.pathname).replace(/\/+$/, '').replace(/\.md$/, '');
345
+ if (path === '' || path === '/llms-full.txt' || path === '/llms.txt')
346
+ path = '/docs';
347
+ if (!PAGE_ROOTS.some((root) => path === root || path.startsWith(`${root}/`)))
348
+ path = `/docs${path}`;
349
+ if (!/^[\w/.-]+$/.test(path) || path.includes('..'))
350
+ return { ok: false, message: `"${value}" is not a docs path.` };
351
+ return { ok: true, pageUrl: `${this.docsUrl}${path}`, path };
352
+ }
353
+ async page(input) {
354
+ const resolved = this.resolve(input);
355
+ if (!resolved.ok)
356
+ return { ok: false, status: 'invalid', message: resolved.message };
357
+ const { pageUrl, path } = resolved;
358
+ const cached = this.#pages.get(pageUrl);
359
+ if (cached && cached.expires > this.#now())
360
+ return { ok: true, url: pageUrl, markdown: cached.markdown, from: 'markdown' };
361
+ let fetchError;
362
+ try {
363
+ const res = await this.#get(`${pageUrl}.md`, 'text/markdown');
364
+ const type = res.headers.get('content-type') ?? '';
365
+ if (res.ok && !type.includes('text/html')) {
366
+ const markdown = truncate(await res.text());
367
+ this.#remember(pageUrl, markdown);
368
+ return { ok: true, url: pageUrl, markdown, from: 'markdown' };
369
+ }
370
+ if (res.status !== 404 && !res.ok)
371
+ fetchError = `${pageUrl}.md answered ${res.status}`;
372
+ }
373
+ catch (err) {
374
+ fetchError = err instanceof Error ? err.message : String(err);
375
+ }
376
+ // Guides and glossary entries have no .md twin; llms-full.txt has every page.
377
+ try {
378
+ const index = await this.index();
379
+ const page = index.pages.find((p) => samePath(p.url, path));
380
+ if (page)
381
+ return { ok: true, url: page.url, markdown: truncate(page.markdown), from: 'llms-full' };
382
+ }
383
+ catch (err) {
384
+ fetchError ??= err instanceof Error ? err.message : String(err);
385
+ }
386
+ if (fetchError)
387
+ return { ok: false, status: 'unavailable', message: `Could not fetch ${pageUrl}: ${fetchError}. Retry shortly.` };
388
+ return { ok: false, status: 'not_found', message: `No docs page at ${pageUrl}.` };
389
+ }
390
+ #remember(url, markdown) {
391
+ if (this.#pages.size >= 200)
392
+ this.#pages.delete(this.#pages.keys().next().value);
393
+ this.#pages.set(url, { markdown, expires: this.#now() + this.#ttlMs });
394
+ }
395
+ #get(url, accept) {
396
+ return this.#fetch(url, {
397
+ headers: { accept, 'user-agent': USER_AGENT },
398
+ signal: AbortSignal.timeout(this.#timeoutMs),
399
+ });
400
+ }
401
+ }
402
+ function samePath(url, path) {
403
+ try {
404
+ return new URL(url).pathname.replace(/\/+$/, '') === path;
405
+ }
406
+ catch {
407
+ return false;
408
+ }
409
+ }
410
+ function truncate(markdown) {
411
+ if (markdown.length <= MAX_PAGE_CHARS)
412
+ return markdown;
413
+ return `${markdown.slice(0, MAX_PAGE_CHARS)}\n\n[Truncated at ${MAX_PAGE_CHARS} characters.]`;
414
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,119 @@
1
+ import { KEYS_URL, PLAN_URL, REMOTE_URL, SIGNUP_URL } from './config.js';
2
+ export function toolError(text) {
3
+ return { isError: true, content: [{ type: 'text', text }] };
4
+ }
5
+ export function missingKeyError(tool) {
6
+ return toolError([
7
+ `No FlightByte API key is configured, so \`${tool}\` cannot call the API. Do not retry until a key is set.`,
8
+ '',
9
+ 'Tell the user to add their key to this MCP server, then restart or reconnect it:',
10
+ '- Local server (npx @flightbyte/mcp): set the FLIGHTBYTE_KEY environment variable in the MCP client config, e.g. `claude mcp add flightbyte --env FLIGHTBYTE_KEY=<key> -- npx -y @flightbyte/mcp`.',
11
+ `- Remote server (${REMOTE_URL}): send the header \`Authorization: Bearer <key>\` (or \`x-api-key: <key>\`).`,
12
+ `A free key comes from ${SIGNUP_URL}. The docs tools (search_docs, get_doc_page) work without a key.`,
13
+ ].join('\n'));
14
+ }
15
+ /** Turn a failed API call into an `isError` result that tells the model what to do next. */
16
+ export function apiErrorResult(result, ctx) {
17
+ if (result.kind === 'network_error') {
18
+ return toolError(`Could not reach the FlightByte API (${result.url}): ${result.message}. ` +
19
+ 'This is usually temporary: retry once after a few seconds, and tell the user if it keeps failing.');
20
+ }
21
+ const env = isObject(result.body) ? result.body : {};
22
+ const status = result.status;
23
+ const message = Array.isArray(env.message) ? env.message.join('; ') : env.message;
24
+ const lines = [];
25
+ lines.push(`FlightByte API error ${status}${env.error ? ` ${env.error}` : ''}${env.code ? ` (code: ${env.code})` : ''}.`);
26
+ if (message)
27
+ lines.push(`Message: ${message}`);
28
+ else if (!isObject(result.body) && result.text)
29
+ lines.push(`Body: ${result.text.slice(0, 500)}`);
30
+ lines.push('');
31
+ lines.push(`What to do: ${guidance(status, env, result.headers, ctx)}`);
32
+ const legs = Array.isArray(env.legs) ? env.legs : [];
33
+ if (legs.length > 0) {
34
+ lines.push('', 'Legs this flight number operates on that date:');
35
+ for (const leg of legs)
36
+ lines.push(`- ${describeLeg(leg)}`);
37
+ }
38
+ if (env.requestId)
39
+ lines.push('', `requestId: ${env.requestId} (quote it if the user contacts FlightByte support)`);
40
+ return toolError(lines.join('\n'));
41
+ }
42
+ function guidance(status, env, headers, ctx) {
43
+ const { tool } = ctx;
44
+ switch (status) {
45
+ case 400:
46
+ if (Array.isArray(env.legs) && env.legs.length > 0) {
47
+ return ('This flight number operates more than one leg on that date, and FlightByte will not guess which one is meant. ' +
48
+ 'Use the departure airport the user mentioned, or ask them which leg, then call ' +
49
+ `\`${tool}\` again with \`origin\` (add \`destination\` if two legs leave the same airport), or with \`flightId\` from the list below.`);
50
+ }
51
+ return 'The request failed validation. Fix the arguments named in the message and try again; repeating it unchanged gets the same answer.';
52
+ case 401:
53
+ return ('The API key is missing, invalid, revoked or expired. Do not retry. Ask the user to check the key configured for this MCP server ' +
54
+ `(FLIGHTBYTE_KEY locally, or the Authorization header for ${REMOTE_URL}); keys are managed at ${KEYS_URL}, and a free one comes from ${SIGNUP_URL}.`);
55
+ case 402: {
56
+ if (env.code === 'watched_flight_limit') {
57
+ const usage = env.used !== undefined && env.limit !== undefined ? ` (${env.used}/${env.limit} this month)` : '';
58
+ const reset = env.resetsAt ? ` The allowance resets at ${env.resetsAt}.` : '';
59
+ return (`The plan's monthly cap on watched flights is reached${usage}, so no new subscription can be created.${reset} ` +
60
+ `The existing subscriptions keep running. Do not retry; tell the user they can wait for the reset or upgrade at ${PLAN_URL}.`);
61
+ }
62
+ return `The plan does not allow this request. Do not retry; tell the user to check their plan at ${PLAN_URL}.`;
63
+ }
64
+ case 403:
65
+ return 'This key is not allowed to do that. Do not retry; tell the user.';
66
+ case 404:
67
+ if (ctx.resource === 'subscription') {
68
+ return ('No subscription with this id exists for this API key (subscriptions belong to the key that created them). ' +
69
+ 'Call `list_subscriptions` to find the right id instead of guessing.');
70
+ }
71
+ return ('No such flight, or no leg matching the selector. If you passed origin, destination or flightId, the message names the legs that exist: retry with one of them. ' +
72
+ 'Otherwise check carrier, flightNumber and date (the local departure date at the origin, YYYY-MM-DD). ' +
73
+ 'Live records cover a few days back to about two days ahead; a subscription can still be created up to seven days ahead and waits as `pending`.');
74
+ case 409:
75
+ return ('A subscription for this flight and webhook URL already exists under the same Idempotency-Key. ' +
76
+ 'Call `list_subscriptions` (filter by carrier, flightNumber and date) to find it rather than creating another.');
77
+ case 429: {
78
+ const wait = retryAfterSeconds(env, headers);
79
+ const window = env.window ? `the ${env.window} quota window` : 'a quota window';
80
+ return (`Rate limited: ${window} is full.${wait !== undefined ? ` Wait ${wait} seconds (Retry-After) before calling the API again.` : ' Wait before calling the API again.'} ` +
81
+ 'Do not retry in a loop; if the wait is long, tell the user. Refused requests do not count against the quota.');
82
+ }
83
+ case 502:
84
+ case 503:
85
+ case 504:
86
+ return 'A temporary failure on FlightByte\'s side or upstream. Safe to retry once after a few seconds; tell the user if it persists.';
87
+ default:
88
+ if (status >= 500)
89
+ return 'An unexpected server error. Retry once after a few seconds; if it persists, tell the user and include the requestId.';
90
+ return 'The API refused the request. Read the message, fix the arguments, and do not repeat the same call unchanged.';
91
+ }
92
+ }
93
+ function retryAfterSeconds(env, headers) {
94
+ const header = headers.get('retry-after');
95
+ if (header) {
96
+ const seconds = Number(header);
97
+ if (Number.isFinite(seconds))
98
+ return Math.max(0, Math.ceil(seconds));
99
+ const at = Date.parse(header);
100
+ if (!Number.isNaN(at))
101
+ return Math.max(0, Math.ceil((at - Date.now()) / 1000));
102
+ }
103
+ return typeof env.retryAfterSeconds === 'number' ? env.retryAfterSeconds : undefined;
104
+ }
105
+ function describeLeg(leg) {
106
+ const route = `${leg.origin ?? '?'} → ${leg.destination ?? '?'}`;
107
+ const parts = [route];
108
+ if (leg.carrier)
109
+ parts.push(`operated by ${leg.carrier}`);
110
+ if (leg.scheduledDepartureLocal)
111
+ parts.push(`departs ${leg.scheduledDepartureLocal} local`);
112
+ const pick = [leg.origin ? `origin "${leg.origin}"` : undefined, leg.flightId ? `flightId "${leg.flightId}"` : undefined]
113
+ .filter(Boolean)
114
+ .join(' or ');
115
+ return pick ? `${parts.join(', ')}: pass ${pick}` : parts.join(', ');
116
+ }
117
+ function isObject(value) {
118
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
119
+ }