@ulpi/browse 0.7.5 → 1.0.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.
@@ -1,484 +0,0 @@
1
- /**
2
- * Write commands — navigate and interact with pages (side effects)
3
- *
4
- * goto, back, forward, reload, click, dblclick, fill, select, hover,
5
- * focus, check, uncheck, type, press, scroll, wait, viewport, cookie,
6
- * header, useragent, drag, keydown, keyup
7
- */
8
-
9
- import type { BrowserContext } from 'playwright';
10
- import type { BrowserManager } from '../browser-manager';
11
- import { resolveDevice, listDevices } from '../browser-manager';
12
- import type { DomainFilter } from '../domain-filter';
13
- import { DEFAULTS } from '../constants';
14
- import * as fs from 'fs';
15
-
16
- /**
17
- * Clear all routes and re-register them in correct order:
18
- * user routes first, domain filter last (Playwright checks last-registered first).
19
- */
20
- async function rebuildRoutes(context: BrowserContext, bm: BrowserManager, domainFilter?: DomainFilter | null): Promise<void> {
21
- await context.unrouteAll();
22
- // User routes first (checked last by Playwright)
23
- for (const r of bm.getUserRoutes()) {
24
- if (r.action === 'block') {
25
- await context.route(r.pattern, (route) => route.abort('blockedbyclient'));
26
- } else {
27
- await context.route(r.pattern, (route) => route.fulfill({ status: r.status || 200, body: r.body || '', contentType: 'text/plain' }));
28
- }
29
- }
30
- // Domain filter last (checked first by Playwright)
31
- if (domainFilter) {
32
- await context.route('**/*', (route) => {
33
- const url = route.request().url();
34
- if (domainFilter.isAllowed(url)) { route.fallback(); } else { route.abort('blockedbyclient'); }
35
- });
36
- }
37
- }
38
-
39
- export async function handleWriteCommand(
40
- command: string,
41
- args: string[],
42
- bm: BrowserManager,
43
- domainFilter?: DomainFilter | null
44
- ): Promise<string> {
45
- const page = bm.getPage();
46
-
47
- switch (command) {
48
- case 'goto': {
49
- const url = args[0];
50
- if (!url) throw new Error('Usage: browse goto <url>');
51
- if (domainFilter && !domainFilter.isAllowed(url)) {
52
- throw new Error(domainFilter.blockedMessage(url));
53
- }
54
- const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
55
- const status = response?.status() || 'unknown';
56
- return `Navigated to ${url} (${status})`;
57
- }
58
-
59
- case 'back': {
60
- await page.goBack({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
61
- return `Back → ${page.url()}`;
62
- }
63
-
64
- case 'forward': {
65
- await page.goForward({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
66
- return `Forward → ${page.url()}`;
67
- }
68
-
69
- case 'reload': {
70
- await page.reload({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
71
- return `Reloaded ${page.url()}`;
72
- }
73
-
74
- case 'click': {
75
- const selector = args[0];
76
- if (!selector) throw new Error('Usage: browse click <selector|x,y>');
77
- // Coordinate click: "590,461" or "590, 461"
78
- const coordMatch = selector.match(/^(\d+)\s*,\s*(\d+)$/);
79
- if (coordMatch) {
80
- const x = parseInt(coordMatch[1], 10);
81
- const y = parseInt(coordMatch[2], 10);
82
- await page.mouse.click(x, y);
83
- await page.waitForLoadState('domcontentloaded').catch(() => {});
84
- return `Clicked at (${x}, ${y}) → now at ${page.url()}`;
85
- }
86
- const resolved = bm.resolveRef(selector);
87
- if ('locator' in resolved) {
88
- await resolved.locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
89
- } else {
90
- await page.click(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
91
- }
92
- // Wait briefly for any navigation/DOM update
93
- await page.waitForLoadState('domcontentloaded').catch(() => {});
94
- return `Clicked ${selector} → now at ${page.url()}`;
95
- }
96
-
97
- case 'fill': {
98
- const [selector, ...valueParts] = args;
99
- const value = valueParts.join(' ');
100
- if (!selector) throw new Error('Usage: browse fill <selector> <value>');
101
- const resolved = bm.resolveRef(selector);
102
- if ('locator' in resolved) {
103
- await resolved.locator.fill(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
104
- } else {
105
- await page.fill(resolved.selector, value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
106
- }
107
- return `Filled ${selector}`;
108
- }
109
-
110
- case 'select': {
111
- const [selector, ...valueParts] = args;
112
- const value = valueParts.join(' ');
113
- if (!selector) throw new Error('Usage: browse select <selector> <value>');
114
- const resolved = bm.resolveRef(selector);
115
- if ('locator' in resolved) {
116
- await resolved.locator.selectOption(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
117
- } else {
118
- await page.selectOption(resolved.selector, value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
119
- }
120
- return `Selected "${value}" in ${selector}`;
121
- }
122
-
123
- case 'hover': {
124
- const selector = args[0];
125
- if (!selector) throw new Error('Usage: browse hover <selector>');
126
- const resolved = bm.resolveRef(selector);
127
- if ('locator' in resolved) {
128
- await resolved.locator.hover({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
129
- } else {
130
- await page.hover(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
131
- }
132
- return `Hovered ${selector}`;
133
- }
134
-
135
- case 'type': {
136
- const text = args.join(' ');
137
- if (!text) throw new Error('Usage: browse type <text>');
138
- await page.keyboard.type(text);
139
- return `Typed "${text}"`;
140
- }
141
-
142
- case 'press': {
143
- const key = args[0];
144
- if (!key) throw new Error('Usage: browse press <key> (e.g., Enter, Tab, Escape)');
145
- await page.keyboard.press(key);
146
- return `Pressed ${key}`;
147
- }
148
-
149
- case 'scroll': {
150
- const selector = args[0];
151
- if (selector === 'up') {
152
- const scrollCtx = await bm.getFrameContext() || page;
153
- await scrollCtx.evaluate(() => window.scrollBy(0, -window.innerHeight));
154
- return 'Scrolled up one viewport';
155
- }
156
- if (selector === 'down') {
157
- const scrollCtx = await bm.getFrameContext() || page;
158
- await scrollCtx.evaluate(() => window.scrollBy(0, window.innerHeight));
159
- return 'Scrolled down one viewport';
160
- }
161
- if (selector && selector !== 'bottom') {
162
- const resolved = bm.resolveRef(selector);
163
- if ('locator' in resolved) {
164
- await resolved.locator.scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
165
- } else {
166
- await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
167
- }
168
- return `Scrolled ${selector} into view`;
169
- }
170
- // Scroll to bottom (default or explicit "bottom")
171
- const scrollCtx = await bm.getFrameContext() || page;
172
- await scrollCtx.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
173
- return 'Scrolled to bottom';
174
- }
175
-
176
- case 'wait': {
177
- const selector = args[0];
178
- if (!selector) throw new Error('Usage: browse wait <selector|--url|--network-idle> [timeout]');
179
-
180
- // wait --network-idle [timeout] — wait for network to settle
181
- if (selector === '--network-idle') {
182
- const timeout = args[1] ? parseInt(args[1], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
183
- await page.waitForLoadState('networkidle', { timeout });
184
- return 'Network idle';
185
- }
186
-
187
- // wait --url <pattern> [timeout] — wait for URL to match
188
- if (selector === '--url') {
189
- const pattern = args[1];
190
- if (!pattern) throw new Error('Usage: browse wait --url <pattern> [timeout]');
191
- const timeout = args[2] ? parseInt(args[2], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
192
- await page.waitForURL(pattern, { timeout });
193
- return `URL matched: ${page.url()}`;
194
- }
195
-
196
- const timeout = args[1] ? parseInt(args[1], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
197
- const resolved = bm.resolveRef(selector);
198
- if ('locator' in resolved) {
199
- await resolved.locator.waitFor({ state: 'visible', timeout });
200
- } else {
201
- await page.waitForSelector(resolved.selector, { timeout });
202
- }
203
- return `Element ${selector} appeared`;
204
- }
205
-
206
- case 'viewport': {
207
- const size = args[0];
208
- if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
209
- const [w, h] = size.split('x').map(Number);
210
- await bm.setViewport(w, h);
211
- return `Viewport set to ${w}x${h}`;
212
- }
213
-
214
- case 'cookie': {
215
- const cookieStr = args[0];
216
- if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value>');
217
- const eq = cookieStr.indexOf('=');
218
- const name = cookieStr.slice(0, eq);
219
- const value = cookieStr.slice(eq + 1);
220
- const url = new URL(page.url());
221
- await page.context().addCookies([{
222
- name,
223
- value,
224
- domain: url.hostname,
225
- path: '/',
226
- }]);
227
- return `Cookie set: ${name}=${value}`;
228
- }
229
-
230
- case 'header': {
231
- const headerStr = args[0];
232
- if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header <name>:<value>');
233
- const sep = headerStr.indexOf(':');
234
- const name = headerStr.slice(0, sep).trim();
235
- const value = headerStr.slice(sep + 1).trim();
236
- await bm.setExtraHeader(name, value);
237
- return `Header set: ${name}: ${value}`;
238
- }
239
-
240
- case 'useragent': {
241
- const ua = args.join(' ');
242
- if (!ua) throw new Error('Usage: browse useragent <string>');
243
- const prevUA = bm.getUserAgent();
244
- bm.setUserAgent(ua);
245
- try {
246
- await bm.applyUserAgent();
247
- } catch (err) {
248
- // Rollback: restore previous UA so stored state matches the live context
249
- bm.setUserAgent(prevUA || '');
250
- throw err;
251
- }
252
- return `User agent set: ${ua}\nNote: Cookies and tab URLs preserved. localStorage/sessionStorage were reset (Playwright limitation).`;
253
- }
254
-
255
- case 'upload': {
256
- const [selector, ...filePaths] = args;
257
- if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2] ...');
258
- for (const fp of filePaths) {
259
- if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
260
- }
261
- const resolved = bm.resolveRef(selector);
262
- if ('locator' in resolved) {
263
- await resolved.locator.setInputFiles(filePaths, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
264
- } else {
265
- await page.locator(resolved.selector).setInputFiles(filePaths, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
266
- }
267
- return `Uploaded ${filePaths.length} file(s) to ${selector}`;
268
- }
269
-
270
- case 'dialog-accept': {
271
- const value = args.join(' ') || undefined;
272
- bm.setAutoDialogAction('accept', value);
273
- return `Dialog auto-action set to: accept${value ? ` (with value: "${value}")` : ''}`;
274
- }
275
-
276
- case 'dialog-dismiss': {
277
- bm.setAutoDialogAction('dismiss');
278
- return 'Dialog auto-action set to: dismiss';
279
- }
280
-
281
- case 'emulate': {
282
- const deviceName = args.join(' ');
283
- if (!deviceName) {
284
- throw new Error(
285
- 'Usage: browse emulate <device>\n' +
286
- 'Examples: browse emulate iPhone 15, browse emulate Pixel 7, browse emulate reset\n' +
287
- 'Run "browse devices" to see all available devices.'
288
- );
289
- }
290
-
291
- // Reset to desktop
292
- if (deviceName.toLowerCase() === 'reset' || deviceName.toLowerCase() === 'desktop') {
293
- await bm.emulateDevice(null);
294
- return 'Device emulation reset to desktop (1920x1080)';
295
- }
296
-
297
- const device = resolveDevice(deviceName);
298
- if (!device) {
299
- // Find close matches
300
- const all = listDevices();
301
- const lower = deviceName.toLowerCase();
302
- const suggestions = all.filter(d => d.toLowerCase().includes(lower)).slice(0, 5);
303
- throw new Error(
304
- `Unknown device: "${deviceName}"\n` +
305
- (suggestions.length > 0
306
- ? `Did you mean: ${suggestions.join(', ')}?\n`
307
- : '') +
308
- 'Run "browse devices" to see all available devices.'
309
- );
310
- }
311
-
312
- await bm.emulateDevice(device);
313
- return [
314
- `Emulating: ${deviceName}`,
315
- ` Viewport: ${device.viewport.width}x${device.viewport.height}`,
316
- ` Scale: ${device.deviceScaleFactor}x`,
317
- ` Mobile: ${device.isMobile}`,
318
- ` Touch: ${device.hasTouch}`,
319
- ` UA: ${device.userAgent.slice(0, 80)}...`,
320
- 'Note: Cookies and tab URLs preserved. localStorage/sessionStorage were reset (Playwright limitation).',
321
- ].join('\n');
322
- }
323
-
324
- case 'dblclick': {
325
- const selector = args[0];
326
- if (!selector) throw new Error('Usage: browse dblclick <selector>');
327
- const resolved = bm.resolveRef(selector);
328
- if ('locator' in resolved) {
329
- await resolved.locator.dblclick({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
330
- } else {
331
- await page.dblclick(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
332
- }
333
- return `Double-clicked ${selector}`;
334
- }
335
-
336
- case 'focus': {
337
- const selector = args[0];
338
- if (!selector) throw new Error('Usage: browse focus <selector>');
339
- const resolved = bm.resolveRef(selector);
340
- if ('locator' in resolved) {
341
- await resolved.locator.focus({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
342
- } else {
343
- await page.locator(resolved.selector).focus({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
344
- }
345
- return `Focused ${selector}`;
346
- }
347
-
348
- case 'check': {
349
- const selector = args[0];
350
- if (!selector) throw new Error('Usage: browse check <selector>');
351
- const resolved = bm.resolveRef(selector);
352
- if ('locator' in resolved) {
353
- await resolved.locator.check({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
354
- } else {
355
- await page.locator(resolved.selector).check({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
356
- }
357
- return `Checked ${selector}`;
358
- }
359
-
360
- case 'uncheck': {
361
- const selector = args[0];
362
- if (!selector) throw new Error('Usage: browse uncheck <selector>');
363
- const resolved = bm.resolveRef(selector);
364
- if ('locator' in resolved) {
365
- await resolved.locator.uncheck({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
366
- } else {
367
- await page.locator(resolved.selector).uncheck({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
368
- }
369
- return `Unchecked ${selector}`;
370
- }
371
-
372
- case 'drag': {
373
- const [srcSel, tgtSel] = args;
374
- if (!srcSel || !tgtSel) throw new Error('Usage: browse drag <source> <target>');
375
- const srcResolved = bm.resolveRef(srcSel);
376
- const tgtResolved = bm.resolveRef(tgtSel);
377
- const srcLocator = 'locator' in srcResolved ? srcResolved.locator : page.locator(srcResolved.selector);
378
- const tgtLocator = 'locator' in tgtResolved ? tgtResolved.locator : page.locator(tgtResolved.selector);
379
- await srcLocator.dragTo(tgtLocator, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
380
- return `Dragged ${srcSel} to ${tgtSel}`;
381
- }
382
-
383
- case 'keydown': {
384
- const key = args[0];
385
- if (!key) throw new Error('Usage: browse keydown <key>');
386
- await page.keyboard.down(key);
387
- return `Key down: ${key}`;
388
- }
389
-
390
- case 'keyup': {
391
- const key = args[0];
392
- if (!key) throw new Error('Usage: browse keyup <key>');
393
- await page.keyboard.up(key);
394
- return `Key up: ${key}`;
395
- }
396
-
397
- case 'highlight': {
398
- const selector = args[0];
399
- if (!selector) throw new Error('Usage: browse highlight <selector>');
400
- const resolved = bm.resolveRef(selector);
401
- const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
402
- await locator.evaluate((el) => {
403
- el.style.outline = '3px solid #e11d48';
404
- el.style.outlineOffset = '2px';
405
- });
406
- return `Highlighted ${selector}`;
407
- }
408
-
409
- case 'download': {
410
- const [selector, savePath] = args;
411
- if (!selector) throw new Error('Usage: browse download <selector> [path]');
412
- const resolved = bm.resolveRef(selector);
413
- const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
414
- const [download] = await Promise.all([
415
- page.waitForEvent('download', { timeout: DEFAULTS.COMMAND_TIMEOUT_MS }),
416
- locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS }),
417
- ]);
418
- const finalPath = savePath || download.suggestedFilename();
419
- await download.saveAs(finalPath);
420
- return `Downloaded: ${finalPath}`;
421
- }
422
-
423
- case 'offline': {
424
- const mode = args[0];
425
- if (mode === 'on') {
426
- await bm.setOffline(true);
427
- return 'Offline mode: ON';
428
- }
429
- if (mode === 'off') {
430
- await bm.setOffline(false);
431
- return 'Offline mode: OFF';
432
- }
433
- // Toggle
434
- const newState = !bm.isOffline();
435
- await bm.setOffline(newState);
436
- return `Offline mode: ${newState ? 'ON' : 'OFF'}`;
437
- }
438
-
439
- case 'route': {
440
- // route <pattern> block — abort matching requests
441
- // route <pattern> fulfill <status> [body] — respond with custom data
442
- // route clear — remove all routes
443
- const pattern = args[0];
444
- if (!pattern) throw new Error('Usage: browse route <url-pattern> block | browse route <url-pattern> fulfill <status> [body] | browse route clear');
445
-
446
- const context = bm.getContext();
447
- if (!context) throw new Error('No browser context');
448
-
449
- if (pattern === 'clear') {
450
- await context.unrouteAll();
451
- bm.clearUserRoutes();
452
- // Re-apply domain filter route if active
453
- if (domainFilter) {
454
- await context.route('**/*', (route) => {
455
- const url = route.request().url();
456
- if (domainFilter!.isAllowed(url)) { route.fallback(); } else { route.abort('blockedbyclient'); }
457
- });
458
- }
459
- return domainFilter ? 'All routes cleared (domain filter preserved)' : 'All routes cleared';
460
- }
461
-
462
- const action = args[1] || 'block';
463
-
464
- if (action === 'block') {
465
- bm.addUserRoute(pattern, 'block');
466
- await rebuildRoutes(context, bm, domainFilter);
467
- return `Blocking requests matching: ${pattern}`;
468
- }
469
-
470
- if (action === 'fulfill') {
471
- const status = parseInt(args[2] || '200', 10);
472
- const body = args[3] || '';
473
- bm.addUserRoute(pattern, 'fulfill', status, body);
474
- await rebuildRoutes(context, bm, domainFilter);
475
- return `Mocking requests matching: ${pattern} → ${status}${body ? ` "${body}"` : ''}`;
476
- }
477
-
478
- throw new Error('Usage: browse route <pattern> block | browse route <pattern> fulfill <status> [body]');
479
- }
480
-
481
- default:
482
- throw new Error(`Unknown write command: ${command}`);
483
- }
484
- }
package/src/config.ts DELETED
@@ -1,45 +0,0 @@
1
- /**
2
- * Config file loader — reads browse.json from project root.
3
- * Config values serve as defaults — CLI flags and env vars override.
4
- */
5
-
6
- import * as fs from 'fs';
7
- import * as path from 'path';
8
-
9
- export interface BrowseConfig {
10
- session?: string;
11
- json?: boolean;
12
- contentBoundaries?: boolean;
13
- allowedDomains?: string[];
14
- idleTimeout?: number;
15
- viewport?: string;
16
- device?: string;
17
- runtime?: string;
18
- }
19
-
20
- /**
21
- * Load browse.json from the project root (directory containing .git or .claude).
22
- * Returns empty config if file doesn't exist or is malformed.
23
- */
24
- export function loadConfig(): BrowseConfig {
25
- let dir = process.cwd();
26
- for (let i = 0; i < 20; i++) {
27
- if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.claude'))) {
28
- const configPath = path.join(dir, 'browse.json');
29
- if (fs.existsSync(configPath)) {
30
- try {
31
- const raw = fs.readFileSync(configPath, 'utf-8');
32
- return JSON.parse(raw) as BrowseConfig;
33
- } catch {
34
- // Malformed JSON — silently ignore
35
- return {};
36
- }
37
- }
38
- return {};
39
- }
40
- const parent = path.dirname(dir);
41
- if (parent === dir) break;
42
- dir = parent;
43
- }
44
- return {};
45
- }
package/src/constants.ts DELETED
@@ -1,14 +0,0 @@
1
- const BROWSE_TIMEOUT = parseInt(process.env.BROWSE_TIMEOUT || '0', 10);
2
-
3
- export const DEFAULTS = {
4
- PORT_RANGE_START: 9400,
5
- PORT_RANGE_END: 10400,
6
- IDLE_TIMEOUT_MS: 30 * 60 * 1000, // 30 min
7
- COMMAND_TIMEOUT_MS: BROWSE_TIMEOUT || 15_000, // 15s for navigation
8
- ACTION_TIMEOUT_MS: BROWSE_TIMEOUT || 5_000, // 5s for clicks/fills
9
- HEALTH_CHECK_TIMEOUT_MS: 2_000,
10
- BUFFER_HIGH_WATER_MARK: 50_000,
11
- BUFFER_FLUSH_INTERVAL_MS: 1_000,
12
- NETWORK_SETTLE_MS: 5_000,
13
- LOCK_STALE_THRESHOLD_MS: 15_000,
14
- } as const;
package/src/diff.d.ts DELETED
@@ -1,12 +0,0 @@
1
- declare module 'diff' {
2
- interface Change {
3
- value: string;
4
- added?: boolean;
5
- removed?: boolean;
6
- count?: number;
7
- }
8
-
9
- export function diffLines(oldStr: string, newStr: string): Change[];
10
- export function diffWords(oldStr: string, newStr: string): Change[];
11
- export function diffChars(oldStr: string, newStr: string): Change[];
12
- }
@@ -1,140 +0,0 @@
1
- /**
2
- * Domain filter — blocks navigation and sub-resource requests outside an allowlist.
3
- *
4
- * Supports:
5
- * - Exact domain: "example.com" matches only example.com
6
- * - Wildcard: "*.example.com" matches example.com AND any subdomain
7
- * - Case-insensitive matching
8
- */
9
-
10
- export class DomainFilter {
11
- private domains: string[];
12
-
13
- constructor(domains: string[]) {
14
- this.domains = domains.map(d => d.toLowerCase());
15
- }
16
-
17
- /**
18
- * Check if a URL's domain is in the allowlist.
19
- * Returns true if allowed, false if blocked.
20
- * Non-HTTP URLs (about:blank, data:, etc.) are always allowed.
21
- */
22
- isAllowed(url: string): boolean {
23
- // Block file:// and javascript: URLs — security risk
24
- if (url.startsWith('file://') || url.startsWith('javascript:')) {
25
- return false;
26
- }
27
- // Non-HTTP(S) URLs (about:blank, data:, blob:) are always allowed
28
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
29
- return true;
30
- }
31
-
32
- let hostname: string;
33
- try {
34
- hostname = new URL(url).hostname.toLowerCase();
35
- } catch {
36
- return false; // Invalid URL = blocked
37
- }
38
-
39
- for (const pattern of this.domains) {
40
- if (pattern.startsWith('*.')) {
41
- // Wildcard: *.example.com matches example.com itself AND any subdomain
42
- const base = pattern.slice(2); // "example.com"
43
- if (hostname === base || hostname.endsWith('.' + base)) {
44
- return true;
45
- }
46
- } else {
47
- // Exact match
48
- if (hostname === pattern) {
49
- return true;
50
- }
51
- }
52
- }
53
-
54
- return false;
55
- }
56
-
57
- /**
58
- * Get a human-readable error message for a blocked URL.
59
- */
60
- blockedMessage(url: string): string {
61
- let hostname = url;
62
- try {
63
- hostname = new URL(url).hostname;
64
- } catch {}
65
- return `Domain "${hostname}" is not in the allowed list: ${this.domains.join(', ')}`;
66
- }
67
-
68
- /**
69
- * Generate a JS init script that wraps WebSocket, EventSource, and
70
- * navigator.sendBeacon with domain checks. Playwright's context.route()
71
- * only covers HTTP — these JS-level APIs bypass it entirely.
72
- *
73
- * Injected via context.addInitScript() so it runs before any page JS.
74
- */
75
- generateInitScript(): string {
76
- const domainsJson = JSON.stringify(this.domains);
77
- return `(function() {
78
- const __allowedDomains = ${domainsJson};
79
-
80
- function __isAllowed(url) {
81
- if (!url) return true;
82
- var str = String(url);
83
- // Normalize ws/wss to http/https for URL parsing
84
- if (str.startsWith('ws://')) str = 'http://' + str.slice(5);
85
- else if (str.startsWith('wss://')) str = 'https://' + str.slice(6);
86
- // Block file:// and javascript: URLs
87
- if (str.startsWith('file://') || str.startsWith('javascript:')) return false;
88
- // Non-HTTP(S) always allowed (data:, blob:, about:)
89
- if (!str.startsWith('http://') && !str.startsWith('https://')) return true;
90
- var hostname;
91
- try { hostname = new URL(str).hostname.toLowerCase(); } catch(e) { return false; }
92
- for (var i = 0; i < __allowedDomains.length; i++) {
93
- var pattern = __allowedDomains[i];
94
- if (pattern.startsWith('*.')) {
95
- var base = pattern.slice(2);
96
- if (hostname === base || hostname.endsWith('.' + base)) return true;
97
- } else {
98
- if (hostname === pattern) return true;
99
- }
100
- }
101
- return false;
102
- }
103
-
104
- // Wrap WebSocket
105
- var OrigWebSocket = window.WebSocket;
106
- if (OrigWebSocket) {
107
- window.WebSocket = function(url, protocols) {
108
- if (!__isAllowed(url)) throw new Error('WebSocket blocked by domain filter: ' + url);
109
- if (protocols !== undefined) return new OrigWebSocket(url, protocols);
110
- return new OrigWebSocket(url);
111
- };
112
- window.WebSocket.prototype = OrigWebSocket.prototype;
113
- window.WebSocket.CONNECTING = OrigWebSocket.CONNECTING;
114
- window.WebSocket.OPEN = OrigWebSocket.OPEN;
115
- window.WebSocket.CLOSING = OrigWebSocket.CLOSING;
116
- window.WebSocket.CLOSED = OrigWebSocket.CLOSED;
117
- }
118
-
119
- // Wrap EventSource
120
- var OrigEventSource = window.EventSource;
121
- if (OrigEventSource) {
122
- window.EventSource = function(url, opts) {
123
- if (!__isAllowed(url)) throw new Error('EventSource blocked by domain filter: ' + url);
124
- if (opts !== undefined) return new OrigEventSource(url, opts);
125
- return new OrigEventSource(url);
126
- };
127
- window.EventSource.prototype = OrigEventSource.prototype;
128
- }
129
-
130
- // Wrap navigator.sendBeacon
131
- if (navigator.sendBeacon) {
132
- var origSendBeacon = navigator.sendBeacon.bind(navigator);
133
- navigator.sendBeacon = function(url, data) {
134
- if (!__isAllowed(url)) return false;
135
- return origSendBeacon(url, data);
136
- };
137
- }
138
- })();`;
139
- }
140
- }