Skip to content

Commit 0d782bc

Browse files
authored
feat: export SessionPool interface, minimize ISession, allow custom implementations (#3644)
Exports `ISessionPool` interface that can be used to implement custom `SessionPool` implementations. Adds documentation.
1 parent 886cb69 commit 0d782bc

22 files changed

Lines changed: 234 additions & 446 deletions

docs/guides/session_management_basic.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const crawler = new BasicCrawler({
1717
headers: {
1818
// If you want to use the cookieJar.
1919
// This way you get the Cookie headers string from session.
20-
Cookie: session?.getCookieString(url) ?? '',
20+
Cookie: session?.cookieJar.getCookieStringSync(url) ?? '',
2121
},
2222
});
2323
let response;

docs/guides/session_management_standalone.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ const sessionPool = new SessionPool(sessionPoolOptions);
1111
const session = await sessionPool.getSession();
1212

1313
// Increase the errorScore.
14-
session.markBad();
14+
session?.markBad();
1515

1616
// Throw away the session.
17-
session.retire();
17+
session?.retire();
1818

1919
// Lower the errorScore and mark the session good.
20-
session.markGood();
20+
session?.markGood();

docs/upgrading/upgrading_v4.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,63 @@ new SessionPool({
136136

137137
## `Session` no longer requires a `sessionPool` reference
138138

139-
`Session` no longer holds a back-reference to its `SessionPool` and no longer emits a `sessionRetired` event when retired. The `sessionPool` constructor option is gone, `SessionPool` is no longer an `EventEmitter`, and the `EVENT_SESSION_RETIRED` constant is no longer exported.
139+
`Session` no longer holds a back-reference to its `SessionPool` and no longer emits a `sessionRetired` event when retired. The `sessionPool` constructor option is gone, `SessionPool` is no longer an `EventEmitter`, and the `EVENT_SESSION_RETIRED` constant is no longer exported. Custom `createSessionFunction` implementations that constructed `Session` instances manually should drop the `sessionPool` argument.
140+
141+
**Before:**
142+
```typescript
143+
new SessionPool({
144+
createSessionFunction: async (pool, opts) =>
145+
new Session({ ...opts?.sessionOptions, sessionPool: pool }),
146+
});
147+
```
148+
149+
**After:**
150+
```typescript
151+
new SessionPool({
152+
createSessionFunction: async (opts) =>
153+
new Session({ ...opts?.sessionOptions }),
154+
});
155+
```
140156

141157
If you previously subscribed to `sessionRetired` on the pool to clean up resources tied to a session, perform the cleanup at the end of your request handler (or via a context-pipeline cleanup hook) by checking `session.isUsable()` instead. `Session.retire()` is now a terminal state — once retired, `isUsable()` returns `false` permanently and cannot be undone by a subsequent `markGood()`.
142158

159+
## Custom `SessionPool` implementations via the `ISessionPool` interface
160+
161+
Crawlers now accept any object implementing the new `ISessionPool` interface as their `sessionPool` option, not just instances of the built-in `SessionPool`. The contract is intentionally tiny — a single method, `getSession()` / `getSession(id)`, that hands out a `Session` for a request. Lifecycle (reset, teardown) is the responsibility of whoever owns the pool: a custom pool you construct yourself is never owned by the crawler, so the crawler never tears it down. This makes it straightforward to plug in a remote, shared, or otherwise customized session-management strategy without subclassing `SessionPool` or copying its internals.
162+
163+
```typescript
164+
import { BasicCrawler, Session, type ISessionPool } from '@crawlee/core';
165+
166+
class MySessionPool implements ISessionPool {
167+
private readonly sessions = new Map<string, Session>();
168+
169+
async getSession(): Promise<Session>;
170+
async getSession(sessionId: string): Promise<Session | undefined>;
171+
async getSession(sessionId?: string): Promise<Session | undefined> {
172+
if (sessionId) {
173+
const existing = this.sessions.get(sessionId);
174+
return existing?.isUsable() ? existing : undefined;
175+
}
176+
177+
const usable = [...this.sessions.values()].find((s) => s.isUsable());
178+
if (usable) return usable;
179+
180+
const fresh = new Session();
181+
this.sessions.set(fresh.id, fresh);
182+
return fresh;
183+
}
184+
}
185+
186+
const crawler = new BasicCrawler({
187+
sessionPool: new MySessionPool(),
188+
requestHandler: async ({ session }) => {
189+
// session is a Session instance, use it as usual
190+
},
191+
});
192+
```
193+
194+
The returned objects must be `Session` instances — the rest of the crawler relies on `session.markGood()`, `session.cookieJar`, `session.proxyInfo`, and the rest of the concrete `Session` API.
195+
143196
## `retireOnBlockedStatusCodes` is removed from `Session`
144197

145198
`Session.retireOnBlockedStatusCodes` is removed. Blocked status code handling is now internal to the crawler. Configure blocked status codes via the `blockedStatusCodes` crawler option (moved from `sessionPoolOptions`).

packages/basic-crawler/src/internals/basic-crawler.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ import type {
7070
BaseHttpClient,
7171
BatchAddRequestsResult,
7272
Dictionary,
73+
ISession,
74+
ISessionPool,
7375
ProxyInfo,
7476
SetStatusMessageOptions,
7577
StorageClient,
@@ -302,11 +304,14 @@ export interface BasicCrawlerOptions<
302304
keepAlive?: boolean;
303305

304306
/**
305-
* An existing {@apilink SessionPool} instance to use. When provided, the crawler will use this
306-
* pool directly instead of creating a new one, enabling session sharing across multiple crawlers.
307-
* The crawler will not tear down a shared pool — the caller is responsible for its lifecycle.
307+
* An existing session pool instance to use. When provided, the crawler will use this pool directly instead of
308+
* creating a new one, enabling session sharing across multiple crawlers. The crawler will not tear down a shared
309+
* pool — the caller is responsible for its lifecycle.
310+
*
311+
* Accepts the built-in {@apilink SessionPool} or any object implementing the {@apilink ISessionPool} interface,
312+
* so custom session-management strategies can be plugged in.
308313
*/
309-
sessionPool?: SessionPool;
314+
sessionPool?: ISessionPool;
310315

311316
/**
312317
* Defines the length of the interval for calling the `setStatusMessage` in seconds.
@@ -556,14 +561,18 @@ export class BasicCrawler<
556561
protected requestManager?: IRequestManager;
557562

558563
/**
559-
* A reference to the underlying {@apilink SessionPool} class that manages the crawler's {@apilink Session|sessions}.
564+
* A reference to the underlying session pool that manages the crawler's {@apilink Session|sessions}. Typed as
565+
* {@apilink ISessionPool} so custom implementations can be plugged in via the `sessionPool` constructor option.
560566
*/
561-
sessionPool: SessionPool;
567+
sessionPool: ISessionPool;
562568

563569
/**
564-
* Indicates whether the crawler owns the session pool (it was not passed from the outside using the `sessionPool` constructor option).
570+
* Set when the crawler constructed its own {@apilink SessionPool} (no `sessionPool` option was provided).
571+
* Holds the same instance as `sessionPool`, but typed as the concrete class so the crawler can call
572+
* lifecycle methods (`resetStore`, `teardown`) that aren't part of {@apilink ISessionPool}. A user-supplied
573+
* pool is never owned and never torn down by the crawler.
565574
*/
566-
private ownsSessionPool: boolean;
575+
private ownedSessionPool?: SessionPool;
567576

568577
/**
569578
* A reference to the underlying {@apilink AutoscaledPool} class that manages the concurrency of the crawler.
@@ -675,7 +684,7 @@ export class BasicCrawler<
675684
maxRequestsPerCrawl: ow.optional.number,
676685
maxCrawlDepth: ow.optional.number,
677686
autoscaledPoolOptions: ow.optional.object,
678-
sessionPool: ow.optional.object.instanceOf(SessionPool),
687+
sessionPool: ow.optional.object.validate(validators.sessionPool),
679688
proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
680689

681690
statusMessageLoggingInterval: ow.optional.number,
@@ -866,18 +875,19 @@ export class BasicCrawler<
866875
);
867876
}
868877

869-
this.sessionPool =
870-
sessionPool ??
871-
new SessionPool({
878+
if (sessionPool) {
879+
this.sessionPool = sessionPool;
880+
} else {
881+
this.ownedSessionPool = new SessionPool({
872882
createSessionFunction: async (opts) =>
873883
new Session({
874884
...opts?.sessionOptions,
875885
proxyInfo:
876886
opts?.sessionOptions?.proxyInfo ?? (await this.proxyConfiguration?.newProxyInfo()),
877887
}),
878888
});
879-
880-
this.ownsSessionPool = !sessionPool;
889+
this.sessionPool = this.ownedSessionPool;
890+
}
881891

882892
this.blockedStatusCodes = new Set(blockedStatusCodesInput ?? BLOCKED_STATUS_CODES);
883893

@@ -1111,26 +1121,22 @@ export class BasicCrawler<
11111121
private async resolveSession({ request }: { request: Request }) {
11121122
const session = await this._timeoutAndRetry(
11131123
async () => {
1114-
if (request.sessionId) {
1115-
const existingSession = await this.sessionPool!.getSession(request.sessionId);
1124+
const existingSession = await this.sessionPool.getSession(request.sessionId);
11161125

1117-
if (!existingSession) {
1118-
throw new ContextPipelineInitializationError(new MissingSessionError(request.sessionId));
1119-
}
1120-
1121-
return existingSession;
1126+
if (!existingSession) {
1127+
throw new ContextPipelineInitializationError(new MissingSessionError(request.sessionId));
11221128
}
11231129

1124-
return await this.sessionPool!.getSession();
1130+
return existingSession;
11251131
},
11261132
this.internalTimeoutMillis,
11271133
`Fetching session timed out after ${this.internalTimeoutMillis / 1e3} seconds.`,
11281134
);
11291135

1130-
return { session, proxyInfo: session.proxyInfo };
1136+
return { session, proxyInfo: session?.proxyInfo };
11311137
}
11321138

1133-
private async createContextHelpers({ request, session }: { request: Request; session: Session }) {
1139+
private async createContextHelpers({ request, session }: { request: Request; session: ISession }) {
11341140
const enqueueLinksWrapper: CrawlingContext['enqueueLinks'] = async (options) => {
11351141
const requestQueue = await this.getRequestQueue();
11361142

@@ -1296,9 +1302,7 @@ export class BasicCrawler<
12961302

12971303
this.stats.reset();
12981304
await this.stats.resetStore();
1299-
if (this.ownsSessionPool) {
1300-
await this.sessionPool.resetStore();
1301-
}
1305+
await this.ownedSessionPool?.resetStore();
13021306
}
13031307

13041308
this.unexpectedStop = false;
@@ -2241,9 +2245,7 @@ export class BasicCrawler<
22412245
await serviceLocator.getEventManager().close();
22422246
}
22432247

2244-
if (this.ownsSessionPool) {
2245-
await this.sessionPool.teardown();
2246-
}
2248+
await this.ownedSessionPool?.teardown();
22472249

22482250
await this.autoscaledPool?.abort();
22492251
}
@@ -2337,7 +2339,7 @@ export class BasicCrawler<
23372339

23382340
export interface CreateContextOptions {
23392341
request: Request;
2340-
session: Session;
2342+
session: ISession;
23412343
proxyInfo?: ProxyInfo;
23422344
}
23432345

packages/basic-crawler/src/internals/send-request.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Request as CrawleeRequest, Session } from '@crawlee/core';
2-
import type { BaseHttpClient, HttpRequestOptions, SendRequestOptions } from '@crawlee/types';
1+
import type { Request as CrawleeRequest } from '@crawlee/core';
2+
import type { BaseHttpClient, HttpRequestOptions, ISession, SendRequestOptions } from '@crawlee/types';
33

44
/**
55
* Prepares a function to be used as the `sendRequest` context helper.
@@ -9,7 +9,7 @@ import type { BaseHttpClient, HttpRequestOptions, SendRequestOptions } from '@cr
99
* @param originRequest The crawling request being processed.
1010
* @param session The user session associated with the current request.
1111
*/
12-
export function createSendRequest(httpClient: BaseHttpClient, originRequest: CrawleeRequest, session: Session) {
12+
export function createSendRequest(httpClient: BaseHttpClient, originRequest: CrawleeRequest, session: ISession) {
1313
return async (
1414
overrideRequest: Partial<HttpRequestOptions> = {},
1515
overrideOptions: SendRequestOptions = {},

packages/basic-crawler/test/batch-add-requests.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BasicCrawler } from '@crawlee/basic';
22

33
import { MemoryStorageEmulator } from '../../../test/shared/MemoryStorageEmulator.js';
4+
import { SessionPool } from '@crawlee/core';
45

56
describe('BasicCrawler#addRequests with big batch sizes', () => {
67
const localStorageEmulator = new MemoryStorageEmulator();
@@ -74,17 +75,20 @@ describe('BasicCrawler - request.sessionId', () => {
7475
});
7576

7677
test('uses the session matching request.sessionId from the session pool', async () => {
78+
const REQUESTED_SESSION_ID = 'my-session';
7779
let resolvedSessionId: string | undefined;
7880

81+
const sessionPool = new SessionPool();
82+
sessionPool.addSession({ id: REQUESTED_SESSION_ID });
83+
7984
const crawler = new BasicCrawler({
8085
requestHandler({ session }) {
8186
resolvedSessionId = session.id;
8287
},
88+
sessionPool,
8389
});
8490

85-
const session = await crawler.sessionPool.newSession({ id: 'my-session' });
86-
87-
await crawler.run([{ url: 'http://localhost', sessionId: session.id }]);
91+
await crawler.run([{ url: 'http://localhost', sessionId: REQUESTED_SESSION_ID }]);
8892

8993
expect(resolvedSessionId).toBe('my-session');
9094
});
@@ -104,7 +108,7 @@ describe('BasicCrawler - request.sessionId', () => {
104108

105109
expect(errors).toHaveLength(1);
106110
expect(errors[0].message).toContain(
107-
"The current SessionPool instance doesn't contain the requested sessionId: nonexistent",
111+
"The current SessionPool instance couldn't find a valid session for the following id: nonexistent",
108112
);
109113
});
110114
});

packages/browser-crawler/src/internals/browser-crawler.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import type {
1010
Request,
1111
RequestHandler,
1212
RequestProvider,
13-
Session,
1413
SkippedRequestCallback,
1514
} from '@crawlee/basic';
1615
import {
1716
BasicCrawler,
17+
browserPoolCookieToToughCookie,
1818
ContextPipeline,
1919
cookieStringToToughCookie,
2020
enqueueLinks,
@@ -23,6 +23,7 @@ import {
2323
RequestState,
2424
resolveBaseUrlForEnqueueLinksFiltering,
2525
SessionError,
26+
toughCookieToBrowserPoolCookie,
2627
tryAbsoluteURL,
2728
validators,
2829
} from '@crawlee/basic';
@@ -36,7 +37,7 @@ import type {
3637
LaunchContext,
3738
} from '@crawlee/browser-pool';
3839
import { BrowserPool } from '@crawlee/browser-pool';
39-
import type { BatchAddRequestsResult, Cookie as CookieObject } from '@crawlee/types';
40+
import type { BatchAddRequestsResult, Cookie as CookieObject, ISession } from '@crawlee/types';
4041
import type { RobotsTxtFile } from '@crawlee/utils';
4142
import { CLOUDFLARE_RETRY_CSS_SELECTORS, RETRY_CSS_SELECTORS, sleep } from '@crawlee/utils';
4243
import ow from 'ow';
@@ -400,7 +401,7 @@ export abstract class BrowserCrawler<
400401
action: this.preparePage.bind(this),
401402
cleanup: async (context: {
402403
page: Page;
403-
session: Session;
404+
session: ISession;
404405
browserController: ProvidedController;
405406
registerDeferredCleanup: BasicCrawlingContext['registerDeferredCleanup'];
406407
}) => {
@@ -563,10 +564,19 @@ export abstract class BrowserCrawler<
563564

564565
// save cookies
565566
// TODO: Should we save the cookies also after/only the handle page?
566-
if (this.saveResponseCookies) {
567+
if (this.saveResponseCookies && crawlingContext.session) {
567568
const cookies = await crawlingContext.browserController.getCookies(crawlingContext.page);
568569
tryCancel();
569-
crawlingContext.session?.setCookies(cookies, crawlingContext.request.loadedUrl!);
570+
const url = crawlingContext.request.loadedUrl!;
571+
for (const cookie of cookies) {
572+
try {
573+
crawlingContext.session.cookieJar.setCookieSync(browserPoolCookieToToughCookie(cookie), url, {
574+
ignoreError: false,
575+
});
576+
} catch (e) {
577+
this.log.debug(`Could not set cookie: ${(e as Error).message}`);
578+
}
579+
}
570580
}
571581

572582
if (response !== undefined) {
@@ -602,7 +612,7 @@ export abstract class BrowserCrawler<
602612
preHooksCookies: string,
603613
postHooksCookies: string,
604614
) {
605-
const sessionCookie = session?.getCookies(request.url) ?? [];
615+
const sessionCookie = session?.cookieJar.getCookiesSync(request.url).map(toughCookieToBrowserPoolCookie) ?? [];
606616
const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
607617
const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
608618

packages/core/src/cookie_utils.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,24 @@ export function toughCookieToBrowserPoolCookie(toughCookie: Cookie): CookieObjec
5959
/**
6060
* Transforms browser-pool cookie to tough-cookie.
6161
* @param cookieObject Cookie object (for instance from the `page.cookies` method).
62+
* @param maxAgeSecs Fallback expiration in seconds when the cookie itself has no `expires`.
63+
* When omitted, such a cookie is stored as a session cookie (no automatic expiration).
6264
* @internal
6365
*/
64-
export function browserPoolCookieToToughCookie(cookieObject: CookieObject, maxAgeSecs: number) {
66+
export function browserPoolCookieToToughCookie(cookieObject: CookieObject, maxAgeSecs?: number) {
6567
const isExpiresValid = cookieObject.expires && typeof cookieObject.expires === 'number' && cookieObject.expires > 0;
66-
const expires = isExpiresValid
67-
? new Date(cookieObject.expires! * 1000)
68-
: getDefaultCookieExpirationDate(maxAgeSecs);
68+
let expires: Date | 'Infinity' | undefined;
69+
if (isExpiresValid) {
70+
expires = new Date(cookieObject.expires! * 1000);
71+
} else if (maxAgeSecs != null) {
72+
expires = getDefaultCookieExpirationDate(maxAgeSecs);
73+
}
6974
const domainHasLeadingDot = cookieObject.domain?.startsWith?.('.');
7075
const domain = domainHasLeadingDot ? cookieObject.domain?.slice?.(1) : cookieObject.domain;
7176
return new Cookie({
7277
key: cookieObject.name,
7378
value: cookieObject.value,
74-
expires,
79+
...(expires !== undefined && { expires }),
7580
domain,
7681
path: cookieObject.path,
7782
secure: cookieObject.secure,

0 commit comments

Comments
 (0)