crawlforge-mcp-server 6.5.0 → 6.6.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.
@@ -593,6 +593,7 @@ class AuthManager {
593
593
  analyze_content: 3,
594
594
  extract_structured: 3,
595
595
  extract_with_llm: 3,
596
+ browser_session: 3, // ceiling — `open`'s price; the schedule is per operation below
596
597
 
597
598
  // 4 credits
598
599
  summarize_content: 4,
@@ -635,6 +636,28 @@ class AuthManager {
635
636
  if (bookkeepingOps.has(params?.operation)) return 1;
636
637
  }
637
638
 
639
+ // browser_session bills per operation for the same reason: one session is
640
+ // many calls, and a flat price would charge the ceiling for every cheap
641
+ // one. `open` launches a browser and navigates, so it costs more than a
642
+ // `scrape` (2); `read` extracts content and is priced with `scrape`;
643
+ // `snapshot`, `act` and `screenshot` are one injected script or one action
644
+ // batch against a page that is already open. `close` and `list` are
645
+ // bookkeeping but still cost 1 — nothing here runs for free. An unknown or
646
+ // absent operation falls through to the flat 3: the published price is the
647
+ // ceiling, never the floor.
648
+ if (tool === 'browser_session') {
649
+ const operationCosts = new Map([
650
+ ['open', 3],
651
+ ['snapshot', 1],
652
+ ['act', 1],
653
+ ['read', 2],
654
+ ['screenshot', 1],
655
+ ['close', 1],
656
+ ['list', 1]
657
+ ]);
658
+ if (operationCosts.has(params?.operation)) return operationCosts.get(params.operation);
659
+ }
660
+
638
661
  // localize_search with a query runs a real web search through the same
639
662
  // adapter search_web uses, so it is priced as one rather than undercutting it.
640
663
  if (tool === 'localization' && params?.operation === 'localize_search' && params?.searchParams?.query) {
@@ -719,6 +742,9 @@ class AuthManager {
719
742
  ? 'Bookkeeping operation — launches no browser.'
720
743
  : 'Browser operation. configure/enable/disable/get_stats/cleanup cost 1 credit each.';
721
744
  break;
745
+ case 'browser_session':
746
+ note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call bills ${projected}.`;
747
+ break;
722
748
  case 'serp_rank':
723
749
  note = projected === 0
724
750
  ? 'DataForSEO not configured — no-op, no credits charged. Set DATAFORSEO_LOGIN/PASSWORD to enable.'
@@ -0,0 +1,331 @@
1
+ /**
2
+ * BrowserSessionStore — the registry behind a browser session that outlives a
3
+ * single tool call.
4
+ *
5
+ * A session is a page the caller gets to keep: log in once, then click, type
6
+ * and snapshot across several calls without paying for the login every time.
7
+ * This file holds those pages, decides when one has gone stale, and hands it
8
+ * back. How a page was made — stealth or not, which engine, which context — is
9
+ * the caller's business, never ours.
10
+ *
11
+ * NO SECOND POOL. Sessions hold pages whose browser contexts are already
12
+ * registered in StealthBrowserManager's BrowserContextPool by the code that
13
+ * opened them. Constructing a pool here would double-count every context
14
+ * against a cap that exists to protect a 2 GB box, so do not "fix" the missing
15
+ * pool. The plan's "context id" field is carried implicitly by `releasePage`,
16
+ * the closure the creator supplies to give the page back: the store knows how
17
+ * to return a page, not how it was built, and so imports neither
18
+ * BrowserProcessor nor StealthBrowserManager.
19
+ *
20
+ * REFS NEED NO FIELD either. Element refs (`@e1`) live in a page-scoped WeakMap
21
+ * inside src/core/browser/snapshot.js, so a session that keeps its page keeps
22
+ * its refs for free — and loses them exactly when it should, on navigation or
23
+ * when the page closes. A second copy here could only disagree with that one.
24
+ *
25
+ * OWNERSHIP IS A TENANT BOUNDARY, not a convenience. Ids are random and every
26
+ * lookup is scoped to the caller; a wrong owner is answered with the same
27
+ * "session not found" an unknown id gets, so ids cannot be probed from the
28
+ * outside. See SessionNotFoundError.
29
+ */
30
+
31
+ import { randomUUID } from 'node:crypto';
32
+
33
+ /**
34
+ * Thrown for an unknown id, a wrong owner and an expired session alike.
35
+ *
36
+ * It takes no message on purpose: a call site cannot vary what it says, so the
37
+ * three cases cannot drift apart over time and start telling a caller which one
38
+ * they hit. That sameness is what makes session ids non-enumerable, and it is
39
+ * also why the message never echoes the id back.
40
+ */
41
+ export class SessionNotFoundError extends Error {
42
+ constructor() {
43
+ super(
44
+ 'Session not found. It may have expired, been closed, or never existed — ' +
45
+ 'open a new session and try again.'
46
+ );
47
+ this.name = 'SessionNotFoundError';
48
+ this.code = 'SESSION_NOT_FOUND';
49
+ }
50
+ }
51
+
52
+ /** Thrown when a new session would breach the per-owner or process-wide cap. */
53
+ export class SessionLimitError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = 'SessionLimitError';
57
+ this.code = 'SESSION_LIMIT';
58
+ }
59
+ }
60
+
61
+ // Two clocks, and their bounds. The numbers match Firecrawl's session TTLs so
62
+ // that our documentation reads familiar to anyone arriving from theirs.
63
+ export const TTL_DEFAULT_MS = 600_000; // 10 minutes from creation
64
+ export const TTL_MIN_MS = 30_000;
65
+ export const TTL_MAX_MS = 3_600_000; // 1 hour
66
+ export const ACTIVITY_TTL_DEFAULT_MS = 300_000; // 5 minutes since last use
67
+ export const ACTIVITY_TTL_MIN_MS = 10_000;
68
+ export const ACTIVITY_TTL_MAX_MS = 3_600_000;
69
+
70
+ export const DEFAULT_MAX_SESSIONS_PER_OWNER = 3;
71
+
72
+ // Every live session pins one browser context for as long as it lives, and
73
+ // one-shot scrapes draw contexts from the same pool — which caps at
74
+ // MAX_BROWSER_CONTEXTS. Sessions therefore get at most half of it, so a burst
75
+ // of them can never leave an ordinary scrape waiting on a slot that will not
76
+ // free for another ten minutes.
77
+ //
78
+ // This is the SECOND reader of that variable — BrowserContextPool.js reads it
79
+ // with the same '10' fallback — and "half the pool" holds only while the two
80
+ // defaults agree. Change one and change this one with it. Deliberately not an
81
+ // import of the pool's constant: nothing in this file may reach for the pool
82
+ // (see the header), and a grep for MAX_BROWSER_CONTEXTS finds both sites.
83
+ const CONTEXT_CAP = parseInt(process.env.MAX_BROWSER_CONTEXTS || '10', 10) || 10;
84
+ export const DEFAULT_MAX_SESSIONS_TOTAL = Math.max(1, Math.floor(CONTEXT_CAP / 2));
85
+
86
+ const DEFAULT_SWEEP_INTERVAL_MS = 30_000;
87
+
88
+ /** Caller-supplied ttls are clamped, not rejected — see create(). */
89
+ function clamp(value, min, max, fallback) {
90
+ const n = Number(value);
91
+ return Number.isFinite(n) ? Math.min(Math.max(n, min), max) : fallback;
92
+ }
93
+
94
+ export class BrowserSessionStore {
95
+ /**
96
+ * @param {Object} [opts]
97
+ * @param {number} [opts.maxPerOwner] — concurrent sessions one API key may hold
98
+ * @param {number} [opts.maxTotal] — concurrent sessions the process may hold
99
+ * @param {number} [opts.sweepIntervalMs]
100
+ */
101
+ constructor(opts = {}) {
102
+ this._maxPerOwner = opts.maxPerOwner ?? DEFAULT_MAX_SESSIONS_PER_OWNER;
103
+ this._maxTotal = opts.maxTotal ?? DEFAULT_MAX_SESSIONS_TOTAL;
104
+
105
+ // The only index. A store holds single digits of sessions, so the
106
+ // per-owner questions (cap, list, stats) are answered by scanning this map
107
+ // rather than by a second one that could fall out of step with it.
108
+ /** @type {Map<string, { id: string, ownerId: string, page: any, releasePage: Function, url: string|null, stealth: boolean, createdAt: number, lastUsedAt: number, ttlMs: number, activityTtlMs: number }>} */
109
+ this._sessions = new Map();
110
+
111
+ this._sweepTimer = setInterval(() => {
112
+ // An unhandled rejection inside a timer callback takes the server down
113
+ // with it. _release never rejects, and this is what keeps that true even
114
+ // if someone later changes it.
115
+ this.sweep().catch(() => {});
116
+ }, opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS);
117
+ this._sweepTimer.unref?.(); // never hold the process open
118
+ }
119
+
120
+ /**
121
+ * Register a live page as a session.
122
+ *
123
+ * @param {Object} args
124
+ * @param {string} args.ownerId — the caller's API-key identity; the tenant boundary
125
+ * @param {any} args.page — the Playwright page the session keeps
126
+ * @param {() => Promise<void>} args.releasePage — hands the page, and its context, back
127
+ * @param {string} [args.url]
128
+ * @param {boolean} [args.stealth=false]
129
+ * @param {number} [args.ttlMs] — clamped into [TTL_MIN_MS, TTL_MAX_MS]
130
+ * @param {number} [args.activityTtlMs] — clamped into [ACTIVITY_TTL_MIN_MS, ACTIVITY_TTL_MAX_MS]
131
+ * @param {number} [args.maxPerOwner] — a tighter cap for THIS owner, in place
132
+ * of the store's. Some callers are not equal: a hosted REST customer shares
133
+ * one box with every other one, where a stdio install has the box to itself.
134
+ * Only the creator may say so, because only the creator knows who is asking.
135
+ * @throws {SessionLimitError} when the owner is at maxPerOwner, or the store at maxTotal
136
+ */
137
+ create({ ownerId, page, releasePage, url = null, stealth = false, ttlMs, activityTtlMs, maxPerOwner }) {
138
+ // Both of these are load-bearing rather than defensive: without an ownerId
139
+ // two tenants' sessions would share one anonymous bucket, and without a
140
+ // releasePage the page's context is pinned with no way to give it back.
141
+ if (!ownerId) {
142
+ throw new TypeError('BrowserSessionStore.create requires an ownerId');
143
+ }
144
+ if (typeof releasePage !== 'function') {
145
+ throw new TypeError('BrowserSessionStore.create requires a releasePage callback');
146
+ }
147
+
148
+ // Expired-but-unswept sessions must not count against the caps: a caller
149
+ // whose three sessions all timed out a second ago is not over quota.
150
+ this._purgeExpired();
151
+
152
+ // A refusal, never a queue. BrowserContextPool can make a caller wait for a
153
+ // slot because a context frees in milliseconds; a session slot frees on its
154
+ // TTL, minutes away, so waiting would simply hang the call.
155
+ const ownerCap = maxPerOwner ?? this._maxPerOwner;
156
+ if (this._countFor(ownerId) >= ownerCap) {
157
+ throw new SessionLimitError(
158
+ `You already have ${ownerCap} open browser session${ownerCap === 1 ? '' : 's'}, the maximum per API key. ` +
159
+ `Close one before opening another — sessions also close themselves when their TTL expires.`
160
+ );
161
+ }
162
+ if (this._sessions.size >= this._maxTotal) {
163
+ throw new SessionLimitError(
164
+ `The server is holding its maximum of ${this._maxTotal} browser sessions. ` +
165
+ `Try again shortly, or use a one-shot scrape instead of a session.`
166
+ );
167
+ }
168
+
169
+ const now = Date.now();
170
+ const session = {
171
+ id: randomUUID(),
172
+ ownerId,
173
+ page,
174
+ releasePage,
175
+ url,
176
+ stealth,
177
+ createdAt: now,
178
+ lastUsedAt: now,
179
+ // Clamped rather than rejected: the tool validates the caller-facing
180
+ // seconds with zod, so anything arriving out of range here is a bug on
181
+ // our side of the boundary, not a caller error to report.
182
+ ttlMs: clamp(ttlMs, TTL_MIN_MS, TTL_MAX_MS, TTL_DEFAULT_MS),
183
+ activityTtlMs: clamp(activityTtlMs, ACTIVITY_TTL_MIN_MS, ACTIVITY_TTL_MAX_MS, ACTIVITY_TTL_DEFAULT_MS)
184
+ };
185
+
186
+ this._sessions.set(session.id, session);
187
+ return session;
188
+ }
189
+
190
+ /**
191
+ * Look a session up on behalf of its owner. It records nothing — touch() does
192
+ * that — so a read never extends a session's life by accident.
193
+ *
194
+ * Unknown id, wrong owner and expired session all raise the identical
195
+ * SessionNotFoundError.
196
+ *
197
+ * @throws {SessionNotFoundError}
198
+ */
199
+ get(sessionId, ownerId) {
200
+ const session = this._sessions.get(sessionId);
201
+ if (!session || session.ownerId !== ownerId) throw new SessionNotFoundError();
202
+
203
+ if (this._isExpired(session, Date.now())) {
204
+ // Expiry is not the sweep's alone to notice. A session that timed out
205
+ // between sweeps is gone the moment it is asked for, and its page goes
206
+ // back now rather than up to half a minute later. get() is synchronous,
207
+ // so the release runs unawaited; _release never rejects.
208
+ this._sessions.delete(sessionId);
209
+ this._release(session);
210
+ throw new SessionNotFoundError();
211
+ }
212
+
213
+ return session;
214
+ }
215
+
216
+ /** Record activity, and the page's new url when it moved. Restarts the idle clock. */
217
+ touch(session, url) {
218
+ session.lastUsedAt = Date.now();
219
+ if (url) session.url = url;
220
+ }
221
+
222
+ /**
223
+ * Close a session on behalf of its owner and give its page back.
224
+ * @throws {SessionNotFoundError}
225
+ */
226
+ async close(sessionId, ownerId) {
227
+ const session = this.get(sessionId, ownerId);
228
+ // Out of the map BEFORE the first await. That is what makes releasePage run
229
+ // exactly once when two closes race, and what stops a throwing release from
230
+ // leaving a dead session behind for someone to find.
231
+ this._sessions.delete(sessionId);
232
+ await this._release(session);
233
+ }
234
+
235
+ /** One owner's live sessions, in the shape the tool reports. Pages stay in here. */
236
+ list(ownerId) {
237
+ this._purgeExpired();
238
+ const sessions = [];
239
+ for (const session of this._sessions.values()) {
240
+ if (session.ownerId !== ownerId) continue;
241
+ sessions.push({
242
+ id: session.id,
243
+ url: session.url,
244
+ stealth: session.stealth,
245
+ createdAt: session.createdAt,
246
+ lastUsedAt: session.lastUsedAt,
247
+ expiresAt: session.createdAt + session.ttlMs,
248
+ idleExpiresAt: session.lastUsedAt + session.activityTtlMs
249
+ });
250
+ }
251
+ return sessions;
252
+ }
253
+
254
+ /**
255
+ * Close everything past either clock. This is the backstop that keeps an
256
+ * abandoned session from pinning a browser context for the life of the
257
+ * process — on the 2 GB box that is an outage, not a leak.
258
+ *
259
+ * @returns {Promise<number>} sessions closed
260
+ */
261
+ async sweep() {
262
+ const expired = this._takeExpired(Date.now());
263
+ await Promise.all(expired.map((session) => this._release(session)));
264
+ return expired.length;
265
+ }
266
+
267
+ /** Close every session and stop the sweep. Safe to call twice. */
268
+ async destroy() {
269
+ clearInterval(this._sweepTimer);
270
+ const sessions = Array.from(this._sessions.values());
271
+ this._sessions.clear();
272
+ await Promise.all(sessions.map((session) => this._release(session)));
273
+ }
274
+
275
+ getStats() {
276
+ const byOwner = {};
277
+ for (const session of this._sessions.values()) {
278
+ byOwner[session.ownerId] = (byOwner[session.ownerId] || 0) + 1;
279
+ }
280
+ return { total: this._sessions.size, byOwner };
281
+ }
282
+
283
+ // ── internals ───────────────────────────────────────────────────────────────
284
+
285
+ _countFor(ownerId) {
286
+ let count = 0;
287
+ for (const session of this._sessions.values()) {
288
+ if (session.ownerId === ownerId) count++;
289
+ }
290
+ return count;
291
+ }
292
+
293
+ /** Past its absolute TTL, or idle past its activity TTL — whichever fires first. */
294
+ _isExpired(session, now) {
295
+ return now >= session.createdAt + session.ttlMs
296
+ || now >= session.lastUsedAt + session.activityTtlMs;
297
+ }
298
+
299
+ /** Remove every expired session from the map and return them, still unreleased. */
300
+ _takeExpired(now) {
301
+ const expired = [];
302
+ for (const [id, session] of this._sessions.entries()) {
303
+ if (this._isExpired(session, now)) {
304
+ this._sessions.delete(id);
305
+ expired.push(session);
306
+ }
307
+ }
308
+ return expired;
309
+ }
310
+
311
+ /** _takeExpired for the synchronous paths, which have no way to await the releases. */
312
+ _purgeExpired() {
313
+ for (const session of this._takeExpired(Date.now())) this._release(session);
314
+ }
315
+
316
+ /**
317
+ * Give one page back. Never rejects: by the time this runs the session is
318
+ * already out of the map, so a failed close is the pool's problem rather than
319
+ * anything the caller can act on — the same call BrowserContextPool.dispose
320
+ * makes about a context that will not close.
321
+ */
322
+ async _release(session) {
323
+ try {
324
+ await session.releasePage();
325
+ } catch {
326
+ // ignore release errors
327
+ }
328
+ }
329
+ }
330
+
331
+ export default BrowserSessionStore;