@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/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @durable-streams/server
|
|
2
|
+
|
|
3
|
+
Node.js reference server implementation for the Durable Streams protocol.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @durable-streams/server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
This package provides a reference implementation of the Durable Streams protocol for Node.js. It supports both in-memory and file-backed storage modes, making it suitable for development, testing, and production workloads.
|
|
14
|
+
|
|
15
|
+
For a standalone binary option, see the [Caddy-based server](https://github.com/durable-streams/durable-streams/releases).
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { DurableStreamTestServer } from "@durable-streams/server"
|
|
21
|
+
|
|
22
|
+
const server = new DurableStreamTestServer({
|
|
23
|
+
port: 4437,
|
|
24
|
+
host: "127.0.0.1",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await server.start()
|
|
28
|
+
console.log("Server running on http://127.0.0.1:4437")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Storage Modes
|
|
32
|
+
|
|
33
|
+
### In-Memory (Default)
|
|
34
|
+
|
|
35
|
+
Fast, ephemeral storage for development and testing:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { DurableStreamTestServer, StreamStore } from "@durable-streams/server"
|
|
39
|
+
|
|
40
|
+
const store = new StreamStore()
|
|
41
|
+
const server = new DurableStreamTestServer({
|
|
42
|
+
port: 4437,
|
|
43
|
+
store,
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### File-Backed
|
|
48
|
+
|
|
49
|
+
Persistent storage with streams stored as log files and LMDB for metadata:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import {
|
|
53
|
+
DurableStreamTestServer,
|
|
54
|
+
FileBackedStreamStore,
|
|
55
|
+
} from "@durable-streams/server"
|
|
56
|
+
|
|
57
|
+
const store = new FileBackedStreamStore({
|
|
58
|
+
path: "./data/streams",
|
|
59
|
+
})
|
|
60
|
+
const server = new DurableStreamTestServer({
|
|
61
|
+
port: 4437,
|
|
62
|
+
store,
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Registry Hooks
|
|
67
|
+
|
|
68
|
+
Track stream lifecycle events (creation, deletion):
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import {
|
|
72
|
+
DurableStreamTestServer,
|
|
73
|
+
createRegistryHooks,
|
|
74
|
+
} from "@durable-streams/server"
|
|
75
|
+
|
|
76
|
+
const server = new DurableStreamTestServer({
|
|
77
|
+
port: 4437,
|
|
78
|
+
hooks: createRegistryHooks({
|
|
79
|
+
registryPath: "__registry__",
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The registry maintains a system stream that tracks all stream creates and deletes, useful for building admin UIs or monitoring.
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
### DurableStreamTestServer
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
interface TestServerOptions {
|
|
92
|
+
port?: number
|
|
93
|
+
host?: string
|
|
94
|
+
store?: StreamStore | FileBackedStreamStore
|
|
95
|
+
hooks?: StreamLifecycleHook[]
|
|
96
|
+
cors?: boolean
|
|
97
|
+
cursorOptions?: CursorOptions
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class DurableStreamTestServer {
|
|
101
|
+
constructor(options?: TestServerOptions)
|
|
102
|
+
start(): Promise<void>
|
|
103
|
+
stop(): Promise<void>
|
|
104
|
+
readonly port: number
|
|
105
|
+
readonly baseUrl: string
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### StreamStore
|
|
110
|
+
|
|
111
|
+
In-memory stream storage:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
class StreamStore {
|
|
115
|
+
create(path: string, contentType: string, options?: CreateOptions): Stream
|
|
116
|
+
get(path: string): Stream | undefined
|
|
117
|
+
delete(path: string): boolean
|
|
118
|
+
append(path: string, data: Uint8Array, seq?: string): void
|
|
119
|
+
read(path: string, offset: string): ReadResult
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### FileBackedStreamStore
|
|
124
|
+
|
|
125
|
+
File-backed persistent storage (log files for streams, LMDB for metadata) with the same interface as `StreamStore`.
|
|
126
|
+
|
|
127
|
+
## Exports
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
export { DurableStreamTestServer } from "./server"
|
|
131
|
+
export { StreamStore } from "./store"
|
|
132
|
+
export { FileBackedStreamStore } from "./file-store"
|
|
133
|
+
export { encodeStreamPath, decodeStreamPath } from "./path-encoding"
|
|
134
|
+
export { createRegistryHooks } from "./registry-hook"
|
|
135
|
+
export {
|
|
136
|
+
calculateCursor,
|
|
137
|
+
handleCursorCollision,
|
|
138
|
+
generateResponseCursor,
|
|
139
|
+
DEFAULT_CURSOR_EPOCH,
|
|
140
|
+
DEFAULT_CURSOR_INTERVAL_SECONDS,
|
|
141
|
+
type CursorOptions,
|
|
142
|
+
} from "./cursor"
|
|
143
|
+
export type {
|
|
144
|
+
Stream,
|
|
145
|
+
StreamMessage,
|
|
146
|
+
TestServerOptions,
|
|
147
|
+
PendingLongPoll,
|
|
148
|
+
StreamLifecycleEvent,
|
|
149
|
+
StreamLifecycleHook,
|
|
150
|
+
} from "./types"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Testing Your Implementation
|
|
154
|
+
|
|
155
|
+
Use the conformance test suite to validate protocol compliance:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { runConformanceTests } from "@durable-streams/server-conformance-tests"
|
|
159
|
+
|
|
160
|
+
runConformanceTests({
|
|
161
|
+
baseUrl: "http://localhost:4437",
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
Apache-2.0
|
package/dist/index.cjs
CHANGED
|
@@ -88,12 +88,40 @@ var StreamStore = class {
|
|
|
88
88
|
streams = new Map();
|
|
89
89
|
pendingLongPolls = [];
|
|
90
90
|
/**
|
|
91
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
92
|
+
*/
|
|
93
|
+
isExpired(stream) {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (stream.expiresAt) {
|
|
96
|
+
const expiryTime = new Date(stream.expiresAt).getTime();
|
|
97
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
98
|
+
}
|
|
99
|
+
if (stream.ttlSeconds !== void 0) {
|
|
100
|
+
const expiryTime = stream.createdAt + stream.ttlSeconds * 1e3;
|
|
101
|
+
if (now >= expiryTime) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get a stream, deleting it if expired.
|
|
107
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
108
|
+
*/
|
|
109
|
+
getIfNotExpired(path) {
|
|
110
|
+
const stream = this.streams.get(path);
|
|
111
|
+
if (!stream) return void 0;
|
|
112
|
+
if (this.isExpired(stream)) {
|
|
113
|
+
this.delete(path);
|
|
114
|
+
return void 0;
|
|
115
|
+
}
|
|
116
|
+
return stream;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
91
119
|
* Create a new stream.
|
|
92
120
|
* @throws Error if stream already exists with different config
|
|
93
121
|
* @returns existing stream if config matches (idempotent)
|
|
94
122
|
*/
|
|
95
123
|
create(path, options = {}) {
|
|
96
|
-
const existing = this.
|
|
124
|
+
const existing = this.getIfNotExpired(path);
|
|
97
125
|
if (existing) {
|
|
98
126
|
const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
|
|
99
127
|
const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
|
|
@@ -116,15 +144,16 @@ var StreamStore = class {
|
|
|
116
144
|
}
|
|
117
145
|
/**
|
|
118
146
|
* Get a stream by path.
|
|
147
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
119
148
|
*/
|
|
120
149
|
get(path) {
|
|
121
|
-
return this.
|
|
150
|
+
return this.getIfNotExpired(path);
|
|
122
151
|
}
|
|
123
152
|
/**
|
|
124
|
-
* Check if a stream exists.
|
|
153
|
+
* Check if a stream exists (and is not expired).
|
|
125
154
|
*/
|
|
126
155
|
has(path) {
|
|
127
|
-
return this.
|
|
156
|
+
return this.getIfNotExpired(path) !== void 0;
|
|
128
157
|
}
|
|
129
158
|
/**
|
|
130
159
|
* Delete a stream.
|
|
@@ -135,12 +164,12 @@ var StreamStore = class {
|
|
|
135
164
|
}
|
|
136
165
|
/**
|
|
137
166
|
* Append data to a stream.
|
|
138
|
-
* @throws Error if stream doesn't exist
|
|
167
|
+
* @throws Error if stream doesn't exist or is expired
|
|
139
168
|
* @throws Error if seq is lower than lastSeq
|
|
140
169
|
* @throws Error if JSON mode and array is empty
|
|
141
170
|
*/
|
|
142
171
|
append(path, data, options = {}) {
|
|
143
|
-
const stream = this.
|
|
172
|
+
const stream = this.getIfNotExpired(path);
|
|
144
173
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
145
174
|
if (options.contentType && stream.contentType) {
|
|
146
175
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -157,9 +186,10 @@ var StreamStore = class {
|
|
|
157
186
|
}
|
|
158
187
|
/**
|
|
159
188
|
* Read messages from a stream starting at the given offset.
|
|
189
|
+
* @throws Error if stream doesn't exist or is expired
|
|
160
190
|
*/
|
|
161
191
|
read(path, offset) {
|
|
162
|
-
const stream = this.
|
|
192
|
+
const stream = this.getIfNotExpired(path);
|
|
163
193
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
164
194
|
if (!offset || offset === `-1`) return {
|
|
165
195
|
messages: [...stream.messages],
|
|
@@ -178,9 +208,10 @@ var StreamStore = class {
|
|
|
178
208
|
/**
|
|
179
209
|
* Format messages for response.
|
|
180
210
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
211
|
+
* @throws Error if stream doesn't exist or is expired
|
|
181
212
|
*/
|
|
182
213
|
formatResponse(path, messages) {
|
|
183
|
-
const stream = this.
|
|
214
|
+
const stream = this.getIfNotExpired(path);
|
|
184
215
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
185
216
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
186
217
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -194,9 +225,10 @@ var StreamStore = class {
|
|
|
194
225
|
}
|
|
195
226
|
/**
|
|
196
227
|
* Wait for new messages (long-poll).
|
|
228
|
+
* @throws Error if stream doesn't exist or is expired
|
|
197
229
|
*/
|
|
198
230
|
async waitForMessages(path, offset, timeoutMs) {
|
|
199
|
-
const stream = this.
|
|
231
|
+
const stream = this.getIfNotExpired(path);
|
|
200
232
|
if (!stream) throw new Error(`Stream not found: ${path}`);
|
|
201
233
|
const { messages } = this.read(path, offset);
|
|
202
234
|
if (messages.length > 0) return {
|
|
@@ -229,9 +261,10 @@ var StreamStore = class {
|
|
|
229
261
|
}
|
|
230
262
|
/**
|
|
231
263
|
* Get the current offset for a stream.
|
|
264
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
232
265
|
*/
|
|
233
266
|
getCurrentOffset(path) {
|
|
234
|
-
return this.
|
|
267
|
+
return this.getIfNotExpired(path)?.currentOffset;
|
|
235
268
|
}
|
|
236
269
|
/**
|
|
237
270
|
* Clear all streams.
|
|
@@ -605,6 +638,35 @@ var FileBackedStreamStore = class {
|
|
|
605
638
|
};
|
|
606
639
|
}
|
|
607
640
|
/**
|
|
641
|
+
* Check if a stream is expired based on TTL or Expires-At.
|
|
642
|
+
*/
|
|
643
|
+
isExpired(meta) {
|
|
644
|
+
const now = Date.now();
|
|
645
|
+
if (meta.expiresAt) {
|
|
646
|
+
const expiryTime = new Date(meta.expiresAt).getTime();
|
|
647
|
+
if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
|
|
648
|
+
}
|
|
649
|
+
if (meta.ttlSeconds !== void 0) {
|
|
650
|
+
const expiryTime = meta.createdAt + meta.ttlSeconds * 1e3;
|
|
651
|
+
if (now >= expiryTime) return true;
|
|
652
|
+
}
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Get stream metadata, deleting it if expired.
|
|
657
|
+
* Returns undefined if stream doesn't exist or is expired.
|
|
658
|
+
*/
|
|
659
|
+
getMetaIfNotExpired(streamPath) {
|
|
660
|
+
const key = `stream:${streamPath}`;
|
|
661
|
+
const meta = this.db.get(key);
|
|
662
|
+
if (!meta) return void 0;
|
|
663
|
+
if (this.isExpired(meta)) {
|
|
664
|
+
this.delete(streamPath);
|
|
665
|
+
return void 0;
|
|
666
|
+
}
|
|
667
|
+
return meta;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
608
670
|
* Close the store, closing all file handles and database.
|
|
609
671
|
* All data is already fsynced on each append, so no final flush needed.
|
|
610
672
|
*/
|
|
@@ -613,8 +675,7 @@ var FileBackedStreamStore = class {
|
|
|
613
675
|
await this.db.close();
|
|
614
676
|
}
|
|
615
677
|
async create(streamPath, options = {}) {
|
|
616
|
-
const
|
|
617
|
-
const existing = this.db.get(key);
|
|
678
|
+
const existing = this.getMetaIfNotExpired(streamPath);
|
|
618
679
|
if (existing) {
|
|
619
680
|
const normalizeMimeType = (ct) => (ct ?? `application/octet-stream`).toLowerCase();
|
|
620
681
|
const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
|
|
@@ -623,6 +684,7 @@ var FileBackedStreamStore = class {
|
|
|
623
684
|
if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
|
|
624
685
|
else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
|
|
625
686
|
}
|
|
687
|
+
const key = `stream:${streamPath}`;
|
|
626
688
|
const streamMeta = {
|
|
627
689
|
path: streamPath,
|
|
628
690
|
contentType: options.contentType,
|
|
@@ -656,13 +718,11 @@ var FileBackedStreamStore = class {
|
|
|
656
718
|
return this.streamMetaToStream(streamMeta);
|
|
657
719
|
}
|
|
658
720
|
get(streamPath) {
|
|
659
|
-
const
|
|
660
|
-
const meta = this.db.get(key);
|
|
721
|
+
const meta = this.getMetaIfNotExpired(streamPath);
|
|
661
722
|
return meta ? this.streamMetaToStream(meta) : void 0;
|
|
662
723
|
}
|
|
663
724
|
has(streamPath) {
|
|
664
|
-
|
|
665
|
-
return this.db.get(key) !== void 0;
|
|
725
|
+
return this.getMetaIfNotExpired(streamPath) !== void 0;
|
|
666
726
|
}
|
|
667
727
|
delete(streamPath) {
|
|
668
728
|
const key = `stream:${streamPath}`;
|
|
@@ -680,8 +740,7 @@ var FileBackedStreamStore = class {
|
|
|
680
740
|
return true;
|
|
681
741
|
}
|
|
682
742
|
async append(streamPath, data, options = {}) {
|
|
683
|
-
const
|
|
684
|
-
const streamMeta = this.db.get(key);
|
|
743
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
685
744
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
686
745
|
if (options.contentType && streamMeta.contentType) {
|
|
687
746
|
const providedType = normalizeContentType(options.contentType);
|
|
@@ -729,13 +788,13 @@ var FileBackedStreamStore = class {
|
|
|
729
788
|
lastSeq: options.seq ?? streamMeta.lastSeq,
|
|
730
789
|
totalBytes: streamMeta.totalBytes + processedData.length + 5
|
|
731
790
|
};
|
|
791
|
+
const key = `stream:${streamPath}`;
|
|
732
792
|
this.db.putSync(key, updatedMeta);
|
|
733
793
|
this.notifyLongPolls(streamPath);
|
|
734
794
|
return message;
|
|
735
795
|
}
|
|
736
796
|
read(streamPath, offset) {
|
|
737
|
-
const
|
|
738
|
-
const streamMeta = this.db.get(key);
|
|
797
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
739
798
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
740
799
|
const startOffset = offset ?? `0000000000000000_0000000000000000`;
|
|
741
800
|
const startParts = startOffset.split(`_`).map(Number);
|
|
@@ -787,8 +846,7 @@ var FileBackedStreamStore = class {
|
|
|
787
846
|
};
|
|
788
847
|
}
|
|
789
848
|
async waitForMessages(streamPath, offset, timeoutMs) {
|
|
790
|
-
const
|
|
791
|
-
const streamMeta = this.db.get(key);
|
|
849
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
792
850
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
793
851
|
const { messages } = this.read(streamPath, offset);
|
|
794
852
|
if (messages.length > 0) return {
|
|
@@ -822,10 +880,10 @@ var FileBackedStreamStore = class {
|
|
|
822
880
|
/**
|
|
823
881
|
* Format messages for response.
|
|
824
882
|
* For JSON mode, wraps concatenated data in array brackets.
|
|
883
|
+
* @throws Error if stream doesn't exist or is expired
|
|
825
884
|
*/
|
|
826
885
|
formatResponse(streamPath, messages) {
|
|
827
|
-
const
|
|
828
|
-
const streamMeta = this.db.get(key);
|
|
886
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
829
887
|
if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
|
|
830
888
|
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
|
|
831
889
|
const concatenated = new Uint8Array(totalSize);
|
|
@@ -838,8 +896,7 @@ var FileBackedStreamStore = class {
|
|
|
838
896
|
return concatenated;
|
|
839
897
|
}
|
|
840
898
|
getCurrentOffset(streamPath) {
|
|
841
|
-
const
|
|
842
|
-
const streamMeta = this.db.get(key);
|
|
899
|
+
const streamMeta = this.getMetaIfNotExpired(streamPath);
|
|
843
900
|
return streamMeta?.currentOffset;
|
|
844
901
|
}
|
|
845
902
|
clear() {
|
|
@@ -1018,10 +1075,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
|
|
|
1018
1075
|
/**
|
|
1019
1076
|
* Encode data for SSE format.
|
|
1020
1077
|
* Per SSE spec, each line in the payload needs its own "data:" prefix.
|
|
1021
|
-
*
|
|
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.
|
|
1022
1081
|
*/
|
|
1023
1082
|
function encodeSSEData(payload) {
|
|
1024
|
-
const lines = payload.split(
|
|
1083
|
+
const lines = payload.split(/\r\n|\r|\n/);
|
|
1025
1084
|
return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
|
|
1026
1085
|
}
|
|
1027
1086
|
/**
|
|
@@ -1060,8 +1119,8 @@ var DurableStreamTestServer = class {
|
|
|
1060
1119
|
_url = null;
|
|
1061
1120
|
activeSSEResponses = new Set();
|
|
1062
1121
|
isShuttingDown = false;
|
|
1063
|
-
/** Injected
|
|
1064
|
-
|
|
1122
|
+
/** Injected faults for testing retry/resilience */
|
|
1123
|
+
injectedFaults = new Map();
|
|
1065
1124
|
constructor(options = {}) {
|
|
1066
1125
|
if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
|
|
1067
1126
|
else this.store = new StreamStore();
|
|
@@ -1146,30 +1205,71 @@ var DurableStreamTestServer = class {
|
|
|
1146
1205
|
/**
|
|
1147
1206
|
* Inject an error to be returned on the next N requests to a path.
|
|
1148
1207
|
* Used for testing retry/resilience behavior.
|
|
1208
|
+
* @deprecated Use injectFault for full fault injection capabilities
|
|
1149
1209
|
*/
|
|
1150
1210
|
injectError(path, status, count = 1, retryAfter) {
|
|
1151
|
-
this.
|
|
1211
|
+
this.injectedFaults.set(path, {
|
|
1152
1212
|
status,
|
|
1153
1213
|
count,
|
|
1154
1214
|
retryAfter
|
|
1155
1215
|
});
|
|
1156
1216
|
}
|
|
1157
1217
|
/**
|
|
1158
|
-
*
|
|
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.
|
|
1159
1220
|
*/
|
|
1160
|
-
|
|
1161
|
-
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();
|
|
1162
1232
|
}
|
|
1163
1233
|
/**
|
|
1164
|
-
* Check if there's an injected
|
|
1165
|
-
* Returns the
|
|
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.
|
|
1166
1236
|
*/
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
if (!
|
|
1170
|
-
|
|
1171
|
-
if (
|
|
1172
|
-
|
|
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
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
1257
|
+
* Returns modified body, or original if no modifications needed.
|
|
1258
|
+
*/
|
|
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;
|
|
1173
1273
|
}
|
|
1174
1274
|
async handleRequest(req, res) {
|
|
1175
1275
|
const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
|
|
@@ -1179,6 +1279,8 @@ var DurableStreamTestServer = class {
|
|
|
1179
1279
|
res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
|
|
1180
1280
|
res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
|
|
1181
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`);
|
|
1182
1284
|
if (method === `OPTIONS`) {
|
|
1183
1285
|
res.writeHead(204);
|
|
1184
1286
|
res.end();
|
|
@@ -1188,13 +1290,21 @@ var DurableStreamTestServer = class {
|
|
|
1188
1290
|
await this.handleTestInjectError(method, req, res);
|
|
1189
1291
|
return;
|
|
1190
1292
|
}
|
|
1191
|
-
const
|
|
1192
|
-
if (
|
|
1193
|
-
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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;
|
|
1198
1308
|
}
|
|
1199
1309
|
try {
|
|
1200
1310
|
switch (method) {
|
|
@@ -1309,7 +1419,10 @@ var DurableStreamTestServer = class {
|
|
|
1309
1419
|
res.end();
|
|
1310
1420
|
return;
|
|
1311
1421
|
}
|
|
1312
|
-
const headers = {
|
|
1422
|
+
const headers = {
|
|
1423
|
+
[STREAM_OFFSET_HEADER]: stream.currentOffset,
|
|
1424
|
+
"cache-control": `no-store`
|
|
1425
|
+
};
|
|
1313
1426
|
if (stream.contentType) headers[`content-type`] = stream.contentType;
|
|
1314
1427
|
headers[`etag`] = `"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}"`;
|
|
1315
1428
|
res.writeHead(200, headers);
|
|
@@ -1400,6 +1513,7 @@ var DurableStreamTestServer = class {
|
|
|
1400
1513
|
headers[`vary`] = `accept-encoding`;
|
|
1401
1514
|
}
|
|
1402
1515
|
}
|
|
1516
|
+
finalData = this.applyFaultBodyModification(res, finalData);
|
|
1403
1517
|
res.writeHead(200, headers);
|
|
1404
1518
|
res.end(Buffer.from(finalData));
|
|
1405
1519
|
}
|
|
@@ -1412,7 +1526,9 @@ var DurableStreamTestServer = class {
|
|
|
1412
1526
|
"content-type": `text/event-stream`,
|
|
1413
1527
|
"cache-control": `no-cache`,
|
|
1414
1528
|
connection: `keep-alive`,
|
|
1415
|
-
"access-control-allow-origin":
|
|
1529
|
+
"access-control-allow-origin": `*`,
|
|
1530
|
+
"x-content-type-options": `nosniff`,
|
|
1531
|
+
"cross-origin-resource-policy": `cross-origin`
|
|
1416
1532
|
});
|
|
1417
1533
|
let currentOffset = initialOffset;
|
|
1418
1534
|
let isConnected = true;
|
|
@@ -1483,7 +1599,7 @@ var DurableStreamTestServer = class {
|
|
|
1483
1599
|
seq,
|
|
1484
1600
|
contentType
|
|
1485
1601
|
}));
|
|
1486
|
-
res.writeHead(
|
|
1602
|
+
res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
|
|
1487
1603
|
res.end();
|
|
1488
1604
|
}
|
|
1489
1605
|
/**
|
|
@@ -1514,12 +1630,29 @@ var DurableStreamTestServer = class {
|
|
|
1514
1630
|
const body = await this.readBody(req);
|
|
1515
1631
|
try {
|
|
1516
1632
|
const config = JSON.parse(new TextDecoder().decode(body));
|
|
1517
|
-
if (!config.path
|
|
1633
|
+
if (!config.path) {
|
|
1518
1634
|
res.writeHead(400, { "content-type": `text/plain` });
|
|
1519
|
-
res.end(`Missing required
|
|
1635
|
+
res.end(`Missing required field: path`);
|
|
1520
1636
|
return;
|
|
1521
1637
|
}
|
|
1522
|
-
|
|
1638
|
+
const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
|
|
1639
|
+
if (!hasFaultType) {
|
|
1640
|
+
res.writeHead(400, { "content-type": `text/plain` });
|
|
1641
|
+
res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
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
|
+
});
|
|
1523
1656
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1524
1657
|
res.end(JSON.stringify({ ok: true }));
|
|
1525
1658
|
} catch {
|
|
@@ -1527,7 +1660,7 @@ var DurableStreamTestServer = class {
|
|
|
1527
1660
|
res.end(`Invalid JSON body`);
|
|
1528
1661
|
}
|
|
1529
1662
|
} else if (method === `DELETE`) {
|
|
1530
|
-
this.
|
|
1663
|
+
this.clearInjectedFaults();
|
|
1531
1664
|
res.writeHead(200, { "content-type": `application/json` });
|
|
1532
1665
|
res.end(JSON.stringify({ ok: true }));
|
|
1533
1666
|
} else {
|