@replayio-app-building/netlify-recorder 0.33.0 → 0.35.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 +82 -2
- package/dist/index.d.ts +63 -14
- package/dist/index.js +429 -156
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,7 +113,63 @@ export default createRecordingRequestHandler(
|
|
|
113
113
|
|
|
114
114
|
> **Note:** Always use the response returned by the wrapper (or `finishRequest`), not your original response object. The wrapper adds the `X-Replay-Request-Id` header to the response it returns.
|
|
115
115
|
|
|
116
|
-
### 4.
|
|
116
|
+
### 4. Expose a recording endpoint for other services
|
|
117
|
+
|
|
118
|
+
Use `createRecordingEndpoint` to create a standalone Netlify function that other services can call to trigger recording creation or check recording status. This is the simplest way to let external services interact with the `backend_requests` table without importing the full package.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// netlify/functions/ensure-recording.ts
|
|
122
|
+
import { createRecordingEndpoint } from "@replayio-app-building/netlify-recorder";
|
|
123
|
+
import { neon } from "@neondatabase/serverless";
|
|
124
|
+
|
|
125
|
+
const sql = neon(process.env.DATABASE_URL!);
|
|
126
|
+
|
|
127
|
+
export default createRecordingEndpoint({
|
|
128
|
+
sql,
|
|
129
|
+
recorderUrl: "https://netlify-recorder-bm4wmw.netlify.app",
|
|
130
|
+
// Optional: require callers to authenticate with a shared secret
|
|
131
|
+
secret: process.env.RECORDER_ENDPOINT_SECRET,
|
|
132
|
+
// Optional: receive a webhook when the recording completes
|
|
133
|
+
webhookUrl: "https://my-app.netlify.app/.netlify/functions/recording-webhook",
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Calling the endpoint from another service:**
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// Trigger recording creation (POST)
|
|
141
|
+
const res = await fetch("https://my-app.netlify.app/.netlify/functions/ensure-recording", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
// Include if `secret` is configured:
|
|
146
|
+
"Authorization": "Bearer my-shared-secret",
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({ requestId: "a1b2c3d4-..." }),
|
|
149
|
+
});
|
|
150
|
+
const result = await res.json();
|
|
151
|
+
// { status: "queued", requestId: "a1b2c3d4-..." }
|
|
152
|
+
// or { status: "recorded", recordingId: "...", requestId: "..." }
|
|
153
|
+
// or { status: "pending", requestId: "..." }
|
|
154
|
+
|
|
155
|
+
// Check status without triggering (GET)
|
|
156
|
+
const status = await fetch(
|
|
157
|
+
"https://my-app.netlify.app/.netlify/functions/ensure-recording?requestId=a1b2c3d4-...",
|
|
158
|
+
{ headers: { "Authorization": "Bearer my-shared-secret" } },
|
|
159
|
+
);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Response statuses:**
|
|
163
|
+
|
|
164
|
+
| Status | HTTP Code | Meaning |
|
|
165
|
+
|--------|-----------|---------|
|
|
166
|
+
| `recorded` | 200 | Recording exists — `recordingId` is included |
|
|
167
|
+
| `pending` | 200 | Recording is queued or processing — check back later |
|
|
168
|
+
| `queued` | 202 | Recording was just queued by this POST call |
|
|
169
|
+
| `not_found` | 404 | Request ID not found in `backend_requests` |
|
|
170
|
+
| `error` | 4xx/5xx | Validation error, auth failure, or recording failure |
|
|
171
|
+
|
|
172
|
+
### 5. Create recordings programmatically
|
|
117
173
|
|
|
118
174
|
Use `ensureRequestRecording` to turn a captured request into a Replay recording. It checks the `backend_requests` table first — if a recording already exists, it returns the recording ID immediately without calling the service. Otherwise it passes the stored blob data URL to the Netlify Recorder service and updates the row status to `"queued"`.
|
|
119
175
|
|
|
@@ -150,7 +206,7 @@ On failure:
|
|
|
150
206
|
{ "status": "failed", "error": "Error message" }
|
|
151
207
|
```
|
|
152
208
|
|
|
153
|
-
###
|
|
209
|
+
### 6. Manage stored requests
|
|
154
210
|
|
|
155
211
|
Use the `backendRequests*` helpers to query and manage captured requests in your database:
|
|
156
212
|
|
|
@@ -410,6 +466,30 @@ Ensures a Replay recording exists (or is being created) for a backend request. L
|
|
|
410
466
|
|
|
411
467
|
**Throws:** If the request ID is not found in `backend_requests`, or if the service call fails.
|
|
412
468
|
|
|
469
|
+
### `createRecordingEndpoint(options): (req: Request) => Promise<Response>`
|
|
470
|
+
|
|
471
|
+
Creates a Netlify Function v2 handler that other services can call to trigger recording creation or check recording status for entries in the `backend_requests` table.
|
|
472
|
+
|
|
473
|
+
Supports **POST** (trigger recording) and **GET** (check status). When `secret` is provided, all requests must include an `Authorization: Bearer <secret>` header.
|
|
474
|
+
|
|
475
|
+
**Parameters:**
|
|
476
|
+
- `options.sql` — A Neon SQL tagged-template function
|
|
477
|
+
- `options.recorderUrl` — Base URL of the Netlify Recorder service
|
|
478
|
+
- `options.secret` — Shared secret for authentication (optional — when omitted, the endpoint is open)
|
|
479
|
+
- `options.webhookUrl` — URL to POST the recording result to when complete (optional)
|
|
480
|
+
|
|
481
|
+
**Returns:** An async function `(req: Request) => Promise<Response>` suitable as a Netlify Functions v2 default export.
|
|
482
|
+
|
|
483
|
+
**POST body:** `{ "requestId": "<uuid>" }` — triggers recording if needed.
|
|
484
|
+
|
|
485
|
+
**GET query:** `?requestId=<uuid>` — returns current status without triggering.
|
|
486
|
+
|
|
487
|
+
**Response body** (`RecordingEndpointResponse`):
|
|
488
|
+
- `status` — `"recorded"`, `"pending"`, `"queued"`, `"not_found"`, or `"error"`
|
|
489
|
+
- `recordingId` — Present when `status` is `"recorded"`
|
|
490
|
+
- `requestId` — The request ID echoed back
|
|
491
|
+
- `error` — Error message when `status` is `"not_found"` or `"error"`
|
|
492
|
+
|
|
413
493
|
### `createRequestRecording(blobUrlOrData, handlerPath, requestInfo): Promise<RecordingResult>`
|
|
414
494
|
|
|
415
495
|
Called inside a recording container running under `replay-node`. Downloads the captured data blob (or accepts pre-parsed `BlobData`), installs replay-mode interceptors that return pre-recorded responses instead of making real calls, and executes the original handler so `replay-node` can record the execution.
|
package/dist/index.d.ts
CHANGED
|
@@ -198,6 +198,10 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
|
|
|
198
198
|
* after, capturing all outbound network calls and environment variable reads.
|
|
199
199
|
* The captured data is stored via the provided callbacks.
|
|
200
200
|
*
|
|
201
|
+
* Each request runs inside its own AsyncLocalStorage context so that
|
|
202
|
+
* concurrent requests in local dev (Netlify Dev) do not bleed into each
|
|
203
|
+
* other's captured network calls, environment reads, or audit trail tags.
|
|
204
|
+
*
|
|
201
205
|
* **Response timing:** When the Netlify Functions v2 `context` object is
|
|
202
206
|
* available (with `waitUntil`), the response is returned to the client
|
|
203
207
|
* **immediately** with a pre-generated `X-Replay-Request-Id` header. The
|
|
@@ -231,7 +235,7 @@ interface RecordingResult {
|
|
|
231
235
|
*/
|
|
232
236
|
declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
|
|
233
237
|
|
|
234
|
-
type SqlFunction$
|
|
238
|
+
type SqlFunction$2 = (...args: any[]) => Promise<any[]>;
|
|
235
239
|
interface BackendRequest {
|
|
236
240
|
id: string;
|
|
237
241
|
blob_data_url: string;
|
|
@@ -252,8 +256,8 @@ interface BackendRequest {
|
|
|
252
256
|
* using this table. The blob data (captured network calls, env reads, etc.)
|
|
253
257
|
* is uploaded to UploadThing at capture time and only the URL is stored.
|
|
254
258
|
*/
|
|
255
|
-
declare function backendRequestsEnsureTable(sql: SqlFunction$
|
|
256
|
-
declare function backendRequestsInsert(sql: SqlFunction$
|
|
259
|
+
declare function backendRequestsEnsureTable(sql: SqlFunction$2): Promise<void>;
|
|
260
|
+
declare function backendRequestsInsert(sql: SqlFunction$2, data: {
|
|
257
261
|
id?: string;
|
|
258
262
|
blobDataUrl: string;
|
|
259
263
|
handlerPath: string;
|
|
@@ -261,13 +265,13 @@ declare function backendRequestsInsert(sql: SqlFunction$1, data: {
|
|
|
261
265
|
branchName: string;
|
|
262
266
|
repositoryUrl?: string | null;
|
|
263
267
|
}): Promise<string>;
|
|
264
|
-
declare function backendRequestsGet(sql: SqlFunction$
|
|
265
|
-
declare function backendRequestsGetBlobUrl(sql: SqlFunction$
|
|
266
|
-
declare function backendRequestsList(sql: SqlFunction$
|
|
268
|
+
declare function backendRequestsGet(sql: SqlFunction$2, id: string): Promise<BackendRequest | null>;
|
|
269
|
+
declare function backendRequestsGetBlobUrl(sql: SqlFunction$2, id: string): Promise<string | null>;
|
|
270
|
+
declare function backendRequestsList(sql: SqlFunction$2, filters?: {
|
|
267
271
|
status?: string;
|
|
268
272
|
limit?: number;
|
|
269
273
|
}): Promise<BackendRequest[]>;
|
|
270
|
-
declare function backendRequestsUpdateStatus(sql: SqlFunction$
|
|
274
|
+
declare function backendRequestsUpdateStatus(sql: SqlFunction$2, id: string, status: string, recordingId?: string, errorMessage?: string): Promise<void>;
|
|
271
275
|
/**
|
|
272
276
|
* Convenience helper: creates `FinishRequestCallbacks` that upload
|
|
273
277
|
* captured request data to UploadThing and store the URL in the
|
|
@@ -275,7 +279,15 @@ declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, sta
|
|
|
275
279
|
*
|
|
276
280
|
* Requires `UPLOADTHING_TOKEN` environment variable and the `uploadthing` package.
|
|
277
281
|
*/
|
|
278
|
-
declare function databaseCallbacks(sql: SqlFunction$
|
|
282
|
+
declare function databaseCallbacks(sql: SqlFunction$2): FinishRequestCallbacks;
|
|
283
|
+
/**
|
|
284
|
+
* Creates `FinishRequestCallbacks` that POST captured request data to a
|
|
285
|
+
* remote Netlify Recorder service's `/api/store-request` endpoint.
|
|
286
|
+
*
|
|
287
|
+
* Use this instead of `databaseCallbacks` when the app does not own the
|
|
288
|
+
* recorder database — the hosted service handles blob upload and storage.
|
|
289
|
+
*/
|
|
290
|
+
declare function remoteCallbacks(recorderUrl: string): FinishRequestCallbacks;
|
|
279
291
|
interface EnsureRequestRecordingOptions {
|
|
280
292
|
/** Base URL of the Netlify Recorder service (e.g. "https://netlify-recorder-bm4wmw.netlify.app"). */
|
|
281
293
|
recorderUrl: string;
|
|
@@ -295,9 +307,9 @@ interface EnsureRequestRecordingOptions {
|
|
|
295
307
|
* This function is idempotent — calling it multiple times for the same request
|
|
296
308
|
* is safe. Once the recording completes, subsequent calls return the recording ID.
|
|
297
309
|
*/
|
|
298
|
-
declare function ensureRequestRecording(sql: SqlFunction$
|
|
310
|
+
declare function ensureRequestRecording(sql: SqlFunction$2, requestId: string, options: EnsureRequestRecordingOptions): Promise<string | null>;
|
|
299
311
|
|
|
300
|
-
type SqlFunction = (...args: any[]) => Promise<any[]>;
|
|
312
|
+
type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
|
|
301
313
|
/**
|
|
302
314
|
* Creates the `audit_log` table and a generic PL/pgSQL trigger function
|
|
303
315
|
* (`audit_trigger_function`) that records INSERT, UPDATE, and DELETE
|
|
@@ -305,19 +317,56 @@ type SqlFunction = (...args: any[]) => Promise<any[]>;
|
|
|
305
317
|
*
|
|
306
318
|
* Call this once during schema initialization.
|
|
307
319
|
*/
|
|
308
|
-
declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
|
|
320
|
+
declare function databaseAuditEnsureLogTable(sql: SqlFunction$1): Promise<void>;
|
|
309
321
|
/**
|
|
310
322
|
* Creates a trigger on the specified table that calls
|
|
311
323
|
* `audit_trigger_function` for INSERT, UPDATE, and DELETE operations.
|
|
312
324
|
*
|
|
313
325
|
* Throws if `tableName` is `'audit_log'` (cannot monitor itself).
|
|
314
326
|
*/
|
|
315
|
-
declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string, primaryKeyColumn?: string): Promise<void>;
|
|
327
|
+
declare function databaseAuditMonitorTable(sql: SqlFunction$1, tableName: string, primaryKeyColumn?: string): Promise<void>;
|
|
316
328
|
/**
|
|
317
329
|
* Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
318
330
|
*/
|
|
319
|
-
declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
|
|
331
|
+
declare function databaseAuditDumpLogTable(sql: SqlFunction$1): Promise<Record<string, unknown>[]>;
|
|
332
|
+
|
|
333
|
+
type SqlFunction = (...args: any[]) => Promise<any[]>;
|
|
334
|
+
interface CreateRecordingEndpointOptions {
|
|
335
|
+
sql: SqlFunction;
|
|
336
|
+
recorderUrl: string;
|
|
337
|
+
secret?: string;
|
|
338
|
+
webhookUrl?: string;
|
|
339
|
+
}
|
|
340
|
+
interface RecordingEndpointResponse {
|
|
341
|
+
status: "recorded" | "pending" | "queued" | "not_found" | "error";
|
|
342
|
+
recordingId?: string;
|
|
343
|
+
requestId?: string;
|
|
344
|
+
error?: string;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Creates a Netlify Function handler that other services can call to trigger
|
|
348
|
+
* recording creation for a backend request (or retrieve its current status).
|
|
349
|
+
*
|
|
350
|
+
* **POST** with `{ "requestId": "<uuid>" }` — triggers recording creation if
|
|
351
|
+
* needed and returns the current status. Idempotent: re-posting the same
|
|
352
|
+
* request ID will not re-queue an already-queued or recorded request.
|
|
353
|
+
*
|
|
354
|
+
* **GET** with `?requestId=<uuid>` — returns the current recording status
|
|
355
|
+
* without triggering any recording.
|
|
356
|
+
*
|
|
357
|
+
* When `secret` is provided, every request must include an
|
|
358
|
+
* `Authorization: Bearer <secret>` header or receive a 401 response.
|
|
359
|
+
*
|
|
360
|
+
* Response body shape (`RecordingEndpointResponse`):
|
|
361
|
+
* - `{ status: "recorded", recordingId, requestId }` — recording exists
|
|
362
|
+
* - `{ status: "pending", requestId }` — recording is queued or processing
|
|
363
|
+
* - `{ status: "queued", requestId }` — recording was just queued by this call
|
|
364
|
+
* - `{ status: "not_found", error }` — request ID not in backend_requests
|
|
365
|
+
* - `{ status: "error", requestId?, error }` — recording failed or server error
|
|
366
|
+
*/
|
|
367
|
+
declare function createRecordingEndpoint(options: CreateRecordingEndpointOptions): (req: Request) => Promise<Response>;
|
|
320
368
|
|
|
369
|
+
declare function runInRequestContext<T>(requestId: string | null, fn: () => Promise<T>): Promise<T>;
|
|
321
370
|
declare function getCurrentRequestId(): string | null;
|
|
322
371
|
|
|
323
|
-
export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobUrl, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, startRequest };
|
|
372
|
+
export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingEndpointOptions, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingEndpointResponse, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobUrl, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingEndpoint, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, remoteCallbacks, runInRequestContext, startRequest };
|
package/dist/index.js
CHANGED
|
@@ -6,17 +6,29 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
// src/requestState.ts
|
|
9
|
-
|
|
10
|
-
var
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
10
|
+
var als = new AsyncLocalStorage();
|
|
11
|
+
var _fallbackRequestId = null;
|
|
12
|
+
var _fallbackCallIndex = 0;
|
|
13
|
+
function runInRequestContext(requestId, fn) {
|
|
14
|
+
return als.run(
|
|
15
|
+
{ requestId, callIndex: 0, networkCalls: null, envReads: null },
|
|
16
|
+
fn
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
function getRequestStore() {
|
|
20
|
+
return als.getStore();
|
|
14
21
|
}
|
|
15
22
|
function getCurrentRequestId() {
|
|
16
|
-
|
|
23
|
+
const store = als.getStore();
|
|
24
|
+
return store ? store.requestId : _fallbackRequestId;
|
|
17
25
|
}
|
|
18
26
|
function incrementCallIndex() {
|
|
19
|
-
|
|
27
|
+
const store = als.getStore();
|
|
28
|
+
if (store) {
|
|
29
|
+
return ++store.callIndex;
|
|
30
|
+
}
|
|
31
|
+
return ++_fallbackCallIndex;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
// src/interceptors/network.ts
|
|
@@ -40,10 +52,84 @@ function buildSetConfigQueries(requestId, callIndex) {
|
|
|
40
52
|
{ query: "SELECT set_config('app.replay_call_index', $1, true)", params: [String(callIndex)] }
|
|
41
53
|
];
|
|
42
54
|
}
|
|
55
|
+
var _realOriginalFetch = null;
|
|
56
|
+
var _captureInterceptorInstalled = false;
|
|
57
|
+
function ensureCaptureInterceptor() {
|
|
58
|
+
if (_captureInterceptorInstalled) return;
|
|
59
|
+
_captureInterceptorInstalled = true;
|
|
60
|
+
_realOriginalFetch = globalThis.fetch;
|
|
61
|
+
const captureFetch = async (input, init) => {
|
|
62
|
+
const store = getRequestStore();
|
|
63
|
+
const calls = store?.networkCalls;
|
|
64
|
+
if (!calls) {
|
|
65
|
+
return _realOriginalFetch(input, init);
|
|
66
|
+
}
|
|
67
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
68
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
69
|
+
const requestHeaders = {};
|
|
70
|
+
if (init?.headers) {
|
|
71
|
+
new Headers(init.headers).forEach((v, k) => {
|
|
72
|
+
requestHeaders[k] = v;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
76
|
+
const requestId = store.requestId;
|
|
77
|
+
if (requestId && method.toUpperCase() === "POST" && isNeonSqlRequest(url, requestBody)) {
|
|
78
|
+
return await handleNeonSqlRequest(
|
|
79
|
+
_realOriginalFetch,
|
|
80
|
+
input,
|
|
81
|
+
init,
|
|
82
|
+
url,
|
|
83
|
+
method,
|
|
84
|
+
requestHeaders,
|
|
85
|
+
requestBody,
|
|
86
|
+
requestId,
|
|
87
|
+
calls
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const response = await _realOriginalFetch(input, init);
|
|
91
|
+
const responseBody = await response.clone().text();
|
|
92
|
+
const responseHeaders = {};
|
|
93
|
+
response.headers.forEach((v, k) => {
|
|
94
|
+
responseHeaders[k] = v;
|
|
95
|
+
});
|
|
96
|
+
calls.push({
|
|
97
|
+
url,
|
|
98
|
+
method,
|
|
99
|
+
requestHeaders,
|
|
100
|
+
requestBody,
|
|
101
|
+
responseStatus: response.status,
|
|
102
|
+
responseHeaders,
|
|
103
|
+
responseBody,
|
|
104
|
+
timestamp: Date.now()
|
|
105
|
+
});
|
|
106
|
+
return response;
|
|
107
|
+
};
|
|
108
|
+
globalThis.fetch = captureFetch;
|
|
109
|
+
}
|
|
43
110
|
function installNetworkInterceptor(mode, calls) {
|
|
44
|
-
const originalFetch = globalThis.fetch;
|
|
45
|
-
const consumed = /* @__PURE__ */ new Set();
|
|
46
111
|
if (mode === "capture") {
|
|
112
|
+
const store = getRequestStore();
|
|
113
|
+
if (store) {
|
|
114
|
+
ensureCaptureInterceptor();
|
|
115
|
+
store.networkCalls = calls;
|
|
116
|
+
return {
|
|
117
|
+
restore() {
|
|
118
|
+
const s = getRequestStore();
|
|
119
|
+
if (s) s.networkCalls = null;
|
|
120
|
+
},
|
|
121
|
+
consumedCount() {
|
|
122
|
+
return 0;
|
|
123
|
+
},
|
|
124
|
+
totalCount() {
|
|
125
|
+
return calls.length;
|
|
126
|
+
},
|
|
127
|
+
unconsumedIndices() {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const originalFetch2 = globalThis.fetch;
|
|
47
133
|
const captureFetch = async (input, init) => {
|
|
48
134
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
49
135
|
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
@@ -57,7 +143,7 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
57
143
|
const requestId = getCurrentRequestId();
|
|
58
144
|
if (requestId && method.toUpperCase() === "POST" && isNeonSqlRequest(url, requestBody)) {
|
|
59
145
|
return await handleNeonSqlRequest(
|
|
60
|
-
|
|
146
|
+
originalFetch2,
|
|
61
147
|
input,
|
|
62
148
|
init,
|
|
63
149
|
url,
|
|
@@ -68,7 +154,7 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
68
154
|
calls
|
|
69
155
|
);
|
|
70
156
|
}
|
|
71
|
-
const response = await
|
|
157
|
+
const response = await originalFetch2(input, init);
|
|
72
158
|
const responseBody = await response.clone().text();
|
|
73
159
|
const responseHeaders = {};
|
|
74
160
|
response.headers.forEach((v, k) => {
|
|
@@ -87,69 +173,84 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
87
173
|
return response;
|
|
88
174
|
};
|
|
89
175
|
globalThis.fetch = captureFetch;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
176
|
+
return {
|
|
177
|
+
restore() {
|
|
178
|
+
globalThis.fetch = originalFetch2;
|
|
179
|
+
},
|
|
180
|
+
consumedCount() {
|
|
181
|
+
return 0;
|
|
182
|
+
},
|
|
183
|
+
totalCount() {
|
|
184
|
+
return calls.length;
|
|
185
|
+
},
|
|
186
|
+
unconsumedIndices() {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const originalFetch = globalThis.fetch;
|
|
192
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
193
|
+
const replayFetch = async (input, init) => {
|
|
194
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : typeof input === "object" && input !== null && "url" in input ? input.url : String(input);
|
|
195
|
+
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
196
|
+
let matchIdx = -1;
|
|
197
|
+
for (let i = 0; i < calls.length; i++) {
|
|
198
|
+
if (consumed.has(i)) continue;
|
|
199
|
+
const c = calls[i];
|
|
200
|
+
if (c && c.url === url && c.requestBody === requestBody) {
|
|
201
|
+
matchIdx = i;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (matchIdx === -1) {
|
|
95
206
|
for (let i = 0; i < calls.length; i++) {
|
|
96
|
-
if (consumed.has(i))
|
|
97
|
-
const c = calls[i];
|
|
98
|
-
if (c && c.url === url && c.requestBody === requestBody) {
|
|
207
|
+
if (!consumed.has(i)) {
|
|
99
208
|
matchIdx = i;
|
|
100
209
|
break;
|
|
101
210
|
}
|
|
102
211
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const call = calls[matchIdx];
|
|
112
|
-
if (matchIdx === -1 || !call) {
|
|
113
|
-
throw new Error(
|
|
114
|
-
`No more recorded network calls to replay (exhausted ${calls.length} calls)`
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
consumed.add(matchIdx);
|
|
118
|
-
console.log(
|
|
119
|
-
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus}`
|
|
212
|
+
}
|
|
213
|
+
const call = calls[matchIdx];
|
|
214
|
+
if (matchIdx === -1 || !call) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`No more recorded network calls to replay (exhausted ${calls.length} calls)`
|
|
120
217
|
);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
},
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
body: null,
|
|
138
|
-
bodyUsed: false,
|
|
139
|
-
redirected: false,
|
|
140
|
-
type: "basic",
|
|
141
|
-
url: call.url,
|
|
142
|
-
arrayBuffer: async () => new ArrayBuffer(0),
|
|
143
|
-
blob: async () => {
|
|
144
|
-
throw new Error("blob() not supported in replay");
|
|
145
|
-
},
|
|
146
|
-
formData: async () => {
|
|
147
|
-
throw new Error("formData() not supported in replay");
|
|
218
|
+
}
|
|
219
|
+
consumed.add(matchIdx);
|
|
220
|
+
console.log(
|
|
221
|
+
` [network-replay] Consumed call ${consumed.size}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus}`
|
|
222
|
+
);
|
|
223
|
+
const body = call.responseBody ?? "";
|
|
224
|
+
const status = call.responseStatus;
|
|
225
|
+
return {
|
|
226
|
+
ok: status >= 200 && status < 300,
|
|
227
|
+
status,
|
|
228
|
+
statusText: "",
|
|
229
|
+
headers: {
|
|
230
|
+
get: (name) => (call.responseHeaders ?? {})[name.toLowerCase()] ?? null,
|
|
231
|
+
has: (name) => name.toLowerCase() in (call.responseHeaders ?? {}),
|
|
232
|
+
forEach: (cb) => {
|
|
233
|
+
for (const [k, v] of Object.entries(call.responseHeaders ?? {})) cb(v, k);
|
|
148
234
|
}
|
|
149
|
-
}
|
|
235
|
+
},
|
|
236
|
+
text: async () => body,
|
|
237
|
+
json: async () => JSON.parse(body),
|
|
238
|
+
clone: () => ({ text: async () => body, json: async () => JSON.parse(body) }),
|
|
239
|
+
body: null,
|
|
240
|
+
bodyUsed: false,
|
|
241
|
+
redirected: false,
|
|
242
|
+
type: "basic",
|
|
243
|
+
url: call.url,
|
|
244
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
245
|
+
blob: async () => {
|
|
246
|
+
throw new Error("blob() not supported in replay");
|
|
247
|
+
},
|
|
248
|
+
formData: async () => {
|
|
249
|
+
throw new Error("formData() not supported in replay");
|
|
250
|
+
}
|
|
150
251
|
};
|
|
151
|
-
|
|
152
|
-
|
|
252
|
+
};
|
|
253
|
+
globalThis.fetch = replayFetch;
|
|
153
254
|
return {
|
|
154
255
|
restore() {
|
|
155
256
|
globalThis.fetch = originalFetch;
|
|
@@ -215,9 +316,59 @@ async function handleNeonSqlRequest(originalFetch, input, init, url, method, req
|
|
|
215
316
|
}
|
|
216
317
|
|
|
217
318
|
// src/interceptors/environment.ts
|
|
319
|
+
var _realOriginalEnv = null;
|
|
320
|
+
var _captureProxyInstalled = false;
|
|
321
|
+
function ensureCaptureProxy() {
|
|
322
|
+
if (_captureProxyInstalled) return;
|
|
323
|
+
_captureProxyInstalled = true;
|
|
324
|
+
_realOriginalEnv = process.env;
|
|
325
|
+
process.env = new Proxy(_realOriginalEnv, {
|
|
326
|
+
get(target, prop) {
|
|
327
|
+
const value = target[prop];
|
|
328
|
+
if (typeof prop === "string" && prop !== "toJSON") {
|
|
329
|
+
const store = getRequestStore();
|
|
330
|
+
const reads = store?.envReads;
|
|
331
|
+
if (reads) {
|
|
332
|
+
reads.push({ key: prop, value, timestamp: Date.now() });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return value;
|
|
336
|
+
},
|
|
337
|
+
set(target, prop, value) {
|
|
338
|
+
target[prop] = value;
|
|
339
|
+
return true;
|
|
340
|
+
},
|
|
341
|
+
defineProperty(target, prop, descriptor) {
|
|
342
|
+
if ("value" in descriptor) {
|
|
343
|
+
target[prop] = descriptor.value;
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
return Reflect.defineProperty(target, prop, descriptor);
|
|
347
|
+
},
|
|
348
|
+
deleteProperty(target, prop) {
|
|
349
|
+
delete target[prop];
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
218
354
|
function installEnvironmentInterceptor(mode, reads) {
|
|
219
355
|
const originalEnv = process.env;
|
|
220
356
|
if (mode === "capture") {
|
|
357
|
+
const store = getRequestStore();
|
|
358
|
+
if (store) {
|
|
359
|
+
ensureCaptureProxy();
|
|
360
|
+
const now2 = Date.now();
|
|
361
|
+
for (const key of Object.keys(_realOriginalEnv)) {
|
|
362
|
+
reads.push({ key, value: _realOriginalEnv[key], timestamp: now2 });
|
|
363
|
+
}
|
|
364
|
+
store.envReads = reads;
|
|
365
|
+
return {
|
|
366
|
+
restore() {
|
|
367
|
+
const s = getRequestStore();
|
|
368
|
+
if (s) s.envReads = null;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
221
372
|
const now = Date.now();
|
|
222
373
|
for (const key of Object.keys(originalEnv)) {
|
|
223
374
|
reads.push({ key, value: originalEnv[key], timestamp: now });
|
|
@@ -246,35 +397,39 @@ function installEnvironmentInterceptor(mode, reads) {
|
|
|
246
397
|
return true;
|
|
247
398
|
}
|
|
248
399
|
});
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
readMap.set(read.key, read.value);
|
|
253
|
-
}
|
|
254
|
-
process.env = new Proxy(originalEnv, {
|
|
255
|
-
get(target, prop) {
|
|
256
|
-
if (typeof prop === "string" && readMap.has(prop)) {
|
|
257
|
-
return readMap.get(prop);
|
|
258
|
-
}
|
|
259
|
-
return target[prop];
|
|
260
|
-
},
|
|
261
|
-
set(target, prop, value) {
|
|
262
|
-
target[prop] = value;
|
|
263
|
-
return true;
|
|
264
|
-
},
|
|
265
|
-
defineProperty(target, prop, descriptor) {
|
|
266
|
-
if ("value" in descriptor) {
|
|
267
|
-
target[prop] = descriptor.value;
|
|
268
|
-
return true;
|
|
269
|
-
}
|
|
270
|
-
return Reflect.defineProperty(target, prop, descriptor);
|
|
271
|
-
},
|
|
272
|
-
deleteProperty(target, prop) {
|
|
273
|
-
delete target[prop];
|
|
274
|
-
return true;
|
|
400
|
+
return {
|
|
401
|
+
restore() {
|
|
402
|
+
process.env = originalEnv;
|
|
275
403
|
}
|
|
276
|
-
}
|
|
404
|
+
};
|
|
277
405
|
}
|
|
406
|
+
const readMap = /* @__PURE__ */ new Map();
|
|
407
|
+
for (const read of reads) {
|
|
408
|
+
readMap.set(read.key, read.value);
|
|
409
|
+
}
|
|
410
|
+
process.env = new Proxy(originalEnv, {
|
|
411
|
+
get(target, prop) {
|
|
412
|
+
if (typeof prop === "string" && readMap.has(prop)) {
|
|
413
|
+
return readMap.get(prop);
|
|
414
|
+
}
|
|
415
|
+
return target[prop];
|
|
416
|
+
},
|
|
417
|
+
set(target, prop, value) {
|
|
418
|
+
target[prop] = value;
|
|
419
|
+
return true;
|
|
420
|
+
},
|
|
421
|
+
defineProperty(target, prop, descriptor) {
|
|
422
|
+
if ("value" in descriptor) {
|
|
423
|
+
target[prop] = descriptor.value;
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
return Reflect.defineProperty(target, prop, descriptor);
|
|
427
|
+
},
|
|
428
|
+
deleteProperty(target, prop) {
|
|
429
|
+
delete target[prop];
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
});
|
|
278
433
|
return {
|
|
279
434
|
restore() {
|
|
280
435
|
process.env = originalEnv;
|
|
@@ -597,82 +752,81 @@ import crypto2 from "crypto";
|
|
|
597
752
|
function createRecordingRequestHandler(handler, options) {
|
|
598
753
|
return async (event, context) => {
|
|
599
754
|
const requestId = crypto2.randomUUID();
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
755
|
+
return runInRequestContext(requestId, async () => {
|
|
756
|
+
const reqContext = startRequest(event);
|
|
757
|
+
let response;
|
|
758
|
+
try {
|
|
759
|
+
response = await handler(event, context);
|
|
760
|
+
} catch (handlerErr) {
|
|
761
|
+
const errorMessage = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
|
|
762
|
+
const errorResponse = {
|
|
763
|
+
statusCode: 500,
|
|
764
|
+
body: JSON.stringify({ error: errorMessage })
|
|
765
|
+
};
|
|
766
|
+
const finishOpts2 = { ...options, requestId };
|
|
767
|
+
const ctx2 = context;
|
|
768
|
+
if (ctx2 && typeof ctx2.waitUntil === "function") {
|
|
769
|
+
ctx2.waitUntil(
|
|
770
|
+
finishRequest(reqContext, options.callbacks, errorResponse, finishOpts2).catch(
|
|
771
|
+
(finishErr) => {
|
|
772
|
+
console.error(
|
|
773
|
+
`netlify-recorder: background finishRequest failed after handler error (handler: ${options.handlerPath ?? "unknown"})`,
|
|
774
|
+
finishErr
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
)
|
|
778
|
+
);
|
|
779
|
+
} else {
|
|
780
|
+
await finishRequest(reqContext, options.callbacks, errorResponse, finishOpts2).catch(
|
|
616
781
|
(finishErr) => {
|
|
617
782
|
console.error(
|
|
618
|
-
`netlify-recorder:
|
|
783
|
+
`netlify-recorder: finishRequest failed after handler error (handler: ${options.handlerPath ?? "unknown"})`,
|
|
619
784
|
finishErr
|
|
620
785
|
);
|
|
621
786
|
}
|
|
622
|
-
)
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
finishErr
|
|
630
|
-
);
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
...errorResponse,
|
|
791
|
+
headers: {
|
|
792
|
+
...errorResponse.headers,
|
|
793
|
+
"X-Replay-Request-Id": requestId
|
|
631
794
|
}
|
|
632
|
-
|
|
795
|
+
};
|
|
633
796
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
...
|
|
797
|
+
reqContext.cleanup();
|
|
798
|
+
const responseWithHeader = {
|
|
799
|
+
...response,
|
|
637
800
|
headers: {
|
|
638
|
-
...
|
|
801
|
+
...response.headers,
|
|
639
802
|
"X-Replay-Request-Id": requestId
|
|
640
803
|
}
|
|
641
804
|
};
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
805
|
+
const finishOpts = { ...options, requestId };
|
|
806
|
+
const ctx = context;
|
|
807
|
+
if (ctx && typeof ctx.waitUntil === "function") {
|
|
808
|
+
ctx.waitUntil(
|
|
809
|
+
finishRequest(reqContext, options.callbacks, response, finishOpts).catch(
|
|
810
|
+
(err) => {
|
|
811
|
+
console.error(
|
|
812
|
+
`netlify-recorder: background finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
|
|
813
|
+
err
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
)
|
|
817
|
+
);
|
|
818
|
+
return responseWithHeader;
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
await finishRequest(reqContext, options.callbacks, response, finishOpts);
|
|
822
|
+
} catch (err) {
|
|
823
|
+
console.error(
|
|
824
|
+
`netlify-recorder: finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
|
|
825
|
+
err
|
|
826
|
+
);
|
|
650
827
|
}
|
|
651
|
-
};
|
|
652
|
-
const finishOpts = { ...options, requestId };
|
|
653
|
-
const ctx = context;
|
|
654
|
-
if (ctx && typeof ctx.waitUntil === "function") {
|
|
655
|
-
ctx.waitUntil(
|
|
656
|
-
finishRequest(reqContext, options.callbacks, response, finishOpts).catch(
|
|
657
|
-
(err) => {
|
|
658
|
-
console.error(
|
|
659
|
-
`netlify-recorder: background finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
|
|
660
|
-
err
|
|
661
|
-
);
|
|
662
|
-
}
|
|
663
|
-
)
|
|
664
|
-
);
|
|
665
828
|
return responseWithHeader;
|
|
666
|
-
}
|
|
667
|
-
try {
|
|
668
|
-
await finishRequest(reqContext, options.callbacks, response, finishOpts);
|
|
669
|
-
} catch (err) {
|
|
670
|
-
console.error(
|
|
671
|
-
`netlify-recorder: finishRequest failed (handler: ${options.handlerPath ?? "unknown"})`,
|
|
672
|
-
err
|
|
673
|
-
);
|
|
674
|
-
}
|
|
675
|
-
return responseWithHeader;
|
|
829
|
+
});
|
|
676
830
|
};
|
|
677
831
|
}
|
|
678
832
|
|
|
@@ -820,6 +974,9 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
820
974
|
headers["content-type"] = "application/json";
|
|
821
975
|
return new ResponseShim(body, { ...init, headers });
|
|
822
976
|
}
|
|
977
|
+
get body() {
|
|
978
|
+
return this._body;
|
|
979
|
+
}
|
|
823
980
|
async text() {
|
|
824
981
|
return this._body ?? "";
|
|
825
982
|
}
|
|
@@ -1126,6 +1283,33 @@ function databaseCallbacks(sql) {
|
|
|
1126
1283
|
}
|
|
1127
1284
|
};
|
|
1128
1285
|
}
|
|
1286
|
+
function remoteCallbacks(recorderUrl) {
|
|
1287
|
+
const baseUrl = recorderUrl.replace(/\/+$/, "");
|
|
1288
|
+
return {
|
|
1289
|
+
storeRequest: async (data) => {
|
|
1290
|
+
const res = await fetch(`${baseUrl}/api/store-request`, {
|
|
1291
|
+
method: "POST",
|
|
1292
|
+
headers: { "Content-Type": "application/json" },
|
|
1293
|
+
body: JSON.stringify({
|
|
1294
|
+
blobData: data.blobData,
|
|
1295
|
+
commitSha: data.commitSha,
|
|
1296
|
+
branchName: data.branchName,
|
|
1297
|
+
repositoryUrl: data.repositoryUrl,
|
|
1298
|
+
handlerPath: data.handlerPath,
|
|
1299
|
+
requestId: data.requestId
|
|
1300
|
+
})
|
|
1301
|
+
});
|
|
1302
|
+
if (!res.ok) {
|
|
1303
|
+
const errBody = await res.text().catch(() => "(unreadable)");
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
`netlify-recorder: remote store-request failed: ${res.status} ${errBody}`
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
const result = await res.json();
|
|
1309
|
+
return result.requestId;
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1129
1313
|
async function ensureRequestRecording(sql, requestId, options) {
|
|
1130
1314
|
const request = await backendRequestsGet(sql, requestId);
|
|
1131
1315
|
if (!request) {
|
|
@@ -1251,6 +1435,92 @@ async function databaseAuditDumpLogTable(sql) {
|
|
|
1251
1435
|
const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
|
|
1252
1436
|
return rows;
|
|
1253
1437
|
}
|
|
1438
|
+
|
|
1439
|
+
// src/createRecordingEndpoint.ts
|
|
1440
|
+
function jsonResponse(body, status) {
|
|
1441
|
+
return new Response(JSON.stringify(body), {
|
|
1442
|
+
status,
|
|
1443
|
+
headers: { "Content-Type": "application/json" }
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
function formatStatus(request) {
|
|
1447
|
+
if (request.status === "recorded" && request.recording_id) {
|
|
1448
|
+
return { status: "recorded", recordingId: request.recording_id, requestId: request.id };
|
|
1449
|
+
}
|
|
1450
|
+
if (request.status === "queued" || request.status === "processing") {
|
|
1451
|
+
return { status: "pending", requestId: request.id };
|
|
1452
|
+
}
|
|
1453
|
+
if (request.status === "failed") {
|
|
1454
|
+
return { status: "error", requestId: request.id, error: request.error_message ?? "Recording failed" };
|
|
1455
|
+
}
|
|
1456
|
+
return { status: "pending", requestId: request.id };
|
|
1457
|
+
}
|
|
1458
|
+
function createRecordingEndpoint(options) {
|
|
1459
|
+
const { sql, recorderUrl, secret, webhookUrl } = options;
|
|
1460
|
+
return async (req) => {
|
|
1461
|
+
if (secret) {
|
|
1462
|
+
const auth = req.headers.get("authorization");
|
|
1463
|
+
if (auth !== `Bearer ${secret}`) {
|
|
1464
|
+
return jsonResponse({ status: "error", error: "Unauthorized" }, 401);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
try {
|
|
1468
|
+
if (req.method === "GET") {
|
|
1469
|
+
const url = new URL(req.url);
|
|
1470
|
+
const requestId = url.searchParams.get("requestId");
|
|
1471
|
+
if (!requestId) {
|
|
1472
|
+
return jsonResponse({ status: "error", error: "Missing requestId query parameter" }, 400);
|
|
1473
|
+
}
|
|
1474
|
+
const request = await backendRequestsGet(sql, requestId);
|
|
1475
|
+
if (!request) {
|
|
1476
|
+
return jsonResponse({ status: "not_found", error: `Request ${requestId} not found` }, 404);
|
|
1477
|
+
}
|
|
1478
|
+
return jsonResponse(formatStatus(request), 200);
|
|
1479
|
+
}
|
|
1480
|
+
if (req.method === "POST") {
|
|
1481
|
+
const body = await req.json();
|
|
1482
|
+
const requestId = body.requestId;
|
|
1483
|
+
if (!requestId) {
|
|
1484
|
+
return jsonResponse({ status: "error", error: "Missing requestId in request body" }, 400);
|
|
1485
|
+
}
|
|
1486
|
+
const request = await backendRequestsGet(sql, requestId);
|
|
1487
|
+
if (!request) {
|
|
1488
|
+
return jsonResponse({ status: "not_found", error: `Request ${requestId} not found` }, 404);
|
|
1489
|
+
}
|
|
1490
|
+
if (request.status === "recorded" && request.recording_id) {
|
|
1491
|
+
return jsonResponse(
|
|
1492
|
+
{ status: "recorded", recordingId: request.recording_id, requestId },
|
|
1493
|
+
200
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
if (request.status === "queued" || request.status === "processing") {
|
|
1497
|
+
return jsonResponse({ status: "pending", requestId }, 200);
|
|
1498
|
+
}
|
|
1499
|
+
try {
|
|
1500
|
+
const recordingId = await ensureRequestRecording(sql, requestId, {
|
|
1501
|
+
recorderUrl,
|
|
1502
|
+
webhookUrl
|
|
1503
|
+
});
|
|
1504
|
+
if (recordingId) {
|
|
1505
|
+
return jsonResponse({ status: "recorded", recordingId, requestId }, 200);
|
|
1506
|
+
}
|
|
1507
|
+
return jsonResponse({ status: "queued", requestId }, 202);
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1510
|
+
await backendRequestsUpdateStatus(sql, requestId, "failed", void 0, message).catch(
|
|
1511
|
+
() => {
|
|
1512
|
+
}
|
|
1513
|
+
);
|
|
1514
|
+
return jsonResponse({ status: "error", requestId, error: message }, 502);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
return jsonResponse({ status: "error", error: "Method not allowed" }, 405);
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1520
|
+
return jsonResponse({ status: "error", error: message }, 500);
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1254
1524
|
export {
|
|
1255
1525
|
backendRequestsEnsureTable,
|
|
1256
1526
|
backendRequestsGet,
|
|
@@ -1258,6 +1528,7 @@ export {
|
|
|
1258
1528
|
backendRequestsInsert,
|
|
1259
1529
|
backendRequestsList,
|
|
1260
1530
|
backendRequestsUpdateStatus,
|
|
1531
|
+
createRecordingEndpoint,
|
|
1261
1532
|
createRecordingRequestHandler,
|
|
1262
1533
|
createRequestRecording,
|
|
1263
1534
|
databaseAuditDumpLogTable,
|
|
@@ -1268,5 +1539,7 @@ export {
|
|
|
1268
1539
|
finishRequest,
|
|
1269
1540
|
getCurrentRequestId,
|
|
1270
1541
|
redactBlobData,
|
|
1542
|
+
remoteCallbacks,
|
|
1543
|
+
runInRequestContext,
|
|
1271
1544
|
startRequest
|
|
1272
1545
|
};
|