@durable-streams/server 0.1.3 → 0.1.4
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/dist/index.cjs +106 -30
- package/dist/index.d.cts +55 -10
- package/dist/index.d.ts +55 -10
- package/dist/index.js +106 -30
- package/package.json +5 -5
- package/src/server.ts +196 -44
package/dist/index.cjs
CHANGED
|
@@ -1075,10 +1075,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
|
|
|
1075
1075
|
/**
|
|
1076
1076
|
* Encode data for SSE format.
|
|
1077
1077
|
* Per SSE spec, each line in the payload needs its own "data:" prefix.
|
|
1078
|
-
*
|
|
1078
|
+
* Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
|
|
1079
|
+
* This prevents CRLF injection attacks where malicious payloads could inject
|
|
1080
|
+
* fake SSE events using CR-only line terminators.
|
|
1079
1081
|
*/
|
|
1080
1082
|
function encodeSSEData(payload) {
|
|
1081
|
-
const lines = payload.split(
|
|
1083
|
+
const lines = payload.split(/\r\n|\r|\n/);
|
|
1082
1084
|
return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
|
|
1083
1085
|
}
|
|
1084
1086
|
/**
|
|
@@ -1117,8 +1119,8 @@ var DurableStreamTestServer = class {
|
|
|
1117
1119
|
_url = null;
|
|
1118
1120
|
activeSSEResponses = new Set();
|
|
1119
1121
|
isShuttingDown = false;
|
|
1120
|
-
/** Injected
|
|
1121
|
-
|
|
1122
|
+
/** Injected faults for testing retry/resilience */
|
|
1123
|
+
injectedFaults = new Map();
|
|
1122
1124
|
constructor(options = {}) {
|
|
1123
1125
|
if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
|
|
1124
1126
|
else this.store = new StreamStore();
|
|
@@ -1203,30 +1205,71 @@ var DurableStreamTestServer = class {
|
|
|
1203
1205
|
/**
|
|
1204
1206
|
* Inject an error to be returned on the next N requests to a path.
|
|
1205
1207
|
* Used for testing retry/resilience behavior.
|
|
1208
|
+
* @deprecated Use injectFault for full fault injection capabilities
|
|
1206
1209
|
*/
|
|
1207
1210
|
injectError(path, status, count = 1, retryAfter) {
|
|
1208
|
-
this.
|
|
1211
|
+
this.injectedFaults.set(path, {
|
|
1209
1212
|
status,
|
|
1210
1213
|
count,
|
|
1211
1214
|
retryAfter
|
|
1212
1215
|
});
|
|
1213
1216
|
}
|
|
1214
1217
|
/**
|
|
1215
|
-
*
|
|
1218
|
+
* Inject a fault to be triggered on the next N requests to a path.
|
|
1219
|
+
* Supports various fault types: delays, connection drops, body corruption, etc.
|
|
1216
1220
|
*/
|
|
1217
|
-
|
|
1218
|
-
this.
|
|
1221
|
+
injectFault(path, fault) {
|
|
1222
|
+
this.injectedFaults.set(path, {
|
|
1223
|
+
count: 1,
|
|
1224
|
+
...fault
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Clear all injected faults.
|
|
1229
|
+
*/
|
|
1230
|
+
clearInjectedFaults() {
|
|
1231
|
+
this.injectedFaults.clear();
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Check if there's an injected fault for this path/method and consume it.
|
|
1235
|
+
* Returns the fault config if one should be triggered, null otherwise.
|
|
1236
|
+
*/
|
|
1237
|
+
consumeInjectedFault(path, method) {
|
|
1238
|
+
const fault = this.injectedFaults.get(path);
|
|
1239
|
+
if (!fault) return null;
|
|
1240
|
+
if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) return null;
|
|
1241
|
+
if (fault.probability !== void 0 && Math.random() > fault.probability) return null;
|
|
1242
|
+
fault.count--;
|
|
1243
|
+
if (fault.count <= 0) this.injectedFaults.delete(path);
|
|
1244
|
+
return fault;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Apply delay from fault config (including jitter).
|
|
1248
|
+
*/
|
|
1249
|
+
async applyFaultDelay(fault) {
|
|
1250
|
+
if (fault.delayMs !== void 0 && fault.delayMs > 0) {
|
|
1251
|
+
const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0;
|
|
1252
|
+
await new Promise((resolve) => setTimeout(resolve, fault.delayMs + jitter));
|
|
1253
|
+
}
|
|
1219
1254
|
}
|
|
1220
1255
|
/**
|
|
1221
|
-
*
|
|
1222
|
-
* Returns
|
|
1256
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
1257
|
+
* Returns modified body, or original if no modifications needed.
|
|
1223
1258
|
*/
|
|
1224
|
-
|
|
1225
|
-
const
|
|
1226
|
-
if (!
|
|
1227
|
-
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
1259
|
+
applyFaultBodyModification(res, body) {
|
|
1260
|
+
const fault = res._injectedFault;
|
|
1261
|
+
if (!fault) return body;
|
|
1262
|
+
let modified = body;
|
|
1263
|
+
if (fault.truncateBodyBytes !== void 0 && modified.length > fault.truncateBodyBytes) modified = modified.slice(0, fault.truncateBodyBytes);
|
|
1264
|
+
if (fault.corruptBody && modified.length > 0) {
|
|
1265
|
+
modified = new Uint8Array(modified);
|
|
1266
|
+
const numCorrupt = Math.max(1, Math.floor(modified.length * .03));
|
|
1267
|
+
for (let i = 0; i < numCorrupt; i++) {
|
|
1268
|
+
const pos = Math.floor(Math.random() * modified.length);
|
|
1269
|
+
modified[pos] = modified[pos] ^ 1 << Math.floor(Math.random() * 8);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return modified;
|
|
1230
1273
|
}
|
|
1231
1274
|
async handleRequest(req, res) {
|
|
1232
1275
|
const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
|
|
@@ -1236,6 +1279,8 @@ var DurableStreamTestServer = class {
|
|
|
1236
1279
|
res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
|
|
1237
1280
|
res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
|
|
1238
1281
|
res.setHeader(`access-control-expose-headers`, `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`);
|
|
1282
|
+
res.setHeader(`x-content-type-options`, `nosniff`);
|
|
1283
|
+
res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
|
|
1239
1284
|
if (method === `OPTIONS`) {
|
|
1240
1285
|
res.writeHead(204);
|
|
1241
1286
|
res.end();
|
|
@@ -1245,13 +1290,21 @@ var DurableStreamTestServer = class {
|
|
|
1245
1290
|
await this.handleTestInjectError(method, req, res);
|
|
1246
1291
|
return;
|
|
1247
1292
|
}
|
|
1248
|
-
const
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1251
|
-
if (
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1293
|
+
const fault = this.consumeInjectedFault(path, method ?? `GET`);
|
|
1294
|
+
if (fault) {
|
|
1295
|
+
await this.applyFaultDelay(fault);
|
|
1296
|
+
if (fault.dropConnection) {
|
|
1297
|
+
res.socket?.destroy();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (fault.status !== void 0) {
|
|
1301
|
+
const headers = { "content-type": `text/plain` };
|
|
1302
|
+
if (fault.retryAfter !== void 0) headers[`retry-after`] = fault.retryAfter.toString();
|
|
1303
|
+
res.writeHead(fault.status, headers);
|
|
1304
|
+
res.end(`Injected error for testing`);
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
if (fault.truncateBodyBytes !== void 0 || fault.corruptBody) res._injectedFault = fault;
|
|
1255
1308
|
}
|
|
1256
1309
|
try {
|
|
1257
1310
|
switch (method) {
|
|
@@ -1366,7 +1419,10 @@ var DurableStreamTestServer = class {
|
|
|
1366
1419
|
res.end();
|
|
1367
1420
|
return;
|
|
1368
1421
|
}
|
|
1369
|
-
const headers = {
|
|
1422
|
+
const headers = {
|
|
1423
|
+
[STREAM_OFFSET_HEADER]: stream.currentOffset,
|
|
1424
|
+
"cache-control": `no-store`
|
|
1425
|
+
};
|
|
1370
1426
|
if (stream.contentType) headers[`content-type`] = stream.contentType;
|
|
1371
1427
|
headers[`etag`] = `"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}"`;
|
|
1372
1428
|
res.writeHead(200, headers);
|
|
@@ -1457,6 +1513,7 @@ var DurableStreamTestServer = class {
|
|
|
1457
1513
|
headers[`vary`] = `accept-encoding`;
|
|
1458
1514
|
}
|
|
1459
1515
|
}
|
|
1516
|
+
finalData = this.applyFaultBodyModification(res, finalData);
|
|
1460
1517
|
res.writeHead(200, headers);
|
|
1461
1518
|
res.end(Buffer.from(finalData));
|
|
1462
1519
|
}
|
|
@@ -1469,7 +1526,9 @@ var DurableStreamTestServer = class {
|
|
|
1469
1526
|
"content-type": `text/event-stream`,
|
|
1470
1527
|
"cache-control": `no-cache`,
|
|
1471
1528
|
connection: `keep-alive`,
|
|
1472
|
-
"access-control-allow-origin":
|
|
1529
|
+
"access-control-allow-origin": `*`,
|
|
1530
|
+
"x-content-type-options": `nosniff`,
|
|
1531
|
+
"cross-origin-resource-policy": `cross-origin`
|
|
1473
1532
|
});
|
|
1474
1533
|
let currentOffset = initialOffset;
|
|
1475
1534
|
let isConnected = true;
|
|
@@ -1540,7 +1599,7 @@ var DurableStreamTestServer = class {
|
|
|
1540
1599
|
seq,
|
|
1541
1600
|
contentType
|
|
1542
1601
|
}));
|
|
1543
|
-
res.writeHead(
|
|
1602
|
+
res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
|
|
1544
1603
|
res.end();
|
|
1545
1604
|
}
|
|
1546
1605
|
/**
|
|
@@ -1571,12 +1630,29 @@ var DurableStreamTestServer = class {
|
|
|
1571
1630
|
const body = await this.readBody(req);
|
|
1572
1631
|
try {
|
|
1573
1632
|
const config = JSON.parse(new TextDecoder().decode(body));
|
|
1574
|
-
if (!config.path
|
|
1633
|
+
if (!config.path) {
|
|
1634
|
+
res.writeHead(400, { "content-type": `text/plain` });
|
|
1635
|
+
res.end(`Missing required field: path`);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
|
|
1639
|
+
if (!hasFaultType) {
|
|
1575
1640
|
res.writeHead(400, { "content-type": `text/plain` });
|
|
1576
|
-
res.end(`
|
|
1641
|
+
res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
|
|
1577
1642
|
return;
|
|
1578
1643
|
}
|
|
1579
|
-
this.
|
|
1644
|
+
this.injectFault(config.path, {
|
|
1645
|
+
status: config.status,
|
|
1646
|
+
count: config.count ?? 1,
|
|
1647
|
+
retryAfter: config.retryAfter,
|
|
1648
|
+
delayMs: config.delayMs,
|
|
1649
|
+
dropConnection: config.dropConnection,
|
|
1650
|
+
truncateBodyBytes: config.truncateBodyBytes,
|
|
1651
|
+
probability: config.probability,
|
|
1652
|
+
method: config.method,
|
|
1653
|
+
corruptBody: config.corruptBody,
|
|
1654
|
+
jitterMs: config.jitterMs
|
|
1655
|
+
});
|
|
1580
1656
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1581
1657
|
res.end(JSON.stringify({ ok: true }));
|
|
1582
1658
|
} catch {
|
|
@@ -1584,7 +1660,7 @@ var DurableStreamTestServer = class {
|
|
|
1584
1660
|
res.end(`Invalid JSON body`);
|
|
1585
1661
|
}
|
|
1586
1662
|
} else if (method === `DELETE`) {
|
|
1587
|
-
this.
|
|
1663
|
+
this.clearInjectedFaults();
|
|
1588
1664
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1589
1665
|
res.end(JSON.stringify({ ok: true }));
|
|
1590
1666
|
} else {
|
package/dist/index.d.cts
CHANGED
|
@@ -332,6 +332,36 @@ declare class FileBackedStreamStore {
|
|
|
332
332
|
|
|
333
333
|
//#endregion
|
|
334
334
|
//#region src/server.d.ts
|
|
335
|
+
/**
|
|
336
|
+
* HTTP server for testing durable streams.
|
|
337
|
+
* Supports both in-memory and file-backed storage modes.
|
|
338
|
+
*/
|
|
339
|
+
/**
|
|
340
|
+
* Configuration for injected faults (for testing retry/resilience).
|
|
341
|
+
* Supports various fault types beyond simple HTTP errors.
|
|
342
|
+
*/
|
|
343
|
+
interface InjectedFault {
|
|
344
|
+
/** HTTP status code to return (if set, returns error response) */
|
|
345
|
+
status?: number;
|
|
346
|
+
/** Number of times to trigger this fault (decremented on each use) */
|
|
347
|
+
count: number;
|
|
348
|
+
/** Optional Retry-After header value (seconds) */
|
|
349
|
+
retryAfter?: number;
|
|
350
|
+
/** Delay in milliseconds before responding */
|
|
351
|
+
delayMs?: number;
|
|
352
|
+
/** Drop the connection after sending headers (simulates network failure) */
|
|
353
|
+
dropConnection?: boolean;
|
|
354
|
+
/** Truncate response body to this many bytes */
|
|
355
|
+
truncateBodyBytes?: number;
|
|
356
|
+
/** Probability of triggering fault (0-1, default 1.0 = always) */
|
|
357
|
+
probability?: number;
|
|
358
|
+
/** Only match specific HTTP method (GET, POST, PUT, DELETE) */
|
|
359
|
+
method?: string;
|
|
360
|
+
/** Corrupt the response body by flipping random bits */
|
|
361
|
+
corruptBody?: boolean;
|
|
362
|
+
/** Add jitter to delay (random 0-jitterMs added to delayMs) */
|
|
363
|
+
jitterMs?: number;
|
|
364
|
+
}
|
|
335
365
|
declare class DurableStreamTestServer {
|
|
336
366
|
readonly store: StreamStore | FileBackedStreamStore;
|
|
337
367
|
private server;
|
|
@@ -339,8 +369,8 @@ declare class DurableStreamTestServer {
|
|
|
339
369
|
private _url;
|
|
340
370
|
private activeSSEResponses;
|
|
341
371
|
private isShuttingDown;
|
|
342
|
-
/** Injected
|
|
343
|
-
private
|
|
372
|
+
/** Injected faults for testing retry/resilience */
|
|
373
|
+
private injectedFaults;
|
|
344
374
|
constructor(options?: TestServerOptions);
|
|
345
375
|
/**
|
|
346
376
|
* Start the server.
|
|
@@ -361,17 +391,34 @@ declare class DurableStreamTestServer {
|
|
|
361
391
|
/**
|
|
362
392
|
* Inject an error to be returned on the next N requests to a path.
|
|
363
393
|
* Used for testing retry/resilience behavior.
|
|
394
|
+
* @deprecated Use injectFault for full fault injection capabilities
|
|
364
395
|
*/
|
|
365
396
|
injectError(path: string, status: number, count?: number, retryAfter?: number): void;
|
|
366
397
|
/**
|
|
367
|
-
*
|
|
398
|
+
* Inject a fault to be triggered on the next N requests to a path.
|
|
399
|
+
* Supports various fault types: delays, connection drops, body corruption, etc.
|
|
400
|
+
*/
|
|
401
|
+
injectFault(path: string, fault: Omit<InjectedFault, `count`> & {
|
|
402
|
+
count?: number;
|
|
403
|
+
}): void;
|
|
404
|
+
/**
|
|
405
|
+
* Clear all injected faults.
|
|
368
406
|
*/
|
|
369
|
-
|
|
407
|
+
clearInjectedFaults(): void;
|
|
370
408
|
/**
|
|
371
|
-
* Check if there's an injected
|
|
372
|
-
* Returns the
|
|
409
|
+
* Check if there's an injected fault for this path/method and consume it.
|
|
410
|
+
* Returns the fault config if one should be triggered, null otherwise.
|
|
373
411
|
*/
|
|
374
|
-
private
|
|
412
|
+
private consumeInjectedFault;
|
|
413
|
+
/**
|
|
414
|
+
* Apply delay from fault config (including jitter).
|
|
415
|
+
*/
|
|
416
|
+
private applyFaultDelay;
|
|
417
|
+
/**
|
|
418
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
419
|
+
* Returns modified body, or original if no modifications needed.
|
|
420
|
+
*/
|
|
421
|
+
private applyFaultBodyModification;
|
|
375
422
|
private handleRequest;
|
|
376
423
|
/**
|
|
377
424
|
* Handle PUT - create stream
|
|
@@ -404,9 +451,7 @@ declare class DurableStreamTestServer {
|
|
|
404
451
|
*/
|
|
405
452
|
private handleTestInjectError;
|
|
406
453
|
private readBody;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
//#endregion
|
|
454
|
+
} //#endregion
|
|
410
455
|
//#region src/path-encoding.d.ts
|
|
411
456
|
/**
|
|
412
457
|
* Encode a stream path to a filesystem-safe directory name using base64url encoding.
|
package/dist/index.d.ts
CHANGED
|
@@ -332,6 +332,36 @@ declare class FileBackedStreamStore {
|
|
|
332
332
|
|
|
333
333
|
//#endregion
|
|
334
334
|
//#region src/server.d.ts
|
|
335
|
+
/**
|
|
336
|
+
* HTTP server for testing durable streams.
|
|
337
|
+
* Supports both in-memory and file-backed storage modes.
|
|
338
|
+
*/
|
|
339
|
+
/**
|
|
340
|
+
* Configuration for injected faults (for testing retry/resilience).
|
|
341
|
+
* Supports various fault types beyond simple HTTP errors.
|
|
342
|
+
*/
|
|
343
|
+
interface InjectedFault {
|
|
344
|
+
/** HTTP status code to return (if set, returns error response) */
|
|
345
|
+
status?: number;
|
|
346
|
+
/** Number of times to trigger this fault (decremented on each use) */
|
|
347
|
+
count: number;
|
|
348
|
+
/** Optional Retry-After header value (seconds) */
|
|
349
|
+
retryAfter?: number;
|
|
350
|
+
/** Delay in milliseconds before responding */
|
|
351
|
+
delayMs?: number;
|
|
352
|
+
/** Drop the connection after sending headers (simulates network failure) */
|
|
353
|
+
dropConnection?: boolean;
|
|
354
|
+
/** Truncate response body to this many bytes */
|
|
355
|
+
truncateBodyBytes?: number;
|
|
356
|
+
/** Probability of triggering fault (0-1, default 1.0 = always) */
|
|
357
|
+
probability?: number;
|
|
358
|
+
/** Only match specific HTTP method (GET, POST, PUT, DELETE) */
|
|
359
|
+
method?: string;
|
|
360
|
+
/** Corrupt the response body by flipping random bits */
|
|
361
|
+
corruptBody?: boolean;
|
|
362
|
+
/** Add jitter to delay (random 0-jitterMs added to delayMs) */
|
|
363
|
+
jitterMs?: number;
|
|
364
|
+
}
|
|
335
365
|
declare class DurableStreamTestServer {
|
|
336
366
|
readonly store: StreamStore | FileBackedStreamStore;
|
|
337
367
|
private server;
|
|
@@ -339,8 +369,8 @@ declare class DurableStreamTestServer {
|
|
|
339
369
|
private _url;
|
|
340
370
|
private activeSSEResponses;
|
|
341
371
|
private isShuttingDown;
|
|
342
|
-
/** Injected
|
|
343
|
-
private
|
|
372
|
+
/** Injected faults for testing retry/resilience */
|
|
373
|
+
private injectedFaults;
|
|
344
374
|
constructor(options?: TestServerOptions);
|
|
345
375
|
/**
|
|
346
376
|
* Start the server.
|
|
@@ -361,17 +391,34 @@ declare class DurableStreamTestServer {
|
|
|
361
391
|
/**
|
|
362
392
|
* Inject an error to be returned on the next N requests to a path.
|
|
363
393
|
* Used for testing retry/resilience behavior.
|
|
394
|
+
* @deprecated Use injectFault for full fault injection capabilities
|
|
364
395
|
*/
|
|
365
396
|
injectError(path: string, status: number, count?: number, retryAfter?: number): void;
|
|
366
397
|
/**
|
|
367
|
-
*
|
|
398
|
+
* Inject a fault to be triggered on the next N requests to a path.
|
|
399
|
+
* Supports various fault types: delays, connection drops, body corruption, etc.
|
|
400
|
+
*/
|
|
401
|
+
injectFault(path: string, fault: Omit<InjectedFault, `count`> & {
|
|
402
|
+
count?: number;
|
|
403
|
+
}): void;
|
|
404
|
+
/**
|
|
405
|
+
* Clear all injected faults.
|
|
368
406
|
*/
|
|
369
|
-
|
|
407
|
+
clearInjectedFaults(): void;
|
|
370
408
|
/**
|
|
371
|
-
* Check if there's an injected
|
|
372
|
-
* Returns the
|
|
409
|
+
* Check if there's an injected fault for this path/method and consume it.
|
|
410
|
+
* Returns the fault config if one should be triggered, null otherwise.
|
|
373
411
|
*/
|
|
374
|
-
private
|
|
412
|
+
private consumeInjectedFault;
|
|
413
|
+
/**
|
|
414
|
+
* Apply delay from fault config (including jitter).
|
|
415
|
+
*/
|
|
416
|
+
private applyFaultDelay;
|
|
417
|
+
/**
|
|
418
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
419
|
+
* Returns modified body, or original if no modifications needed.
|
|
420
|
+
*/
|
|
421
|
+
private applyFaultBodyModification;
|
|
375
422
|
private handleRequest;
|
|
376
423
|
/**
|
|
377
424
|
* Handle PUT - create stream
|
|
@@ -404,9 +451,7 @@ declare class DurableStreamTestServer {
|
|
|
404
451
|
*/
|
|
405
452
|
private handleTestInjectError;
|
|
406
453
|
private readBody;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
//#endregion
|
|
454
|
+
} //#endregion
|
|
410
455
|
//#region src/path-encoding.d.ts
|
|
411
456
|
/**
|
|
412
457
|
* Encode a stream path to a filesystem-safe directory name using base64url encoding.
|
package/dist/index.js
CHANGED
|
@@ -1052,10 +1052,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
|
|
|
1052
1052
|
/**
|
|
1053
1053
|
* Encode data for SSE format.
|
|
1054
1054
|
* Per SSE spec, each line in the payload needs its own "data:" prefix.
|
|
1055
|
-
*
|
|
1055
|
+
* Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
|
|
1056
|
+
* This prevents CRLF injection attacks where malicious payloads could inject
|
|
1057
|
+
* fake SSE events using CR-only line terminators.
|
|
1056
1058
|
*/
|
|
1057
1059
|
function encodeSSEData(payload) {
|
|
1058
|
-
const lines = payload.split(
|
|
1060
|
+
const lines = payload.split(/\r\n|\r|\n/);
|
|
1059
1061
|
return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
|
|
1060
1062
|
}
|
|
1061
1063
|
/**
|
|
@@ -1094,8 +1096,8 @@ var DurableStreamTestServer = class {
|
|
|
1094
1096
|
_url = null;
|
|
1095
1097
|
activeSSEResponses = new Set();
|
|
1096
1098
|
isShuttingDown = false;
|
|
1097
|
-
/** Injected
|
|
1098
|
-
|
|
1099
|
+
/** Injected faults for testing retry/resilience */
|
|
1100
|
+
injectedFaults = new Map();
|
|
1099
1101
|
constructor(options = {}) {
|
|
1100
1102
|
if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
|
|
1101
1103
|
else this.store = new StreamStore();
|
|
@@ -1180,30 +1182,71 @@ var DurableStreamTestServer = class {
|
|
|
1180
1182
|
/**
|
|
1181
1183
|
* Inject an error to be returned on the next N requests to a path.
|
|
1182
1184
|
* Used for testing retry/resilience behavior.
|
|
1185
|
+
* @deprecated Use injectFault for full fault injection capabilities
|
|
1183
1186
|
*/
|
|
1184
1187
|
injectError(path$2, status, count = 1, retryAfter) {
|
|
1185
|
-
this.
|
|
1188
|
+
this.injectedFaults.set(path$2, {
|
|
1186
1189
|
status,
|
|
1187
1190
|
count,
|
|
1188
1191
|
retryAfter
|
|
1189
1192
|
});
|
|
1190
1193
|
}
|
|
1191
1194
|
/**
|
|
1192
|
-
*
|
|
1195
|
+
* Inject a fault to be triggered on the next N requests to a path.
|
|
1196
|
+
* Supports various fault types: delays, connection drops, body corruption, etc.
|
|
1193
1197
|
*/
|
|
1194
|
-
|
|
1195
|
-
this.
|
|
1198
|
+
injectFault(path$2, fault) {
|
|
1199
|
+
this.injectedFaults.set(path$2, {
|
|
1200
|
+
count: 1,
|
|
1201
|
+
...fault
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Clear all injected faults.
|
|
1206
|
+
*/
|
|
1207
|
+
clearInjectedFaults() {
|
|
1208
|
+
this.injectedFaults.clear();
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Check if there's an injected fault for this path/method and consume it.
|
|
1212
|
+
* Returns the fault config if one should be triggered, null otherwise.
|
|
1213
|
+
*/
|
|
1214
|
+
consumeInjectedFault(path$2, method) {
|
|
1215
|
+
const fault = this.injectedFaults.get(path$2);
|
|
1216
|
+
if (!fault) return null;
|
|
1217
|
+
if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) return null;
|
|
1218
|
+
if (fault.probability !== void 0 && Math.random() > fault.probability) return null;
|
|
1219
|
+
fault.count--;
|
|
1220
|
+
if (fault.count <= 0) this.injectedFaults.delete(path$2);
|
|
1221
|
+
return fault;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Apply delay from fault config (including jitter).
|
|
1225
|
+
*/
|
|
1226
|
+
async applyFaultDelay(fault) {
|
|
1227
|
+
if (fault.delayMs !== void 0 && fault.delayMs > 0) {
|
|
1228
|
+
const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0;
|
|
1229
|
+
await new Promise((resolve) => setTimeout(resolve, fault.delayMs + jitter));
|
|
1230
|
+
}
|
|
1196
1231
|
}
|
|
1197
1232
|
/**
|
|
1198
|
-
*
|
|
1199
|
-
* Returns
|
|
1233
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
1234
|
+
* Returns modified body, or original if no modifications needed.
|
|
1200
1235
|
*/
|
|
1201
|
-
|
|
1202
|
-
const
|
|
1203
|
-
if (!
|
|
1204
|
-
|
|
1205
|
-
if (
|
|
1206
|
-
|
|
1236
|
+
applyFaultBodyModification(res, body) {
|
|
1237
|
+
const fault = res._injectedFault;
|
|
1238
|
+
if (!fault) return body;
|
|
1239
|
+
let modified = body;
|
|
1240
|
+
if (fault.truncateBodyBytes !== void 0 && modified.length > fault.truncateBodyBytes) modified = modified.slice(0, fault.truncateBodyBytes);
|
|
1241
|
+
if (fault.corruptBody && modified.length > 0) {
|
|
1242
|
+
modified = new Uint8Array(modified);
|
|
1243
|
+
const numCorrupt = Math.max(1, Math.floor(modified.length * .03));
|
|
1244
|
+
for (let i = 0; i < numCorrupt; i++) {
|
|
1245
|
+
const pos = Math.floor(Math.random() * modified.length);
|
|
1246
|
+
modified[pos] = modified[pos] ^ 1 << Math.floor(Math.random() * 8);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return modified;
|
|
1207
1250
|
}
|
|
1208
1251
|
async handleRequest(req, res) {
|
|
1209
1252
|
const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
|
|
@@ -1213,6 +1256,8 @@ var DurableStreamTestServer = class {
|
|
|
1213
1256
|
res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
|
|
1214
1257
|
res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
|
|
1215
1258
|
res.setHeader(`access-control-expose-headers`, `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`);
|
|
1259
|
+
res.setHeader(`x-content-type-options`, `nosniff`);
|
|
1260
|
+
res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
|
|
1216
1261
|
if (method === `OPTIONS`) {
|
|
1217
1262
|
res.writeHead(204);
|
|
1218
1263
|
res.end();
|
|
@@ -1222,13 +1267,21 @@ var DurableStreamTestServer = class {
|
|
|
1222
1267
|
await this.handleTestInjectError(method, req, res);
|
|
1223
1268
|
return;
|
|
1224
1269
|
}
|
|
1225
|
-
const
|
|
1226
|
-
if (
|
|
1227
|
-
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1270
|
+
const fault = this.consumeInjectedFault(path$2, method ?? `GET`);
|
|
1271
|
+
if (fault) {
|
|
1272
|
+
await this.applyFaultDelay(fault);
|
|
1273
|
+
if (fault.dropConnection) {
|
|
1274
|
+
res.socket?.destroy();
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
if (fault.status !== void 0) {
|
|
1278
|
+
const headers = { "content-type": `text/plain` };
|
|
1279
|
+
if (fault.retryAfter !== void 0) headers[`retry-after`] = fault.retryAfter.toString();
|
|
1280
|
+
res.writeHead(fault.status, headers);
|
|
1281
|
+
res.end(`Injected error for testing`);
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
if (fault.truncateBodyBytes !== void 0 || fault.corruptBody) res._injectedFault = fault;
|
|
1232
1285
|
}
|
|
1233
1286
|
try {
|
|
1234
1287
|
switch (method) {
|
|
@@ -1343,7 +1396,10 @@ var DurableStreamTestServer = class {
|
|
|
1343
1396
|
res.end();
|
|
1344
1397
|
return;
|
|
1345
1398
|
}
|
|
1346
|
-
const headers = {
|
|
1399
|
+
const headers = {
|
|
1400
|
+
[STREAM_OFFSET_HEADER]: stream.currentOffset,
|
|
1401
|
+
"cache-control": `no-store`
|
|
1402
|
+
};
|
|
1347
1403
|
if (stream.contentType) headers[`content-type`] = stream.contentType;
|
|
1348
1404
|
headers[`etag`] = `"${Buffer.from(path$2).toString(`base64`)}:-1:${stream.currentOffset}"`;
|
|
1349
1405
|
res.writeHead(200, headers);
|
|
@@ -1434,6 +1490,7 @@ var DurableStreamTestServer = class {
|
|
|
1434
1490
|
headers[`vary`] = `accept-encoding`;
|
|
1435
1491
|
}
|
|
1436
1492
|
}
|
|
1493
|
+
finalData = this.applyFaultBodyModification(res, finalData);
|
|
1437
1494
|
res.writeHead(200, headers);
|
|
1438
1495
|
res.end(Buffer.from(finalData));
|
|
1439
1496
|
}
|
|
@@ -1446,7 +1503,9 @@ var DurableStreamTestServer = class {
|
|
|
1446
1503
|
"content-type": `text/event-stream`,
|
|
1447
1504
|
"cache-control": `no-cache`,
|
|
1448
1505
|
connection: `keep-alive`,
|
|
1449
|
-
"access-control-allow-origin":
|
|
1506
|
+
"access-control-allow-origin": `*`,
|
|
1507
|
+
"x-content-type-options": `nosniff`,
|
|
1508
|
+
"cross-origin-resource-policy": `cross-origin`
|
|
1450
1509
|
});
|
|
1451
1510
|
let currentOffset = initialOffset;
|
|
1452
1511
|
let isConnected = true;
|
|
@@ -1517,7 +1576,7 @@ var DurableStreamTestServer = class {
|
|
|
1517
1576
|
seq,
|
|
1518
1577
|
contentType
|
|
1519
1578
|
}));
|
|
1520
|
-
res.writeHead(
|
|
1579
|
+
res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
|
|
1521
1580
|
res.end();
|
|
1522
1581
|
}
|
|
1523
1582
|
/**
|
|
@@ -1548,12 +1607,29 @@ var DurableStreamTestServer = class {
|
|
|
1548
1607
|
const body = await this.readBody(req);
|
|
1549
1608
|
try {
|
|
1550
1609
|
const config = JSON.parse(new TextDecoder().decode(body));
|
|
1551
|
-
if (!config.path
|
|
1610
|
+
if (!config.path) {
|
|
1611
|
+
res.writeHead(400, { "content-type": `text/plain` });
|
|
1612
|
+
res.end(`Missing required field: path`);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
|
|
1616
|
+
if (!hasFaultType) {
|
|
1552
1617
|
res.writeHead(400, { "content-type": `text/plain` });
|
|
1553
|
-
res.end(`
|
|
1618
|
+
res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
|
|
1554
1619
|
return;
|
|
1555
1620
|
}
|
|
1556
|
-
this.
|
|
1621
|
+
this.injectFault(config.path, {
|
|
1622
|
+
status: config.status,
|
|
1623
|
+
count: config.count ?? 1,
|
|
1624
|
+
retryAfter: config.retryAfter,
|
|
1625
|
+
delayMs: config.delayMs,
|
|
1626
|
+
dropConnection: config.dropConnection,
|
|
1627
|
+
truncateBodyBytes: config.truncateBodyBytes,
|
|
1628
|
+
probability: config.probability,
|
|
1629
|
+
method: config.method,
|
|
1630
|
+
corruptBody: config.corruptBody,
|
|
1631
|
+
jitterMs: config.jitterMs
|
|
1632
|
+
});
|
|
1557
1633
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1558
1634
|
res.end(JSON.stringify({ ok: true }));
|
|
1559
1635
|
} catch {
|
|
@@ -1561,7 +1637,7 @@ var DurableStreamTestServer = class {
|
|
|
1561
1637
|
res.end(`Invalid JSON body`);
|
|
1562
1638
|
}
|
|
1563
1639
|
} else if (method === `DELETE`) {
|
|
1564
|
-
this.
|
|
1640
|
+
this.clearInjectedFaults();
|
|
1565
1641
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1566
1642
|
res.end(JSON.stringify({ ok: true }));
|
|
1567
1643
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Node.js reference server implementation for Durable Streams",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,15 +39,15 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@neophi/sieve-cache": "^1.0.0",
|
|
41
41
|
"lmdb": "^3.3.0",
|
|
42
|
-
"@durable-streams/client": "0.1.
|
|
43
|
-
"@durable-streams/state": "0.1.
|
|
42
|
+
"@durable-streams/client": "0.1.3",
|
|
43
|
+
"@durable-streams/state": "0.1.3"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.0.0",
|
|
47
47
|
"tsdown": "^0.9.0",
|
|
48
48
|
"typescript": "^5.0.0",
|
|
49
|
-
"vitest": "^
|
|
50
|
-
"@durable-streams/server-conformance-tests": "0.1.
|
|
49
|
+
"vitest": "^4.0.0",
|
|
50
|
+
"@durable-streams/server-conformance-tests": "0.1.6"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"dist",
|
package/src/server.ts
CHANGED
|
@@ -32,10 +32,14 @@ const CURSOR_QUERY_PARAM = `cursor`
|
|
|
32
32
|
/**
|
|
33
33
|
* Encode data for SSE format.
|
|
34
34
|
* Per SSE spec, each line in the payload needs its own "data:" prefix.
|
|
35
|
-
*
|
|
35
|
+
* Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
|
|
36
|
+
* This prevents CRLF injection attacks where malicious payloads could inject
|
|
37
|
+
* fake SSE events using CR-only line terminators.
|
|
36
38
|
*/
|
|
37
39
|
function encodeSSEData(payload: string): string {
|
|
38
|
-
|
|
40
|
+
// Split on all SSE-valid line terminators: CRLF, CR, or LF
|
|
41
|
+
// Order matters: \r\n must be matched before \r alone
|
|
42
|
+
const lines = payload.split(/\r\n|\r|\n/)
|
|
39
43
|
return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -92,15 +96,30 @@ function compressData(
|
|
|
92
96
|
* Supports both in-memory and file-backed storage modes.
|
|
93
97
|
*/
|
|
94
98
|
/**
|
|
95
|
-
* Configuration for injected
|
|
99
|
+
* Configuration for injected faults (for testing retry/resilience).
|
|
100
|
+
* Supports various fault types beyond simple HTTP errors.
|
|
96
101
|
*/
|
|
97
|
-
interface
|
|
98
|
-
/** HTTP status code to return */
|
|
99
|
-
status
|
|
100
|
-
/** Number of times to
|
|
102
|
+
interface InjectedFault {
|
|
103
|
+
/** HTTP status code to return (if set, returns error response) */
|
|
104
|
+
status?: number
|
|
105
|
+
/** Number of times to trigger this fault (decremented on each use) */
|
|
101
106
|
count: number
|
|
102
107
|
/** Optional Retry-After header value (seconds) */
|
|
103
108
|
retryAfter?: number
|
|
109
|
+
/** Delay in milliseconds before responding */
|
|
110
|
+
delayMs?: number
|
|
111
|
+
/** Drop the connection after sending headers (simulates network failure) */
|
|
112
|
+
dropConnection?: boolean
|
|
113
|
+
/** Truncate response body to this many bytes */
|
|
114
|
+
truncateBodyBytes?: number
|
|
115
|
+
/** Probability of triggering fault (0-1, default 1.0 = always) */
|
|
116
|
+
probability?: number
|
|
117
|
+
/** Only match specific HTTP method (GET, POST, PUT, DELETE) */
|
|
118
|
+
method?: string
|
|
119
|
+
/** Corrupt the response body by flipping random bits */
|
|
120
|
+
corruptBody?: boolean
|
|
121
|
+
/** Add jitter to delay (random 0-jitterMs added to delayMs) */
|
|
122
|
+
jitterMs?: number
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
export class DurableStreamTestServer {
|
|
@@ -126,8 +145,8 @@ export class DurableStreamTestServer {
|
|
|
126
145
|
private _url: string | null = null
|
|
127
146
|
private activeSSEResponses = new Set<ServerResponse>()
|
|
128
147
|
private isShuttingDown = false
|
|
129
|
-
/** Injected
|
|
130
|
-
private
|
|
148
|
+
/** Injected faults for testing retry/resilience */
|
|
149
|
+
private injectedFaults = new Map<string, InjectedFault>()
|
|
131
150
|
|
|
132
151
|
constructor(options: TestServerOptions = {}) {
|
|
133
152
|
// Choose store based on dataDir option
|
|
@@ -253,6 +272,7 @@ export class DurableStreamTestServer {
|
|
|
253
272
|
/**
|
|
254
273
|
* Inject an error to be returned on the next N requests to a path.
|
|
255
274
|
* Used for testing retry/resilience behavior.
|
|
275
|
+
* @deprecated Use injectFault for full fault injection capabilities
|
|
256
276
|
*/
|
|
257
277
|
injectError(
|
|
258
278
|
path: string,
|
|
@@ -260,30 +280,102 @@ export class DurableStreamTestServer {
|
|
|
260
280
|
count: number = 1,
|
|
261
281
|
retryAfter?: number
|
|
262
282
|
): void {
|
|
263
|
-
this.
|
|
283
|
+
this.injectedFaults.set(path, { status, count, retryAfter })
|
|
264
284
|
}
|
|
265
285
|
|
|
266
286
|
/**
|
|
267
|
-
*
|
|
287
|
+
* Inject a fault to be triggered on the next N requests to a path.
|
|
288
|
+
* Supports various fault types: delays, connection drops, body corruption, etc.
|
|
268
289
|
*/
|
|
269
|
-
|
|
270
|
-
|
|
290
|
+
injectFault(
|
|
291
|
+
path: string,
|
|
292
|
+
fault: Omit<InjectedFault, `count`> & { count?: number }
|
|
293
|
+
): void {
|
|
294
|
+
this.injectedFaults.set(path, { count: 1, ...fault })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Clear all injected faults.
|
|
299
|
+
*/
|
|
300
|
+
clearInjectedFaults(): void {
|
|
301
|
+
this.injectedFaults.clear()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Check if there's an injected fault for this path/method and consume it.
|
|
306
|
+
* Returns the fault config if one should be triggered, null otherwise.
|
|
307
|
+
*/
|
|
308
|
+
private consumeInjectedFault(
|
|
309
|
+
path: string,
|
|
310
|
+
method: string
|
|
311
|
+
): InjectedFault | null {
|
|
312
|
+
const fault = this.injectedFaults.get(path)
|
|
313
|
+
if (!fault) return null
|
|
314
|
+
|
|
315
|
+
// Check method filter
|
|
316
|
+
if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) {
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Check probability
|
|
321
|
+
if (fault.probability !== undefined && Math.random() > fault.probability) {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
fault.count--
|
|
326
|
+
if (fault.count <= 0) {
|
|
327
|
+
this.injectedFaults.delete(path)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return fault
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Apply delay from fault config (including jitter).
|
|
335
|
+
*/
|
|
336
|
+
private async applyFaultDelay(fault: InjectedFault): Promise<void> {
|
|
337
|
+
if (fault.delayMs !== undefined && fault.delayMs > 0) {
|
|
338
|
+
const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0
|
|
339
|
+
await new Promise((resolve) =>
|
|
340
|
+
setTimeout(resolve, fault.delayMs! + jitter)
|
|
341
|
+
)
|
|
342
|
+
}
|
|
271
343
|
}
|
|
272
344
|
|
|
273
345
|
/**
|
|
274
|
-
*
|
|
275
|
-
* Returns
|
|
346
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
347
|
+
* Returns modified body, or original if no modifications needed.
|
|
276
348
|
*/
|
|
277
|
-
private
|
|
278
|
-
|
|
279
|
-
|
|
349
|
+
private applyFaultBodyModification(
|
|
350
|
+
res: ServerResponse,
|
|
351
|
+
body: Uint8Array
|
|
352
|
+
): Uint8Array {
|
|
353
|
+
const fault = (res as ServerResponse & { _injectedFault?: InjectedFault })
|
|
354
|
+
._injectedFault
|
|
355
|
+
if (!fault) return body
|
|
356
|
+
|
|
357
|
+
let modified = body
|
|
280
358
|
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
359
|
+
// Truncate body if configured
|
|
360
|
+
if (
|
|
361
|
+
fault.truncateBodyBytes !== undefined &&
|
|
362
|
+
modified.length > fault.truncateBodyBytes
|
|
363
|
+
) {
|
|
364
|
+
modified = modified.slice(0, fault.truncateBodyBytes)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Corrupt body if configured (flip random bits)
|
|
368
|
+
if (fault.corruptBody && modified.length > 0) {
|
|
369
|
+
modified = new Uint8Array(modified) // Make a copy to avoid mutating original
|
|
370
|
+
// Flip 1-5% of bytes
|
|
371
|
+
const numCorrupt = Math.max(1, Math.floor(modified.length * 0.03))
|
|
372
|
+
for (let i = 0; i < numCorrupt; i++) {
|
|
373
|
+
const pos = Math.floor(Math.random() * modified.length)
|
|
374
|
+
modified[pos] = modified[pos]! ^ (1 << Math.floor(Math.random() * 8))
|
|
375
|
+
}
|
|
284
376
|
}
|
|
285
377
|
|
|
286
|
-
return
|
|
378
|
+
return modified
|
|
287
379
|
}
|
|
288
380
|
|
|
289
381
|
// ============================================================================
|
|
@@ -313,6 +405,10 @@ export class DurableStreamTestServer {
|
|
|
313
405
|
`Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`
|
|
314
406
|
)
|
|
315
407
|
|
|
408
|
+
// Browser security headers (Protocol Section 10.7)
|
|
409
|
+
res.setHeader(`x-content-type-options`, `nosniff`)
|
|
410
|
+
res.setHeader(`cross-origin-resource-policy`, `cross-origin`)
|
|
411
|
+
|
|
316
412
|
// Handle CORS preflight
|
|
317
413
|
if (method === `OPTIONS`) {
|
|
318
414
|
res.writeHead(204)
|
|
@@ -326,18 +422,37 @@ export class DurableStreamTestServer {
|
|
|
326
422
|
return
|
|
327
423
|
}
|
|
328
424
|
|
|
329
|
-
// Check for injected
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
425
|
+
// Check for injected faults (for testing retry/resilience)
|
|
426
|
+
const fault = this.consumeInjectedFault(path, method ?? `GET`)
|
|
427
|
+
if (fault) {
|
|
428
|
+
// Apply delay if configured
|
|
429
|
+
await this.applyFaultDelay(fault)
|
|
430
|
+
|
|
431
|
+
// Drop connection if configured (simulates network failure)
|
|
432
|
+
if (fault.dropConnection) {
|
|
433
|
+
res.socket?.destroy()
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// If status is set, return an error response
|
|
438
|
+
if (fault.status !== undefined) {
|
|
439
|
+
const headers: Record<string, string> = {
|
|
440
|
+
"content-type": `text/plain`,
|
|
441
|
+
}
|
|
442
|
+
if (fault.retryAfter !== undefined) {
|
|
443
|
+
headers[`retry-after`] = fault.retryAfter.toString()
|
|
444
|
+
}
|
|
445
|
+
res.writeHead(fault.status, headers)
|
|
446
|
+
res.end(`Injected error for testing`)
|
|
447
|
+
return
|
|
334
448
|
}
|
|
335
|
-
|
|
336
|
-
|
|
449
|
+
|
|
450
|
+
// Store fault for response modification (truncation, corruption)
|
|
451
|
+
if (fault.truncateBodyBytes !== undefined || fault.corruptBody) {
|
|
452
|
+
;(
|
|
453
|
+
res as ServerResponse & { _injectedFault?: InjectedFault }
|
|
454
|
+
)._injectedFault = fault
|
|
337
455
|
}
|
|
338
|
-
res.writeHead(injectedError.status, headers)
|
|
339
|
-
res.end(`Injected error for testing`)
|
|
340
|
-
return
|
|
341
456
|
}
|
|
342
457
|
|
|
343
458
|
try {
|
|
@@ -511,6 +626,8 @@ export class DurableStreamTestServer {
|
|
|
511
626
|
|
|
512
627
|
const headers: Record<string, string> = {
|
|
513
628
|
[STREAM_OFFSET_HEADER]: stream.currentOffset,
|
|
629
|
+
// HEAD responses should not be cached to avoid stale tail offsets (Protocol Section 5.4)
|
|
630
|
+
"cache-control": `no-store`,
|
|
514
631
|
}
|
|
515
632
|
|
|
516
633
|
if (stream.contentType) {
|
|
@@ -680,6 +797,9 @@ export class DurableStreamTestServer {
|
|
|
680
797
|
}
|
|
681
798
|
}
|
|
682
799
|
|
|
800
|
+
// Apply fault body modifications (truncation, corruption) if configured
|
|
801
|
+
finalData = this.applyFaultBodyModification(res, finalData)
|
|
802
|
+
|
|
683
803
|
res.writeHead(200, headers)
|
|
684
804
|
res.end(Buffer.from(finalData))
|
|
685
805
|
}
|
|
@@ -697,12 +817,14 @@ export class DurableStreamTestServer {
|
|
|
697
817
|
// Track this SSE connection
|
|
698
818
|
this.activeSSEResponses.add(res)
|
|
699
819
|
|
|
700
|
-
// Set SSE headers
|
|
820
|
+
// Set SSE headers (explicitly including security headers for clarity)
|
|
701
821
|
res.writeHead(200, {
|
|
702
822
|
"content-type": `text/event-stream`,
|
|
703
823
|
"cache-control": `no-cache`,
|
|
704
824
|
connection: `keep-alive`,
|
|
705
825
|
"access-control-allow-origin": `*`,
|
|
826
|
+
"x-content-type-options": `nosniff`,
|
|
827
|
+
"cross-origin-resource-policy": `cross-origin`,
|
|
706
828
|
})
|
|
707
829
|
|
|
708
830
|
let currentOffset = initialOffset
|
|
@@ -841,7 +963,7 @@ export class DurableStreamTestServer {
|
|
|
841
963
|
this.store.append(path, body, { seq, contentType })
|
|
842
964
|
)
|
|
843
965
|
|
|
844
|
-
res.writeHead(
|
|
966
|
+
res.writeHead(204, {
|
|
845
967
|
[STREAM_OFFSET_HEADER]: message!.offset,
|
|
846
968
|
})
|
|
847
969
|
res.end()
|
|
@@ -889,23 +1011,53 @@ export class DurableStreamTestServer {
|
|
|
889
1011
|
try {
|
|
890
1012
|
const config = JSON.parse(new TextDecoder().decode(body)) as {
|
|
891
1013
|
path: string
|
|
892
|
-
|
|
1014
|
+
// Legacy fields (still supported)
|
|
1015
|
+
status?: number
|
|
893
1016
|
count?: number
|
|
894
1017
|
retryAfter?: number
|
|
1018
|
+
// New fault injection fields
|
|
1019
|
+
delayMs?: number
|
|
1020
|
+
dropConnection?: boolean
|
|
1021
|
+
truncateBodyBytes?: number
|
|
1022
|
+
probability?: number
|
|
1023
|
+
method?: string
|
|
1024
|
+
corruptBody?: boolean
|
|
1025
|
+
jitterMs?: number
|
|
895
1026
|
}
|
|
896
1027
|
|
|
897
|
-
if (!config.path
|
|
1028
|
+
if (!config.path) {
|
|
898
1029
|
res.writeHead(400, { "content-type": `text/plain` })
|
|
899
|
-
res.end(`Missing required
|
|
1030
|
+
res.end(`Missing required field: path`)
|
|
900
1031
|
return
|
|
901
1032
|
}
|
|
902
1033
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
config.status
|
|
906
|
-
config.
|
|
907
|
-
config.
|
|
908
|
-
|
|
1034
|
+
// Must have at least one fault type specified
|
|
1035
|
+
const hasFaultType =
|
|
1036
|
+
config.status !== undefined ||
|
|
1037
|
+
config.delayMs !== undefined ||
|
|
1038
|
+
config.dropConnection ||
|
|
1039
|
+
config.truncateBodyBytes !== undefined ||
|
|
1040
|
+
config.corruptBody
|
|
1041
|
+
if (!hasFaultType) {
|
|
1042
|
+
res.writeHead(400, { "content-type": `text/plain` })
|
|
1043
|
+
res.end(
|
|
1044
|
+
`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`
|
|
1045
|
+
)
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
this.injectFault(config.path, {
|
|
1050
|
+
status: config.status,
|
|
1051
|
+
count: config.count ?? 1,
|
|
1052
|
+
retryAfter: config.retryAfter,
|
|
1053
|
+
delayMs: config.delayMs,
|
|
1054
|
+
dropConnection: config.dropConnection,
|
|
1055
|
+
truncateBodyBytes: config.truncateBodyBytes,
|
|
1056
|
+
probability: config.probability,
|
|
1057
|
+
method: config.method,
|
|
1058
|
+
corruptBody: config.corruptBody,
|
|
1059
|
+
jitterMs: config.jitterMs,
|
|
1060
|
+
})
|
|
909
1061
|
|
|
910
1062
|
res.writeHead(200, { "content-type": `application/json` })
|
|
911
1063
|
res.end(JSON.stringify({ ok: true }))
|
|
@@ -914,7 +1066,7 @@ export class DurableStreamTestServer {
|
|
|
914
1066
|
res.end(`Invalid JSON body`)
|
|
915
1067
|
}
|
|
916
1068
|
} else if (method === `DELETE`) {
|
|
917
|
-
this.
|
|
1069
|
+
this.clearInjectedFaults()
|
|
918
1070
|
res.writeHead(200, { "content-type": `application/json` })
|
|
919
1071
|
res.end(JSON.stringify({ ok: true }))
|
|
920
1072
|
} else {
|