Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ logs
*.log
test.mjs
test.js

# Local-only agent context files (not for commit)
CLAUDE.md
AGENTS.md
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const screenshot = await client.scrapeUrl('https://example.com', {
// Full options
const result = await client.scrapeUrl('https://example.com', {
format: 'raw', // 'raw' (default) or 'json'
dataFormat: 'html', // 'html' (default), 'markdown', 'screenshot'
dataFormat: 'html', // 'html' (default), 'markdown' (alias: 'md'), 'screenshot'
country: 'gb', // two-letter country code
method: 'GET', // HTTP method
});
Expand Down Expand Up @@ -177,10 +177,23 @@ console.log(result.rowCount);

AI-powered web search with relevance ranking based on intent.

`discover()` resolves to a `DiscoverResult` wrapper (not a bare array). The items
are on `result.data` (or its alias `result.results`), and the result is iterable.
On failure `result.success` is `false`, `result.error` carries the reason, and
`result.data` / `result.results` stay an empty array — so iterating never throws.

```javascript
// Basic search
const result = await client.discover('artificial intelligence trends 2026');
console.log(result.data); // [{ link, title, description, relevance_score }, ...]

if (!result.success) {
console.error('discover failed:', result.error);
} else {
console.log(result.results); // [{ link, title, description, relevance_score }, ...]
for (const item of result) {
console.log(`[${item.relevance_score}] ${item.title}`);
}
}

// With intent for semantic ranking
const result = await client.discover('Tesla battery technology', {
Expand Down Expand Up @@ -317,7 +330,7 @@ Get your API token from [Bright Data Control Panel](https://brightdata.com/cp/se
### Environment Variables

```env
BRIGHTDATA_API_TOKEN=your_api_token
BRIGHTDATA_API_TOKEN=your_api_token # BRIGHTDATA_API_KEY also accepted
BRIGHTDATA_WEB_UNLOCKER_ZONE=your_web_unlocker_zone # Optional
BRIGHTDATA_SERP_ZONE=your_serp_zone # Optional
BRIGHTDATA_BROWSERAPI_USERNAME=your_browser_username # Optional, for Browser API
Expand Down
4 changes: 3 additions & 1 deletion src/api/discover/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ export class DiscoverJob {
}

if (response.status === 'error' || response.status === 'failed') {
const detail = response.error || response.message;
throw new APIError(
`Discover task ${this.taskId} failed with status: ${response.status}`,
`Discover task ${this.taskId} failed with status: ${response.status}` +
(detail ? ` — ${detail}` : ''),
);
}

Expand Down
17 changes: 16 additions & 1 deletion src/api/discover/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,29 @@ export class DiscoverResult extends BaseResult<DiscoverResultItem[]> {
readonly taskId: string | null;

constructor(fields: DiscoverResultFields) {
super(fields);
// `data` is always an array (empty on failure) so callers can safely
// iterate `result.data` / `result.results` without a null check.
super({ ...fields, data: fields.data ?? [] });
this.query = fields.query;
this.intent = fields.intent ?? null;
this.durationSeconds = fields.durationSeconds ?? null;
this.totalResults = fields.totalResults ?? null;
this.taskId = fields.taskId ?? null;
}

/**
* The discovered items. Alias for `data`, kept in sync with the raw API's
* `results` field. Always an array (empty when the search failed).
*/
get results(): DiscoverResultItem[] {
return this.data ?? [];
}

/** Iterate the discovered items directly: `for (const item of result)`. */
[Symbol.iterator](): Iterator<DiscoverResultItem> {
return this.results[Symbol.iterator]();
}

override toJSON(): Record<string, unknown> {
return {
...super.toJSON(),
Expand Down
3 changes: 2 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export class bdclient {
);
const {
BRIGHTDATA_API_TOKEN,
BRIGHTDATA_API_KEY,
BRIGHTDATA_VERBOSE,
BRIGHTDATA_WEB_UNLOCKER_ZONE,
BRIGHTDATA_SERP_ZONE,
Expand All @@ -123,7 +124,7 @@ export class bdclient {

const apiKey = assertSchema(
ApiKeySchema,
opt.apiKey || BRIGHTDATA_API_TOKEN,
opt.apiKey || BRIGHTDATA_API_TOKEN || BRIGHTDATA_API_KEY,
'bdclient.options.apiKey',
);

Expand Down
48 changes: 36 additions & 12 deletions src/core/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,40 @@ function isAbortTimeout(err: Error): boolean {
);
}

// A single shared `beforeExit` listener tracks every open Transport via a
// counter, instead of one listener per instance. Registering a listener per
// Transport tripped Node's MaxListenersExceededWarning once an app created
// more than ~10 clients. The listener is installed lazily on the first open
// Transport and removed again once the last one is closed.
let openTransportCount = 0;
let beforeExitListener: (() => void) | null = null;

function warnUnclosedTransports(): void {
if (openTransportCount > 0) {
console.warn(
`[brightdata-sdk] ${openTransportCount} Transport(s) were not closed. ` +
'Call client.close() or use "await using client = new bdclient()" ' +
'to avoid keeping the process alive.',
);
}
}

function registerOpenTransport(): void {
openTransportCount++;
if (!beforeExitListener) {
beforeExitListener = warnUnclosedTransports;
process.on('beforeExit', beforeExitListener);
}
}

function unregisterOpenTransport(): void {
if (openTransportCount > 0) openTransportCount--;
if (openTransportCount === 0 && beforeExitListener) {
process.removeListener('beforeExit', beforeExitListener);
beforeExitListener = null;
}
}

export interface TransportOptions {
apiKey: string;
timeout?: number;
Expand Down Expand Up @@ -76,16 +110,6 @@ export class Transport {
private closed = false;
private logger = getLogger('transport');

private onBeforeExit = () => {
if (!this.closed) {
console.warn(
'[brightdata-sdk] Transport was not closed. ' +
'Call client.close() or use "await using client = new bdclient()" ' +
'to avoid keeping the process alive.',
);
}
};

constructor(opts: TransportOptions) {
this.authHeaders = getAuthHeaders(opts.apiKey);
this.defaultTimeout = opts.timeout ?? DEFAULT_TIMEOUT;
Expand All @@ -109,7 +133,7 @@ export class Transport {
errorCodes: RETRY_ERROR_CODES,
}),
);
process.on('beforeExit', this.onBeforeExit);
registerOpenTransport();
}

get headers(): Record<string, string> {
Expand Down Expand Up @@ -235,7 +259,7 @@ export class Transport {
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
process.removeListener('beforeExit', this.onBeforeExit);
unregisterOpenTransport();
this.rateLimiter?.destroy();
await (this.agent as Agent).close();
}
Expand Down
3 changes: 3 additions & 0 deletions src/schemas/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@ export const DiscoverTriggerResponseSchema = z
export const DiscoverPollResponseSchema = z
.object({
status: z.enum(['processing', 'done', 'error', 'failed']),
// Surfaced when status is 'error'/'failed' — server provides a reason
error: z.string().optional(),
message: z.string().optional(),
})
.passthrough();
6 changes: 5 additions & 1 deletion src/schemas/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export const RequestOptionsBaseSchema = z.object({
.transform((v) => v.toUpperCase() as 'GET' | 'POST')
.optional(),
format: z.enum(['json', 'raw']).optional(),
dataFormat: z.enum(['html', 'markdown', 'screenshot']).optional(),
dataFormat: z
// 'md' is accepted as a convenience alias for 'markdown'
.enum(['html', 'markdown', 'md', 'screenshot'])
.transform((v) => (v === 'md' ? 'markdown' : v))
.optional(),
});

export const FetchingOptionsSchema = z.object({
Expand Down
2 changes: 2 additions & 0 deletions src/types/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ export interface DiscoverOperations {
status: string;
results?: unknown[];
duration_seconds?: number;
error?: string;
message?: string;
}>;
}
35 changes: 35 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,38 @@ describe('bdclient - Lazy initialization', () => {
expect(keys).toContain('datasets');
});
});

describe('bdclient - dataFormat md alias', () => {
test("dataFormat: 'md' is sent to the API as 'markdown'", async () => {
vi.mocked(Transport.prototype.request).mockClear();
const client = new bdclient({
apiKey: 'test_token_1234567890abcdef',
autoCreateZones: false,
});
await client.scrapeUrl('https://example.com', { dataFormat: 'md' });

const body = JSON.parse(
vi.mocked(Transport.prototype.request).mock.calls[0][1]
?.body as string,
);
expect(body.data_format).toBe('markdown');
});
});

describe('bdclient - API key env fallback', () => {
test('accepts BRIGHTDATA_API_KEY when BRIGHTDATA_API_TOKEN is unset', () => {
const { BRIGHTDATA_API_TOKEN, BRIGHTDATA_API_KEY } = process.env;
delete process.env.BRIGHTDATA_API_TOKEN;
process.env.BRIGHTDATA_API_KEY = 'env_key_1234567890abcdef';
try {
expect(() => new bdclient({ autoCreateZones: false })).not.toThrow();
} finally {
if (BRIGHTDATA_API_TOKEN === undefined)
delete process.env.BRIGHTDATA_API_TOKEN;
else process.env.BRIGHTDATA_API_TOKEN = BRIGHTDATA_API_TOKEN;
if (BRIGHTDATA_API_KEY === undefined)
delete process.env.BRIGHTDATA_API_KEY;
else process.env.BRIGHTDATA_API_KEY = BRIGHTDATA_API_KEY;
}
});
});
57 changes: 57 additions & 0 deletions tests/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,39 @@ describe('DiscoverService.search', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('failed with status: error');
});

test('failed result keeps data/results as an empty array (never null)', async () => {
mockRequestSequence([
{ statusCode: 200, body: JSON.stringify({ task_id: 'task_fail' }) },
{ statusCode: 200, body: JSON.stringify({ status: 'failed' }) },
]);

const result = await service.search('test');
expect(result.success).toBe(false);
expect(result.data).toEqual([]);
expect(result.results).toEqual([]);
// documented usage must not throw on failure
expect(() => {
for (const _ of result) void _;
}).not.toThrow();
});

test('surfaces server-provided error detail on failure', async () => {
mockRequestSequence([
{ statusCode: 200, body: JSON.stringify({ task_id: 'task_fail' }) },
{
statusCode: 200,
body: JSON.stringify({
status: 'error',
error: 'quota exceeded',
}),
},
]);

const result = await service.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('quota exceeded');
});
});

// --- DiscoverJob ---
Expand Down Expand Up @@ -287,4 +320,28 @@ describe('DiscoverResult', () => {
expect(result.totalResults).toBeNull();
expect(result.taskId).toBeNull();
});

test('data and results default to an empty array when omitted', () => {
const result = new DiscoverResult({ success: false, query: 'q' });
expect(result.data).toEqual([]);
expect(result.results).toEqual([]);
});

test('.results aliases .data', () => {
const items = [
{ link: 'https://x.com', title: 'X', description: 'd', relevance_score: 0.8 },
];
const result = new DiscoverResult({ success: true, data: items, query: 'q' });
expect(result.results).toBe(result.data);
expect(result.results).toEqual(items);
});

test('is iterable over its items', () => {
const items = [
{ link: 'https://a.com', title: 'A', description: 'd', relevance_score: 1 },
{ link: 'https://b.com', title: 'B', description: 'd', relevance_score: 0.5 },
];
const result = new DiscoverResult({ success: true, data: items, query: 'q' });
expect([...result]).toEqual(items);
});
});
20 changes: 18 additions & 2 deletions tests/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('Transport lifecycle', () => {
process.emit('beforeExit', 0);

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Transport was not closed'),
expect.stringContaining('were not closed'),
);
warnSpy.mockRestore();
});
Expand All @@ -138,10 +138,26 @@ describe('Transport lifecycle', () => {
process.emit('beforeExit', 0);

expect(warnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Transport was not closed'),
expect.stringContaining('were not closed'),
);
warnSpy.mockRestore();
});

it('registers a single shared beforeExit listener for many transports', async () => {
const before = process.listenerCount('beforeExit');
const transports = Array.from(
{ length: 15 },
() => new Transport({ apiKey: API_KEY }),
);

// one shared listener regardless of how many transports are open
expect(process.listenerCount('beforeExit')).toBe(before + 1);

for (const t of transports) await t.close();

// listener is removed once the last transport closes
expect(process.listenerCount('beforeExit')).toBe(before);
});
});
});

Expand Down