test-proxy-recorder 0.1.11 → 0.3.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/README.md CHANGED
@@ -21,6 +21,7 @@ HTTP proxy server for recording and replaying network requests in testing. Works
21
21
  - [Complete Setup Guide](#complete-setup-guide)
22
22
  - [CLI Usage](#cli-usage)
23
23
  - [Playwright Integration](#playwright-integration)
24
+ - [Next.js Integration](#nextjs-integration)
24
25
  - [Control Endpoint](#control-endpoint)
25
26
  - [Typical Workflow](#typical-workflow)
26
27
  - [Recording Format](#recording-format)
@@ -99,10 +100,10 @@ This marks recording files as binary, which causes long mock files to be collaps
99
100
  Create `e2e/global-teardown.ts`:
100
101
 
101
102
  ```typescript
102
- import { setProxyMode } from 'test-proxy-recorder';
103
+ import { playwrightProxy } from 'test-proxy-recorder';
103
104
 
104
105
  async function globalTeardown() {
105
- await setProxyMode('transparent').catch(err => { console.error(err) });
106
+ await playwrightProxy.teardown();
106
107
  }
107
108
 
108
109
  export default globalTeardown;
@@ -130,13 +131,11 @@ import { playwrightProxy } from 'test-proxy-recorder';
130
131
 
131
132
  test('example test with proxy', async ({ page }, testInfo) => {
132
133
  // Set proxy mode: 'record' to capture, 'replay' to use recordings
133
- await playwrightProxy.before(testInfo, 'replay');
134
+ // This automatically sets up page.on('close') for cleanup
135
+ await playwrightProxy.before(page, testInfo, 'replay');
134
136
 
135
137
  await page.goto('/');
136
138
  await expect(page.getByText('Welcome')).toBeVisible();
137
-
138
- // Always cleanup after test
139
- await playwrightProxy.after(testInfo);
140
139
  });
141
140
  ```
142
141
 
@@ -145,13 +144,13 @@ test('example test with proxy', async ({ page }, testInfo) => {
145
144
  **First run (record mode)**:
146
145
 
147
146
  ```typescript
148
- await playwrightProxy.before(testInfo, 'record');
147
+ await playwrightProxy.before(page, testInfo, 'record');
149
148
  ```
150
149
 
151
150
  **Subsequent runs (replay mode)**:
152
151
 
153
152
  ```typescript
154
- await playwrightProxy.before(testInfo, 'replay');
153
+ await playwrightProxy.before(page, testInfo, 'replay');
155
154
  ```
156
155
 
157
156
  ## CLI Usage
@@ -184,6 +183,12 @@ test-proxy-recorder http://localhost:8000 http://localhost:9000 --port 8100
184
183
 
185
184
  ## Playwright Integration
186
185
 
186
+ ### Session Identification
187
+
188
+ The proxy uses a **custom HTTP header** (`x-test-rcrd-id`) to identify recording sessions. This header is automatically set by the `playwrightProxy.before()` method and works seamlessly with Next.js and other server-side rendering frameworks.
189
+
190
+ **Cookie fallback**: For backward compatibility, the proxy also supports cookie-based session identification, but the custom header is preferred.
191
+
187
192
  ### Basic Test Structure
188
193
 
189
194
  Every test using the proxy should follow this pattern:
@@ -193,40 +198,38 @@ import { test } from '@playwright/test';
193
198
  import { playwrightProxy } from 'test-proxy-recorder';
194
199
 
195
200
  test('test name', async ({ page }, testInfo) => {
196
- // 1. Set mode BEFORE test actions
197
- await playwrightProxy.before(testInfo, 'replay');
201
+ // Set mode BEFORE test actions
202
+ // This automatically sets the recording ID header and cleanup handler
203
+ await playwrightProxy.before(page, testInfo, 'replay');
198
204
 
199
- // 2. Test code
205
+ // Test code
200
206
  await page.goto('/page');
201
-
202
- // 3. Reset mode AFTER test completes
203
- await playwrightProxy.after(testInfo);
207
+ // Test assertions...
204
208
  });
205
209
  ```
206
210
 
207
211
  ### Recording vs Replay
208
212
 
209
213
  ```typescript
214
+ import { test } from '@playwright/test';
215
+ import { playwrightProxy } from 'test-proxy-recorder';
216
+
210
217
  // Recording mode - captures API responses
211
218
  test('create user', async ({ page }, testInfo) => {
212
- await playwrightProxy.before(testInfo, 'record');
219
+ await playwrightProxy.before(page, testInfo, 'record');
213
220
 
214
221
  await page.goto('/users/new');
215
222
  await page.fill('[name="username"]', 'testuser');
216
223
  await page.click('button[type="submit"]');
217
-
218
- await playwrightProxy.after(testInfo);
219
224
  });
220
225
 
221
226
  // Replay mode - uses recorded responses
222
227
  test('create user', async ({ page }, testInfo) => {
223
- await playwrightProxy.before(testInfo, 'replay');
228
+ await playwrightProxy.before(page, testInfo, 'replay');
224
229
 
225
230
  await page.goto('/users/new');
226
231
  await page.fill('[name="username"]', 'testuser');
227
232
  await page.click('button[type="submit"]');
228
-
229
- await playwrightProxy.after(testInfo);
230
233
  });
231
234
  ```
232
235
 
@@ -244,10 +247,10 @@ Recording files are auto-generated from test names:
244
247
  Create `e2e/global-teardown.ts`:
245
248
 
246
249
  ```typescript
247
- import { setProxyMode } from 'test-proxy-recorder';
250
+ import { playwrightProxy } from 'test-proxy-recorder';
248
251
 
249
252
  async function globalTeardown() {
250
- await setProxyMode('transparent').catch(err => { console.error(err) });
253
+ await playwrightProxy.teardown();
251
254
  }
252
255
 
253
256
  export default globalTeardown;
@@ -265,6 +268,78 @@ export default defineConfig({
265
268
  });
266
269
  ```
267
270
 
271
+ ## Next.js Integration
272
+
273
+ When testing Next.js applications with server-side rendering (SSR) or API routes, you need to ensure the recording ID header is forwarded to the proxy. The package provides helpers for this.
274
+
275
+ ### Option 1: Using Next.js Middleware (Recommended)
276
+
277
+ Create or update `middleware.ts` in your Next.js project root:
278
+
279
+ ```typescript
280
+ import { NextResponse } from 'next/server';
281
+ import type { NextRequest } from 'next/server';
282
+ import { setNextProxyHeaders } from 'test-proxy-recorder/nextjs';
283
+
284
+ export function middleware(request: NextRequest) {
285
+ const response = NextResponse.next();
286
+
287
+ // Forward the recording ID header during tests
288
+ // Only runs in non-production or when TEST_PROXY_RECORDER_ENABLED=true
289
+ setNextProxyHeaders(request, response);
290
+
291
+ return response;
292
+ }
293
+ ```
294
+
295
+ **Environment Variables:**
296
+ - Automatically skipped when `NODE_ENV=production`
297
+ - Can be explicitly enabled in production with `TEST_PROXY_RECORDER_ENABLED=true`
298
+
299
+ ### Option 2: Manual Header Forwarding in API Routes
300
+
301
+ For API routes or server components, manually include the header in fetch requests:
302
+
303
+ ```typescript
304
+ // app/api/data/route.ts
305
+ import { headers } from 'next/headers';
306
+ import { createHeadersWithRecordingId } from 'test-proxy-recorder/nextjs';
307
+
308
+ export async function GET() {
309
+ const requestHeaders = await headers();
310
+
311
+ const response = await fetch('http://localhost:8100/api/data', {
312
+ headers: createHeadersWithRecordingId(requestHeaders, {
313
+ 'Content-Type': 'application/json',
314
+ })
315
+ });
316
+
317
+ return Response.json(await response.json());
318
+ }
319
+ ```
320
+
321
+ ### Option 3: Using getRecordingId Helper
322
+
323
+ For more control, extract the recording ID and use it manually:
324
+
325
+ ```typescript
326
+ import { headers } from 'next/headers';
327
+ import { getRecordingId, RECORDING_ID_HEADER } from 'test-proxy-recorder/nextjs';
328
+
329
+ export async function GET() {
330
+ const recordingId = getRecordingId(await headers());
331
+
332
+ const response = await fetch('http://localhost:8100/api/data', {
333
+ headers: {
334
+ 'Content-Type': 'application/json',
335
+ ...(recordingId && { [RECORDING_ID_HEADER]: recordingId })
336
+ }
337
+ });
338
+
339
+ return Response.json(await response.json());
340
+ }
341
+ ```
342
+
268
343
  ## Control Endpoint
269
344
 
270
345
  The proxy exposes a control endpoint at `/__control` for programmatic mode switching.
@@ -401,19 +476,24 @@ class ProxyServer {
401
476
  ### Playwright Integration
402
477
 
403
478
  ```typescript
404
- import { playwrightProxy, setProxyMode } from 'test-proxy-recorder';
479
+ import { playwrightProxy, setProxyMode, RECORDING_ID_HEADER } from 'test-proxy-recorder';
480
+ import type { Page } from '@playwright/test';
405
481
 
406
482
  // Main helper for Playwright tests
407
483
  const playwrightProxy = {
408
- // Set proxy mode before test
484
+ // Set proxy mode before test and configure page with recording ID header
485
+ // Automatically sets up page.on('close') handler for cleanup
409
486
  async before(
487
+ page: Page,
410
488
  testInfo: TestInfo,
411
489
  mode: 'record' | 'replay' | 'transparent',
412
490
  timeout?: number
413
491
  ): Promise<void>;
414
492
 
415
- // Reset to transparent mode after test
416
- async after(testInfo: TestInfo): Promise<void>;
493
+
494
+ // Global teardown - switches proxy to transparent mode
495
+ // Use in Playwright's globalTeardown configuration
496
+ async teardown(): Promise<void>;
417
497
  };
418
498
 
419
499
  // Direct mode control
@@ -422,6 +502,41 @@ async function setProxyMode(
422
502
  id?: string,
423
503
  timeout?: number
424
504
  ): Promise<void>;
505
+
506
+ // Recording ID header constant
507
+ const RECORDING_ID_HEADER: string; // 'x-test-rcrd-id'
508
+ ```
509
+
510
+ ### Next.js Integration
511
+
512
+ **IMPORTANT**: Use the `/nextjs` import path to avoid webpack bundling issues in Next.js:
513
+
514
+ ```typescript
515
+ import {
516
+ setNextProxyHeaders,
517
+ getRecordingId,
518
+ createHeadersWithRecordingId,
519
+ RECORDING_ID_HEADER
520
+ } from 'test-proxy-recorder/nextjs';
521
+ import type { NextRequest, NextResponse } from 'next/server';
522
+
523
+ // Forward recording ID header in Next.js middleware
524
+ // Automatically skipped in production unless TEST_PROXY_RECORDER_ENABLED=true
525
+ function setNextProxyHeaders(
526
+ request: NextRequest,
527
+ response: NextResponse
528
+ ): void;
529
+
530
+ // Get recording ID from request headers
531
+ function getRecordingId(
532
+ requestHeaders: NextRequest | Headers
533
+ ): string | null;
534
+
535
+ // Create headers object with recording ID for fetch requests
536
+ function createHeadersWithRecordingId(
537
+ requestHeaders: NextRequest | Headers,
538
+ additionalHeaders?: Record<string, string>
539
+ ): Record<string, string>;
425
540
  ```
426
541
 
427
542
  ### Control Endpoint
@@ -438,6 +553,8 @@ async function setProxyMode(
438
553
  }
439
554
  ```
440
555
 
556
+ **Note**: Switching to replay mode automatically resets session counters (clears served recordings tracker), allowing replay from the beginning.
557
+
441
558
  **Response**:
442
559
 
443
560
  ```typescript
@@ -1,4 +1,4 @@
1
- import { TestInfo } from '@playwright/test';
1
+ import { TestInfo, Page } from '@playwright/test';
2
2
  import http from 'node:http';
3
3
 
4
4
  declare const Modes: {
@@ -82,20 +82,23 @@ declare function startReplay(testInfo: PlaywrightTestInfo): Promise<void>;
82
82
  declare function stopProxy(testInfo: PlaywrightTestInfo): Promise<void>;
83
83
  /**
84
84
  * Playwright test fixture helper for managing proxy mode
85
- * Use this in beforeEach/afterEach hooks
85
+ * Use this in test functions with page.on('close') for automatic cleanup
86
86
  */
87
87
  declare const playwrightProxy: {
88
88
  /**
89
- * Setup before test - sets the proxy mode
89
+ * Setup before test - sets the proxy mode and configures page with custom header
90
+ * Automatically sets up page.on('close') handler for cleanup
91
+ * @param page - Playwright page object
90
92
  * @param testInfo - Playwright test info object
91
93
  * @param mode - The proxy mode to use for this test
94
+ * @param timeout - Optional timeout in milliseconds
92
95
  */
93
- before(testInfo: PlaywrightTestInfo, mode: Mode): Promise<void>;
96
+ before(page: Page, testInfo: PlaywrightTestInfo, mode: Mode, timeout?: number): Promise<void>;
94
97
  /**
95
- * Cleanup after test - returns to transparent mode
96
- * @param testInfo - Playwright test info object
98
+ * Global teardown - switches proxy to transparent mode
99
+ * Use this in Playwright's globalTeardown to ensure clean state
97
100
  */
98
- after(testInfo: PlaywrightTestInfo): Promise<void>;
101
+ teardown(): Promise<void>;
99
102
  };
100
103
 
101
104
  export { type ControlRequest as C, type Mode as M, type PlaywrightTestInfo as P, type Recording as R, type WebSocketRecording as W, type RecordingSession as a, startRecording as b, startReplay as c, stopProxy as d, generateSessionId as g, playwrightProxy as p, setProxyMode as s };
@@ -1,4 +1,4 @@
1
- import { TestInfo } from '@playwright/test';
1
+ import { TestInfo, Page } from '@playwright/test';
2
2
  import http from 'node:http';
3
3
 
4
4
  declare const Modes: {
@@ -82,20 +82,23 @@ declare function startReplay(testInfo: PlaywrightTestInfo): Promise<void>;
82
82
  declare function stopProxy(testInfo: PlaywrightTestInfo): Promise<void>;
83
83
  /**
84
84
  * Playwright test fixture helper for managing proxy mode
85
- * Use this in beforeEach/afterEach hooks
85
+ * Use this in test functions with page.on('close') for automatic cleanup
86
86
  */
87
87
  declare const playwrightProxy: {
88
88
  /**
89
- * Setup before test - sets the proxy mode
89
+ * Setup before test - sets the proxy mode and configures page with custom header
90
+ * Automatically sets up page.on('close') handler for cleanup
91
+ * @param page - Playwright page object
90
92
  * @param testInfo - Playwright test info object
91
93
  * @param mode - The proxy mode to use for this test
94
+ * @param timeout - Optional timeout in milliseconds
92
95
  */
93
- before(testInfo: PlaywrightTestInfo, mode: Mode): Promise<void>;
96
+ before(page: Page, testInfo: PlaywrightTestInfo, mode: Mode, timeout?: number): Promise<void>;
94
97
  /**
95
- * Cleanup after test - returns to transparent mode
96
- * @param testInfo - Playwright test info object
98
+ * Global teardown - switches proxy to transparent mode
99
+ * Use this in Playwright's globalTeardown to ensure clean state
97
100
  */
98
- after(testInfo: PlaywrightTestInfo): Promise<void>;
101
+ teardown(): Promise<void>;
99
102
  };
100
103
 
101
104
  export { type ControlRequest as C, type Mode as M, type PlaywrightTestInfo as P, type Recording as R, type WebSocketRecording as W, type RecordingSession as a, startRecording as b, startReplay as c, stopProxy as d, generateSessionId as g, playwrightProxy as p, setProxyMode as s };
package/dist/index.cjs CHANGED
@@ -19,8 +19,6 @@ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
19
19
  var path__default = /*#__PURE__*/_interopDefault(path);
20
20
  var filenamify2__default = /*#__PURE__*/_interopDefault(filenamify2);
21
21
 
22
- // src/ProxyServer.ts
23
-
24
22
  // src/constants.ts
25
23
  var DEFAULT_TIMEOUT_MS = 120 * 1e3;
26
24
  var HTTP_STATUS_BAD_GATEWAY = 502;
@@ -28,6 +26,7 @@ var HTTP_STATUS_OK = 200;
28
26
  var HTTP_STATUS_BAD_REQUEST = 400;
29
27
  var HTTP_STATUS_NOT_FOUND = 404;
30
28
  var CONTROL_ENDPOINT = "/__control";
29
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
31
30
 
32
31
  // src/types.ts
33
32
  var Modes = {
@@ -200,7 +199,7 @@ var ProxyServer = class {
200
199
  return {
201
200
  "access-control-allow-origin": origin || "*",
202
201
  "access-control-allow-credentials": "true",
203
- "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
202
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
204
203
  "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
205
204
  "access-control-expose-headers": "*"
206
205
  };
@@ -214,9 +213,22 @@ var ProxyServer = class {
214
213
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
215
214
  return target;
216
215
  }
216
+ /**
217
+ * Extract recording ID from custom HTTP header
218
+ * Used for concurrent replay session routing, especially with Next.js
219
+ * @param req The incoming HTTP request
220
+ * @returns The recording ID from header, or null if not found
221
+ */
222
+ getRecordingIdFromHeader(req) {
223
+ const headerValue = req.headers[RECORDING_ID_HEADER];
224
+ if (!headerValue) {
225
+ return null;
226
+ }
227
+ return Array.isArray(headerValue) ? headerValue[0] : headerValue;
228
+ }
217
229
  /**
218
230
  * Extract recording ID from request cookie
219
- * Used for concurrent replay session routing
231
+ * Used for concurrent replay session routing (fallback method)
220
232
  * @param req The incoming HTTP request
221
233
  * @returns The recording ID from cookie, or null if not found
222
234
  */
@@ -228,6 +240,14 @@ var ProxyServer = class {
228
240
  const match = cookies.match(/proxy-recording-id=([^;]+)/);
229
241
  return match ? decodeURIComponent(match[1]) : null;
230
242
  }
243
+ /**
244
+ * Extract recording ID from request using custom header (preferred) or cookie (fallback)
245
+ * @param req The incoming HTTP request
246
+ * @returns The recording ID, or null if not found
247
+ */
248
+ getRecordingIdFromRequest(req) {
249
+ return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
250
+ }
231
251
  /**
232
252
  * Get or create a replay session state for a given recording ID
233
253
  * @param recordingId The recording ID to get/create session for
@@ -505,7 +525,7 @@ var ProxyServer = class {
505
525
  return true;
506
526
  }
507
527
  async handleReplayRequest(req, res) {
508
- const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
528
+ const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
509
529
  if (!recordingId) {
510
530
  const corsHeaders = this.getCorsHeaders(req);
511
531
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
@@ -529,7 +549,11 @@ var ProxyServer = class {
529
549
  }
530
550
  const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
531
551
  const host = req.headers.host || "unknown";
532
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
552
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
553
+ const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
554
+ const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
555
+ return aSeq - bSeq;
556
+ });
533
557
  if (recordsWithKey.length === 0) {
534
558
  const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
535
559
  console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
@@ -552,7 +576,7 @@ var ProxyServer = class {
552
576
  }
553
577
  const requestCount = servedForThisKey.size + 1;
554
578
  console.log(
555
- `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
579
+ `[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
556
580
  );
557
581
  let record;
558
582
  for (const rec of recordsWithKey) {
@@ -569,7 +593,7 @@ var ProxyServer = class {
569
593
  }
570
594
  servedForThisKey.add(record.recordingId);
571
595
  console.log(
572
- `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
596
+ `[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
573
597
  );
574
598
  if (!record.response) {
575
599
  throw new Error(
@@ -950,28 +974,78 @@ async function stopProxy(testInfo) {
950
974
  }
951
975
  var playwrightProxy = {
952
976
  /**
953
- * Setup before test - sets the proxy mode
977
+ * Setup before test - sets the proxy mode and configures page with custom header
978
+ * Automatically sets up page.on('close') handler for cleanup
979
+ * @param page - Playwright page object
954
980
  * @param testInfo - Playwright test info object
955
981
  * @param mode - The proxy mode to use for this test
982
+ * @param timeout - Optional timeout in milliseconds
956
983
  */
957
- async before(testInfo, mode) {
984
+ async before(page, testInfo, mode, timeout) {
958
985
  const sessionId = generateSessionId(testInfo);
959
- console.log("Proxy setup:", { mode, sessionId });
960
- await setProxyMode(mode, sessionId);
986
+ await page.setExtraHTTPHeaders({
987
+ [RECORDING_ID_HEADER]: sessionId
988
+ });
989
+ await setProxyMode(mode, sessionId, timeout);
990
+ page.on("close", async () => {
991
+ try {
992
+ await setProxyMode(Modes.replay, sessionId);
993
+ console.log(
994
+ `[Cleanup] Switched to transparent mode for session: ${sessionId}`
995
+ );
996
+ } catch (error) {
997
+ console.error("[Cleanup] Error during page close cleanup:", error);
998
+ }
999
+ });
961
1000
  },
962
1001
  /**
963
- * Cleanup after test - returns to transparent mode
964
- * @param testInfo - Playwright test info object
1002
+ * Global teardown - switches proxy to transparent mode
1003
+ * Use this in Playwright's globalTeardown to ensure clean state
965
1004
  */
966
- async after(testInfo) {
967
- const sessionId = generateSessionId(testInfo);
968
- await setProxyMode(Modes.transparent, sessionId);
1005
+ async teardown() {
1006
+ await setProxyMode(Modes.transparent);
969
1007
  }
970
1008
  };
971
1009
 
1010
+ // src/nextjs/middleware.ts
1011
+ function isRecorderEnabled() {
1012
+ const isProduction = process.env.NODE_ENV === "production";
1013
+ const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
1014
+ return !isProduction || isExplicitlyEnabled;
1015
+ }
1016
+ function setNextProxyHeaders(request, response) {
1017
+ if (!isRecorderEnabled()) {
1018
+ return;
1019
+ }
1020
+ const recordingId = request.headers.get(RECORDING_ID_HEADER);
1021
+ if (recordingId) {
1022
+ response.headers.set(RECORDING_ID_HEADER, recordingId);
1023
+ }
1024
+ }
1025
+ function getRecordingId(requestHeaders) {
1026
+ if (requestHeaders instanceof Headers) {
1027
+ return requestHeaders.get(RECORDING_ID_HEADER);
1028
+ }
1029
+ return requestHeaders.headers.get(RECORDING_ID_HEADER);
1030
+ }
1031
+ function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
1032
+ if (!isRecorderEnabled()) {
1033
+ return additionalHeaders;
1034
+ }
1035
+ const recordingId = getRecordingId(requestHeaders);
1036
+ return {
1037
+ ...additionalHeaders,
1038
+ ...recordingId && { [RECORDING_ID_HEADER]: recordingId }
1039
+ };
1040
+ }
1041
+
972
1042
  exports.ProxyServer = ProxyServer;
1043
+ exports.RECORDING_ID_HEADER = RECORDING_ID_HEADER;
1044
+ exports.createHeadersWithRecordingId = createHeadersWithRecordingId;
973
1045
  exports.generateSessionId = generateSessionId;
1046
+ exports.getRecordingId = getRecordingId;
974
1047
  exports.playwrightProxy = playwrightProxy;
1048
+ exports.setNextProxyHeaders = setNextProxyHeaders;
975
1049
  exports.setProxyMode = setProxyMode;
976
1050
  exports.startRecording = startRecording;
977
1051
  exports.startReplay = startReplay;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,6 @@
1
+ export { RECORDING_ID_HEADER, createHeadersWithRecordingId, getRecordingId, setNextProxyHeaders } from './nextjs/index.cjs';
1
2
  import http from 'node:http';
2
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-Cx_Kflfl.cjs';
3
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CVuiglPk.cjs';
3
4
  import '@playwright/test';
4
5
 
5
6
  declare class ProxyServer {
@@ -28,13 +29,26 @@ declare class ProxyServer {
28
29
  private getCorsHeaders;
29
30
  private addCorsHeaders;
30
31
  private getTarget;
32
+ /**
33
+ * Extract recording ID from custom HTTP header
34
+ * Used for concurrent replay session routing, especially with Next.js
35
+ * @param req The incoming HTTP request
36
+ * @returns The recording ID from header, or null if not found
37
+ */
38
+ private getRecordingIdFromHeader;
31
39
  /**
32
40
  * Extract recording ID from request cookie
33
- * Used for concurrent replay session routing
41
+ * Used for concurrent replay session routing (fallback method)
34
42
  * @param req The incoming HTTP request
35
43
  * @returns The recording ID from cookie, or null if not found
36
44
  */
37
45
  private getRecordingIdFromCookie;
46
+ /**
47
+ * Extract recording ID from request using custom header (preferred) or cookie (fallback)
48
+ * @param req The incoming HTTP request
49
+ * @returns The recording ID, or null if not found
50
+ */
51
+ private getRecordingIdFromRequest;
38
52
  /**
39
53
  * Get or create a replay session state for a given recording ID
40
54
  * @param recordingId The recording ID to get/create session for
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
+ export { RECORDING_ID_HEADER, createHeadersWithRecordingId, getRecordingId, setNextProxyHeaders } from './nextjs/index.js';
1
2
  import http from 'node:http';
2
- export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-Cx_Kflfl.js';
3
+ export { C as ControlRequest, M as Mode, P as PlaywrightTestInfo, R as Recording, a as RecordingSession, W as WebSocketRecording, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from './index-CVuiglPk.js';
3
4
  import '@playwright/test';
4
5
 
5
6
  declare class ProxyServer {
@@ -28,13 +29,26 @@ declare class ProxyServer {
28
29
  private getCorsHeaders;
29
30
  private addCorsHeaders;
30
31
  private getTarget;
32
+ /**
33
+ * Extract recording ID from custom HTTP header
34
+ * Used for concurrent replay session routing, especially with Next.js
35
+ * @param req The incoming HTTP request
36
+ * @returns The recording ID from header, or null if not found
37
+ */
38
+ private getRecordingIdFromHeader;
31
39
  /**
32
40
  * Extract recording ID from request cookie
33
- * Used for concurrent replay session routing
41
+ * Used for concurrent replay session routing (fallback method)
34
42
  * @param req The incoming HTTP request
35
43
  * @returns The recording ID from cookie, or null if not found
36
44
  */
37
45
  private getRecordingIdFromCookie;
46
+ /**
47
+ * Extract recording ID from request using custom header (preferred) or cookie (fallback)
48
+ * @param req The incoming HTTP request
49
+ * @returns The recording ID, or null if not found
50
+ */
51
+ private getRecordingIdFromRequest;
38
52
  /**
39
53
  * Get or create a replay session state for a given recording ID
40
54
  * @param recordingId The recording ID to get/create session for
package/dist/index.mjs CHANGED
@@ -7,8 +7,6 @@ import crypto from 'crypto';
7
7
  import path from 'path';
8
8
  import filenamify2 from 'filenamify';
9
9
 
10
- // src/ProxyServer.ts
11
-
12
10
  // src/constants.ts
13
11
  var DEFAULT_TIMEOUT_MS = 120 * 1e3;
14
12
  var HTTP_STATUS_BAD_GATEWAY = 502;
@@ -16,6 +14,7 @@ var HTTP_STATUS_OK = 200;
16
14
  var HTTP_STATUS_BAD_REQUEST = 400;
17
15
  var HTTP_STATUS_NOT_FOUND = 404;
18
16
  var CONTROL_ENDPOINT = "/__control";
17
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
19
18
 
20
19
  // src/types.ts
21
20
  var Modes = {
@@ -188,7 +187,7 @@ var ProxyServer = class {
188
187
  return {
189
188
  "access-control-allow-origin": origin || "*",
190
189
  "access-control-allow-credentials": "true",
191
- "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
190
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
192
191
  "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
193
192
  "access-control-expose-headers": "*"
194
193
  };
@@ -202,9 +201,22 @@ var ProxyServer = class {
202
201
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
203
202
  return target;
204
203
  }
204
+ /**
205
+ * Extract recording ID from custom HTTP header
206
+ * Used for concurrent replay session routing, especially with Next.js
207
+ * @param req The incoming HTTP request
208
+ * @returns The recording ID from header, or null if not found
209
+ */
210
+ getRecordingIdFromHeader(req) {
211
+ const headerValue = req.headers[RECORDING_ID_HEADER];
212
+ if (!headerValue) {
213
+ return null;
214
+ }
215
+ return Array.isArray(headerValue) ? headerValue[0] : headerValue;
216
+ }
205
217
  /**
206
218
  * Extract recording ID from request cookie
207
- * Used for concurrent replay session routing
219
+ * Used for concurrent replay session routing (fallback method)
208
220
  * @param req The incoming HTTP request
209
221
  * @returns The recording ID from cookie, or null if not found
210
222
  */
@@ -216,6 +228,14 @@ var ProxyServer = class {
216
228
  const match = cookies.match(/proxy-recording-id=([^;]+)/);
217
229
  return match ? decodeURIComponent(match[1]) : null;
218
230
  }
231
+ /**
232
+ * Extract recording ID from request using custom header (preferred) or cookie (fallback)
233
+ * @param req The incoming HTTP request
234
+ * @returns The recording ID, or null if not found
235
+ */
236
+ getRecordingIdFromRequest(req) {
237
+ return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
238
+ }
219
239
  /**
220
240
  * Get or create a replay session state for a given recording ID
221
241
  * @param recordingId The recording ID to get/create session for
@@ -493,7 +513,7 @@ var ProxyServer = class {
493
513
  return true;
494
514
  }
495
515
  async handleReplayRequest(req, res) {
496
- const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
516
+ const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
497
517
  if (!recordingId) {
498
518
  const corsHeaders = this.getCorsHeaders(req);
499
519
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
@@ -517,7 +537,11 @@ var ProxyServer = class {
517
537
  }
518
538
  const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
519
539
  const host = req.headers.host || "unknown";
520
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
540
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
541
+ const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
542
+ const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
543
+ return aSeq - bSeq;
544
+ });
521
545
  if (recordsWithKey.length === 0) {
522
546
  const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
523
547
  console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
@@ -540,7 +564,7 @@ var ProxyServer = class {
540
564
  }
541
565
  const requestCount = servedForThisKey.size + 1;
542
566
  console.log(
543
- `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
567
+ `[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
544
568
  );
545
569
  let record;
546
570
  for (const rec of recordsWithKey) {
@@ -557,7 +581,7 @@ var ProxyServer = class {
557
581
  }
558
582
  servedForThisKey.add(record.recordingId);
559
583
  console.log(
560
- `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
584
+ `[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
561
585
  );
562
586
  if (!record.response) {
563
587
  throw new Error(
@@ -938,25 +962,71 @@ async function stopProxy(testInfo) {
938
962
  }
939
963
  var playwrightProxy = {
940
964
  /**
941
- * Setup before test - sets the proxy mode
965
+ * Setup before test - sets the proxy mode and configures page with custom header
966
+ * Automatically sets up page.on('close') handler for cleanup
967
+ * @param page - Playwright page object
942
968
  * @param testInfo - Playwright test info object
943
969
  * @param mode - The proxy mode to use for this test
970
+ * @param timeout - Optional timeout in milliseconds
944
971
  */
945
- async before(testInfo, mode) {
972
+ async before(page, testInfo, mode, timeout) {
946
973
  const sessionId = generateSessionId(testInfo);
947
- console.log("Proxy setup:", { mode, sessionId });
948
- await setProxyMode(mode, sessionId);
974
+ await page.setExtraHTTPHeaders({
975
+ [RECORDING_ID_HEADER]: sessionId
976
+ });
977
+ await setProxyMode(mode, sessionId, timeout);
978
+ page.on("close", async () => {
979
+ try {
980
+ await setProxyMode(Modes.replay, sessionId);
981
+ console.log(
982
+ `[Cleanup] Switched to transparent mode for session: ${sessionId}`
983
+ );
984
+ } catch (error) {
985
+ console.error("[Cleanup] Error during page close cleanup:", error);
986
+ }
987
+ });
949
988
  },
950
989
  /**
951
- * Cleanup after test - returns to transparent mode
952
- * @param testInfo - Playwright test info object
990
+ * Global teardown - switches proxy to transparent mode
991
+ * Use this in Playwright's globalTeardown to ensure clean state
953
992
  */
954
- async after(testInfo) {
955
- const sessionId = generateSessionId(testInfo);
956
- await setProxyMode(Modes.transparent, sessionId);
993
+ async teardown() {
994
+ await setProxyMode(Modes.transparent);
957
995
  }
958
996
  };
959
997
 
960
- export { ProxyServer, generateSessionId, playwrightProxy, setProxyMode, startRecording, startReplay, stopProxy };
998
+ // src/nextjs/middleware.ts
999
+ function isRecorderEnabled() {
1000
+ const isProduction = process.env.NODE_ENV === "production";
1001
+ const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
1002
+ return !isProduction || isExplicitlyEnabled;
1003
+ }
1004
+ function setNextProxyHeaders(request, response) {
1005
+ if (!isRecorderEnabled()) {
1006
+ return;
1007
+ }
1008
+ const recordingId = request.headers.get(RECORDING_ID_HEADER);
1009
+ if (recordingId) {
1010
+ response.headers.set(RECORDING_ID_HEADER, recordingId);
1011
+ }
1012
+ }
1013
+ function getRecordingId(requestHeaders) {
1014
+ if (requestHeaders instanceof Headers) {
1015
+ return requestHeaders.get(RECORDING_ID_HEADER);
1016
+ }
1017
+ return requestHeaders.headers.get(RECORDING_ID_HEADER);
1018
+ }
1019
+ function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
1020
+ if (!isRecorderEnabled()) {
1021
+ return additionalHeaders;
1022
+ }
1023
+ const recordingId = getRecordingId(requestHeaders);
1024
+ return {
1025
+ ...additionalHeaders,
1026
+ ...recordingId && { [RECORDING_ID_HEADER]: recordingId }
1027
+ };
1028
+ }
1029
+
1030
+ export { ProxyServer, RECORDING_ID_HEADER, createHeadersWithRecordingId, generateSessionId, getRecordingId, playwrightProxy, setNextProxyHeaders, setProxyMode, startRecording, startReplay, stopProxy };
961
1031
  //# sourceMappingURL=index.mjs.map
962
1032
  //# sourceMappingURL=index.mjs.map
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // src/constants.ts
4
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
5
+
6
+ // src/nextjs/middleware.ts
7
+ function isRecorderEnabled() {
8
+ const isProduction = process.env.NODE_ENV === "production";
9
+ const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
10
+ return !isProduction || isExplicitlyEnabled;
11
+ }
12
+ function setNextProxyHeaders(request, response) {
13
+ if (!isRecorderEnabled()) {
14
+ return;
15
+ }
16
+ const recordingId = request.headers.get(RECORDING_ID_HEADER);
17
+ if (recordingId) {
18
+ response.headers.set(RECORDING_ID_HEADER, recordingId);
19
+ }
20
+ }
21
+ function getRecordingId(requestHeaders) {
22
+ if (requestHeaders instanceof Headers) {
23
+ return requestHeaders.get(RECORDING_ID_HEADER);
24
+ }
25
+ return requestHeaders.headers.get(RECORDING_ID_HEADER);
26
+ }
27
+ function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
28
+ if (!isRecorderEnabled()) {
29
+ return additionalHeaders;
30
+ }
31
+ const recordingId = getRecordingId(requestHeaders);
32
+ return {
33
+ ...additionalHeaders,
34
+ ...recordingId && { [RECORDING_ID_HEADER]: recordingId }
35
+ };
36
+ }
37
+
38
+ exports.RECORDING_ID_HEADER = RECORDING_ID_HEADER;
39
+ exports.createHeadersWithRecordingId = createHeadersWithRecordingId;
40
+ exports.getRecordingId = getRecordingId;
41
+ exports.setNextProxyHeaders = setNextProxyHeaders;
42
+ //# sourceMappingURL=index.cjs.map
43
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1,89 @@
1
+ declare const RECORDING_ID_HEADER = "x-test-rcrd-id";
2
+
3
+ /**
4
+ * Minimal type for Next.js Request - compatible with next/server's NextRequest
5
+ * We define this locally to avoid requiring Next.js as a dependency
6
+ */
7
+ interface NextJSRequest {
8
+ headers: Headers;
9
+ }
10
+ /**
11
+ * Minimal type for Next.js Response - compatible with next/server's NextResponse
12
+ * We define this locally to avoid requiring Next.js as a dependency
13
+ */
14
+ interface NextJSResponse {
15
+ headers: Headers;
16
+ }
17
+ /**
18
+ * Next.js middleware helper for forwarding test proxy recording headers
19
+ * Automatically forwards the recording ID header from incoming requests to the proxy
20
+ * Only runs in non-production environments or when TEST_PROXY_RECORDER_ENABLED is set
21
+ *
22
+ * @example
23
+ * // middleware.ts
24
+ * import { NextResponse } from 'next/server';
25
+ * import type { NextRequest } from 'next/server';
26
+ * import { setNextProxyHeaders } from 'test-proxy-recorder/nextjs';
27
+ *
28
+ * export function middleware(request: NextRequest) {
29
+ * const response = NextResponse.next();
30
+ * // Only forwards headers in test/dev environments
31
+ * setNextProxyHeaders(request, response);
32
+ * return response;
33
+ * }
34
+ *
35
+ * @param request - Next.js request object (NextRequest from next/server)
36
+ * @param response - Next.js response object (NextResponse from next/server)
37
+ */
38
+ declare function setNextProxyHeaders(request: NextJSRequest, response: NextJSResponse): void;
39
+ /**
40
+ * Get the recording ID from the request if present
41
+ * Useful for manually adding the header to fetch requests in Next.js
42
+ *
43
+ * @example
44
+ * // In your API route or server component
45
+ * import { getRecordingId } from 'test-proxy-recorder/nextjs';
46
+ * import { headers } from 'next/headers';
47
+ *
48
+ * export async function GET() {
49
+ * const recordingId = getRecordingId(headers());
50
+ *
51
+ * const response = await fetch('http://localhost:8100/api/data', {
52
+ * headers: {
53
+ * ...(recordingId && { 'x-test-rcrd-id': recordingId })
54
+ * }
55
+ * });
56
+ *
57
+ * return Response.json(await response.json());
58
+ * }
59
+ *
60
+ * @param requestHeaders - Next.js headers object or NextRequest from next/server
61
+ * @returns The recording ID if present, null otherwise
62
+ */
63
+ declare function getRecordingId(requestHeaders: NextJSRequest | Headers): string | null;
64
+ /**
65
+ * Create headers object with recording ID for fetch requests
66
+ * Use this helper when making fetch requests in Next.js to forward the recording ID
67
+ *
68
+ * @example
69
+ * // In your API route or server component
70
+ * import { createHeadersWithRecordingId } from 'test-proxy-recorder/nextjs';
71
+ * import { headers } from 'next/headers';
72
+ *
73
+ * export async function GET() {
74
+ * const response = await fetch('http://localhost:8100/api/data', {
75
+ * headers: createHeadersWithRecordingId(headers(), {
76
+ * 'Content-Type': 'application/json',
77
+ * })
78
+ * });
79
+ *
80
+ * return Response.json(await response.json());
81
+ * }
82
+ *
83
+ * @param requestHeaders - Next.js headers object or NextRequest from next/server
84
+ * @param additionalHeaders - Optional additional headers to include
85
+ * @returns Headers object with recording ID if present
86
+ */
87
+ declare function createHeadersWithRecordingId(requestHeaders: NextJSRequest | Headers, additionalHeaders?: Record<string, string>): Record<string, string>;
88
+
89
+ export { type NextJSRequest, type NextJSResponse, RECORDING_ID_HEADER, createHeadersWithRecordingId, getRecordingId, setNextProxyHeaders };
@@ -0,0 +1,89 @@
1
+ declare const RECORDING_ID_HEADER = "x-test-rcrd-id";
2
+
3
+ /**
4
+ * Minimal type for Next.js Request - compatible with next/server's NextRequest
5
+ * We define this locally to avoid requiring Next.js as a dependency
6
+ */
7
+ interface NextJSRequest {
8
+ headers: Headers;
9
+ }
10
+ /**
11
+ * Minimal type for Next.js Response - compatible with next/server's NextResponse
12
+ * We define this locally to avoid requiring Next.js as a dependency
13
+ */
14
+ interface NextJSResponse {
15
+ headers: Headers;
16
+ }
17
+ /**
18
+ * Next.js middleware helper for forwarding test proxy recording headers
19
+ * Automatically forwards the recording ID header from incoming requests to the proxy
20
+ * Only runs in non-production environments or when TEST_PROXY_RECORDER_ENABLED is set
21
+ *
22
+ * @example
23
+ * // middleware.ts
24
+ * import { NextResponse } from 'next/server';
25
+ * import type { NextRequest } from 'next/server';
26
+ * import { setNextProxyHeaders } from 'test-proxy-recorder/nextjs';
27
+ *
28
+ * export function middleware(request: NextRequest) {
29
+ * const response = NextResponse.next();
30
+ * // Only forwards headers in test/dev environments
31
+ * setNextProxyHeaders(request, response);
32
+ * return response;
33
+ * }
34
+ *
35
+ * @param request - Next.js request object (NextRequest from next/server)
36
+ * @param response - Next.js response object (NextResponse from next/server)
37
+ */
38
+ declare function setNextProxyHeaders(request: NextJSRequest, response: NextJSResponse): void;
39
+ /**
40
+ * Get the recording ID from the request if present
41
+ * Useful for manually adding the header to fetch requests in Next.js
42
+ *
43
+ * @example
44
+ * // In your API route or server component
45
+ * import { getRecordingId } from 'test-proxy-recorder/nextjs';
46
+ * import { headers } from 'next/headers';
47
+ *
48
+ * export async function GET() {
49
+ * const recordingId = getRecordingId(headers());
50
+ *
51
+ * const response = await fetch('http://localhost:8100/api/data', {
52
+ * headers: {
53
+ * ...(recordingId && { 'x-test-rcrd-id': recordingId })
54
+ * }
55
+ * });
56
+ *
57
+ * return Response.json(await response.json());
58
+ * }
59
+ *
60
+ * @param requestHeaders - Next.js headers object or NextRequest from next/server
61
+ * @returns The recording ID if present, null otherwise
62
+ */
63
+ declare function getRecordingId(requestHeaders: NextJSRequest | Headers): string | null;
64
+ /**
65
+ * Create headers object with recording ID for fetch requests
66
+ * Use this helper when making fetch requests in Next.js to forward the recording ID
67
+ *
68
+ * @example
69
+ * // In your API route or server component
70
+ * import { createHeadersWithRecordingId } from 'test-proxy-recorder/nextjs';
71
+ * import { headers } from 'next/headers';
72
+ *
73
+ * export async function GET() {
74
+ * const response = await fetch('http://localhost:8100/api/data', {
75
+ * headers: createHeadersWithRecordingId(headers(), {
76
+ * 'Content-Type': 'application/json',
77
+ * })
78
+ * });
79
+ *
80
+ * return Response.json(await response.json());
81
+ * }
82
+ *
83
+ * @param requestHeaders - Next.js headers object or NextRequest from next/server
84
+ * @param additionalHeaders - Optional additional headers to include
85
+ * @returns Headers object with recording ID if present
86
+ */
87
+ declare function createHeadersWithRecordingId(requestHeaders: NextJSRequest | Headers, additionalHeaders?: Record<string, string>): Record<string, string>;
88
+
89
+ export { type NextJSRequest, type NextJSResponse, RECORDING_ID_HEADER, createHeadersWithRecordingId, getRecordingId, setNextProxyHeaders };
@@ -0,0 +1,38 @@
1
+ // src/constants.ts
2
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
3
+
4
+ // src/nextjs/middleware.ts
5
+ function isRecorderEnabled() {
6
+ const isProduction = process.env.NODE_ENV === "production";
7
+ const isExplicitlyEnabled = process.env.TEST_PROXY_RECORDER_ENABLED === "true" || Number.parseInt(process.env.TEST_PROXY_RECORDER_ENABLED || "") === 1;
8
+ return !isProduction || isExplicitlyEnabled;
9
+ }
10
+ function setNextProxyHeaders(request, response) {
11
+ if (!isRecorderEnabled()) {
12
+ return;
13
+ }
14
+ const recordingId = request.headers.get(RECORDING_ID_HEADER);
15
+ if (recordingId) {
16
+ response.headers.set(RECORDING_ID_HEADER, recordingId);
17
+ }
18
+ }
19
+ function getRecordingId(requestHeaders) {
20
+ if (requestHeaders instanceof Headers) {
21
+ return requestHeaders.get(RECORDING_ID_HEADER);
22
+ }
23
+ return requestHeaders.headers.get(RECORDING_ID_HEADER);
24
+ }
25
+ function createHeadersWithRecordingId(requestHeaders, additionalHeaders = {}) {
26
+ if (!isRecorderEnabled()) {
27
+ return additionalHeaders;
28
+ }
29
+ const recordingId = getRecordingId(requestHeaders);
30
+ return {
31
+ ...additionalHeaders,
32
+ ...recordingId && { [RECORDING_ID_HEADER]: recordingId }
33
+ };
34
+ }
35
+
36
+ export { RECORDING_ID_HEADER, createHeadersWithRecordingId, getRecordingId, setNextProxyHeaders };
37
+ //# sourceMappingURL=index.mjs.map
38
+ //# sourceMappingURL=index.mjs.map
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ // src/constants.ts
4
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
5
+
3
6
  // src/types.ts
4
7
  var Modes = {
5
8
  transparent: "transparent",
@@ -86,22 +89,36 @@ async function stopProxy(testInfo) {
86
89
  }
87
90
  var playwrightProxy = {
88
91
  /**
89
- * Setup before test - sets the proxy mode
92
+ * Setup before test - sets the proxy mode and configures page with custom header
93
+ * Automatically sets up page.on('close') handler for cleanup
94
+ * @param page - Playwright page object
90
95
  * @param testInfo - Playwright test info object
91
96
  * @param mode - The proxy mode to use for this test
97
+ * @param timeout - Optional timeout in milliseconds
92
98
  */
93
- async before(testInfo, mode) {
99
+ async before(page, testInfo, mode, timeout) {
94
100
  const sessionId = generateSessionId(testInfo);
95
- console.log("Proxy setup:", { mode, sessionId });
96
- await setProxyMode(mode, sessionId);
101
+ await page.setExtraHTTPHeaders({
102
+ [RECORDING_ID_HEADER]: sessionId
103
+ });
104
+ await setProxyMode(mode, sessionId, timeout);
105
+ page.on("close", async () => {
106
+ try {
107
+ await setProxyMode(Modes.replay, sessionId);
108
+ console.log(
109
+ `[Cleanup] Switched to transparent mode for session: ${sessionId}`
110
+ );
111
+ } catch (error) {
112
+ console.error("[Cleanup] Error during page close cleanup:", error);
113
+ }
114
+ });
97
115
  },
98
116
  /**
99
- * Cleanup after test - returns to transparent mode
100
- * @param testInfo - Playwright test info object
117
+ * Global teardown - switches proxy to transparent mode
118
+ * Use this in Playwright's globalTeardown to ensure clean state
101
119
  */
102
- async after(testInfo) {
103
- const sessionId = generateSessionId(testInfo);
104
- await setProxyMode(Modes.transparent, sessionId);
120
+ async teardown() {
121
+ await setProxyMode(Modes.transparent);
105
122
  }
106
123
  };
107
124
 
@@ -1,3 +1,3 @@
1
1
  import '@playwright/test';
2
- export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-Cx_Kflfl.cjs';
2
+ export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CVuiglPk.cjs';
3
3
  import 'node:http';
@@ -1,3 +1,3 @@
1
1
  import '@playwright/test';
2
- export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-Cx_Kflfl.js';
2
+ export { P as PlaywrightTestInfo, g as generateSessionId, p as playwrightProxy, s as setProxyMode, b as startRecording, c as startReplay, d as stopProxy } from '../index-CVuiglPk.js';
3
3
  import 'node:http';
@@ -1,3 +1,6 @@
1
+ // src/constants.ts
2
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
3
+
1
4
  // src/types.ts
2
5
  var Modes = {
3
6
  transparent: "transparent",
@@ -84,22 +87,36 @@ async function stopProxy(testInfo) {
84
87
  }
85
88
  var playwrightProxy = {
86
89
  /**
87
- * Setup before test - sets the proxy mode
90
+ * Setup before test - sets the proxy mode and configures page with custom header
91
+ * Automatically sets up page.on('close') handler for cleanup
92
+ * @param page - Playwright page object
88
93
  * @param testInfo - Playwright test info object
89
94
  * @param mode - The proxy mode to use for this test
95
+ * @param timeout - Optional timeout in milliseconds
90
96
  */
91
- async before(testInfo, mode) {
97
+ async before(page, testInfo, mode, timeout) {
92
98
  const sessionId = generateSessionId(testInfo);
93
- console.log("Proxy setup:", { mode, sessionId });
94
- await setProxyMode(mode, sessionId);
99
+ await page.setExtraHTTPHeaders({
100
+ [RECORDING_ID_HEADER]: sessionId
101
+ });
102
+ await setProxyMode(mode, sessionId, timeout);
103
+ page.on("close", async () => {
104
+ try {
105
+ await setProxyMode(Modes.replay, sessionId);
106
+ console.log(
107
+ `[Cleanup] Switched to transparent mode for session: ${sessionId}`
108
+ );
109
+ } catch (error) {
110
+ console.error("[Cleanup] Error during page close cleanup:", error);
111
+ }
112
+ });
95
113
  },
96
114
  /**
97
- * Cleanup after test - returns to transparent mode
98
- * @param testInfo - Playwright test info object
115
+ * Global teardown - switches proxy to transparent mode
116
+ * Use this in Playwright's globalTeardown to ensure clean state
99
117
  */
100
- async after(testInfo) {
101
- const sessionId = generateSessionId(testInfo);
102
- await setProxyMode(Modes.transparent, sessionId);
118
+ async teardown() {
119
+ await setProxyMode(Modes.transparent);
103
120
  }
104
121
  };
105
122
 
package/dist/proxy.js CHANGED
@@ -50,6 +50,7 @@ var HTTP_STATUS_OK = 200;
50
50
  var HTTP_STATUS_BAD_REQUEST = 400;
51
51
  var HTTP_STATUS_NOT_FOUND = 404;
52
52
  var CONTROL_ENDPOINT = "/__control";
53
+ var RECORDING_ID_HEADER = "x-test-rcrd-id";
53
54
 
54
55
  // src/types.ts
55
56
  var Modes = {
@@ -222,7 +223,7 @@ var ProxyServer = class {
222
223
  return {
223
224
  "access-control-allow-origin": origin || "*",
224
225
  "access-control-allow-credentials": "true",
225
- "access-control-allow-headers": req.headers["access-control-request-headers"] || "Origin, X-Requested-With, Content-Type, Accept, Authorization",
226
+ "access-control-allow-headers": req.headers["access-control-request-headers"] || `Origin, X-Requested-With, Content-Type, Accept, Authorization, ${RECORDING_ID_HEADER}`,
226
227
  "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
227
228
  "access-control-expose-headers": "*"
228
229
  };
@@ -236,9 +237,22 @@ var ProxyServer = class {
236
237
  this.currentTargetIndex = (this.currentTargetIndex + 1) % this.targets.length;
237
238
  return target;
238
239
  }
240
+ /**
241
+ * Extract recording ID from custom HTTP header
242
+ * Used for concurrent replay session routing, especially with Next.js
243
+ * @param req The incoming HTTP request
244
+ * @returns The recording ID from header, or null if not found
245
+ */
246
+ getRecordingIdFromHeader(req) {
247
+ const headerValue = req.headers[RECORDING_ID_HEADER];
248
+ if (!headerValue) {
249
+ return null;
250
+ }
251
+ return Array.isArray(headerValue) ? headerValue[0] : headerValue;
252
+ }
239
253
  /**
240
254
  * Extract recording ID from request cookie
241
- * Used for concurrent replay session routing
255
+ * Used for concurrent replay session routing (fallback method)
242
256
  * @param req The incoming HTTP request
243
257
  * @returns The recording ID from cookie, or null if not found
244
258
  */
@@ -250,6 +264,14 @@ var ProxyServer = class {
250
264
  const match = cookies.match(/proxy-recording-id=([^;]+)/);
251
265
  return match ? decodeURIComponent(match[1]) : null;
252
266
  }
267
+ /**
268
+ * Extract recording ID from request using custom header (preferred) or cookie (fallback)
269
+ * @param req The incoming HTTP request
270
+ * @returns The recording ID, or null if not found
271
+ */
272
+ getRecordingIdFromRequest(req) {
273
+ return this.getRecordingIdFromHeader(req) || this.getRecordingIdFromCookie(req);
274
+ }
253
275
  /**
254
276
  * Get or create a replay session state for a given recording ID
255
277
  * @param recordingId The recording ID to get/create session for
@@ -527,7 +549,7 @@ var ProxyServer = class {
527
549
  return true;
528
550
  }
529
551
  async handleReplayRequest(req, res) {
530
- const recordingId = this.getRecordingIdFromCookie(req) || this.replayId;
552
+ const recordingId = this.getRecordingIdFromRequest(req) || this.replayId;
531
553
  if (!recordingId) {
532
554
  const corsHeaders = this.getCorsHeaders(req);
533
555
  res.writeHead(HTTP_STATUS_BAD_REQUEST, {
@@ -551,7 +573,11 @@ var ProxyServer = class {
551
573
  }
552
574
  const servedForThisKey = sessionState.servedRecordingIdsByKey.get(key);
553
575
  const host = req.headers.host || "unknown";
554
- const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => a.recordingId - b.recordingId);
576
+ const recordsWithKey = session.recordings.filter((r) => r.key === key && r.response).toSorted((a, b) => {
577
+ const aSeq = a.sequence !== void 0 ? a.sequence : a.recordingId;
578
+ const bSeq = b.sequence !== void 0 ? b.sequence : b.recordingId;
579
+ return aSeq - bSeq;
580
+ });
555
581
  if (recordsWithKey.length === 0) {
556
582
  const errorMsg = `No recording found for ${key} at ${req.method} ${host}${req.url}`;
557
583
  console.error(`[REPLAY ERROR] ${errorMsg} (session: ${recordingId})`);
@@ -574,7 +600,7 @@ var ProxyServer = class {
574
600
  }
575
601
  const requestCount = servedForThisKey.size + 1;
576
602
  console.log(
577
- `[REPLAY REQUEST #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
603
+ `[replay request #${requestCount}] ${req.method} ${req.url} (session: ${recordingId}, total: ${recordsWithKey.length}, served: ${servedForThisKey.size})`
578
604
  );
579
605
  let record;
580
606
  for (const rec of recordsWithKey) {
@@ -591,7 +617,7 @@ var ProxyServer = class {
591
617
  }
592
618
  servedForThisKey.add(record.recordingId);
593
619
  console.log(
594
- `[REPLAY SERVING] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
620
+ `[replay serving] recordingId: ${record.recordingId}, session: ${recordingId}, body_len: ${record.response?.body?.length || 0}`
595
621
  );
596
622
  if (!record.response) {
597
623
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-proxy-recorder",
3
- "version": "0.1.11",
3
+ "version": "0.3.0",
4
4
  "description": "HTTP proxy server for recording and replaying network requests in testing. Works seamlessly with Playwright testing framework.",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -15,6 +15,11 @@
15
15
  "require": "./dist/playwright/index.cjs",
16
16
  "types": "./dist/playwright/index.d.ts",
17
17
  "default": "./dist/playwright/index.mjs"
18
+ },
19
+ "./nextjs": {
20
+ "require": "./dist/nextjs/index.cjs",
21
+ "types": "./dist/nextjs/index.d.ts",
22
+ "default": "./dist/nextjs/index.mjs"
18
23
  }
19
24
  },
20
25
  "bin": {