@durable-streams/server 0.1.2 → 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/README.md +167 -0
- package/dist/index.cjs +189 -56
- package/dist/index.d.cts +81 -12
- package/dist/index.d.ts +81 -12
- package/dist/index.js +189 -56
- package/package.json +5 -5
- package/src/file-store.ts +58 -16
- package/src/server.ts +196 -44
- package/src/store.ts +59 -10
package/dist/index.js
CHANGED
|
@@ -65,12 +65,40 @@ var StreamStore = class {
|
|
|
65
65
|
streams = new Map();
|
|
66
66
|
pendingLongPolls = [];
|
|
67
67
|
/**
|
|
68
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
69
|
+
*/
|
|
70
|
+
isExpired(stream) {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (stream.expiresAt) {
|
|
73
|
+
const expiryTime = new Date(stream.expiresAt).getTime();
|
|
74
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
75
|
+
}
|
|
76
|
+
if (stream.ttlSeconds !== void 0) {
|
|
77
|
+
const expiryTime = stream.createdAt + stream.ttlSeconds * 1e3;
|
|
78
|
+
if (now >= expiryTime) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get a stream, deleting it if expired.
|
|
84
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
85
|
+
*/
|
|
86
|
+
getIfNotExpired(path$2) {
|
|
87
|
+
const stream = this.streams.get(path$2);
|
|
88
|
+
if (!stream) return void 0;
|
|
89
|
+
if (this.isExpired(stream)) {
|
|
90
|
+
this.delete(path$2);
|
|
91
|
+
return void 0;
|
|
92
|
+
}
|
|
93
|
+
return stream;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
68
96
|
* Create a new stream.
|
|
69
97
|
* @throws Error if stream already exists with different config
|
|
70
98
|
* @returns existing stream if config matches (idempotent)
|
|
71
99
|
*/
|
|
72
100
|
create(path$2, options = {}) {
|
|
73
|
-
const existing = this.
|
|
101
|
+
const existing = this.getIfNotExpired(path$2);
|
|
74
102
|
if (existing) {
|
|
75
103
|
const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
|
|
76
104
|
const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
|
|
@@ -93,15 +121,16 @@ var StreamStore = class {
|
|
|
93
121
|
}
|
|
94
122
|
/**
|
|
95
123
|
* Get a stream by path.
|
|
124
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
96
125
|
*/
|
|
97
126
|
get(path$2) {
|
|
98
|
-
return this.
|
|
127
|
+
return this.getIfNotExpired(path$2);
|
|
99
128
|
}
|
|
100
129
|
/**
|
|
101
|
-
* Check if a stream exists.
|
|
130
|
+
* Check if a stream exists (and is not expired).
|
|
102
131
|
*/
|
|
103
132
|
has(path$2) {
|
|
104
|
-
return this.
|
|
133
|
+
return this.getIfNotExpired(path$2) !== void 0;
|
|
105
134
|
}
|
|
106
135
|
/**
|
|
107
136
|
* Delete a stream.
|
|
@@ -112,12 +141,12 @@ var StreamStore = class {
|
|
|
112
141
|
}
|
|
113
142
|
/**
|
|
114
143
|
* Append data to a stream.
|
|
115
|
-
* @throws Error if stream doesn't exist
|
|
144
|
+
* @throws Error if stream doesn't exist or is expired
|
|
116
145
|
* @throws Error if seq is lower than lastSeq
|
|
117
146
|
* @throws Error if JSON mode and array is empty
|
|
118
147
|
*/
|
|
119
148
|
append(path$2, data, options = {}) {
|
|
120
|
-
const stream = this.
|
|
149
|
+
const stream = this.getIfNotExpired(path$2);
|
|
121
150
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
122
151
|
if (options.contentType && stream.contentType) {
|
|
123
152
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -134,9 +163,10 @@ var StreamStore = class {
|
|
|
134
163
|
}
|
|
135
164
|
/**
|
|
136
165
|
* Read messages from a stream starting at the given offset.
|
|
166
|
+
* @throws Error if stream doesn't exist or is expired
|
|
137
167
|
*/
|
|
138
168
|
read(path$2, offset) {
|
|
139
|
-
const stream = this.
|
|
169
|
+
const stream = this.getIfNotExpired(path$2);
|
|
140
170
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
141
171
|
if (!offset || offset === `-1`) return {
|
|
142
172
|
messages: [...stream.messages],
|
|
@@ -155,9 +185,10 @@ var StreamStore = class {
|
|
|
155
185
|
/**
|
|
156
186
|
* Format messages for response.
|
|
157
187
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
188
|
+
* @throws Error if stream doesn't exist or is expired
|
|
158
189
|
*/
|
|
159
190
|
formatResponse(path$2, messages) {
|
|
160
|
-
const stream = this.
|
|
191
|
+
const stream = this.getIfNotExpired(path$2);
|
|
161
192
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
162
193
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
163
194
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -171,9 +202,10 @@ var StreamStore = class {
|
|
|
171
202
|
}
|
|
172
203
|
/**
|
|
173
204
|
* Wait for new messages (long-poll).
|
|
205
|
+
* @throws Error if stream doesn't exist or is expired
|
|
174
206
|
*/
|
|
175
207
|
async waitForMessages(path$2, offset, timeoutMs) {
|
|
176
|
-
const stream = this.
|
|
208
|
+
const stream = this.getIfNotExpired(path$2);
|
|
177
209
|
if (!stream) throw new Error(`Stream not found: ${path$2}`);
|
|
178
210
|
const { messages } = this.read(path$2, offset);
|
|
179
211
|
if (messages.length > 0) return {
|
|
@@ -206,9 +238,10 @@ var StreamStore = class {
|
|
|
206
238
|
}
|
|
207
239
|
/**
|
|
208
240
|
* Get the current offset for a stream.
|
|
241
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
209
242
|
*/
|
|
210
243
|
getCurrentOffset(path$2) {
|
|
211
|
-
return this.
|
|
244
|
+
return this.getIfNotExpired(path$2)?.currentOffset;
|
|
212
245
|
}
|
|
213
246
|
/**
|
|
214
247
|
* Clear all streams.
|
|
@@ -582,6 +615,35 @@ var FileBackedStreamStore = class {
|
|
|
582
615
|
};
|
|
583
616
|
}
|
|
584
617
|
/**
|
|
618
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
619
|
+
*/
|
|
620
|
+
isExpired(meta) {
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
if (meta.expiresAt) {
|
|
623
|
+
const expiryTime = new Date(meta.expiresAt).getTime();
|
|
624
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
625
|
+
}
|
|
626
|
+
if (meta.ttlSeconds !== void 0) {
|
|
627
|
+
const expiryTime = meta.createdAt + meta.ttlSeconds * 1e3;
|
|
628
|
+
if (now >= expiryTime) return true;
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Get stream metadata, deleting it if expired.
|
|
634
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
635
|
+
*/
|
|
636
|
+
getMetaIfNotExpired(streamPath) {
|
|
637
|
+
const key = `stream:${streamPath}`;
|
|
638
|
+
const meta = this.db.get(key);
|
|
639
|
+
if (!meta) return void 0;
|
|
640
|
+
if (this.isExpired(meta)) {
|
|
641
|
+
this.delete(streamPath);
|
|
642
|
+
return void 0;
|
|
643
|
+
}
|
|
644
|
+
return meta;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
585
647
|
* Close the store, closing all file handles and database.
|
|
586
648
|
* All data is already fsynced on each append, so no final flush needed.
|
|
587
649
|
*/
|
|
@@ -590,8 +652,7 @@ var FileBackedStreamStore = class {
|
|
|
590
652
|
await this.db.close();
|
|
591
653
|
}
|
|
592
654
|
async create(streamPath, options = {}) {
|
|
593
|
-
const
|
|
594
|
-
const existing = this.db.get(key);
|
|
655
|
+
const existing = this.getMetaIfNotExpired(streamPath);
|
|
595
656
|
if (existing) {
|
|
596
657
|
const normalizeMimeType = (ct) => (ct ?? `application/octet-stream`).toLowerCase();
|
|
597
658
|
const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
|
|
@@ -600,6 +661,7 @@ var FileBackedStreamStore = class {
|
|
|
600
661
|
if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
|
|
601
662
|
else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
|
|
602
663
|
}
|
|
664
|
+
const key = `stream:${streamPath}`;
|
|
603
665
|
const streamMeta = {
|
|
604
666
|
path: streamPath,
|
|
605
667
|
contentType: options.contentType,
|
|
@@ -633,13 +695,11 @@ var FileBackedStreamStore = class {
|
|
|
633
695
|
return this.streamMetaToStream(streamMeta);
|
|
634
696
|
}
|
|
635
697
|
get(streamPath) {
|
|
636
|
-
const
|
|
637
|
-
const meta = this.db.get(key);
|
|
698
|
+
const meta = this.getMetaIfNotExpired(streamPath);
|
|
638
699
|
return meta ? this.streamMetaToStream(meta) : void 0;
|
|
639
700
|
}
|
|
640
701
|
has(streamPath) {
|
|
641
|
-
|
|
642
|
-
return this.db.get(key) !== void 0;
|
|
702
|
+
return this.getMetaIfNotExpired(streamPath) !== void 0;
|
|
643
703
|
}
|
|
644
704
|
delete(streamPath) {
|
|
645
705
|
const key = `stream:${streamPath}`;
|
|
@@ -657,8 +717,7 @@ var FileBackedStreamStore = class {
|
|
|
657
717
|
return true;
|
|
658
718
|
}
|
|
659
719
|
async append(streamPath, data, options = {}) {
|
|
660
|
-
const
|
|
661
|
-
const streamMeta = this.db.get(key);
|
|
720
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
662
721
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
663
722
|
if (options.contentType && streamMeta.contentType) {
|
|
664
723
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -706,13 +765,13 @@ var FileBackedStreamStore = class {
|
|
|
706
765
|
lastSeq: options.seq ?? streamMeta.lastSeq,
|
|
707
766
|
totalBytes: streamMeta.totalBytes + processedData.length + 5
|
|
708
767
|
};
|
|
768
|
+
const key = `stream:${streamPath}`;
|
|
709
769
|
this.db.putSync(key, updatedMeta);
|
|
710
770
|
this.notifyLongPolls(streamPath);
|
|
711
771
|
return message;
|
|
712
772
|
}
|
|
713
773
|
read(streamPath, offset) {
|
|
714
|
-
const
|
|
715
|
-
const streamMeta = this.db.get(key);
|
|
774
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
716
775
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
717
776
|
const startOffset = offset ?? `0000000000000000_0000000000000000`;
|
|
718
777
|
const startParts = startOffset.split(`_`).map(Number);
|
|
@@ -764,8 +823,7 @@ var FileBackedStreamStore = class {
|
|
|
764
823
|
};
|
|
765
824
|
}
|
|
766
825
|
async waitForMessages(streamPath, offset, timeoutMs) {
|
|
767
|
-
const
|
|
768
|
-
const streamMeta = this.db.get(key);
|
|
826
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
769
827
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
770
828
|
const { messages } = this.read(streamPath, offset);
|
|
771
829
|
if (messages.length > 0) return {
|
|
@@ -799,10 +857,10 @@ var FileBackedStreamStore = class {
|
|
|
799
857
|
/**
|
|
800
858
|
* Format messages for response.
|
|
801
859
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
860
|
+
* @throws Error if stream doesn't exist or is expired
|
|
802
861
|
*/
|
|
803
862
|
formatResponse(streamPath, messages) {
|
|
804
|
-
const
|
|
805
|
-
const streamMeta = this.db.get(key);
|
|
863
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
806
864
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
807
865
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
808
866
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -815,8 +873,7 @@ var FileBackedStreamStore = class {
|
|
|
815
873
|
return concatenated;
|
|
816
874
|
}
|
|
817
875
|
getCurrentOffset(streamPath) {
|
|
818
|
-
const
|
|
819
|
-
const streamMeta = this.db.get(key);
|
|
876
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
820
877
|
return streamMeta?.currentOffset;
|
|
821
878
|
}
|
|
822
879
|
clear() {
|
|
@@ -995,10 +1052,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
|
|
|
995
1052
|
/**
|
|
996
1053
|
* Encode data for SSE format.
|
|
997
1054
|
* Per SSE spec, each line in the payload needs its own "data:" prefix.
|
|
998
|
-
*
|
|
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.
|
|
999
1058
|
*/
|
|
1000
1059
|
function encodeSSEData(payload) {
|
|
1001
|
-
const lines = payload.split(
|
|
1060
|
+
const lines = payload.split(/\r\n|\r|\n/);
|
|
1002
1061
|
return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
|
|
1003
1062
|
}
|
|
1004
1063
|
/**
|
|
@@ -1037,8 +1096,8 @@ var DurableStreamTestServer = class {
|
|
|
1037
1096
|
_url = null;
|
|
1038
1097
|
activeSSEResponses = new Set();
|
|
1039
1098
|
isShuttingDown = false;
|
|
1040
|
-
/** Injected
|
|
1041
|
-
|
|
1099
|
+
/** Injected faults for testing retry/resilience */
|
|
1100
|
+
injectedFaults = new Map();
|
|
1042
1101
|
constructor(options = {}) {
|
|
1043
1102
|
if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
|
|
1044
1103
|
else this.store = new StreamStore();
|
|
@@ -1123,30 +1182,71 @@ var DurableStreamTestServer = class {
|
|
|
1123
1182
|
/**
|
|
1124
1183
|
* Inject an error to be returned on the next N requests to a path.
|
|
1125
1184
|
* Used for testing retry/resilience behavior.
|
|
1185
|
+
* @deprecated Use injectFault for full fault injection capabilities
|
|
1126
1186
|
*/
|
|
1127
1187
|
injectError(path$2, status, count = 1, retryAfter) {
|
|
1128
|
-
this.
|
|
1188
|
+
this.injectedFaults.set(path$2, {
|
|
1129
1189
|
status,
|
|
1130
1190
|
count,
|
|
1131
1191
|
retryAfter
|
|
1132
1192
|
});
|
|
1133
1193
|
}
|
|
1134
1194
|
/**
|
|
1135
|
-
*
|
|
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.
|
|
1136
1197
|
*/
|
|
1137
|
-
|
|
1138
|
-
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();
|
|
1139
1209
|
}
|
|
1140
1210
|
/**
|
|
1141
|
-
* Check if there's an injected
|
|
1142
|
-
* Returns the
|
|
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.
|
|
1143
1213
|
*/
|
|
1144
|
-
|
|
1145
|
-
const
|
|
1146
|
-
if (!
|
|
1147
|
-
|
|
1148
|
-
if (
|
|
1149
|
-
|
|
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
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
1234
|
+
* Returns modified body, or original if no modifications needed.
|
|
1235
|
+
*/
|
|
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;
|
|
1150
1250
|
}
|
|
1151
1251
|
async handleRequest(req, res) {
|
|
1152
1252
|
const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
|
|
@@ -1156,6 +1256,8 @@ var DurableStreamTestServer = class {
|
|
|
1156
1256
|
res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
|
|
1157
1257
|
res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
|
|
1158
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`);
|
|
1159
1261
|
if (method === `OPTIONS`) {
|
|
1160
1262
|
res.writeHead(204);
|
|
1161
1263
|
res.end();
|
|
@@ -1165,13 +1267,21 @@ var DurableStreamTestServer = class {
|
|
|
1165
1267
|
await this.handleTestInjectError(method, req, res);
|
|
1166
1268
|
return;
|
|
1167
1269
|
}
|
|
1168
|
-
const
|
|
1169
|
-
if (
|
|
1170
|
-
|
|
1171
|
-
if (
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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;
|
|
1175
1285
|
}
|
|
1176
1286
|
try {
|
|
1177
1287
|
switch (method) {
|
|
@@ -1286,7 +1396,10 @@ var DurableStreamTestServer = class {
|
|
|
1286
1396
|
res.end();
|
|
1287
1397
|
return;
|
|
1288
1398
|
}
|
|
1289
|
-
const headers = {
|
|
1399
|
+
const headers = {
|
|
1400
|
+
[STREAM_OFFSET_HEADER]: stream.currentOffset,
|
|
1401
|
+
"cache-control": `no-store`
|
|
1402
|
+
};
|
|
1290
1403
|
if (stream.contentType) headers[`content-type`] = stream.contentType;
|
|
1291
1404
|
headers[`etag`] = `"${Buffer.from(path$2).toString(`base64`)}:-1:${stream.currentOffset}"`;
|
|
1292
1405
|
res.writeHead(200, headers);
|
|
@@ -1377,6 +1490,7 @@ var DurableStreamTestServer = class {
|
|
|
1377
1490
|
headers[`vary`] = `accept-encoding`;
|
|
1378
1491
|
}
|
|
1379
1492
|
}
|
|
1493
|
+
finalData = this.applyFaultBodyModification(res, finalData);
|
|
1380
1494
|
res.writeHead(200, headers);
|
|
1381
1495
|
res.end(Buffer.from(finalData));
|
|
1382
1496
|
}
|
|
@@ -1389,7 +1503,9 @@ var DurableStreamTestServer = class {
|
|
|
1389
1503
|
"content-type": `text/event-stream`,
|
|
1390
1504
|
"cache-control": `no-cache`,
|
|
1391
1505
|
connection: `keep-alive`,
|
|
1392
|
-
"access-control-allow-origin":
|
|
1506
|
+
"access-control-allow-origin": `*`,
|
|
1507
|
+
"x-content-type-options": `nosniff`,
|
|
1508
|
+
"cross-origin-resource-policy": `cross-origin`
|
|
1393
1509
|
});
|
|
1394
1510
|
let currentOffset = initialOffset;
|
|
1395
1511
|
let isConnected = true;
|
|
@@ -1460,7 +1576,7 @@ var DurableStreamTestServer = class {
|
|
|
1460
1576
|
seq,
|
|
1461
1577
|
contentType
|
|
1462
1578
|
}));
|
|
1463
|
-
res.writeHead(
|
|
1579
|
+
res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
|
|
1464
1580
|
res.end();
|
|
1465
1581
|
}
|
|
1466
1582
|
/**
|
|
@@ -1491,12 +1607,29 @@ var DurableStreamTestServer = class {
|
|
|
1491
1607
|
const body = await this.readBody(req);
|
|
1492
1608
|
try {
|
|
1493
1609
|
const config = JSON.parse(new TextDecoder().decode(body));
|
|
1494
|
-
if (!config.path
|
|
1610
|
+
if (!config.path) {
|
|
1495
1611
|
res.writeHead(400, { "content-type": `text/plain` });
|
|
1496
|
-
res.end(`Missing required
|
|
1612
|
+
res.end(`Missing required field: path`);
|
|
1497
1613
|
return;
|
|
1498
1614
|
}
|
|
1499
|
-
|
|
1615
|
+
const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
|
|
1616
|
+
if (!hasFaultType) {
|
|
1617
|
+
res.writeHead(400, { "content-type": `text/plain` });
|
|
1618
|
+
res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
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
|
+
});
|
|
1500
1633
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1501
1634
|
res.end(JSON.stringify({ ok: true }));
|
|
1502
1635
|
} catch {
|
|
@@ -1504,7 +1637,7 @@ var DurableStreamTestServer = class {
|
|
|
1504
1637
|
res.end(`Invalid JSON body`);
|
|
1505
1638
|
}
|
|
1506
1639
|
} else if (method === `DELETE`) {
|
|
1507
|
-
this.
|
|
1640
|
+
this.clearInjectedFaults();
|
|
1508
1641
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1509
1642
|
res.end(JSON.stringify({ ok: true }));
|
|
1510
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/file-store.ts
CHANGED
|
@@ -332,6 +332,50 @@ export class FileBackedStreamStore {
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
337
|
+
*/
|
|
338
|
+
private isExpired(meta: StreamMetadata): boolean {
|
|
339
|
+
const now = Date.now()
|
|
340
|
+
|
|
341
|
+
// Check absolute expiry time
|
|
342
|
+
if (meta.expiresAt) {
|
|
343
|
+
const expiryTime = new Date(meta.expiresAt).getTime()
|
|
344
|
+
// Treat invalid dates (NaN) as expired (fail closed)
|
|
345
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) {
|
|
346
|
+
return true
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check TTL (relative to creation time)
|
|
351
|
+
if (meta.ttlSeconds !== undefined) {
|
|
352
|
+
const expiryTime = meta.createdAt + meta.ttlSeconds * 1000
|
|
353
|
+
if (now >= expiryTime) {
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get stream metadata, deleting it if expired.
|
|
363
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
364
|
+
*/
|
|
365
|
+
private getMetaIfNotExpired(streamPath: string): StreamMetadata | undefined {
|
|
366
|
+
const key = `stream:${streamPath}`
|
|
367
|
+
const meta = this.db.get(key) as StreamMetadata | undefined
|
|
368
|
+
if (!meta) {
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
if (this.isExpired(meta)) {
|
|
372
|
+
// Delete expired stream
|
|
373
|
+
this.delete(streamPath)
|
|
374
|
+
return undefined
|
|
375
|
+
}
|
|
376
|
+
return meta
|
|
377
|
+
}
|
|
378
|
+
|
|
335
379
|
/**
|
|
336
380
|
* Close the store, closing all file handles and database.
|
|
337
381
|
* All data is already fsynced on each append, so no final flush needed.
|
|
@@ -354,8 +398,8 @@ export class FileBackedStreamStore {
|
|
|
354
398
|
initialData?: Uint8Array
|
|
355
399
|
} = {}
|
|
356
400
|
): Promise<Stream> {
|
|
357
|
-
|
|
358
|
-
const existing = this.
|
|
401
|
+
// Use getMetaIfNotExpired to treat expired streams as non-existent
|
|
402
|
+
const existing = this.getMetaIfNotExpired(streamPath)
|
|
359
403
|
|
|
360
404
|
if (existing) {
|
|
361
405
|
// Check if config matches (idempotent create)
|
|
@@ -379,6 +423,9 @@ export class FileBackedStreamStore {
|
|
|
379
423
|
}
|
|
380
424
|
}
|
|
381
425
|
|
|
426
|
+
// Define key for LMDB operations
|
|
427
|
+
const key = `stream:${streamPath}`
|
|
428
|
+
|
|
382
429
|
// Initialize metadata
|
|
383
430
|
const streamMeta: StreamMetadata = {
|
|
384
431
|
path: streamPath,
|
|
@@ -430,14 +477,12 @@ export class FileBackedStreamStore {
|
|
|
430
477
|
}
|
|
431
478
|
|
|
432
479
|
get(streamPath: string): Stream | undefined {
|
|
433
|
-
const
|
|
434
|
-
const meta = this.db.get(key) as StreamMetadata | undefined
|
|
480
|
+
const meta = this.getMetaIfNotExpired(streamPath)
|
|
435
481
|
return meta ? this.streamMetaToStream(meta) : undefined
|
|
436
482
|
}
|
|
437
483
|
|
|
438
484
|
has(streamPath: string): boolean {
|
|
439
|
-
|
|
440
|
-
return this.db.get(key) !== undefined
|
|
485
|
+
return this.getMetaIfNotExpired(streamPath) !== undefined
|
|
441
486
|
}
|
|
442
487
|
|
|
443
488
|
delete(streamPath: string): boolean {
|
|
@@ -489,8 +534,7 @@ export class FileBackedStreamStore {
|
|
|
489
534
|
isInitialCreate?: boolean
|
|
490
535
|
} = {}
|
|
491
536
|
): Promise<StreamMessage | null> {
|
|
492
|
-
const
|
|
493
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
537
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
494
538
|
|
|
495
539
|
if (!streamMeta) {
|
|
496
540
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -583,6 +627,7 @@ export class FileBackedStreamStore {
|
|
|
583
627
|
lastSeq: options.seq ?? streamMeta.lastSeq,
|
|
584
628
|
totalBytes: streamMeta.totalBytes + processedData.length + 5, // +4 for length, +1 for newline
|
|
585
629
|
}
|
|
630
|
+
const key = `stream:${streamPath}`
|
|
586
631
|
this.db.putSync(key, updatedMeta)
|
|
587
632
|
|
|
588
633
|
// 5. Notify long-polls (data is now readable from disk)
|
|
@@ -596,8 +641,7 @@ export class FileBackedStreamStore {
|
|
|
596
641
|
streamPath: string,
|
|
597
642
|
offset?: string
|
|
598
643
|
): { messages: Array<StreamMessage>; upToDate: boolean } {
|
|
599
|
-
const
|
|
600
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
644
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
601
645
|
|
|
602
646
|
if (!streamMeta) {
|
|
603
647
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -690,8 +734,7 @@ export class FileBackedStreamStore {
|
|
|
690
734
|
offset: string,
|
|
691
735
|
timeoutMs: number
|
|
692
736
|
): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {
|
|
693
|
-
const
|
|
694
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
737
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
695
738
|
|
|
696
739
|
if (!streamMeta) {
|
|
697
740
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -729,13 +772,13 @@ export class FileBackedStreamStore {
|
|
|
729
772
|
/**
|
|
730
773
|
* Format messages for response.
|
|
731
774
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
775
|
+
* @throws Error if stream doesn't exist or is expired
|
|
732
776
|
*/
|
|
733
777
|
formatResponse(
|
|
734
778
|
streamPath: string,
|
|
735
779
|
messages: Array<StreamMessage>
|
|
736
780
|
): Uint8Array {
|
|
737
|
-
const
|
|
738
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
781
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
739
782
|
|
|
740
783
|
if (!streamMeta) {
|
|
741
784
|
throw new Error(`Stream not found: ${streamPath}`)
|
|
@@ -759,8 +802,7 @@ export class FileBackedStreamStore {
|
|
|
759
802
|
}
|
|
760
803
|
|
|
761
804
|
getCurrentOffset(streamPath: string): string | undefined {
|
|
762
|
-
const
|
|
763
|
-
const streamMeta = this.db.get(key) as StreamMetadata | undefined
|
|
805
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath)
|
|
764
806
|
return streamMeta?.currentOffset
|
|
765
807
|
}
|
|
766
808
|
|