stream-guard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +382 -0
- package/dist/index.cjs +356 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +270 -0
- package/dist/index.d.ts +270 -0
- package/dist/index.js +316 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# stream-guard
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/stream-guard)
|
|
4
|
+
[](https://github.com/your-org/stream-guard/actions)
|
|
5
|
+
[](https://www.npmjs.com/package/stream-guard)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
**Production-grade circuit breaker for Node.js streams. Prevents leaks, hangs, and OOM crashes.**
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The Problem: Why Streams Are Dangerous
|
|
14
|
+
|
|
15
|
+
Node.js streams are powerful but **dangerous by default**:
|
|
16
|
+
|
|
17
|
+
| Threat | Description | Impact |
|
|
18
|
+
|--------|-------------|--------|
|
|
19
|
+
| **Slow Loris Attack** | Malicious client sends data extremely slowly | Server hangs forever, file descriptors exhausted |
|
|
20
|
+
| **Zombie Connections** | Stream stops emitting data but never closes | Memory leak, connection pool exhaustion |
|
|
21
|
+
| **Infinite Data** | Unbounded streams consume unlimited memory | OOM crash, service restart |
|
|
22
|
+
| **Memory Leaks** | Event listeners pile up on long-lived streams | Gradual memory growth until crash |
|
|
23
|
+
|
|
24
|
+
### The "Just Use setTimeout" Fallacy
|
|
25
|
+
|
|
26
|
+
```javascript
|
|
27
|
+
// ❌ This is NOT enough
|
|
28
|
+
const timeout = setTimeout(() => {
|
|
29
|
+
stream.destroy();
|
|
30
|
+
}, 30000);
|
|
31
|
+
|
|
32
|
+
stream.on('data', () => {
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
// Now what? Stream could still stall between chunks!
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The `setTimeout` approach fails because:
|
|
39
|
+
- It doesn't detect **stalls between chunks**
|
|
40
|
+
- It doesn't limit **total bytes transferred**
|
|
41
|
+
- It doesn't protect against **memory exhaustion**
|
|
42
|
+
- Timer cleanup is **error-prone** and often missed
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## The Solution: stream-guard
|
|
47
|
+
|
|
48
|
+
`stream-guard` wraps any Node.js stream with configurable safety constraints:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { guard } from 'stream-guard';
|
|
52
|
+
import { createReadStream } from 'node:fs';
|
|
53
|
+
|
|
54
|
+
// ✅ Protected stream - will be destroyed if any limit is violated
|
|
55
|
+
const stream = guard(createReadStream('data.json'), {
|
|
56
|
+
timeout: 30_000, // Absolute timeout: 30 seconds max
|
|
57
|
+
stalled: 5_000, // Stall detection: 5 seconds without data = dead
|
|
58
|
+
maxBytes: 10 * 1024 * 1024, // Byte limit: 10MB max
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
stream.pipe(response);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
When a limit is violated, the stream is **immediately destroyed** with a typed error:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { guard, StreamTimeoutError, StreamStalledError } from 'stream-guard';
|
|
68
|
+
|
|
69
|
+
stream.on('error', (err) => {
|
|
70
|
+
if (err instanceof StreamTimeoutError) {
|
|
71
|
+
console.log(`Timeout after ${err.elapsedMs}ms`);
|
|
72
|
+
} else if (err instanceof StreamStalledError) {
|
|
73
|
+
console.log(`Stalled for ${err.idleMs}ms`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm install stream-guard
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
yarn add stream-guard
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pnpm add stream-guard
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## API Reference
|
|
97
|
+
|
|
98
|
+
### `guard(stream, options)`
|
|
99
|
+
|
|
100
|
+
Wraps a stream with safety constraints. Returns the same stream instance.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
function guard<T extends GuardableStream>(stream: T, options?: GuardOptions): T
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Options
|
|
107
|
+
|
|
108
|
+
| Option | Type | Default | Description |
|
|
109
|
+
|--------|------|---------|-------------|
|
|
110
|
+
| `timeout` | `number` | - | **Absolute timeout** in milliseconds. Stream is destroyed if it doesn't complete within this time. |
|
|
111
|
+
| `stalled` | `number` | - | **Stall protection** in milliseconds. Stream is destroyed if no data flows for this duration. |
|
|
112
|
+
| `maxBytes` | `number` | - | **Byte limit**. Stream is destroyed if total bytes exceed this value. |
|
|
113
|
+
| `maxHeap` | `number` | - | **Heap limit** in bytes. Stream is destroyed if `process.memoryUsage().heapUsed` exceeds this. |
|
|
114
|
+
| `heapCheckInterval` | `number` | `100` | Interval in ms between heap checks. |
|
|
115
|
+
| `heapCheckChunks` | `number` | - | Check heap every N chunks instead of by time interval. |
|
|
116
|
+
| `onDestroy` | `(error: Error) => void` | - | Callback when stream is destroyed by guard. |
|
|
117
|
+
|
|
118
|
+
#### Returns
|
|
119
|
+
|
|
120
|
+
The same stream instance with guards attached. Works seamlessly with `.pipe()`.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
### `createGuard(defaultOptions)`
|
|
125
|
+
|
|
126
|
+
Creates a reusable guard factory with preset defaults.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const apiGuard = createGuard({
|
|
130
|
+
timeout: 30_000,
|
|
131
|
+
maxBytes: 5 * 1024 * 1024,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// All streams use these defaults
|
|
135
|
+
apiGuard(stream1);
|
|
136
|
+
apiGuard(stream2, { stalled: 5000 }); // Can still override
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### `isGuarded(stream)`
|
|
142
|
+
|
|
143
|
+
Check if a stream has already been guarded.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
if (!isGuarded(stream)) {
|
|
147
|
+
guard(stream, options);
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### `getGuardMetadata(stream)`
|
|
154
|
+
|
|
155
|
+
Access guard metadata for monitoring.
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
const metadata = getGuardMetadata(stream);
|
|
159
|
+
|
|
160
|
+
console.log(metadata.guardId); // Unique guard ID
|
|
161
|
+
console.log(metadata.getBytesTransferred()); // Bytes so far
|
|
162
|
+
console.log(metadata.getElapsedTime()); // Time since guard attached
|
|
163
|
+
console.log(metadata.isActive()); // Guard still active?
|
|
164
|
+
|
|
165
|
+
metadata.release(); // Manually remove guards
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Error Classes
|
|
171
|
+
|
|
172
|
+
All errors extend `StreamGuardError` and include:
|
|
173
|
+
- `timestamp`: When the error occurred
|
|
174
|
+
- `constraint`: Which limit was violated
|
|
175
|
+
|
|
176
|
+
| Error Class | Constraint | Properties |
|
|
177
|
+
|-------------|------------|------------|
|
|
178
|
+
| `StreamTimeoutError` | `timeout` | `timeoutMs`, `elapsedMs` |
|
|
179
|
+
| `StreamStalledError` | `stalled` | `stalledMs`, `idleMs` |
|
|
180
|
+
| `StreamLimitError` | `maxBytes` | `maxBytes`, `bytesTransferred` |
|
|
181
|
+
| `StreamHeapError` | `maxHeap` | `maxHeap`, `heapUsed` |
|
|
182
|
+
|
|
183
|
+
### Type Guards
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import {
|
|
187
|
+
isStreamGuardError,
|
|
188
|
+
isStreamTimeoutError,
|
|
189
|
+
isStreamStalledError,
|
|
190
|
+
isStreamLimitError,
|
|
191
|
+
isStreamHeapError,
|
|
192
|
+
} from 'stream-guard';
|
|
193
|
+
|
|
194
|
+
stream.on('error', (err) => {
|
|
195
|
+
if (isStreamGuardError(err)) {
|
|
196
|
+
metrics.increment(`stream.guard.${err.constraint}`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Usage Examples
|
|
204
|
+
|
|
205
|
+
### HTTP File Upload Protection
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { guard, StreamLimitError } from 'stream-guard';
|
|
209
|
+
import express from 'express';
|
|
210
|
+
|
|
211
|
+
app.post('/upload', (req, res) => {
|
|
212
|
+
const upload = guard(req, {
|
|
213
|
+
timeout: 60_000, // 1 minute max
|
|
214
|
+
stalled: 10_000, // 10s stall = abort
|
|
215
|
+
maxBytes: 50 * 1024 * 1024, // 50MB max
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
upload.on('error', (err) => {
|
|
219
|
+
if (err instanceof StreamLimitError) {
|
|
220
|
+
res.status(413).send('File too large');
|
|
221
|
+
} else {
|
|
222
|
+
res.status(408).send('Upload timeout');
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
upload.pipe(fs.createWriteStream('/uploads/file'));
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Database Export with Memory Protection
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
import { guard } from 'stream-guard';
|
|
234
|
+
|
|
235
|
+
const exportStream = guard(db.query('SELECT * FROM huge_table').stream(), {
|
|
236
|
+
timeout: 300_000, // 5 minutes
|
|
237
|
+
maxHeap: 500 * 1024 * 1024, // 500MB heap limit
|
|
238
|
+
heapCheckInterval: 50,
|
|
239
|
+
onDestroy: (err) => {
|
|
240
|
+
logger.error('Export aborted', { error: err.message });
|
|
241
|
+
alertOps('Database export failed - memory limit');
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
exportStream.pipe(csvTransform).pipe(response);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Microservice Request Streaming
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { createGuard } from 'stream-guard';
|
|
252
|
+
|
|
253
|
+
// Reusable guard for all API responses
|
|
254
|
+
const apiGuard = createGuard({
|
|
255
|
+
timeout: 30_000,
|
|
256
|
+
stalled: 5_000,
|
|
257
|
+
maxBytes: 10 * 1024 * 1024,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
async function fetchFromService(url: string) {
|
|
261
|
+
const response = await fetch(url);
|
|
262
|
+
return apiGuard(Readable.fromWeb(response.body));
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### WebSocket Backpressure Protection
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { guard } from 'stream-guard';
|
|
270
|
+
|
|
271
|
+
ws.on('connection', (socket) => {
|
|
272
|
+
const guarded = guard(socket, {
|
|
273
|
+
stalled: 30_000, // Client must ACK within 30s
|
|
274
|
+
maxBytes: 100 * 1024 * 1024, // 100MB per connection
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
guarded.on('error', (err) => {
|
|
278
|
+
logger.warn('WebSocket terminated', { reason: err.message });
|
|
279
|
+
socket.terminate();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Why stream-guard?
|
|
287
|
+
|
|
288
|
+
| Feature | `setTimeout` | `stream.timeout()` | **stream-guard** |
|
|
289
|
+
|---------|--------------|-------------------|------------------|
|
|
290
|
+
| Absolute timeout | ✅ | ❌ | ✅ |
|
|
291
|
+
| Stall detection | ❌ | ❌ | ✅ |
|
|
292
|
+
| Byte limits | ❌ | ❌ | ✅ |
|
|
293
|
+
| Heap protection | ❌ | ❌ | ✅ |
|
|
294
|
+
| Automatic cleanup | ❌ | ❌ | ✅ |
|
|
295
|
+
| Typed errors | ❌ | ❌ | ✅ |
|
|
296
|
+
| Zero dependencies | N/A | N/A | ✅ |
|
|
297
|
+
| Works with pipe() | ❌ | ✅ | ✅ |
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Best Practices
|
|
302
|
+
|
|
303
|
+
### 1. Always set a timeout for network streams
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// Every external stream should have an absolute timeout
|
|
307
|
+
guard(httpResponse, { timeout: 30_000 });
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### 2. Use stall detection for user-facing uploads
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// Slow uploads are often attacks or broken clients
|
|
314
|
+
guard(uploadStream, { stalled: 10_000 });
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### 3. Combine with byte limits for untrusted input
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
guard(request, {
|
|
321
|
+
timeout: 60_000,
|
|
322
|
+
stalled: 5_000,
|
|
323
|
+
maxBytes: 10 * 1024 * 1024,
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### 4. Monitor with onDestroy callback
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
guard(stream, {
|
|
331
|
+
timeout: 30_000,
|
|
332
|
+
onDestroy: (err) => {
|
|
333
|
+
metrics.increment('stream.killed', { reason: err.constraint });
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### 5. Use metadata for debugging
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
const guarded = guard(stream, options);
|
|
342
|
+
|
|
343
|
+
setInterval(() => {
|
|
344
|
+
const meta = getGuardMetadata(guarded);
|
|
345
|
+
if (meta?.isActive()) {
|
|
346
|
+
logger.debug('Stream progress', {
|
|
347
|
+
bytes: meta.getBytesTransferred(),
|
|
348
|
+
elapsed: meta.getElapsedTime(),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}, 5000);
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Requirements
|
|
357
|
+
|
|
358
|
+
- Node.js 18.0.0 or later
|
|
359
|
+
- TypeScript 5.0+ (for type definitions)
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## License
|
|
364
|
+
|
|
365
|
+
MIT © 2026
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Contributing
|
|
370
|
+
|
|
371
|
+
1. Fork the repository
|
|
372
|
+
2. Create a feature branch (`git checkout -b feature/amazing`)
|
|
373
|
+
3. Run tests (`npm test`)
|
|
374
|
+
4. Commit changes (`git commit -m 'Add amazing feature'`)
|
|
375
|
+
5. Push to branch (`git push origin feature/amazing`)
|
|
376
|
+
6. Open a Pull Request
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
<p align="center">
|
|
381
|
+
<strong>Stop streams from ruining your day.</strong>
|
|
382
|
+
</p>
|