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.
- package/CLAUDE.md +5 -5
- package/README.md +6 -5
- package/package.json +1 -1
- package/server.js +76 -12
- package/src/cli/commands/browser.js +77 -0
- package/src/cli/index.js +3 -1
- package/src/core/ActionExecutor.js +176 -6
- package/src/core/AuthManager.js +26 -0
- package/src/core/browser/SessionStore.js +331 -0
- package/src/core/browser/snapshot.js +346 -0
- package/src/server/fallbackHints.js +1 -0
- package/src/server/requestContext.js +25 -4
- package/src/server/toolFilter.js +2 -2
- package/src/server/transports/streamableHttp.js +38 -8
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/installer.js +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +476 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
package/src/core/AuthManager.js
CHANGED
|
@@ -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;
|