webpeel 0.20.2 → 0.20.3

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.
Files changed (86) hide show
  1. package/dist/server/app.d.ts +14 -0
  2. package/dist/server/app.js +384 -0
  3. package/dist/server/auth-store.d.ts +27 -0
  4. package/dist/server/auth-store.js +88 -0
  5. package/dist/server/email-service.d.ts +21 -0
  6. package/dist/server/email-service.js +79 -0
  7. package/dist/server/job-queue.d.ts +100 -0
  8. package/dist/server/job-queue.js +145 -0
  9. package/dist/server/logger.d.ts +10 -0
  10. package/dist/server/logger.js +37 -0
  11. package/dist/server/middleware/auth.d.ts +28 -0
  12. package/dist/server/middleware/auth.js +221 -0
  13. package/dist/server/middleware/rate-limit.d.ts +24 -0
  14. package/dist/server/middleware/rate-limit.js +167 -0
  15. package/dist/server/middleware/url-validator.d.ts +15 -0
  16. package/dist/server/middleware/url-validator.js +186 -0
  17. package/dist/server/openapi.yaml +6418 -0
  18. package/dist/server/pg-auth-store.d.ts +132 -0
  19. package/dist/server/pg-auth-store.js +472 -0
  20. package/dist/server/pg-job-queue.d.ts +59 -0
  21. package/dist/server/pg-job-queue.js +375 -0
  22. package/dist/server/premium/domain-intel.d.ts +16 -0
  23. package/dist/server/premium/domain-intel.js +133 -0
  24. package/dist/server/premium/index.d.ts +17 -0
  25. package/dist/server/premium/index.js +35 -0
  26. package/dist/server/premium/swr-cache.d.ts +14 -0
  27. package/dist/server/premium/swr-cache.js +34 -0
  28. package/dist/server/routes/activity.d.ts +6 -0
  29. package/dist/server/routes/activity.js +74 -0
  30. package/dist/server/routes/answer.d.ts +5 -0
  31. package/dist/server/routes/answer.js +125 -0
  32. package/dist/server/routes/ask.d.ts +28 -0
  33. package/dist/server/routes/ask.js +229 -0
  34. package/dist/server/routes/batch.d.ts +6 -0
  35. package/dist/server/routes/batch.js +493 -0
  36. package/dist/server/routes/cli-usage.d.ts +6 -0
  37. package/dist/server/routes/cli-usage.js +127 -0
  38. package/dist/server/routes/compat.d.ts +23 -0
  39. package/dist/server/routes/compat.js +652 -0
  40. package/dist/server/routes/deep-fetch.d.ts +8 -0
  41. package/dist/server/routes/deep-fetch.js +57 -0
  42. package/dist/server/routes/demo.d.ts +24 -0
  43. package/dist/server/routes/demo.js +517 -0
  44. package/dist/server/routes/do.d.ts +8 -0
  45. package/dist/server/routes/do.js +72 -0
  46. package/dist/server/routes/extract.d.ts +8 -0
  47. package/dist/server/routes/extract.js +235 -0
  48. package/dist/server/routes/fetch.d.ts +7 -0
  49. package/dist/server/routes/fetch.js +999 -0
  50. package/dist/server/routes/health.d.ts +7 -0
  51. package/dist/server/routes/health.js +19 -0
  52. package/dist/server/routes/jobs.d.ts +7 -0
  53. package/dist/server/routes/jobs.js +573 -0
  54. package/dist/server/routes/mcp.d.ts +14 -0
  55. package/dist/server/routes/mcp.js +141 -0
  56. package/dist/server/routes/oauth.d.ts +9 -0
  57. package/dist/server/routes/oauth.js +396 -0
  58. package/dist/server/routes/playground.d.ts +17 -0
  59. package/dist/server/routes/playground.js +283 -0
  60. package/dist/server/routes/screenshot.d.ts +22 -0
  61. package/dist/server/routes/screenshot.js +816 -0
  62. package/dist/server/routes/search.d.ts +6 -0
  63. package/dist/server/routes/search.js +303 -0
  64. package/dist/server/routes/session.d.ts +15 -0
  65. package/dist/server/routes/session.js +397 -0
  66. package/dist/server/routes/stats.d.ts +6 -0
  67. package/dist/server/routes/stats.js +71 -0
  68. package/dist/server/routes/stripe.d.ts +15 -0
  69. package/dist/server/routes/stripe.js +294 -0
  70. package/dist/server/routes/users.d.ts +8 -0
  71. package/dist/server/routes/users.js +1671 -0
  72. package/dist/server/routes/watch.d.ts +15 -0
  73. package/dist/server/routes/watch.js +309 -0
  74. package/dist/server/routes/webhooks.d.ts +26 -0
  75. package/dist/server/routes/webhooks.js +170 -0
  76. package/dist/server/routes/youtube.d.ts +6 -0
  77. package/dist/server/routes/youtube.js +130 -0
  78. package/dist/server/sentry.d.ts +13 -0
  79. package/dist/server/sentry.js +38 -0
  80. package/dist/server/types.d.ts +15 -0
  81. package/dist/server/types.js +7 -0
  82. package/dist/server/utils/response.d.ts +44 -0
  83. package/dist/server/utils/response.js +69 -0
  84. package/dist/server/utils/sse.d.ts +22 -0
  85. package/dist/server/utils/sse.js +38 -0
  86. package/package.json +2 -1
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Browser Session API — stateful Playwright sessions
3
+ *
4
+ * POST /v1/session → create session, returns { sessionId, expiresAt }
5
+ * GET /v1/session/:id → get current page content (Readability text)
6
+ * POST /v1/session/:id/navigate → navigate to URL { url }
7
+ * POST /v1/session/:id/act → execute PageActions array
8
+ * GET /v1/session/:id/screenshot → take screenshot (image/png)
9
+ * DELETE /v1/session/:id → close session
10
+ *
11
+ * Use cases: login flows, multi-step automation, UI testing.
12
+ * This is what Browserbase charges $500/mo for — built into WebPeel.
13
+ */
14
+ import { Router } from 'express';
15
+ import { randomUUID } from 'crypto';
16
+ import { normalizeActions, executeActions } from '../../core/actions.js';
17
+ import { ANTI_DETECTION_ARGS, getRandomViewport, getRandomUserAgent, applyStealthScripts, } from '../../core/browser-pool.js';
18
+ import { extractReadableContent } from '../../core/readability.js';
19
+ const sessions = new Map();
20
+ const SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle TTL
21
+ const MAX_SESSIONS_PER_USER = 3; // prevent abuse
22
+ // Cleanup expired sessions every minute
23
+ const _cleanupInterval = setInterval(() => {
24
+ const now = Date.now();
25
+ for (const [id, session] of sessions) {
26
+ if (now - session.lastUsedAt > SESSION_TTL_MS) {
27
+ session.browser.close().catch(() => { });
28
+ sessions.delete(id);
29
+ }
30
+ }
31
+ }, 60_000);
32
+ // Don't keep the Node process alive just for the cleanup timer
33
+ if (_cleanupInterval.unref)
34
+ _cleanupInterval.unref();
35
+ // ── Helpers ───────────────────────────────────────────────────────────────────
36
+ /** Extract the owner ID from the request — supports both API key and JWT auth. */
37
+ function getOwnerId(req) {
38
+ return req.auth?.keyInfo?.accountId
39
+ || req.user?.userId
40
+ || null;
41
+ }
42
+ /**
43
+ * Look up a session by id and verify it belongs to the requesting owner.
44
+ * Returns null if not found, expired, or owned by someone else.
45
+ */
46
+ function getSession(id, ownerId) {
47
+ const session = sessions.get(id);
48
+ if (!session)
49
+ return null;
50
+ if (ownerId && session.ownerId !== ownerId)
51
+ return null; // ownership check
52
+ return session;
53
+ }
54
+ /** Launch a fresh Chromium browser for a session (separate instance per session). */
55
+ async function launchBrowser() {
56
+ const { chromium } = await import('playwright');
57
+ const vp = getRandomViewport();
58
+ return chromium.launch({
59
+ headless: true,
60
+ args: [...ANTI_DETECTION_ARGS, `--window-size=${vp.width},${vp.height}`],
61
+ });
62
+ }
63
+ /** Extract readable text from an HTML string using WebPeel's built-in Readability engine. */
64
+ function extractReadableText(html, url) {
65
+ try {
66
+ const result = extractReadableContent(html, url);
67
+ return result.content?.trim() || result.excerpt?.trim() || '';
68
+ }
69
+ catch {
70
+ return '';
71
+ }
72
+ }
73
+ // ── Router ────────────────────────────────────────────────────────────────────
74
+ export function createSessionRouter() {
75
+ const router = Router();
76
+ // ── POST /v1/session — create session ────────────────────────────────────────
77
+ router.post('/v1/session', async (req, res) => {
78
+ const ownerId = getOwnerId(req);
79
+ if (!ownerId) {
80
+ res.status(401).json({ success: false, error: { type: 'auth_required', message: 'Valid API key or session required.', docs: 'https://webpeel.dev/docs/authentication' }, requestId: req.requestId });
81
+ return;
82
+ }
83
+ // Enforce per-user session cap
84
+ const userSessions = [...sessions.values()].filter(s => s.ownerId === ownerId);
85
+ if (userSessions.length >= MAX_SESSIONS_PER_USER) {
86
+ res.status(429).json({
87
+ success: false,
88
+ error: {
89
+ type: 'session_limit',
90
+ message: `Maximum ${MAX_SESSIONS_PER_USER} concurrent sessions per user. Delete an existing session first.`,
91
+ hint: 'Delete an existing session via DELETE /v1/session/:id before creating a new one.',
92
+ docs: 'https://webpeel.dev/docs/errors#session-limit',
93
+ },
94
+ requestId: req.requestId || randomUUID(),
95
+ });
96
+ return;
97
+ }
98
+ const { url } = req.body;
99
+ let browser = null;
100
+ try {
101
+ browser = await launchBrowser();
102
+ const context = await browser.newContext({
103
+ userAgent: getRandomUserAgent(),
104
+ viewport: { width: 1280, height: 800 },
105
+ });
106
+ const page = await context.newPage();
107
+ await applyStealthScripts(page);
108
+ if (url) {
109
+ try {
110
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
111
+ }
112
+ catch (navErr) {
113
+ // Navigation failed — still return the session, caller can retry
114
+ const errMsg = navErr instanceof Error ? navErr.message : String(navErr);
115
+ await browser.close().catch(() => { });
116
+ res.status(502).json({
117
+ success: false,
118
+ error: {
119
+ type: 'navigation_failed',
120
+ message: errMsg,
121
+ hint: 'Check that the URL is accessible and try again.',
122
+ docs: 'https://webpeel.dev/docs/errors#navigation-failed',
123
+ },
124
+ requestId: req.requestId || randomUUID(),
125
+ });
126
+ return;
127
+ }
128
+ }
129
+ const id = randomUUID();
130
+ const now = Date.now();
131
+ sessions.set(id, {
132
+ id,
133
+ browser,
134
+ context,
135
+ page,
136
+ ownerId,
137
+ createdAt: now,
138
+ lastUsedAt: now,
139
+ currentUrl: page.url(),
140
+ });
141
+ res.status(201).json({
142
+ sessionId: id,
143
+ currentUrl: page.url(),
144
+ expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
145
+ });
146
+ }
147
+ catch (err) {
148
+ if (browser)
149
+ await browser.close().catch(() => { });
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ res.status(500).json({
152
+ success: false,
153
+ error: {
154
+ type: 'session_create_failed',
155
+ message: msg,
156
+ docs: 'https://webpeel.dev/docs/errors#session-create-failed',
157
+ },
158
+ requestId: req.requestId || randomUUID(),
159
+ });
160
+ }
161
+ });
162
+ // ── GET /v1/session/:id — get page content ───────────────────────────────────
163
+ router.get('/v1/session/:id', async (req, res) => {
164
+ const ownerId = getOwnerId(req);
165
+ const session = getSession(req.params['id'], ownerId);
166
+ if (!session) {
167
+ res.status(404).json({
168
+ success: false,
169
+ error: {
170
+ type: 'session_not_found',
171
+ message: 'Session not found or has expired.',
172
+ hint: 'Create a new session via POST /v1/session.',
173
+ docs: 'https://webpeel.dev/docs/errors#session-not-found',
174
+ },
175
+ requestId: req.requestId || randomUUID(),
176
+ });
177
+ return;
178
+ }
179
+ try {
180
+ const [html, title] = await Promise.all([
181
+ session.page.content(),
182
+ session.page.title(),
183
+ ]);
184
+ const content = await extractReadableText(html, session.page.url());
185
+ session.lastUsedAt = Date.now();
186
+ res.json({
187
+ sessionId: session.id,
188
+ currentUrl: session.page.url(),
189
+ title,
190
+ content,
191
+ expiresAt: new Date(session.lastUsedAt + SESSION_TTL_MS).toISOString(),
192
+ });
193
+ }
194
+ catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ res.status(500).json({
197
+ success: false,
198
+ error: {
199
+ type: 'session_error',
200
+ message: msg,
201
+ docs: 'https://webpeel.dev/docs/errors#session-error',
202
+ },
203
+ requestId: req.requestId || randomUUID(),
204
+ });
205
+ }
206
+ });
207
+ // ── POST /v1/session/:id/navigate ────────────────────────────────────────────
208
+ router.post('/v1/session/:id/navigate', async (req, res) => {
209
+ const ownerId = getOwnerId(req);
210
+ const session = getSession(req.params["id"], ownerId);
211
+ if (!session) {
212
+ res.status(404).json({
213
+ success: false,
214
+ error: {
215
+ type: 'session_not_found',
216
+ message: 'Session not found or has expired.',
217
+ hint: 'Create a new session via POST /v1/session.',
218
+ docs: 'https://webpeel.dev/docs/errors#session-not-found',
219
+ },
220
+ requestId: req.requestId || randomUUID(),
221
+ });
222
+ return;
223
+ }
224
+ const { url } = req.body;
225
+ if (!url) {
226
+ res.status(400).json({
227
+ success: false,
228
+ error: {
229
+ type: 'bad_request',
230
+ message: '`url` is required.',
231
+ hint: 'Pass a URL in the request body: { "url": "https://example.com" }',
232
+ docs: 'https://webpeel.dev/docs/errors#bad-request',
233
+ },
234
+ requestId: req.requestId || randomUUID(),
235
+ });
236
+ return;
237
+ }
238
+ try {
239
+ await session.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
240
+ session.lastUsedAt = Date.now();
241
+ session.currentUrl = session.page.url();
242
+ res.json({
243
+ currentUrl: session.page.url(),
244
+ title: await session.page.title(),
245
+ expiresAt: new Date(session.lastUsedAt + SESSION_TTL_MS).toISOString(),
246
+ });
247
+ }
248
+ catch (err) {
249
+ const msg = err instanceof Error ? err.message : String(err);
250
+ res.status(502).json({
251
+ success: false,
252
+ error: {
253
+ type: 'navigation_failed',
254
+ message: msg,
255
+ hint: 'Check that the URL is accessible and try again.',
256
+ docs: 'https://webpeel.dev/docs/errors#navigation-failed',
257
+ },
258
+ requestId: req.requestId || randomUUID(),
259
+ });
260
+ }
261
+ });
262
+ // ── POST /v1/session/:id/act — execute actions ───────────────────────────────
263
+ router.post('/v1/session/:id/act', async (req, res) => {
264
+ const ownerId = getOwnerId(req);
265
+ const session = getSession(req.params["id"], ownerId);
266
+ if (!session) {
267
+ res.status(404).json({
268
+ success: false,
269
+ error: {
270
+ type: 'session_not_found',
271
+ message: 'Session not found or has expired.',
272
+ hint: 'Create a new session via POST /v1/session.',
273
+ docs: 'https://webpeel.dev/docs/errors#session-not-found',
274
+ },
275
+ requestId: req.requestId || randomUUID(),
276
+ });
277
+ return;
278
+ }
279
+ const { actions, screenshot: takeScreenshot } = req.body;
280
+ let normalized;
281
+ try {
282
+ normalized = normalizeActions(actions);
283
+ }
284
+ catch (err) {
285
+ const msg = err instanceof Error ? err.message : String(err);
286
+ res.status(400).json({
287
+ success: false,
288
+ error: {
289
+ type: 'invalid_actions',
290
+ message: msg,
291
+ hint: 'Pass a valid actions array: [{ "type": "click", "selector": "#btn" }]',
292
+ docs: 'https://webpeel.dev/docs/errors#invalid-actions',
293
+ },
294
+ requestId: req.requestId || randomUUID(),
295
+ });
296
+ return;
297
+ }
298
+ if (!normalized?.length) {
299
+ res.status(400).json({
300
+ success: false,
301
+ error: {
302
+ type: 'bad_request',
303
+ message: '`actions` must be a non-empty array.',
304
+ hint: 'Pass a valid actions array: [{ "type": "click", "selector": "#btn" }]',
305
+ docs: 'https://webpeel.dev/docs/errors#bad-request',
306
+ },
307
+ requestId: req.requestId || randomUUID(),
308
+ });
309
+ return;
310
+ }
311
+ const normalizedActions = normalized;
312
+ try {
313
+ await executeActions(session.page, normalizedActions);
314
+ session.lastUsedAt = Date.now();
315
+ session.currentUrl = session.page.url();
316
+ let screenshot;
317
+ if (takeScreenshot) {
318
+ const buf = await session.page.screenshot({ type: 'png' });
319
+ screenshot = buf.toString('base64');
320
+ }
321
+ const [title, currentUrl] = await Promise.all([
322
+ session.page.title(),
323
+ Promise.resolve(session.page.url()),
324
+ ]);
325
+ res.json({
326
+ currentUrl,
327
+ title,
328
+ screenshot,
329
+ actionsExecuted: normalizedActions.length,
330
+ expiresAt: new Date(session.lastUsedAt + SESSION_TTL_MS).toISOString(),
331
+ });
332
+ }
333
+ catch (err) {
334
+ const msg = err instanceof Error ? err.message : String(err);
335
+ res.status(502).json({
336
+ success: false,
337
+ error: {
338
+ type: 'action_failed',
339
+ message: msg,
340
+ hint: 'Check your action selectors and ensure the page is loaded.',
341
+ docs: 'https://webpeel.dev/docs/errors#action-failed',
342
+ },
343
+ requestId: req.requestId || randomUUID(),
344
+ });
345
+ }
346
+ });
347
+ // ── GET /v1/session/:id/screenshot ───────────────────────────────────────────
348
+ router.get('/v1/session/:id/screenshot', async (req, res) => {
349
+ const ownerId = getOwnerId(req);
350
+ const session = getSession(req.params["id"], ownerId);
351
+ if (!session) {
352
+ res.status(404).json({
353
+ success: false,
354
+ error: {
355
+ type: 'session_not_found',
356
+ message: 'Session not found or has expired.',
357
+ hint: 'Create a new session via POST /v1/session.',
358
+ docs: 'https://webpeel.dev/docs/errors#session-not-found',
359
+ },
360
+ requestId: req.requestId || randomUUID(),
361
+ });
362
+ return;
363
+ }
364
+ try {
365
+ const fullPage = req.query.fullPage === 'true';
366
+ const buf = await session.page.screenshot({ type: 'png', fullPage });
367
+ session.lastUsedAt = Date.now();
368
+ res.setHeader('Content-Type', 'image/png');
369
+ res.setHeader('Cache-Control', 'no-store');
370
+ res.send(buf);
371
+ }
372
+ catch (err) {
373
+ const msg = err instanceof Error ? err.message : String(err);
374
+ res.status(500).json({
375
+ success: false,
376
+ error: {
377
+ type: 'screenshot_failed',
378
+ message: msg,
379
+ docs: 'https://webpeel.dev/docs/errors#screenshot-failed',
380
+ },
381
+ requestId: req.requestId || randomUUID(),
382
+ });
383
+ }
384
+ });
385
+ // ── DELETE /v1/session/:id ───────────────────────────────────────────────────
386
+ router.delete('/v1/session/:id', async (req, res) => {
387
+ const ownerId = getOwnerId(req);
388
+ const session = getSession(req.params["id"], ownerId);
389
+ if (session) {
390
+ sessions.delete(req.params["id"]);
391
+ await session.browser.close().catch(() => { });
392
+ }
393
+ // Always return 200 (idempotent delete)
394
+ res.json({ closed: true });
395
+ });
396
+ return router;
397
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Stats endpoint - provides dashboard statistics
3
+ */
4
+ import { Router } from 'express';
5
+ import { AuthStore } from '../auth-store.js';
6
+ export declare function createStatsRouter(authStore: AuthStore): Router;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Stats endpoint - provides dashboard statistics
3
+ */
4
+ import { Router } from 'express';
5
+ import { PostgresAuthStore } from '../pg-auth-store.js';
6
+ export function createStatsRouter(authStore) {
7
+ const router = Router();
8
+ router.get('/v1/stats', async (req, res) => {
9
+ try {
10
+ // Require authentication (API key or JWT session token)
11
+ const userId = req.auth?.keyInfo?.accountId || req.user?.userId;
12
+ if (!userId) {
13
+ res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'Authentication required.', docs: 'https://webpeel.dev/docs/authentication' }, requestId: req.requestId });
14
+ return;
15
+ }
16
+ // Only works with PostgreSQL backend
17
+ if (!(authStore instanceof PostgresAuthStore)) {
18
+ res.status(501).json({
19
+ success: false,
20
+ error: {
21
+ type: 'not_implemented',
22
+ message: 'Stats endpoint requires PostgreSQL backend',
23
+ docs: 'https://webpeel.dev/docs/errors#not_implemented',
24
+ },
25
+ requestId: req.requestId,
26
+ });
27
+ return;
28
+ }
29
+ // Access pool via any cast (pool is private but we need direct DB access)
30
+ const pgStore = authStore;
31
+ // Get stats from usage_logs table
32
+ const statsQuery = `
33
+ SELECT
34
+ COUNT(*) as total_requests,
35
+ AVG(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1.0 ELSE 0.0 END) * 100 as success_rate,
36
+ AVG(processing_time_ms) as avg_response_time
37
+ FROM usage_logs
38
+ WHERE user_id = $1
39
+ `;
40
+ const result = await pgStore.pool.query(statsQuery, [userId]);
41
+ if (result.rows.length === 0) {
42
+ // No data yet - return defaults
43
+ res.json({
44
+ totalRequests: 0,
45
+ successRate: 100,
46
+ avgResponseTime: 0,
47
+ });
48
+ return;
49
+ }
50
+ const row = result.rows[0];
51
+ res.json({
52
+ totalRequests: parseInt(row.total_requests) || 0,
53
+ successRate: parseFloat(row.success_rate) || 100,
54
+ avgResponseTime: Math.round(parseFloat(row.avg_response_time)) || 0,
55
+ });
56
+ }
57
+ catch (error) {
58
+ console.error('Stats error:', error);
59
+ res.status(500).json({
60
+ success: false,
61
+ error: {
62
+ type: 'internal_error',
63
+ message: 'Failed to retrieve stats',
64
+ docs: 'https://webpeel.dev/docs/errors#internal_error',
65
+ },
66
+ requestId: req.requestId,
67
+ });
68
+ }
69
+ });
70
+ return router;
71
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Stripe webhook handler for subscription management
3
+ */
4
+ import { Router } from 'express';
5
+ import pg from 'pg';
6
+ /**
7
+ * Create Stripe Billing Portal router
8
+ * POST /v1/billing/portal — create a Stripe Customer Portal session
9
+ * Requires global auth middleware to already have run (req.user or req.auth set).
10
+ */
11
+ export declare function createBillingPortalRouter(pool: pg.Pool | null): Router;
12
+ /**
13
+ * Create Stripe webhook router
14
+ */
15
+ export declare function createStripeRouter(): Router;