@replayio-app-building/netlify-recorder 0.15.5 → 0.15.7
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 +20 -26
- package/dist/index.d.ts +1 -21
- package/dist/index.js +93 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,8 +31,9 @@ The Netlify Recorder app (`https://netlify-recorder-bm4wmw.netlify.app`) provide
|
|
|
31
31
|
| `REPLAY_REPOSITORY_URL` | Your app's git repository URL (e.g. `https://github.com/org/repo.git`) | Set in your deploy script or Netlify site settings |
|
|
32
32
|
| `COMMIT_SHA` | The git commit hash of the deployed code | Set in your deploy script via `git rev-parse HEAD` |
|
|
33
33
|
| `BRANCH_NAME` | The git branch of the deployed code | Set in your deploy script via `git rev-parse --abbrev-ref HEAD` |
|
|
34
|
+
| `NETLIFY_RECORDER_SECRET` | Secret string for access control — restricts who can view and act on your captured requests | Set in Netlify site environment variables or via `set-branch-secret` |
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
The first three are **required** — `finishRequest` will throw an error if any are missing. `NETLIFY_RECORDER_SECRET` is strongly recommended to prevent other apps from accessing your captured request data. Your deploy script should resolve the git values and set them on the Netlify site before deploying. Example:
|
|
36
37
|
|
|
37
38
|
```typescript
|
|
38
39
|
// In your deploy script:
|
|
@@ -46,7 +47,7 @@ const repositoryUrl = execSync("git remote get-url origin", { encoding: "utf-8"
|
|
|
46
47
|
|
|
47
48
|
### 2. Wrap your Netlify function
|
|
48
49
|
|
|
49
|
-
Use `createRecordingRequestHandler` with `remoteCallbacks()` to wrap your handler with automatic request capture.
|
|
50
|
+
Use `createRecordingRequestHandler` with `remoteCallbacks()` to wrap your handler with automatic request capture. Set `secret` to restrict access to captured requests — only API calls providing the same secret can view or act on them.
|
|
50
51
|
|
|
51
52
|
**v1 handler** (Netlify Functions v1 — `event` with `httpMethod`, `path`, etc.):
|
|
52
53
|
|
|
@@ -71,6 +72,7 @@ const handler = createRecordingRequestHandler(
|
|
|
71
72
|
{
|
|
72
73
|
callbacks: remoteCallbacks(RECORDER_URL),
|
|
73
74
|
handlerPath: "netlify/functions/my-handler",
|
|
75
|
+
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
74
76
|
}
|
|
75
77
|
);
|
|
76
78
|
|
|
@@ -102,6 +104,7 @@ export default createRecordingRequestHandler(
|
|
|
102
104
|
{
|
|
103
105
|
callbacks: remoteCallbacks(RECORDER_URL),
|
|
104
106
|
handlerPath: "netlify/functions/my-handler",
|
|
107
|
+
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
105
108
|
}
|
|
106
109
|
);
|
|
107
110
|
```
|
|
@@ -262,6 +265,7 @@ const handler = createRecordingRequestHandler(
|
|
|
262
265
|
};
|
|
263
266
|
},
|
|
264
267
|
{
|
|
268
|
+
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
265
269
|
callbacks: {
|
|
266
270
|
uploadBlob: async (data) => {
|
|
267
271
|
// Upload the JSON string to your blob storage (S3, R2, etc.)
|
|
@@ -272,10 +276,10 @@ const handler = createRecordingRequestHandler(
|
|
|
272
276
|
const { url } = await res.json();
|
|
273
277
|
return url;
|
|
274
278
|
},
|
|
275
|
-
storeRequestData: async ({ blobUrl, commitSha, branchName, repositoryUrl, handlerPath }) => {
|
|
279
|
+
storeRequestData: async ({ blobUrl, commitSha, branchName, repositoryUrl, handlerPath, secret }) => {
|
|
276
280
|
const [row] = await sql`
|
|
277
|
-
INSERT INTO requests (blob_url, commit_sha, branch_name, repository_url, handler_path, status)
|
|
278
|
-
VALUES (${blobUrl}, ${commitSha}, ${branchName}, ${repositoryUrl}, ${handlerPath}, 'captured')
|
|
281
|
+
INSERT INTO requests (blob_url, commit_sha, branch_name, repository_url, handler_path, secret, status)
|
|
282
|
+
VALUES (${blobUrl}, ${commitSha}, ${branchName}, ${repositoryUrl}, ${handlerPath}, ${secret}, 'captured')
|
|
279
283
|
RETURNING id
|
|
280
284
|
`;
|
|
281
285
|
return row.id;
|
|
@@ -297,12 +301,15 @@ CREATE TABLE IF NOT EXISTS requests (
|
|
|
297
301
|
branch_name TEXT,
|
|
298
302
|
repository_url TEXT,
|
|
299
303
|
handler_path TEXT,
|
|
304
|
+
secret TEXT,
|
|
300
305
|
recording_id TEXT,
|
|
301
306
|
status TEXT NOT NULL DEFAULT 'captured'
|
|
302
307
|
CHECK (status IN ('captured', 'processing', 'recorded', 'failed')),
|
|
303
308
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
304
309
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
305
310
|
);
|
|
311
|
+
|
|
312
|
+
CREATE INDEX IF NOT EXISTS idx_requests_secret ON requests (secret) WHERE secret IS NOT NULL;
|
|
306
313
|
```
|
|
307
314
|
|
|
308
315
|
### 4. Create a background function to produce recordings
|
|
@@ -386,7 +393,7 @@ Self-hosted recording requires these environment variables:
|
|
|
386
393
|
|
|
387
394
|
## Audit Log Support
|
|
388
395
|
|
|
389
|
-
The package
|
|
396
|
+
The package automatically tracks database mutations (INSERT, UPDATE, DELETE) in an `audit_log` table and links each change to the Replay request that caused it. When your handler is wrapped with `createRecordingRequestHandler`, all Neon SQL queries are automatically tagged with the request ID — no changes to your SQL code required.
|
|
390
397
|
|
|
391
398
|
### Setup
|
|
392
399
|
|
|
@@ -411,29 +418,26 @@ await databaseAuditMonitorTable(sql, "users");
|
|
|
411
418
|
await databaseAuditMonitorTable(sql, "orders");
|
|
412
419
|
```
|
|
413
420
|
|
|
414
|
-
#### 3. Use
|
|
421
|
+
#### 3. Use SQL normally in your handlers
|
|
415
422
|
|
|
416
|
-
|
|
423
|
+
No special SQL wrapper is needed. Any Neon SQL query inside a handler wrapped with `createRecordingRequestHandler` is automatically tagged with the replay request ID:
|
|
417
424
|
|
|
418
425
|
```typescript
|
|
419
426
|
import {
|
|
420
427
|
createRecordingRequestHandler,
|
|
421
428
|
remoteCallbacks,
|
|
422
|
-
createAuditedSql,
|
|
423
429
|
} from "@replayio-app-building/netlify-recorder";
|
|
424
430
|
|
|
425
431
|
export default createRecordingRequestHandler(
|
|
426
432
|
async (req) => {
|
|
427
|
-
|
|
428
|
-
// Queries through auditedSql automatically tag audit_log rows
|
|
429
|
-
// with the replay request ID — no extra code needed.
|
|
430
|
-
await auditedSql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
|
|
433
|
+
await sql`INSERT INTO orders (product, qty) VALUES (${product}, ${qty})`;
|
|
431
434
|
|
|
432
435
|
return { statusCode: 200, body: "OK" };
|
|
433
436
|
},
|
|
434
437
|
{
|
|
435
438
|
callbacks: remoteCallbacks(RECORDER_URL),
|
|
436
439
|
handlerPath: "netlify/functions/create-order",
|
|
440
|
+
secret: process.env.NETLIFY_RECORDER_SECRET,
|
|
437
441
|
}
|
|
438
442
|
);
|
|
439
443
|
```
|
|
@@ -459,12 +463,12 @@ Each entry contains:
|
|
|
459
463
|
| `new_data` | New row data (INSERT/UPDATE only) |
|
|
460
464
|
| `changed_fields` | Array of column names that changed (UPDATE only) |
|
|
461
465
|
| `performed_at` | Timestamp of the change |
|
|
462
|
-
| `replay_request_id` | The Replay request ID that caused the change
|
|
466
|
+
| `replay_request_id` | The Replay request ID that caused the change |
|
|
463
467
|
| `replay_request_call_index` | The sequential query index within the request |
|
|
464
468
|
|
|
465
469
|
### How it works
|
|
466
470
|
|
|
467
|
-
`
|
|
471
|
+
The network interceptor detects Neon SQL HTTP requests (which use `fetch` internally) and automatically wraps each query in a transaction with `SET LOCAL` statements that inject the current request ID and call index. The PostgreSQL trigger function reads these via `current_setting()` and stamps audit rows atomically. Outside a handler context, queries execute normally without audit metadata.
|
|
468
472
|
|
|
469
473
|
---
|
|
470
474
|
|
|
@@ -573,17 +577,6 @@ Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
|
573
577
|
**Parameters:**
|
|
574
578
|
- `sql` — A Neon SQL tagged-template function
|
|
575
579
|
|
|
576
|
-
### `createAuditedSql(sql): SqlFunction`
|
|
577
|
-
|
|
578
|
-
Wraps a Neon SQL tagged-template function so that each query runs inside a transaction that injects the current Replay request ID and call index via `SET LOCAL`. The audit trigger reads these values and stamps audit rows automatically.
|
|
579
|
-
|
|
580
|
-
Falls back to non-audited execution when no request context is active or if the transaction fails.
|
|
581
|
-
|
|
582
|
-
**Parameters:**
|
|
583
|
-
- `sql` — A Neon SQL tagged-template function (must have a `.transaction()` method, as provided by the Neon HTTP driver)
|
|
584
|
-
|
|
585
|
-
**Returns:** A wrapped SQL function with the same tagged-template interface.
|
|
586
|
-
|
|
587
580
|
## Environment Variables
|
|
588
581
|
|
|
589
582
|
### Required for all setups (read by `finishRequest`)
|
|
@@ -595,6 +588,7 @@ These must be set on your Netlify site. Your deploy script should resolve them f
|
|
|
595
588
|
| `COMMIT_SHA` | Git commit hash of the deployed code | `git rev-parse HEAD` |
|
|
596
589
|
| `BRANCH_NAME` | Git branch of the deployed code | `git rev-parse --abbrev-ref HEAD` |
|
|
597
590
|
| `REPLAY_REPOSITORY_URL` | Git repository URL (no embedded credentials) | `git remote get-url origin` (strip tokens) |
|
|
591
|
+
| `NETLIFY_RECORDER_SECRET` | Secret for access control (strongly recommended) | `openssl rand -base64 32` — store in Netlify site env vars |
|
|
598
592
|
|
|
599
593
|
### Required for self-hosted recording (Option B)
|
|
600
594
|
|
package/dist/index.d.ts
CHANGED
|
@@ -448,27 +448,7 @@ declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string):
|
|
|
448
448
|
* Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
|
|
449
449
|
*/
|
|
450
450
|
declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
|
|
451
|
-
/**
|
|
452
|
-
* Wraps a Neon SQL tagged-template function so that each query runs
|
|
453
|
-
* inside a transaction that first calls `set_config()` to inject the
|
|
454
|
-
* current request ID and call index. The `audit_trigger_function`
|
|
455
|
-
* reads these via `current_setting()` and stamps audit rows atomically
|
|
456
|
-
* — no separate UPDATE required.
|
|
457
|
-
*
|
|
458
|
-
* Requires the Neon HTTP driver's `.transaction()` method so that
|
|
459
|
-
* `set_config` and the user's query share one transaction.
|
|
460
|
-
*
|
|
461
|
-
* @example
|
|
462
|
-
* ```ts
|
|
463
|
-
* import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
|
|
464
|
-
*
|
|
465
|
-
* const sql = createAuditedSql(getSql());
|
|
466
|
-
* await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
|
|
467
|
-
* // audit_log row created by the trigger already has the request ID
|
|
468
|
-
* ```
|
|
469
|
-
*/
|
|
470
|
-
declare function createAuditedSql(sql: SqlFunction): SqlFunction;
|
|
471
451
|
|
|
472
452
|
declare function getCurrentRequestId(): string | null;
|
|
473
453
|
|
|
474
|
-
export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions,
|
|
454
|
+
export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,41 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
5
5
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
+
// src/requestState.ts
|
|
9
|
+
var _currentRequestId = null;
|
|
10
|
+
var _callIndex = 0;
|
|
11
|
+
function setCurrentRequestId(id) {
|
|
12
|
+
_currentRequestId = id;
|
|
13
|
+
_callIndex = 0;
|
|
14
|
+
}
|
|
15
|
+
function getCurrentRequestId() {
|
|
16
|
+
return _currentRequestId;
|
|
17
|
+
}
|
|
18
|
+
function incrementCallIndex() {
|
|
19
|
+
return ++_callIndex;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
// src/interceptors/network.ts
|
|
23
|
+
function isNeonQuery(obj) {
|
|
24
|
+
return typeof obj === "object" && obj !== null && "query" in obj && "params" in obj;
|
|
25
|
+
}
|
|
26
|
+
function isNeonSqlRequest(url, body) {
|
|
27
|
+
if (!body) return false;
|
|
28
|
+
try {
|
|
29
|
+
const u = new URL(url);
|
|
30
|
+
if (!u.pathname.endsWith("/sql")) return false;
|
|
31
|
+
const parsed = JSON.parse(body);
|
|
32
|
+
return isNeonQuery(parsed) || Array.isArray(parsed) && parsed.length > 0 && isNeonQuery(parsed[0]);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function buildSetConfigQueries(requestId, callIndex) {
|
|
38
|
+
return [
|
|
39
|
+
{ query: "SELECT set_config('app.replay_request_id', $1, true)", params: [requestId] },
|
|
40
|
+
{ query: "SELECT set_config('app.replay_call_index', $1, true)", params: [String(callIndex)] }
|
|
41
|
+
];
|
|
42
|
+
}
|
|
9
43
|
function installNetworkInterceptor(mode, calls) {
|
|
10
44
|
const originalFetch = globalThis.fetch;
|
|
11
45
|
const consumed = /* @__PURE__ */ new Set();
|
|
@@ -20,6 +54,20 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
20
54
|
});
|
|
21
55
|
}
|
|
22
56
|
const requestBody = typeof init?.body === "string" ? init.body : void 0;
|
|
57
|
+
const requestId = getCurrentRequestId();
|
|
58
|
+
if (requestId && method.toUpperCase() === "POST" && isNeonSqlRequest(url, requestBody)) {
|
|
59
|
+
return await handleNeonSqlRequest(
|
|
60
|
+
originalFetch,
|
|
61
|
+
input,
|
|
62
|
+
init,
|
|
63
|
+
url,
|
|
64
|
+
method,
|
|
65
|
+
requestHeaders,
|
|
66
|
+
requestBody,
|
|
67
|
+
requestId,
|
|
68
|
+
calls
|
|
69
|
+
);
|
|
70
|
+
}
|
|
23
71
|
const response = await originalFetch(input, init);
|
|
24
72
|
const responseBody = await response.clone().text();
|
|
25
73
|
const responseHeaders = {};
|
|
@@ -121,6 +169,50 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
121
169
|
}
|
|
122
170
|
};
|
|
123
171
|
}
|
|
172
|
+
async function handleNeonSqlRequest(originalFetch, input, init, url, method, requestHeaders, requestBody, requestId, calls) {
|
|
173
|
+
const parsed = JSON.parse(requestBody);
|
|
174
|
+
const wasTransaction = Array.isArray(parsed);
|
|
175
|
+
const callIndex = incrementCallIndex();
|
|
176
|
+
const setConfigs = buildSetConfigQueries(requestId, callIndex);
|
|
177
|
+
const txBody = wasTransaction ? [...setConfigs, ...parsed] : [...setConfigs, parsed];
|
|
178
|
+
const modifiedInit = {
|
|
179
|
+
...init,
|
|
180
|
+
body: JSON.stringify(txBody)
|
|
181
|
+
};
|
|
182
|
+
const response = await originalFetch(input, modifiedInit);
|
|
183
|
+
const rawBody = await response.clone().text();
|
|
184
|
+
const responseHeaders = {};
|
|
185
|
+
response.headers.forEach((v, k) => {
|
|
186
|
+
responseHeaders[k] = v;
|
|
187
|
+
});
|
|
188
|
+
let recordedBody = rawBody;
|
|
189
|
+
if (response.ok) {
|
|
190
|
+
try {
|
|
191
|
+
const json = JSON.parse(rawBody);
|
|
192
|
+
if (json.results && Array.isArray(json.results)) {
|
|
193
|
+
const stripped = json.results.slice(setConfigs.length);
|
|
194
|
+
const unwrapped = wasTransaction ? { results: stripped } : stripped[0] ?? json;
|
|
195
|
+
recordedBody = JSON.stringify(unwrapped);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
calls.push({
|
|
201
|
+
url,
|
|
202
|
+
method,
|
|
203
|
+
requestHeaders,
|
|
204
|
+
requestBody,
|
|
205
|
+
responseStatus: response.status,
|
|
206
|
+
responseHeaders,
|
|
207
|
+
responseBody: recordedBody,
|
|
208
|
+
timestamp: Date.now()
|
|
209
|
+
});
|
|
210
|
+
return new Response(recordedBody, {
|
|
211
|
+
status: response.status,
|
|
212
|
+
statusText: response.statusText,
|
|
213
|
+
headers: responseHeaders
|
|
214
|
+
});
|
|
215
|
+
}
|
|
124
216
|
|
|
125
217
|
// src/interceptors/environment.ts
|
|
126
218
|
function installEnvironmentInterceptor(mode, reads) {
|
|
@@ -484,17 +576,6 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
484
576
|
|
|
485
577
|
// src/createRecordingRequestHandler.ts
|
|
486
578
|
import crypto from "crypto";
|
|
487
|
-
|
|
488
|
-
// src/requestState.ts
|
|
489
|
-
var _currentRequestId = null;
|
|
490
|
-
function setCurrentRequestId(id) {
|
|
491
|
-
_currentRequestId = id;
|
|
492
|
-
}
|
|
493
|
-
function getCurrentRequestId() {
|
|
494
|
-
return _currentRequestId;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// src/createRecordingRequestHandler.ts
|
|
498
579
|
function createRecordingRequestHandler(handler, options) {
|
|
499
580
|
return async (event, context) => {
|
|
500
581
|
const requestId = crypto.randomUUID();
|
|
@@ -1149,7 +1230,7 @@ async function databaseAuditEnsureLogTable(sql) {
|
|
|
1149
1230
|
req_id TEXT;
|
|
1150
1231
|
call_idx INTEGER;
|
|
1151
1232
|
BEGIN
|
|
1152
|
-
-- Read application context
|
|
1233
|
+
-- Read application context injected by the network interceptor
|
|
1153
1234
|
req_id := COALESCE(current_setting('app.replay_request_id', true), '');
|
|
1154
1235
|
IF req_id = '' THEN req_id := NULL; END IF;
|
|
1155
1236
|
|
|
@@ -1201,30 +1282,7 @@ async function databaseAuditDumpLogTable(sql) {
|
|
|
1201
1282
|
const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
|
|
1202
1283
|
return rows;
|
|
1203
1284
|
}
|
|
1204
|
-
function createAuditedSql(sql) {
|
|
1205
|
-
let callIndex = 0;
|
|
1206
|
-
const sqlWithTx = sql;
|
|
1207
|
-
const auditedSql = async (strings, ...values) => {
|
|
1208
|
-
const requestId = getCurrentRequestId();
|
|
1209
|
-
callIndex++;
|
|
1210
|
-
if (requestId && typeof sqlWithTx.transaction === "function") {
|
|
1211
|
-
try {
|
|
1212
|
-
const results = await sqlWithTx.transaction([
|
|
1213
|
-
sql`SELECT set_config('app.replay_request_id', ${requestId}, true)`,
|
|
1214
|
-
sql`SELECT set_config('app.replay_call_index', ${String(callIndex)}, true)`,
|
|
1215
|
-
sql(strings, ...values)
|
|
1216
|
-
]);
|
|
1217
|
-
return results[2];
|
|
1218
|
-
} catch (err) {
|
|
1219
|
-
console.warn("netlify-recorder: audited transaction failed, falling back:", err);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
return await sql(strings, ...values);
|
|
1223
|
-
};
|
|
1224
|
-
return auditedSql;
|
|
1225
|
-
}
|
|
1226
1285
|
export {
|
|
1227
|
-
createAuditedSql,
|
|
1228
1286
|
createRecordingRequestHandler,
|
|
1229
1287
|
createRequestRecording,
|
|
1230
1288
|
databaseAuditDumpLogTable,
|