scorchcrawl-mcp 1.0.2 → 1.2.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/bundle.js +2777 -0
- package/dist/copilot-agent.js +619 -0
- package/dist/index.js +821 -0
- package/dist/lib/fastmcp/index.js +6 -0
- package/dist/lib/scorch-client/index.js +9 -0
- package/dist/local-scraper.js +319 -0
- package/dist/rate-limiter.js +361 -0
- package/dist/response-utils.js +789 -0
- package/dist/scrape-cache.js +34 -0
- package/package.json +41 -9
- package/dist/cli.js +0 -121
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export of the Firecrawl JS SDK (MIT-licensed, @mendable/firecrawl-js).
|
|
3
|
+
* This is the HTTP client that talks to our local engine API.
|
|
4
|
+
* We re-export here so our source files import from a local path
|
|
5
|
+
* without referencing the upstream package name.
|
|
6
|
+
*/
|
|
7
|
+
import FirecrawlApp from '@mendable/firecrawl-js';
|
|
8
|
+
export default FirecrawlApp;
|
|
9
|
+
export { FirecrawlApp as ScorchClient };
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Scraper — fetches pages through the LOCAL machine's IP and converts to markdown.
|
|
3
|
+
* Used when SCORCHCRAWL_LOCAL_PROXY=true is set, so scraping traffic exits through
|
|
4
|
+
* the user's residential IP instead of the server's datacenter IP.
|
|
5
|
+
*
|
|
6
|
+
* Falls back to the remote ScorchCrawl API for features that need server-side
|
|
7
|
+
* processing (search, crawl, extract, agent, JSON schema extraction).
|
|
8
|
+
*
|
|
9
|
+
* SPA Detection:
|
|
10
|
+
* When the fetched HTML looks like a Single Page Application shell
|
|
11
|
+
* (minimal text, loading indicators, heavy JS bundles), the scraper returns
|
|
12
|
+
* a `SPA_SKELETON_DETECTED` error so the caller can retry via the engine's
|
|
13
|
+
* Playwright-backed scraper which executes JavaScript.
|
|
14
|
+
*/
|
|
15
|
+
import TurndownService from 'turndown';
|
|
16
|
+
import * as cheerio from 'cheerio';
|
|
17
|
+
// Lazy-init singleton
|
|
18
|
+
let _turndown = null;
|
|
19
|
+
function getTurndown() {
|
|
20
|
+
if (!_turndown) {
|
|
21
|
+
_turndown = new TurndownService({
|
|
22
|
+
headingStyle: 'atx',
|
|
23
|
+
codeBlockStyle: 'fenced',
|
|
24
|
+
bulletListMarker: '-',
|
|
25
|
+
});
|
|
26
|
+
// Strip script/style/nav/footer tags
|
|
27
|
+
_turndown.remove(['script', 'style', 'noscript', 'iframe']);
|
|
28
|
+
}
|
|
29
|
+
return _turndown;
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// SPA / JS-rendered page detection
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* Common phrases found in SPA shell HTML before JS hydrates the page.
|
|
36
|
+
* Matched case-insensitively against the visible body text.
|
|
37
|
+
*/
|
|
38
|
+
const SPA_LOADING_PATTERNS = [
|
|
39
|
+
'loading...',
|
|
40
|
+
'loading…',
|
|
41
|
+
'please wait',
|
|
42
|
+
'just a moment',
|
|
43
|
+
'checking your browser',
|
|
44
|
+
'one moment please',
|
|
45
|
+
'redirecting',
|
|
46
|
+
'enable javascript',
|
|
47
|
+
'javascript is required',
|
|
48
|
+
'javascript must be enabled',
|
|
49
|
+
'this app requires javascript',
|
|
50
|
+
'you need to enable javascript',
|
|
51
|
+
'noscript',
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* CSS selectors whose sole presence (with no other meaningful content)
|
|
55
|
+
* strongly indicates a JS-only SPA shell.
|
|
56
|
+
*/
|
|
57
|
+
const SPA_ROOT_SELECTORS = [
|
|
58
|
+
'#root', // React (CRA, Vite)
|
|
59
|
+
'#app', // Vue
|
|
60
|
+
'#__next', // Next.js
|
|
61
|
+
'#__nuxt', // Nuxt
|
|
62
|
+
'#svelte', // SvelteKit
|
|
63
|
+
'app-root', // Angular
|
|
64
|
+
'#___gatsby', // Gatsby
|
|
65
|
+
'#main-app', // misc
|
|
66
|
+
];
|
|
67
|
+
/** Minimum characters of visible text for a page to be considered "real" content. */
|
|
68
|
+
const MIN_MEANINGFUL_TEXT_LENGTH = 200;
|
|
69
|
+
/** Ratio: if (script bytes / total HTML bytes) exceeds this, it's likely a SPA shell. */
|
|
70
|
+
const SCRIPT_HEAVY_RATIO = 0.65;
|
|
71
|
+
/**
|
|
72
|
+
* Inspect raw HTML + extracted text to decide if the page is a SPA shell
|
|
73
|
+
* that hasn't been hydrated (no JS execution happened).
|
|
74
|
+
*
|
|
75
|
+
* Returns a short reason string if SPA-like, or `null` if the page looks real.
|
|
76
|
+
*/
|
|
77
|
+
export function detectSPASkeleton(rawHtml, _bodyText, $) {
|
|
78
|
+
// Get visible text only (strip script, style, noscript content)
|
|
79
|
+
const $clone = cheerio.load($.html());
|
|
80
|
+
$clone('script, style, noscript').remove();
|
|
81
|
+
const visibleText = $clone('body').text() || '';
|
|
82
|
+
const trimmedText = visibleText.replace(/\s+/g, ' ').trim();
|
|
83
|
+
const lowerText = trimmedText.toLowerCase();
|
|
84
|
+
// 1. Very little visible text — likely a shell that JS would populate
|
|
85
|
+
if (trimmedText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
|
|
86
|
+
// Check for SPA root containers
|
|
87
|
+
for (const sel of SPA_ROOT_SELECTORS) {
|
|
88
|
+
const el = $(sel);
|
|
89
|
+
if (el.length > 0) {
|
|
90
|
+
const innerText = el.text().replace(/\s+/g, ' ').trim();
|
|
91
|
+
if (innerText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
|
|
92
|
+
return `SPA root container "${sel}" with minimal content (${innerText.length} chars)`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Check for loading phrases in the sparse text
|
|
97
|
+
for (const pattern of SPA_LOADING_PATTERNS) {
|
|
98
|
+
if (lowerText.includes(pattern)) {
|
|
99
|
+
return `Loading indicator detected: "${pattern}"`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Even without a known root, < 50 chars of body text is almost certainly a shell
|
|
103
|
+
if (trimmedText.length < 50) {
|
|
104
|
+
return `Near-empty body text (${trimmedText.length} chars)`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 2. Loading phrases in an otherwise short page (< 500 chars)
|
|
108
|
+
if (trimmedText.length < 500) {
|
|
109
|
+
for (const pattern of SPA_LOADING_PATTERNS) {
|
|
110
|
+
if (lowerText.includes(pattern)) {
|
|
111
|
+
return `Short page with loading indicator: "${pattern}"`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 3. Script-heavy pages: mostly <script> tags, very little content
|
|
116
|
+
const scriptContent = $('script')
|
|
117
|
+
.toArray()
|
|
118
|
+
.reduce((sum, el) => sum + ($(el).html()?.length || 0), 0);
|
|
119
|
+
const htmlLength = rawHtml.length;
|
|
120
|
+
if (htmlLength > 1000 &&
|
|
121
|
+
scriptContent / htmlLength > SCRIPT_HEAVY_RATIO &&
|
|
122
|
+
trimmedText.length < MIN_MEANINGFUL_TEXT_LENGTH) {
|
|
123
|
+
return `Script-heavy page (${Math.round((scriptContent / htmlLength) * 100)}% scripts, ${trimmedText.length} chars text)`;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Fetches a URL locally (through the user's IP) and converts to markdown.
|
|
129
|
+
*/
|
|
130
|
+
export async function localScrape(url, options = {}) {
|
|
131
|
+
const timeout = options.timeout || 30000;
|
|
132
|
+
// Determine requested formats
|
|
133
|
+
const formats = (options.formats || ['markdown']).map((f) => typeof f === 'string' ? f : f.type);
|
|
134
|
+
const wantMarkdown = formats.includes('markdown');
|
|
135
|
+
const wantHtml = formats.includes('html');
|
|
136
|
+
const wantRawHtml = formats.includes('rawHtml');
|
|
137
|
+
const wantLinks = formats.includes('links');
|
|
138
|
+
// Needs JSON/screenshot/branding? Can't do locally — return null to fall back
|
|
139
|
+
const needsServerSide = formats.some((f) => f === 'json' || f === 'screenshot' || f === 'branding' || f === 'summary');
|
|
140
|
+
if (needsServerSide) {
|
|
141
|
+
return { success: false, error: 'FORMAT_NEEDS_SERVER' };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
146
|
+
const fetchOptions = {
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
headers: {
|
|
149
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
150
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
|
151
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
152
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
153
|
+
'Cache-Control': 'no-cache',
|
|
154
|
+
...(options.headers || {}),
|
|
155
|
+
},
|
|
156
|
+
redirect: 'follow',
|
|
157
|
+
};
|
|
158
|
+
// Node 18+ native TLS rejection control
|
|
159
|
+
if (options.skipTlsVerification) {
|
|
160
|
+
fetchOptions.dispatcher = undefined; // handled below
|
|
161
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
162
|
+
}
|
|
163
|
+
const response = await fetch(url, fetchOptions);
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
if (options.skipTlsVerification) {
|
|
166
|
+
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
167
|
+
}
|
|
168
|
+
const rawHtml = await response.text();
|
|
169
|
+
const statusCode = response.status;
|
|
170
|
+
const contentType = response.headers.get('content-type') || 'text/html';
|
|
171
|
+
// Parse with Cheerio
|
|
172
|
+
const $ = cheerio.load(rawHtml);
|
|
173
|
+
// Extract metadata
|
|
174
|
+
const title = $('title').first().text().trim() ||
|
|
175
|
+
$('meta[property="og:title"]').attr('content') ||
|
|
176
|
+
'';
|
|
177
|
+
const description = $('meta[name="description"]').attr('content') ||
|
|
178
|
+
$('meta[property="og:description"]').attr('content') ||
|
|
179
|
+
'';
|
|
180
|
+
const language = $('html').attr('lang') || '';
|
|
181
|
+
// Remove unwanted elements if onlyMainContent
|
|
182
|
+
if (options.onlyMainContent) {
|
|
183
|
+
$('nav, header, footer, aside, .sidebar, .menu, .navigation, .breadcrumb, .cookie-banner, .ad, .advertisement, [role="navigation"], [role="banner"], [role="complementary"]').remove();
|
|
184
|
+
}
|
|
185
|
+
// Apply excludeTags
|
|
186
|
+
if (options.excludeTags?.length) {
|
|
187
|
+
$(options.excludeTags.join(', ')).remove();
|
|
188
|
+
}
|
|
189
|
+
// Get the target HTML content
|
|
190
|
+
let targetHtml;
|
|
191
|
+
if (options.includeTags?.length) {
|
|
192
|
+
targetHtml = options.includeTags
|
|
193
|
+
.map((sel) => $(sel).html() || '')
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.join('\n');
|
|
196
|
+
}
|
|
197
|
+
else if (options.onlyMainContent) {
|
|
198
|
+
// Try to find main content area
|
|
199
|
+
const mainSelectors = [
|
|
200
|
+
'main',
|
|
201
|
+
'article',
|
|
202
|
+
'[role="main"]',
|
|
203
|
+
'.main-content',
|
|
204
|
+
'.content',
|
|
205
|
+
'#content',
|
|
206
|
+
'#main',
|
|
207
|
+
];
|
|
208
|
+
let mainHtml = '';
|
|
209
|
+
for (const sel of mainSelectors) {
|
|
210
|
+
const el = $(sel).first();
|
|
211
|
+
if (el.length && (el.html()?.length || 0) > 100) {
|
|
212
|
+
mainHtml = el.html() || '';
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
targetHtml = mainHtml || $('body').html() || rawHtml;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
targetHtml = $('body').html() || rawHtml;
|
|
220
|
+
}
|
|
221
|
+
// Build response
|
|
222
|
+
const data = {
|
|
223
|
+
metadata: {
|
|
224
|
+
title,
|
|
225
|
+
description: description || undefined,
|
|
226
|
+
language: language || undefined,
|
|
227
|
+
sourceURL: url,
|
|
228
|
+
url: response.url || url,
|
|
229
|
+
statusCode,
|
|
230
|
+
contentType,
|
|
231
|
+
proxyUsed: 'local',
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
if (wantMarkdown) {
|
|
235
|
+
data.markdown = getTurndown().turndown(targetHtml);
|
|
236
|
+
}
|
|
237
|
+
if (wantHtml) {
|
|
238
|
+
data.html = targetHtml;
|
|
239
|
+
}
|
|
240
|
+
if (wantRawHtml) {
|
|
241
|
+
data.rawHtml = rawHtml;
|
|
242
|
+
}
|
|
243
|
+
if (wantLinks) {
|
|
244
|
+
const links = [];
|
|
245
|
+
$('a[href]').each((_, el) => {
|
|
246
|
+
const href = $(el).attr('href');
|
|
247
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
248
|
+
try {
|
|
249
|
+
links.push(new URL(href, url).href);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
links.push(href);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
data.links = [...new Set(links)];
|
|
257
|
+
}
|
|
258
|
+
// --- SPA detection: check if the fetched content is a JS-only shell ---
|
|
259
|
+
const bodyText = $('body').text() || '';
|
|
260
|
+
const spaReason = detectSPASkeleton(rawHtml, bodyText, $);
|
|
261
|
+
if (spaReason) {
|
|
262
|
+
return { success: false, error: 'SPA_SKELETON_DETECTED', data };
|
|
263
|
+
}
|
|
264
|
+
return { success: true, data };
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (err.name === 'AbortError') {
|
|
268
|
+
return { success: false, error: `Timeout after ${timeout}ms` };
|
|
269
|
+
}
|
|
270
|
+
return { success: false, error: err.message || String(err) };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Check if local proxy mode is enabled.
|
|
275
|
+
* Controlled by:
|
|
276
|
+
* - SCORCHCRAWL_LOCAL_PROXY=true env var
|
|
277
|
+
* - ?localProxy=true query param in SCORCHCRAWL_API_URL
|
|
278
|
+
*/
|
|
279
|
+
export function isLocalProxyEnabled() {
|
|
280
|
+
// Check env var
|
|
281
|
+
if (process.env.SCORCHCRAWL_LOCAL_PROXY?.toLowerCase() === 'true' ||
|
|
282
|
+
process.env.SCORCHCRAWL_LOCAL_PROXY === '1') {
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
// Check URL query param
|
|
286
|
+
const apiUrl = process.env.SCORCHCRAWL_API_URL;
|
|
287
|
+
if (apiUrl) {
|
|
288
|
+
try {
|
|
289
|
+
const parsed = new URL(apiUrl);
|
|
290
|
+
if (parsed.searchParams.get('localProxy') === 'true' ||
|
|
291
|
+
parsed.searchParams.get('localProxy') === '1') {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// ignore invalid URL
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Returns SCORCHCRAWL_API_URL without the localProxy query param
|
|
303
|
+
* (so the scraping SDK doesn't pass it to the API).
|
|
304
|
+
*/
|
|
305
|
+
export function getCleanApiUrl() {
|
|
306
|
+
const apiUrl = process.env.SCORCHCRAWL_API_URL;
|
|
307
|
+
if (!apiUrl)
|
|
308
|
+
return undefined;
|
|
309
|
+
try {
|
|
310
|
+
const parsed = new URL(apiUrl);
|
|
311
|
+
parsed.searchParams.delete('localProxy');
|
|
312
|
+
const cleaned = parsed.toString();
|
|
313
|
+
// Remove trailing ? if no other params
|
|
314
|
+
return cleaned.replace(/\?$/, '');
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return apiUrl;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiting, Concurrency Control & Quota Monitoring
|
|
3
|
+
*
|
|
4
|
+
* The Copilot SDK / CLI engine handles HTTP-level rate limiting automatically:
|
|
5
|
+
* - 429 retries (up to 5) with exponential backoff + jitter
|
|
6
|
+
* - `retry-after` header respect
|
|
7
|
+
* - HTTP/2 GOAWAY recovery
|
|
8
|
+
* - 402 quota-exceeded errors surfaced as session.error
|
|
9
|
+
*
|
|
10
|
+
* This module adds the APPLICATION-LEVEL safeguards that the SDK doesn't:
|
|
11
|
+
* 1. Per-user and global concurrency limits for agent jobs
|
|
12
|
+
* 2. Quota snapshot tracking with proactive rejection
|
|
13
|
+
* 3. Sliding-window request rate tracking to avoid hitting 429 at all
|
|
14
|
+
* 4. Stale job garbage collection
|
|
15
|
+
* 5. Error classification and hook configuration
|
|
16
|
+
*/
|
|
17
|
+
export function buildRateLimitConfig() {
|
|
18
|
+
return {
|
|
19
|
+
maxGlobalConcurrency: envInt('RATE_LIMIT_MAX_GLOBAL_CONCURRENCY', 10),
|
|
20
|
+
maxPerUserConcurrency: envInt('RATE_LIMIT_MAX_PER_USER_CONCURRENCY', 3),
|
|
21
|
+
rateLimitWindowMs: envInt('RATE_LIMIT_WINDOW_MS', 60_000),
|
|
22
|
+
maxRequestsPerWindow: envInt('RATE_LIMIT_MAX_REQUESTS_PER_WINDOW', 20),
|
|
23
|
+
maxGlobalRequestsPerWindow: envInt('RATE_LIMIT_MAX_GLOBAL_REQUESTS_PER_WINDOW', 60),
|
|
24
|
+
quotaRejectThresholdPercent: envInt('RATE_LIMIT_QUOTA_REJECT_PERCENT', 5),
|
|
25
|
+
staleJobTimeoutMs: envInt('RATE_LIMIT_STALE_JOB_TIMEOUT_MS', 10 * 60 * 1000),
|
|
26
|
+
gcIntervalMs: envInt('RATE_LIMIT_GC_INTERVAL_MS', 60_000),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function envInt(key, fallback) {
|
|
30
|
+
const v = process.env[key];
|
|
31
|
+
if (v == null)
|
|
32
|
+
return fallback;
|
|
33
|
+
const n = parseInt(v, 10);
|
|
34
|
+
return Number.isFinite(n) ? n : fallback;
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// ConcurrencyTracker
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Tracks in-flight agent jobs both globally and per-user.
|
|
41
|
+
* Call `acquire()` before starting a job, `release()` when done.
|
|
42
|
+
*/
|
|
43
|
+
export class ConcurrencyTracker {
|
|
44
|
+
config;
|
|
45
|
+
globalActive = 0;
|
|
46
|
+
perUser = new Map();
|
|
47
|
+
constructor(config) {
|
|
48
|
+
this.config = config;
|
|
49
|
+
}
|
|
50
|
+
/** Check if a new job is allowed for the given user key */
|
|
51
|
+
canAcquire(userKey) {
|
|
52
|
+
if (this.globalActive >= this.config.maxGlobalConcurrency) {
|
|
53
|
+
return {
|
|
54
|
+
allowed: false,
|
|
55
|
+
reason: `Server is at maximum capacity (${this.config.maxGlobalConcurrency} concurrent jobs). Please retry shortly.`,
|
|
56
|
+
retryAfterSeconds: 10,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const userCount = this.perUser.get(userKey) || 0;
|
|
60
|
+
if (userCount >= this.config.maxPerUserConcurrency) {
|
|
61
|
+
return {
|
|
62
|
+
allowed: false,
|
|
63
|
+
reason: `You already have ${userCount} concurrent agent jobs (max ${this.config.maxPerUserConcurrency}). Wait for a job to complete before starting another.`,
|
|
64
|
+
retryAfterSeconds: 15,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { allowed: true };
|
|
68
|
+
}
|
|
69
|
+
acquire(userKey) {
|
|
70
|
+
this.globalActive++;
|
|
71
|
+
this.perUser.set(userKey, (this.perUser.get(userKey) || 0) + 1);
|
|
72
|
+
}
|
|
73
|
+
release(userKey) {
|
|
74
|
+
this.globalActive = Math.max(0, this.globalActive - 1);
|
|
75
|
+
const cur = this.perUser.get(userKey) || 0;
|
|
76
|
+
if (cur <= 1) {
|
|
77
|
+
this.perUser.delete(userKey);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
this.perUser.set(userKey, cur - 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Current stats (for observability / health endpoint) */
|
|
84
|
+
stats() {
|
|
85
|
+
return {
|
|
86
|
+
global: this.globalActive,
|
|
87
|
+
perUser: Object.fromEntries(this.perUser),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// SlidingWindowRateLimiter
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
/**
|
|
95
|
+
* Sliding-window rate limiter that tracks request timestamps.
|
|
96
|
+
* This prevents us from *sending* too many requests in the first place,
|
|
97
|
+
* so we rarely hit the API-level 429.
|
|
98
|
+
*/
|
|
99
|
+
export class SlidingWindowRateLimiter {
|
|
100
|
+
config;
|
|
101
|
+
/** Map<userKey, timestamp[]> */
|
|
102
|
+
perUser = new Map();
|
|
103
|
+
globalTimestamps = [];
|
|
104
|
+
constructor(config) {
|
|
105
|
+
this.config = config;
|
|
106
|
+
}
|
|
107
|
+
check(userKey) {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const windowStart = now - this.config.rateLimitWindowMs;
|
|
110
|
+
// --- Global check ---
|
|
111
|
+
this.globalTimestamps = this.globalTimestamps.filter((t) => t > windowStart);
|
|
112
|
+
if (this.globalTimestamps.length >= this.config.maxGlobalRequestsPerWindow) {
|
|
113
|
+
const oldest = this.globalTimestamps[0];
|
|
114
|
+
const retryAfter = Math.ceil((oldest + this.config.rateLimitWindowMs - now) / 1000);
|
|
115
|
+
return {
|
|
116
|
+
allowed: false,
|
|
117
|
+
reason: `Global rate limit reached (${this.config.maxGlobalRequestsPerWindow} requests per ${this.config.rateLimitWindowMs / 1000}s window). Please wait.`,
|
|
118
|
+
retryAfterSeconds: Math.max(1, retryAfter),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// --- Per-user check ---
|
|
122
|
+
let userTs = this.perUser.get(userKey) || [];
|
|
123
|
+
userTs = userTs.filter((t) => t > windowStart);
|
|
124
|
+
this.perUser.set(userKey, userTs);
|
|
125
|
+
if (userTs.length >= this.config.maxRequestsPerWindow) {
|
|
126
|
+
const oldest = userTs[0];
|
|
127
|
+
const retryAfter = Math.ceil((oldest + this.config.rateLimitWindowMs - now) / 1000);
|
|
128
|
+
return {
|
|
129
|
+
allowed: false,
|
|
130
|
+
reason: `Rate limit reached (${this.config.maxRequestsPerWindow} requests per ${this.config.rateLimitWindowMs / 1000}s window). Please wait.`,
|
|
131
|
+
retryAfterSeconds: Math.max(1, retryAfter),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { allowed: true };
|
|
135
|
+
}
|
|
136
|
+
/** Record that a request was made */
|
|
137
|
+
record(userKey) {
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
this.globalTimestamps.push(now);
|
|
140
|
+
const userTs = this.perUser.get(userKey) || [];
|
|
141
|
+
userTs.push(now);
|
|
142
|
+
this.perUser.set(userKey, userTs);
|
|
143
|
+
}
|
|
144
|
+
/** Purge old timestamps (called by GC) */
|
|
145
|
+
gc() {
|
|
146
|
+
const cutoff = Date.now() - this.config.rateLimitWindowMs;
|
|
147
|
+
this.globalTimestamps = this.globalTimestamps.filter((t) => t > cutoff);
|
|
148
|
+
for (const [key, ts] of this.perUser) {
|
|
149
|
+
const filtered = ts.filter((t) => t > cutoff);
|
|
150
|
+
if (filtered.length === 0) {
|
|
151
|
+
this.perUser.delete(key);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this.perUser.set(key, filtered);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// QuotaMonitor
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Tracks Copilot quota snapshots per user to proactively reject requests
|
|
164
|
+
* when quota is about to be exhausted.
|
|
165
|
+
*
|
|
166
|
+
* Quota data is fed from Copilot session `assistant.usage` events.
|
|
167
|
+
*/
|
|
168
|
+
export class QuotaMonitor {
|
|
169
|
+
config;
|
|
170
|
+
perUser = new Map();
|
|
171
|
+
constructor(config) {
|
|
172
|
+
this.config = config;
|
|
173
|
+
}
|
|
174
|
+
/** Update quota snapshot for a user (call from session event handler) */
|
|
175
|
+
update(userKey, snapshot) {
|
|
176
|
+
const existing = this.perUser.get(userKey);
|
|
177
|
+
this.perUser.set(userKey, {
|
|
178
|
+
remainingPercent: snapshot.remainingPercent ?? existing?.remainingPercent ?? 100,
|
|
179
|
+
usedRequests: snapshot.usedRequests ?? existing?.usedRequests ?? 0,
|
|
180
|
+
entitlementRequests: snapshot.entitlementRequests ?? existing?.entitlementRequests ?? -1,
|
|
181
|
+
isUnlimited: snapshot.isUnlimited ?? existing?.isUnlimited ?? false,
|
|
182
|
+
resetDate: snapshot.resetDate ?? existing?.resetDate,
|
|
183
|
+
lastUpdated: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** Check if the user has sufficient quota to start a new agent job */
|
|
187
|
+
check(userKey) {
|
|
188
|
+
const info = this.perUser.get(userKey);
|
|
189
|
+
// No quota info yet → allow (first request, or info not yet retrieved)
|
|
190
|
+
if (!info)
|
|
191
|
+
return { allowed: true };
|
|
192
|
+
// Unlimited entitlement → always allow
|
|
193
|
+
if (info.isUnlimited)
|
|
194
|
+
return { allowed: true };
|
|
195
|
+
// Stale quota info (> 5 min old) → allow but log warning
|
|
196
|
+
if (Date.now() - info.lastUpdated > 5 * 60 * 1000) {
|
|
197
|
+
return { allowed: true };
|
|
198
|
+
}
|
|
199
|
+
if (info.remainingPercent <= this.config.quotaRejectThresholdPercent) {
|
|
200
|
+
const resetMsg = info.resetDate
|
|
201
|
+
? ` Quota resets on ${info.resetDate}.`
|
|
202
|
+
: '';
|
|
203
|
+
return {
|
|
204
|
+
allowed: false,
|
|
205
|
+
reason: `Copilot quota nearly exhausted (${info.remainingPercent.toFixed(1)}% remaining, ${info.usedRequests}/${info.entitlementRequests} used).${resetMsg} Upgrade your plan or wait for reset.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { allowed: true };
|
|
209
|
+
}
|
|
210
|
+
/** Get quota info for a user (for diagnostics) */
|
|
211
|
+
get(userKey) {
|
|
212
|
+
return this.perUser.get(userKey);
|
|
213
|
+
}
|
|
214
|
+
/** Purge old entries (called by GC) */
|
|
215
|
+
gc() {
|
|
216
|
+
const cutoff = Date.now() - 30 * 60 * 1000; // 30 min
|
|
217
|
+
for (const [key, info] of this.perUser) {
|
|
218
|
+
if (info.lastUpdated < cutoff) {
|
|
219
|
+
this.perUser.delete(key);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Unified RateLimitGuard
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
/**
|
|
228
|
+
* Facade that combines concurrency tracking, sliding-window rate limiting,
|
|
229
|
+
* and quota monitoring into a single `guard.check()` call.
|
|
230
|
+
*/
|
|
231
|
+
export class RateLimitGuard {
|
|
232
|
+
config;
|
|
233
|
+
concurrency;
|
|
234
|
+
rateLimit;
|
|
235
|
+
quota;
|
|
236
|
+
gcTimer = null;
|
|
237
|
+
constructor(config) {
|
|
238
|
+
this.config = config || buildRateLimitConfig();
|
|
239
|
+
this.concurrency = new ConcurrencyTracker(this.config);
|
|
240
|
+
this.rateLimit = new SlidingWindowRateLimiter(this.config);
|
|
241
|
+
this.quota = new QuotaMonitor(this.config);
|
|
242
|
+
// Start periodic GC
|
|
243
|
+
this.gcTimer = setInterval(() => {
|
|
244
|
+
this.rateLimit.gc();
|
|
245
|
+
this.quota.gc();
|
|
246
|
+
}, this.config.gcIntervalMs);
|
|
247
|
+
// Don't block process exit
|
|
248
|
+
if (this.gcTimer.unref)
|
|
249
|
+
this.gcTimer.unref();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Check ALL rate-limit gates in priority order.
|
|
253
|
+
* Returns the first rejection, or { allowed: true } if all pass.
|
|
254
|
+
*/
|
|
255
|
+
check(userKey) {
|
|
256
|
+
// 1. Concurrency (cheapest check)
|
|
257
|
+
const concResult = this.concurrency.canAcquire(userKey);
|
|
258
|
+
if (!concResult.allowed)
|
|
259
|
+
return concResult;
|
|
260
|
+
// 2. Sliding-window rate limit
|
|
261
|
+
const rateResult = this.rateLimit.check(userKey);
|
|
262
|
+
if (!rateResult.allowed)
|
|
263
|
+
return rateResult;
|
|
264
|
+
// 3. Quota (proactive rejection)
|
|
265
|
+
const quotaResult = this.quota.check(userKey);
|
|
266
|
+
if (!quotaResult.allowed)
|
|
267
|
+
return quotaResult;
|
|
268
|
+
return { allowed: true };
|
|
269
|
+
}
|
|
270
|
+
/** Record that a request was admitted and is now in-flight */
|
|
271
|
+
acquire(userKey) {
|
|
272
|
+
this.concurrency.acquire(userKey);
|
|
273
|
+
this.rateLimit.record(userKey);
|
|
274
|
+
}
|
|
275
|
+
/** Release concurrency slot when job finishes */
|
|
276
|
+
release(userKey) {
|
|
277
|
+
this.concurrency.release(userKey);
|
|
278
|
+
}
|
|
279
|
+
/** Stats for observability */
|
|
280
|
+
stats() {
|
|
281
|
+
return {
|
|
282
|
+
concurrency: this.concurrency.stats(),
|
|
283
|
+
config: {
|
|
284
|
+
maxGlobalConcurrency: this.config.maxGlobalConcurrency,
|
|
285
|
+
maxPerUserConcurrency: this.config.maxPerUserConcurrency,
|
|
286
|
+
maxRequestsPerWindow: this.config.maxRequestsPerWindow,
|
|
287
|
+
rateLimitWindowMs: this.config.rateLimitWindowMs,
|
|
288
|
+
quotaRejectThresholdPercent: this.config.quotaRejectThresholdPercent,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** Clean shutdown */
|
|
293
|
+
shutdown() {
|
|
294
|
+
if (this.gcTimer) {
|
|
295
|
+
clearInterval(this.gcTimer);
|
|
296
|
+
this.gcTimer = null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Copilot SDK session error hook factory
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
/**
|
|
304
|
+
* Build an `onErrorOccurred` hook for Copilot SDK sessions.
|
|
305
|
+
*
|
|
306
|
+
* This integrates with the SDK's ErrorOccurredHookOutput to make
|
|
307
|
+
* intelligent retry/skip/abort decisions:
|
|
308
|
+
* - model_call + recoverable → retry (up to 2 extra)
|
|
309
|
+
* - tool_execution → skip the failed tool, continue agent
|
|
310
|
+
* - quota / auth errors → abort immediately
|
|
311
|
+
*/
|
|
312
|
+
export function buildErrorHook(jobId, logger) {
|
|
313
|
+
return (input) => {
|
|
314
|
+
const log = logger || console;
|
|
315
|
+
log.warn(`[RateLimit] Agent ${jobId} error`, {
|
|
316
|
+
context: input.errorContext,
|
|
317
|
+
recoverable: input.recoverable,
|
|
318
|
+
error: input.error.substring(0, 200),
|
|
319
|
+
});
|
|
320
|
+
// Quota / auth errors → abort immediately, don't waste retries
|
|
321
|
+
const lowerErr = input.error.toLowerCase();
|
|
322
|
+
if (lowerErr.includes('quota') ||
|
|
323
|
+
lowerErr.includes('402') ||
|
|
324
|
+
lowerErr.includes('not licensed') ||
|
|
325
|
+
lowerErr.includes('authentication')) {
|
|
326
|
+
return { errorHandling: 'abort', suppressOutput: false };
|
|
327
|
+
}
|
|
328
|
+
// Rate limit errors after SDK exhausted its 5 retries → abort
|
|
329
|
+
if (lowerErr.includes('rate limit') || lowerErr.includes('429')) {
|
|
330
|
+
return {
|
|
331
|
+
errorHandling: 'abort',
|
|
332
|
+
suppressOutput: false,
|
|
333
|
+
userNotification: 'Rate limit reached. Please retry later.',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// Model call errors that are recoverable → retry up to 2 additional times
|
|
337
|
+
if (input.errorContext === 'model_call' && input.recoverable) {
|
|
338
|
+
return { errorHandling: 'retry', retryCount: 2 };
|
|
339
|
+
}
|
|
340
|
+
// Tool execution errors → skip the failed tool, let agent continue
|
|
341
|
+
if (input.errorContext === 'tool_execution') {
|
|
342
|
+
return { errorHandling: 'skip' };
|
|
343
|
+
}
|
|
344
|
+
// Everything else → abort
|
|
345
|
+
return { errorHandling: 'abort' };
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Identify agent jobs that have been "processing" longer than the timeout.
|
|
350
|
+
* The caller is responsible for actually marking/removing them.
|
|
351
|
+
*/
|
|
352
|
+
export function findStaleJobs(jobs, timeoutMs) {
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
const stale = [];
|
|
355
|
+
for (const job of jobs) {
|
|
356
|
+
if (job.status === 'processing' && now - job.createdAt > timeoutMs) {
|
|
357
|
+
stale.push(job.id);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return stale;
|
|
361
|
+
}
|