navis.js 4.0.0 → 5.2.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 +37 -2
- package/examples/v5-features-demo.js +167 -0
- package/examples/v5.1-features-demo.js +192 -0
- package/examples/v5.2-features-demo.js +153 -0
- package/package.json +1 -1
- package/src/cache/cache.js +157 -0
- package/src/cache/redis-cache.js +174 -0
- package/src/core/app.js +9 -0
- package/src/core/graceful-shutdown.js +77 -0
- package/src/core/versioning.js +124 -0
- package/src/db/db-pool.js +195 -0
- package/src/docs/swagger.js +188 -0
- package/src/health/health-checker.js +120 -0
- package/src/index.js +51 -0
- package/src/middleware/cache-middleware.js +105 -0
- package/src/middleware/compression.js +97 -0
- package/src/middleware/cors.js +86 -0
- package/src/middleware/security.js +107 -0
- package/src/middleware/upload.js +151 -0
- package/src/sse/server-sent-events.js +171 -0
- package/src/testing/test-helper.js +167 -0
- package/src/websocket/websocket-server.js +301 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Compression Middleware
|
|
3
|
+
* v5: Gzip and Brotli compression support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const zlib = require('zlib');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compression middleware
|
|
10
|
+
* @param {Object} options - Compression options
|
|
11
|
+
* @returns {Function} - Middleware function
|
|
12
|
+
*/
|
|
13
|
+
function compress(options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
level = 6, // Compression level (1-9)
|
|
16
|
+
threshold = 1024, // Minimum size to compress (bytes)
|
|
17
|
+
algorithm = 'gzip', // 'gzip' or 'brotli'
|
|
18
|
+
filter = (req, res) => {
|
|
19
|
+
// Default: compress JSON and text responses
|
|
20
|
+
const contentType = res.headers?.['content-type'] || '';
|
|
21
|
+
return contentType.includes('application/json') ||
|
|
22
|
+
contentType.includes('text/') ||
|
|
23
|
+
contentType.includes('application/javascript');
|
|
24
|
+
},
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
return async (req, res, next) => {
|
|
28
|
+
// Store original body setter
|
|
29
|
+
const originalBody = res.body;
|
|
30
|
+
const originalEnd = res.end || (() => {});
|
|
31
|
+
|
|
32
|
+
// Wrap response to compress before sending
|
|
33
|
+
res.end = function(...args) {
|
|
34
|
+
// Check if should compress
|
|
35
|
+
if (!filter(req, res)) {
|
|
36
|
+
return originalEnd.apply(this, args);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Get response body
|
|
40
|
+
let body = res.body;
|
|
41
|
+
if (typeof body === 'object') {
|
|
42
|
+
body = JSON.stringify(body);
|
|
43
|
+
} else if (typeof body !== 'string') {
|
|
44
|
+
body = String(body);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check threshold
|
|
48
|
+
if (Buffer.byteLength(body, 'utf8') < threshold) {
|
|
49
|
+
return originalEnd.apply(this, args);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check if client supports compression
|
|
53
|
+
const acceptEncoding = req.headers['accept-encoding'] || req.headers['Accept-Encoding'] || '';
|
|
54
|
+
const supportsGzip = acceptEncoding.includes('gzip');
|
|
55
|
+
const supportsBrotli = acceptEncoding.includes('br');
|
|
56
|
+
|
|
57
|
+
// Choose compression algorithm
|
|
58
|
+
let compressed;
|
|
59
|
+
let encoding;
|
|
60
|
+
|
|
61
|
+
if (algorithm === 'brotli' && supportsBrotli) {
|
|
62
|
+
try {
|
|
63
|
+
compressed = zlib.brotliCompressSync(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } });
|
|
64
|
+
encoding = 'br';
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// Fallback to gzip if brotli fails
|
|
67
|
+
compressed = zlib.gzipSync(body, { level });
|
|
68
|
+
encoding = 'gzip';
|
|
69
|
+
}
|
|
70
|
+
} else if (supportsGzip) {
|
|
71
|
+
compressed = zlib.gzipSync(body, { level });
|
|
72
|
+
encoding = 'gzip';
|
|
73
|
+
} else {
|
|
74
|
+
// No compression support
|
|
75
|
+
return originalEnd.apply(this, args);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Set compression headers
|
|
79
|
+
res.headers = res.headers || {};
|
|
80
|
+
res.headers['Content-Encoding'] = encoding;
|
|
81
|
+
res.headers['Vary'] = 'Accept-Encoding';
|
|
82
|
+
|
|
83
|
+
// Update content length
|
|
84
|
+
res.headers['Content-Length'] = compressed.length.toString();
|
|
85
|
+
|
|
86
|
+
// Update body with compressed data
|
|
87
|
+
res.body = compressed;
|
|
88
|
+
|
|
89
|
+
return originalEnd.apply(this, args);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
next();
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = compress;
|
|
97
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS Middleware
|
|
3
|
+
* v5: Cross-Origin Resource Sharing support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CORS middleware
|
|
8
|
+
* @param {Object} options - CORS options
|
|
9
|
+
* @returns {Function} - Middleware function
|
|
10
|
+
*/
|
|
11
|
+
function cors(options = {}) {
|
|
12
|
+
const {
|
|
13
|
+
origin = '*',
|
|
14
|
+
methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
15
|
+
allowedHeaders = ['Content-Type', 'Authorization', 'X-Requested-With'],
|
|
16
|
+
exposedHeaders = [],
|
|
17
|
+
credentials = false,
|
|
18
|
+
maxAge = 86400, // 24 hours
|
|
19
|
+
preflightContinue = false,
|
|
20
|
+
} = options;
|
|
21
|
+
|
|
22
|
+
// Normalize origin
|
|
23
|
+
const allowedOrigins = Array.isArray(origin) ? origin : [origin];
|
|
24
|
+
const isWildcard = allowedOrigins.includes('*');
|
|
25
|
+
|
|
26
|
+
return async (req, res, next) => {
|
|
27
|
+
const requestOrigin = req.headers.origin || req.headers.Origin;
|
|
28
|
+
|
|
29
|
+
// Determine allowed origin
|
|
30
|
+
let allowedOrigin = null;
|
|
31
|
+
if (isWildcard) {
|
|
32
|
+
allowedOrigin = '*';
|
|
33
|
+
} else if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
|
34
|
+
allowedOrigin = requestOrigin;
|
|
35
|
+
} else if (allowedOrigins.length === 1 && allowedOrigins[0] !== '*') {
|
|
36
|
+
allowedOrigin = allowedOrigins[0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle preflight requests
|
|
40
|
+
if (req.method === 'OPTIONS') {
|
|
41
|
+
res.headers = res.headers || {};
|
|
42
|
+
|
|
43
|
+
if (allowedOrigin) {
|
|
44
|
+
res.headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
res.headers['Access-Control-Allow-Methods'] = methods.join(', ');
|
|
48
|
+
res.headers['Access-Control-Allow-Headers'] = allowedHeaders.join(', ');
|
|
49
|
+
res.headers['Access-Control-Max-Age'] = maxAge.toString();
|
|
50
|
+
|
|
51
|
+
if (credentials) {
|
|
52
|
+
res.headers['Access-Control-Allow-Credentials'] = 'true';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (exposedHeaders.length > 0) {
|
|
56
|
+
res.headers['Access-Control-Expose-Headers'] = exposedHeaders.join(', ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!preflightContinue) {
|
|
60
|
+
res.statusCode = 204;
|
|
61
|
+
res.body = null;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Set CORS headers for all responses
|
|
67
|
+
res.headers = res.headers || {};
|
|
68
|
+
|
|
69
|
+
if (allowedOrigin) {
|
|
70
|
+
res.headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (credentials && allowedOrigin && allowedOrigin !== '*') {
|
|
74
|
+
res.headers['Access-Control-Allow-Credentials'] = 'true';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (exposedHeaders.length > 0) {
|
|
78
|
+
res.headers['Access-Control-Expose-Headers'] = exposedHeaders.join(', ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
next();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = cors;
|
|
86
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Headers Middleware
|
|
3
|
+
* v5: Security headers for protection against common attacks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Security headers middleware
|
|
8
|
+
* @param {Object} options - Security options
|
|
9
|
+
* @returns {Function} - Middleware function
|
|
10
|
+
*/
|
|
11
|
+
function security(options = {}) {
|
|
12
|
+
const {
|
|
13
|
+
helmet = true,
|
|
14
|
+
hsts = true,
|
|
15
|
+
hstsMaxAge = 31536000, // 1 year
|
|
16
|
+
hstsIncludeSubDomains = true,
|
|
17
|
+
hstsPreload = false,
|
|
18
|
+
noSniff = true,
|
|
19
|
+
xssFilter = true,
|
|
20
|
+
frameOptions = 'DENY', // DENY, SAMEORIGIN, or false
|
|
21
|
+
contentSecurityPolicy = false,
|
|
22
|
+
cspDirectives = {},
|
|
23
|
+
referrerPolicy = 'no-referrer',
|
|
24
|
+
permissionsPolicy = {},
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
return async (req, res, next) => {
|
|
28
|
+
res.headers = res.headers || {};
|
|
29
|
+
|
|
30
|
+
// X-Content-Type-Options
|
|
31
|
+
if (noSniff) {
|
|
32
|
+
res.headers['X-Content-Type-Options'] = 'nosniff';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// X-XSS-Protection
|
|
36
|
+
if (xssFilter) {
|
|
37
|
+
res.headers['X-XSS-Protection'] = '1; mode=block';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// X-Frame-Options
|
|
41
|
+
if (frameOptions) {
|
|
42
|
+
res.headers['X-Frame-Options'] = frameOptions;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Strict-Transport-Security (HSTS)
|
|
46
|
+
if (hsts && req.headers['x-forwarded-proto'] === 'https') {
|
|
47
|
+
let hstsValue = `max-age=${hstsMaxAge}`;
|
|
48
|
+
if (hstsIncludeSubDomains) {
|
|
49
|
+
hstsValue += '; includeSubDomains';
|
|
50
|
+
}
|
|
51
|
+
if (hstsPreload) {
|
|
52
|
+
hstsValue += '; preload';
|
|
53
|
+
}
|
|
54
|
+
res.headers['Strict-Transport-Security'] = hstsValue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Content-Security-Policy
|
|
58
|
+
if (contentSecurityPolicy) {
|
|
59
|
+
const directives = {
|
|
60
|
+
'default-src': ["'self'"],
|
|
61
|
+
'script-src': ["'self'"],
|
|
62
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
63
|
+
'img-src': ["'self'", 'data:', 'https:'],
|
|
64
|
+
'font-src': ["'self'"],
|
|
65
|
+
'connect-src': ["'self'"],
|
|
66
|
+
...cspDirectives,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const cspValue = Object.entries(directives)
|
|
70
|
+
.map(([key, values]) => {
|
|
71
|
+
const valuesStr = Array.isArray(values) ? values.join(' ') : values;
|
|
72
|
+
return `${key} ${valuesStr}`;
|
|
73
|
+
})
|
|
74
|
+
.join('; ');
|
|
75
|
+
|
|
76
|
+
res.headers['Content-Security-Policy'] = cspValue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Referrer-Policy
|
|
80
|
+
if (referrerPolicy) {
|
|
81
|
+
res.headers['Referrer-Policy'] = referrerPolicy;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Permissions-Policy (formerly Feature-Policy)
|
|
85
|
+
if (Object.keys(permissionsPolicy).length > 0) {
|
|
86
|
+
const policyValue = Object.entries(permissionsPolicy)
|
|
87
|
+
.map(([feature, allowlist]) => {
|
|
88
|
+
const allowlistStr = Array.isArray(allowlist) ? allowlist.join(', ') : allowlist;
|
|
89
|
+
return `${feature}=${allowlistStr}`;
|
|
90
|
+
})
|
|
91
|
+
.join(', ');
|
|
92
|
+
|
|
93
|
+
res.headers['Permissions-Policy'] = policyValue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// X-Powered-By removal (security through obscurity)
|
|
97
|
+
if (helmet) {
|
|
98
|
+
// Remove X-Powered-By if present
|
|
99
|
+
delete res.headers['X-Powered-By'];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
next();
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = security;
|
|
107
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Upload Middleware
|
|
3
|
+
* v5.1: Multipart form data and file upload handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* File upload middleware
|
|
12
|
+
* @param {Object} options - Upload options
|
|
13
|
+
* @returns {Function} - Middleware function
|
|
14
|
+
*/
|
|
15
|
+
function upload(options = {}) {
|
|
16
|
+
const {
|
|
17
|
+
dest = '/tmp/uploads',
|
|
18
|
+
limits = {
|
|
19
|
+
fileSize: 5 * 1024 * 1024, // 5MB default
|
|
20
|
+
files: 10, // Max 10 files
|
|
21
|
+
},
|
|
22
|
+
fileFilter = null, // Function to filter files
|
|
23
|
+
preserveExtension = true,
|
|
24
|
+
generateFilename = (file) => {
|
|
25
|
+
// Generate unique filename
|
|
26
|
+
const ext = preserveExtension ? path.extname(file.originalName || '') : '';
|
|
27
|
+
return `${crypto.randomBytes(16).toString('hex')}${ext}`;
|
|
28
|
+
},
|
|
29
|
+
} = options;
|
|
30
|
+
|
|
31
|
+
// Ensure destination directory exists
|
|
32
|
+
if (!fs.existsSync(dest)) {
|
|
33
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return async (req, res, next) => {
|
|
37
|
+
// Check content type
|
|
38
|
+
const contentType = req.headers['content-type'] || req.headers['Content-Type'] || '';
|
|
39
|
+
|
|
40
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
41
|
+
return next();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Parse multipart form data (simplified implementation)
|
|
45
|
+
// In production, use a library like busboy or multer
|
|
46
|
+
try {
|
|
47
|
+
req.files = [];
|
|
48
|
+
req.body = req.body || {};
|
|
49
|
+
|
|
50
|
+
// For Lambda, body is already parsed
|
|
51
|
+
if (req.event && req.event.isBase64Encoded) {
|
|
52
|
+
// Handle base64 encoded body
|
|
53
|
+
const body = Buffer.from(req.event.body, 'base64').toString();
|
|
54
|
+
// Parse multipart data (simplified)
|
|
55
|
+
// In production, use proper multipart parser
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// For Node.js HTTP, parse from request stream
|
|
59
|
+
if (req.on && typeof req.on === 'function') {
|
|
60
|
+
await parseMultipart(req, dest, limits, fileFilter, generateFilename, (files, fields) => {
|
|
61
|
+
req.files = files;
|
|
62
|
+
req.body = { ...req.body, ...fields };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
next();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
res.statusCode = 400;
|
|
69
|
+
res.body = { error: error.message || 'File upload failed' };
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse multipart form data (simplified)
|
|
76
|
+
* @private
|
|
77
|
+
*/
|
|
78
|
+
function parseMultipart(req, dest, limits, fileFilter, generateFilename, callback) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const files = [];
|
|
81
|
+
const fields = {};
|
|
82
|
+
let totalSize = 0;
|
|
83
|
+
let fileCount = 0;
|
|
84
|
+
|
|
85
|
+
// Simplified multipart parser
|
|
86
|
+
// In production, use busboy or multer
|
|
87
|
+
let buffer = '';
|
|
88
|
+
|
|
89
|
+
req.on('data', (chunk) => {
|
|
90
|
+
buffer += chunk.toString();
|
|
91
|
+
totalSize += chunk.length;
|
|
92
|
+
|
|
93
|
+
if (totalSize > limits.fileSize) {
|
|
94
|
+
reject(new Error('File size exceeds limit'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
req.on('end', () => {
|
|
100
|
+
// Basic multipart parsing (simplified)
|
|
101
|
+
// This is a placeholder - in production use proper parser
|
|
102
|
+
try {
|
|
103
|
+
callback(files, fields);
|
|
104
|
+
resolve();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
reject(error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
req.on('error', reject);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Save uploaded file
|
|
116
|
+
* @param {Object} file - File object
|
|
117
|
+
* @param {string} dest - Destination directory
|
|
118
|
+
* @param {Function} generateFilename - Filename generator
|
|
119
|
+
* @returns {Promise<string>} - File path
|
|
120
|
+
*/
|
|
121
|
+
async function saveFile(file, dest, generateFilename) {
|
|
122
|
+
const filename = generateFilename(file);
|
|
123
|
+
const filepath = path.join(dest, filename);
|
|
124
|
+
|
|
125
|
+
// Ensure directory exists
|
|
126
|
+
const dir = path.dirname(filepath);
|
|
127
|
+
if (!fs.existsSync(dir)) {
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Write file
|
|
132
|
+
if (file.buffer) {
|
|
133
|
+
fs.writeFileSync(filepath, file.buffer);
|
|
134
|
+
} else if (file.stream) {
|
|
135
|
+
// Handle stream
|
|
136
|
+
const writeStream = fs.createWriteStream(filepath);
|
|
137
|
+
file.stream.pipe(writeStream);
|
|
138
|
+
await new Promise((resolve, reject) => {
|
|
139
|
+
writeStream.on('finish', resolve);
|
|
140
|
+
writeStream.on('error', reject);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return filepath;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
upload,
|
|
149
|
+
saveFile,
|
|
150
|
+
};
|
|
151
|
+
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events (SSE) Support
|
|
3
|
+
* v5.2: Real-time event streaming to clients
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class SSEServer {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.clients = new Map();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SSE middleware
|
|
13
|
+
* @returns {Function} - Middleware function
|
|
14
|
+
*/
|
|
15
|
+
middleware() {
|
|
16
|
+
return async (req, res, next) => {
|
|
17
|
+
// Check if this is an SSE request
|
|
18
|
+
const accept = req.headers.accept || req.headers.Accept || '';
|
|
19
|
+
|
|
20
|
+
if (accept.includes('text/event-stream')) {
|
|
21
|
+
// Set SSE headers
|
|
22
|
+
res.headers = res.headers || {};
|
|
23
|
+
res.headers['Content-Type'] = 'text/event-stream';
|
|
24
|
+
res.headers['Cache-Control'] = 'no-cache';
|
|
25
|
+
res.headers['Connection'] = 'keep-alive';
|
|
26
|
+
res.headers['X-Accel-Buffering'] = 'no'; // Disable nginx buffering
|
|
27
|
+
|
|
28
|
+
// Create SSE client
|
|
29
|
+
const clientId = this._generateId();
|
|
30
|
+
const client = {
|
|
31
|
+
id: clientId,
|
|
32
|
+
req,
|
|
33
|
+
res,
|
|
34
|
+
send: (data, event = null, id = null) => this._send(client, data, event, id),
|
|
35
|
+
close: () => this._close(client),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
this.clients.set(clientId, client);
|
|
39
|
+
|
|
40
|
+
// Send initial connection message
|
|
41
|
+
this._send(client, { type: 'connected', clientId }, 'connection', clientId);
|
|
42
|
+
|
|
43
|
+
// Handle client disconnect
|
|
44
|
+
req.on('close', () => {
|
|
45
|
+
this._close(client);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Attach SSE methods to response
|
|
49
|
+
res.sse = {
|
|
50
|
+
send: (data, event, id) => client.send(data, event, id),
|
|
51
|
+
close: () => client.close(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Don't call next() - SSE keeps connection open
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
next();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Send SSE message
|
|
64
|
+
* @private
|
|
65
|
+
*/
|
|
66
|
+
_send(client, data, event = null, id = null) {
|
|
67
|
+
if (!client.res || client.res.destroyed) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let message = '';
|
|
72
|
+
|
|
73
|
+
// Event ID
|
|
74
|
+
if (id !== null) {
|
|
75
|
+
message += `id: ${id}\n`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Event type
|
|
79
|
+
if (event) {
|
|
80
|
+
message += `event: ${event}\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Data
|
|
84
|
+
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
|
|
85
|
+
message += `data: ${dataStr}\n\n`;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// For Node.js HTTP response
|
|
89
|
+
if (client.res.write) {
|
|
90
|
+
client.res.write(message);
|
|
91
|
+
} else {
|
|
92
|
+
// For Lambda, accumulate messages
|
|
93
|
+
if (!client.res.sseMessages) {
|
|
94
|
+
client.res.sseMessages = [];
|
|
95
|
+
}
|
|
96
|
+
client.res.sseMessages.push(message);
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('SSE send error:', error);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Close SSE connection
|
|
107
|
+
* @private
|
|
108
|
+
*/
|
|
109
|
+
_close(client) {
|
|
110
|
+
this.clients.delete(client.id);
|
|
111
|
+
|
|
112
|
+
if (client.res && client.res.end) {
|
|
113
|
+
try {
|
|
114
|
+
client.res.end();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Ignore
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Broadcast to all clients
|
|
123
|
+
* @param {*} data - Message data
|
|
124
|
+
* @param {string} event - Event type
|
|
125
|
+
*/
|
|
126
|
+
broadcast(data, event = null) {
|
|
127
|
+
for (const client of this.clients.values()) {
|
|
128
|
+
this._send(client, data, event);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get all connected clients
|
|
134
|
+
* @returns {Array} - Array of client objects
|
|
135
|
+
*/
|
|
136
|
+
getClients() {
|
|
137
|
+
return Array.from(this.clients.values());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate unique client ID
|
|
142
|
+
* @private
|
|
143
|
+
*/
|
|
144
|
+
_generateId() {
|
|
145
|
+
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create SSE server
|
|
151
|
+
* @returns {SSEServer} - SSE server instance
|
|
152
|
+
*/
|
|
153
|
+
function createSSEServer() {
|
|
154
|
+
return new SSEServer();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* SSE middleware helper
|
|
159
|
+
* @returns {Function} - Middleware function
|
|
160
|
+
*/
|
|
161
|
+
function sse() {
|
|
162
|
+
const sseServer = createSSEServer();
|
|
163
|
+
return sseServer.middleware();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
SSEServer,
|
|
168
|
+
createSSEServer,
|
|
169
|
+
sse,
|
|
170
|
+
};
|
|
171
|
+
|