zero-http 0.2.5 → 0.3.1
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 +1250 -283
- package/documentation/config/db.js +25 -0
- package/documentation/config/middleware.js +44 -0
- package/documentation/config/tls.js +12 -0
- package/documentation/controllers/cookies.js +34 -0
- package/documentation/controllers/tasks.js +108 -0
- package/documentation/full-server.js +25 -184
- package/documentation/models/Task.js +21 -0
- package/documentation/public/data/api.json +404 -24
- package/documentation/public/data/docs.json +1139 -0
- package/documentation/public/data/examples.json +80 -2
- package/documentation/public/data/options.json +23 -8
- package/documentation/public/index.html +138 -99
- package/documentation/public/scripts/app.js +1 -3
- package/documentation/public/scripts/custom-select.js +189 -0
- package/documentation/public/scripts/data-sections.js +233 -250
- package/documentation/public/scripts/playground.js +270 -0
- package/documentation/public/scripts/ui.js +4 -3
- package/documentation/public/styles.css +56 -5
- package/documentation/public/vendor/icons/compress.svg +17 -17
- package/documentation/public/vendor/icons/database.svg +21 -0
- package/documentation/public/vendor/icons/env.svg +21 -0
- package/documentation/public/vendor/icons/fetch.svg +11 -14
- package/documentation/public/vendor/icons/security.svg +15 -0
- package/documentation/public/vendor/icons/sse.svg +12 -13
- package/documentation/public/vendor/icons/static.svg +12 -26
- package/documentation/public/vendor/icons/stream.svg +7 -13
- package/documentation/public/vendor/icons/validate.svg +17 -0
- package/documentation/routes/api.js +41 -0
- package/documentation/routes/core.js +20 -0
- package/documentation/routes/playground.js +29 -0
- package/documentation/routes/realtime.js +49 -0
- package/documentation/routes/uploads.js +71 -0
- package/index.js +62 -1
- package/lib/app.js +200 -8
- package/lib/body/json.js +28 -5
- package/lib/body/multipart.js +29 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/sendError.js +1 -0
- package/lib/body/text.js +1 -1
- package/lib/body/typeMatch.js +6 -2
- package/lib/body/urlencoded.js +5 -2
- package/lib/debug.js +345 -0
- package/lib/env/index.js +440 -0
- package/lib/errors.js +231 -0
- package/lib/http/request.js +219 -1
- package/lib/http/response.js +410 -6
- package/lib/middleware/compress.js +39 -6
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +13 -2
- package/lib/middleware/csrf.js +135 -0
- package/lib/middleware/errorHandler.js +90 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +7 -2
- package/lib/middleware/rateLimit.js +12 -1
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +95 -11
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +257 -0
- package/lib/orm/adapters/json.js +215 -0
- package/lib/orm/adapters/memory.js +383 -0
- package/lib/orm/adapters/mongo.js +444 -0
- package/lib/orm/adapters/mysql.js +272 -0
- package/lib/orm/adapters/postgres.js +394 -0
- package/lib/orm/adapters/sql-base.js +142 -0
- package/lib/orm/adapters/sqlite.js +311 -0
- package/lib/orm/index.js +276 -0
- package/lib/orm/model.js +895 -0
- package/lib/orm/query.js +807 -0
- package/lib/orm/schema.js +172 -0
- package/lib/router/index.js +136 -47
- package/lib/sse/stream.js +15 -3
- package/lib/ws/connection.js +19 -3
- package/lib/ws/handshake.js +3 -0
- package/lib/ws/index.js +3 -1
- package/lib/ws/room.js +222 -0
- package/package.json +15 -5
- package/types/app.d.ts +120 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +147 -0
- package/types/fetch.d.ts +43 -0
- package/types/index.d.ts +135 -0
- package/types/middleware.d.ts +292 -0
- package/types/orm.d.ts +610 -0
- package/types/request.d.ts +99 -0
- package/types/response.d.ts +142 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +119 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/schema
|
|
3
|
+
* @description Schema definition and validation for ORM models.
|
|
4
|
+
* Validates data against column definitions, coerces types,
|
|
5
|
+
* and enforces constraints (required, unique, min, max, enum, match).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Supported column types.
|
|
10
|
+
* @enum {string}
|
|
11
|
+
*/
|
|
12
|
+
const TYPES = {
|
|
13
|
+
STRING: 'string',
|
|
14
|
+
INTEGER: 'integer',
|
|
15
|
+
FLOAT: 'float',
|
|
16
|
+
BOOLEAN: 'boolean',
|
|
17
|
+
DATE: 'date',
|
|
18
|
+
DATETIME: 'datetime',
|
|
19
|
+
JSON: 'json',
|
|
20
|
+
TEXT: 'text',
|
|
21
|
+
BLOB: 'blob',
|
|
22
|
+
UUID: 'uuid',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate and sanitise a single value against a column definition.
|
|
27
|
+
*
|
|
28
|
+
* @param {*} value - Raw input value.
|
|
29
|
+
* @param {object} colDef - Column definition.
|
|
30
|
+
* @param {string} colName - Column name (for error messages).
|
|
31
|
+
* @returns {*} Coerced value.
|
|
32
|
+
* @throws {Error} On validation failure.
|
|
33
|
+
*/
|
|
34
|
+
function validateValue(value, colDef, colName)
|
|
35
|
+
{
|
|
36
|
+
const type = colDef.type || 'string';
|
|
37
|
+
|
|
38
|
+
// Handle null/undefined
|
|
39
|
+
if (value === undefined || value === null)
|
|
40
|
+
{
|
|
41
|
+
if (colDef.required && colDef.default === undefined)
|
|
42
|
+
throw new Error(`"${colName}" is required`);
|
|
43
|
+
if (colDef.default !== undefined)
|
|
44
|
+
return typeof colDef.default === 'function' ? colDef.default() : colDef.default;
|
|
45
|
+
return colDef.nullable !== false ? null : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
switch (type)
|
|
49
|
+
{
|
|
50
|
+
case 'string':
|
|
51
|
+
case 'text':
|
|
52
|
+
{
|
|
53
|
+
const val = String(value);
|
|
54
|
+
if (colDef.minLength !== undefined && val.length < colDef.minLength)
|
|
55
|
+
throw new Error(`"${colName}" must be at least ${colDef.minLength} characters`);
|
|
56
|
+
if (colDef.maxLength !== undefined && val.length > colDef.maxLength)
|
|
57
|
+
throw new Error(`"${colName}" must be at most ${colDef.maxLength} characters`);
|
|
58
|
+
if (colDef.match && !colDef.match.test(val))
|
|
59
|
+
throw new Error(`"${colName}" does not match pattern ${colDef.match}`);
|
|
60
|
+
if (colDef.enum && !colDef.enum.includes(val))
|
|
61
|
+
throw new Error(`"${colName}" must be one of [${colDef.enum.join(', ')}]`);
|
|
62
|
+
// Sanitise: prevent SQL-like injection patterns in string values
|
|
63
|
+
return val;
|
|
64
|
+
}
|
|
65
|
+
case 'integer':
|
|
66
|
+
{
|
|
67
|
+
const val = typeof value === 'string' ? parseInt(value, 10) : Math.floor(Number(value));
|
|
68
|
+
if (isNaN(val)) throw new Error(`"${colName}" must be an integer`);
|
|
69
|
+
if (colDef.min !== undefined && val < colDef.min)
|
|
70
|
+
throw new Error(`"${colName}" must be >= ${colDef.min}`);
|
|
71
|
+
if (colDef.max !== undefined && val > colDef.max)
|
|
72
|
+
throw new Error(`"${colName}" must be <= ${colDef.max}`);
|
|
73
|
+
return val;
|
|
74
|
+
}
|
|
75
|
+
case 'float':
|
|
76
|
+
{
|
|
77
|
+
const val = Number(value);
|
|
78
|
+
if (isNaN(val)) throw new Error(`"${colName}" must be a number`);
|
|
79
|
+
if (colDef.min !== undefined && val < colDef.min)
|
|
80
|
+
throw new Error(`"${colName}" must be >= ${colDef.min}`);
|
|
81
|
+
if (colDef.max !== undefined && val > colDef.max)
|
|
82
|
+
throw new Error(`"${colName}" must be <= ${colDef.max}`);
|
|
83
|
+
return val;
|
|
84
|
+
}
|
|
85
|
+
case 'boolean':
|
|
86
|
+
{
|
|
87
|
+
if (typeof value === 'boolean') return value;
|
|
88
|
+
if (typeof value === 'string')
|
|
89
|
+
{
|
|
90
|
+
const lower = value.toLowerCase();
|
|
91
|
+
if (['true', '1', 'yes'].includes(lower)) return true;
|
|
92
|
+
if (['false', '0', 'no'].includes(lower)) return false;
|
|
93
|
+
}
|
|
94
|
+
if (typeof value === 'number') return value !== 0;
|
|
95
|
+
throw new Error(`"${colName}" must be a boolean`);
|
|
96
|
+
}
|
|
97
|
+
case 'date':
|
|
98
|
+
case 'datetime':
|
|
99
|
+
{
|
|
100
|
+
if (value instanceof Date) return value;
|
|
101
|
+
const d = new Date(value);
|
|
102
|
+
if (isNaN(d.getTime())) throw new Error(`"${colName}" must be a valid date`);
|
|
103
|
+
return d;
|
|
104
|
+
}
|
|
105
|
+
case 'json':
|
|
106
|
+
{
|
|
107
|
+
if (typeof value === 'string')
|
|
108
|
+
{
|
|
109
|
+
try { return JSON.parse(value); }
|
|
110
|
+
catch (e) { throw new Error(`"${colName}" must be valid JSON`); }
|
|
111
|
+
}
|
|
112
|
+
// Already an object/array — return as-is for storage
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
case 'uuid':
|
|
116
|
+
{
|
|
117
|
+
const val = String(value);
|
|
118
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val))
|
|
119
|
+
throw new Error(`"${colName}" must be a valid UUID`);
|
|
120
|
+
return val;
|
|
121
|
+
}
|
|
122
|
+
case 'blob':
|
|
123
|
+
return Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
124
|
+
default:
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate all columns of a data object against the schema.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} data - Input data object.
|
|
133
|
+
* @param {object} columns - Schema column definitions.
|
|
134
|
+
* @param {object} [options]
|
|
135
|
+
* @param {boolean} [options.partial=false] - When true, only validates provided fields (for updates).
|
|
136
|
+
* @returns {{ valid: boolean, errors: string[], sanitized: object }}
|
|
137
|
+
*/
|
|
138
|
+
function validate(data, columns, options = {})
|
|
139
|
+
{
|
|
140
|
+
const errors = [];
|
|
141
|
+
const sanitized = {};
|
|
142
|
+
|
|
143
|
+
for (const [colName, colDef] of Object.entries(columns))
|
|
144
|
+
{
|
|
145
|
+
// Skip auto fields on create
|
|
146
|
+
if (colDef.primaryKey && colDef.autoIncrement && data[colName] === undefined) continue;
|
|
147
|
+
|
|
148
|
+
if (options.partial && data[colName] === undefined) continue;
|
|
149
|
+
|
|
150
|
+
try
|
|
151
|
+
{
|
|
152
|
+
sanitized[colName] = validateValue(data[colName], colDef, colName);
|
|
153
|
+
}
|
|
154
|
+
catch (e)
|
|
155
|
+
{
|
|
156
|
+
errors.push(e.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Reject unknown keys (prevent mass-assignment)
|
|
161
|
+
for (const key of Object.keys(data))
|
|
162
|
+
{
|
|
163
|
+
if (!columns[key])
|
|
164
|
+
{
|
|
165
|
+
errors.push(`Unknown column "${key}"`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { valid: errors.length === 0, errors, sanitized };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { TYPES, validateValue, validate };
|
package/lib/router/index.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* mounting, and route introspection.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const log = require('../debug')('zero:router');
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Convert a route path pattern into a RegExp and extract named parameter keys.
|
|
10
12
|
* Supports `:param` segments and trailing `*` wildcards.
|
|
@@ -56,6 +58,11 @@ class Router
|
|
|
56
58
|
this.routes = [];
|
|
57
59
|
/** @type {{ prefix: string, router: Router }[]} */
|
|
58
60
|
this._children = [];
|
|
61
|
+
/**
|
|
62
|
+
* Parameter pre-processing handlers (set by parent App).
|
|
63
|
+
* @type {Object<string, Function[]>}
|
|
64
|
+
*/
|
|
65
|
+
this._paramHandlers = {};
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/**
|
|
@@ -74,6 +81,7 @@ class Router
|
|
|
74
81
|
const entry = { method: method.toUpperCase(), path, regex, keys, handlers };
|
|
75
82
|
if (options.secure !== undefined) entry.secure = !!options.secure;
|
|
76
83
|
this.routes.push(entry);
|
|
84
|
+
log.debug('route added %s %s', method.toUpperCase(), path);
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
/**
|
|
@@ -90,6 +98,7 @@ class Router
|
|
|
90
98
|
{
|
|
91
99
|
const cleanPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
|
92
100
|
this._children.push({ prefix: cleanPrefix, router });
|
|
101
|
+
log.debug('mounted child router at %s', cleanPrefix);
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
@@ -103,46 +112,10 @@ class Router
|
|
|
103
112
|
*/
|
|
104
113
|
handle(req, res)
|
|
105
114
|
{
|
|
106
|
-
|
|
107
|
-
const url = req.url.split('?')[0];
|
|
108
|
-
|
|
109
|
-
// Try own routes first
|
|
110
|
-
for (const r of this.routes)
|
|
115
|
+
if (!this._matchAndExecute(req, res))
|
|
111
116
|
{
|
|
112
|
-
|
|
113
|
-
// Protocol-aware matching: skip if secure flag doesn't match
|
|
114
|
-
if (r.secure === true && !req.secure) continue;
|
|
115
|
-
if (r.secure === false && req.secure) continue;
|
|
116
|
-
const m = url.match(r.regex);
|
|
117
|
-
if (!m) continue;
|
|
118
|
-
req.params = {};
|
|
119
|
-
r.keys.forEach((k, i) => req.params[k] = decodeURIComponent(m[i + 1] || ''));
|
|
120
|
-
let idx = 0;
|
|
121
|
-
const next = () =>
|
|
122
|
-
{
|
|
123
|
-
if (idx < r.handlers.length)
|
|
124
|
-
{
|
|
125
|
-
const h = r.handlers[idx++];
|
|
126
|
-
return h(req, res, next);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
return next();
|
|
117
|
+
res.status(404).json({ error: 'Not Found' });
|
|
130
118
|
}
|
|
131
|
-
|
|
132
|
-
// Try child routers
|
|
133
|
-
for (const child of this._children)
|
|
134
|
-
{
|
|
135
|
-
if (url === child.prefix || url.startsWith(child.prefix + '/'))
|
|
136
|
-
{
|
|
137
|
-
const origUrl = req.url;
|
|
138
|
-
req.url = req.url.slice(child.prefix.length) || '/';
|
|
139
|
-
const found = child.router._tryHandle(req, res);
|
|
140
|
-
if (found) return;
|
|
141
|
-
req.url = origUrl; // restore if child didn't match
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
res.status(404).json({ error: 'Not Found' });
|
|
146
119
|
}
|
|
147
120
|
|
|
148
121
|
/**
|
|
@@ -155,42 +128,129 @@ class Router
|
|
|
155
128
|
* @private
|
|
156
129
|
*/
|
|
157
130
|
_tryHandle(req, res)
|
|
131
|
+
{
|
|
132
|
+
return this._matchAndExecute(req, res);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Shared route matching and handler execution.
|
|
137
|
+
* Returns `true` if a route matched (handler invoked), `false` otherwise.
|
|
138
|
+
*
|
|
139
|
+
* @param {import('./request')} req
|
|
140
|
+
* @param {import('./response')} res
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
* @private
|
|
143
|
+
*/
|
|
144
|
+
_matchAndExecute(req, res)
|
|
158
145
|
{
|
|
159
146
|
const method = req.method.toUpperCase();
|
|
160
147
|
const url = req.url.split('?')[0];
|
|
148
|
+
log.debug('%s %s', method, url);
|
|
161
149
|
|
|
162
|
-
|
|
150
|
+
// Try own routes first
|
|
151
|
+
for (let ri = 0; ri < this.routes.length; ri++)
|
|
163
152
|
{
|
|
153
|
+
const r = this.routes[ri];
|
|
164
154
|
if (r.method !== 'ALL' && r.method !== method) continue;
|
|
165
|
-
// Protocol-aware matching: skip if secure flag doesn't match
|
|
166
155
|
if (r.secure === true && !req.secure) continue;
|
|
167
156
|
if (r.secure === false && req.secure) continue;
|
|
168
157
|
const m = url.match(r.regex);
|
|
169
158
|
if (!m) continue;
|
|
170
159
|
req.params = {};
|
|
171
|
-
|
|
160
|
+
for (let i = 0; i < r.keys.length; i++)
|
|
161
|
+
{
|
|
162
|
+
req.params[r.keys[i]] = decodeURIComponent(m[i + 1] || '');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run param pre-processing handlers
|
|
166
|
+
const paramHandlers = this._paramHandlers || {};
|
|
167
|
+
let paramKeys;
|
|
168
|
+
let paramCount = 0;
|
|
169
|
+
for (let i = 0; i < r.keys.length; i++)
|
|
170
|
+
{
|
|
171
|
+
if (paramHandlers[r.keys[i]])
|
|
172
|
+
{
|
|
173
|
+
if (!paramKeys) paramKeys = [];
|
|
174
|
+
paramKeys.push(r.keys[i]);
|
|
175
|
+
paramCount++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let pIdx = 0;
|
|
180
|
+
const runParams = () =>
|
|
181
|
+
{
|
|
182
|
+
if (pIdx < paramCount)
|
|
183
|
+
{
|
|
184
|
+
const pk = paramKeys[pIdx++];
|
|
185
|
+
const fns = paramHandlers[pk];
|
|
186
|
+
let fIdx = 0;
|
|
187
|
+
const nextParam = () =>
|
|
188
|
+
{
|
|
189
|
+
if (fIdx < fns.length)
|
|
190
|
+
{
|
|
191
|
+
const fn = fns[fIdx++];
|
|
192
|
+
try
|
|
193
|
+
{
|
|
194
|
+
const result = fn(req, res, nextParam, req.params[pk]);
|
|
195
|
+
if (result && typeof result.catch === 'function')
|
|
196
|
+
{
|
|
197
|
+
result.catch(e => this._handleRouteError(e, req, res));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (e) { this._handleRouteError(e, req, res); }
|
|
201
|
+
}
|
|
202
|
+
else { runParams(); }
|
|
203
|
+
};
|
|
204
|
+
nextParam();
|
|
205
|
+
}
|
|
206
|
+
else { runHandlers(); }
|
|
207
|
+
};
|
|
208
|
+
|
|
172
209
|
let idx = 0;
|
|
173
|
-
const
|
|
210
|
+
const runHandlers = () =>
|
|
174
211
|
{
|
|
175
212
|
if (idx < r.handlers.length)
|
|
176
213
|
{
|
|
177
214
|
const h = r.handlers[idx++];
|
|
178
|
-
|
|
215
|
+
try
|
|
216
|
+
{
|
|
217
|
+
const result = h(req, res, runHandlers);
|
|
218
|
+
if (result && typeof result.catch === 'function')
|
|
219
|
+
{
|
|
220
|
+
result.catch(e => this._handleRouteError(e, req, res));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (e)
|
|
224
|
+
{
|
|
225
|
+
this._handleRouteError(e, req, res);
|
|
226
|
+
}
|
|
179
227
|
}
|
|
180
228
|
};
|
|
181
|
-
|
|
229
|
+
|
|
230
|
+
if (paramCount > 0) runParams();
|
|
231
|
+
else runHandlers();
|
|
182
232
|
return true;
|
|
183
233
|
}
|
|
184
234
|
|
|
185
|
-
|
|
235
|
+
// Try child routers
|
|
236
|
+
for (let ci = 0; ci < this._children.length; ci++)
|
|
186
237
|
{
|
|
238
|
+
const child = this._children[ci];
|
|
187
239
|
if (url === child.prefix || url.startsWith(child.prefix + '/'))
|
|
188
240
|
{
|
|
189
241
|
const origUrl = req.url;
|
|
242
|
+
const origBaseUrl = req.baseUrl || '';
|
|
243
|
+
req.baseUrl = origBaseUrl + child.prefix;
|
|
190
244
|
req.url = req.url.slice(child.prefix.length) || '/';
|
|
191
|
-
|
|
192
|
-
|
|
245
|
+
child.router._paramHandlers = this._paramHandlers;
|
|
246
|
+
try
|
|
247
|
+
{
|
|
248
|
+
const found = child.router._matchAndExecute(req, res);
|
|
249
|
+
if (found) return true;
|
|
250
|
+
}
|
|
251
|
+
catch (e) { this._handleRouteError(e, req, res); return true; }
|
|
193
252
|
req.url = origUrl;
|
|
253
|
+
req.baseUrl = origBaseUrl;
|
|
194
254
|
}
|
|
195
255
|
}
|
|
196
256
|
|
|
@@ -249,6 +309,35 @@ class Router
|
|
|
249
309
|
|
|
250
310
|
// -- Introspection -----------------------------------
|
|
251
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Handle an error thrown by a route handler.
|
|
314
|
+
* Delegates to the app-level error handler if available, otherwise
|
|
315
|
+
* sends a generic 500 JSON response.
|
|
316
|
+
*
|
|
317
|
+
* @param {Error} err
|
|
318
|
+
* @param {import('../http/request')} req
|
|
319
|
+
* @param {import('../http/response')} res
|
|
320
|
+
* @private
|
|
321
|
+
*/
|
|
322
|
+
_handleRouteError(err, req, res)
|
|
323
|
+
{
|
|
324
|
+
log.error('route error: %s', err.message || err);
|
|
325
|
+
// Check if the app has an error handler (set via app.onError())
|
|
326
|
+
if (req.app && req.app._errorHandler)
|
|
327
|
+
{
|
|
328
|
+
return req.app._errorHandler(err, req, res, () => {});
|
|
329
|
+
}
|
|
330
|
+
const statusCode = err.statusCode || err.status || 500;
|
|
331
|
+
if (!res.headersSent && !(res.raw && res.raw.headersSent))
|
|
332
|
+
{
|
|
333
|
+
res.status(statusCode).json(
|
|
334
|
+
typeof err.toJSON === 'function'
|
|
335
|
+
? err.toJSON()
|
|
336
|
+
: { error: err.message || 'Internal Server Error' }
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
252
341
|
/**
|
|
253
342
|
* Return a flat list of all registered routes, including those in
|
|
254
343
|
* mounted child routers. Useful for debugging or auto-documentation.
|
package/lib/sse/stream.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - `lastEventId` {string|null} The `Last-Event-ID` header from the client reconnection.
|
|
9
9
|
* - `eventCount` {number} Total events sent on this stream.
|
|
10
10
|
* - `bytesSent` {number} Total bytes written to the stream.
|
|
11
|
+
*
|
|
12
|
+
* @requires ../debug
|
|
11
13
|
* - `connectedAt` {number} Timestamp (ms) when the stream was opened.
|
|
12
14
|
* - `uptime` {number} Milliseconds since the stream was opened (computed).
|
|
13
15
|
* - `data` {object} Arbitrary user-data store.
|
|
@@ -26,6 +28,7 @@ class SSEStream
|
|
|
26
28
|
{
|
|
27
29
|
this._raw = raw;
|
|
28
30
|
this._closed = false;
|
|
31
|
+
this._log = require('../debug')('zero:sse');
|
|
29
32
|
|
|
30
33
|
/** `true` when the underlying connection is over TLS (HTTPS). */
|
|
31
34
|
this.secure = !!opts.secure;
|
|
@@ -67,10 +70,11 @@ class SSEStream
|
|
|
67
70
|
{
|
|
68
71
|
this._closed = true;
|
|
69
72
|
this._clearKeepAlive();
|
|
73
|
+
this._log.debug('stream closed, %d events sent', this.eventCount);
|
|
70
74
|
this._emit('close');
|
|
71
75
|
});
|
|
72
76
|
|
|
73
|
-
raw.on('error', (err) => this._emit('error', err));
|
|
77
|
+
raw.on('error', (err) => { this._log.error('stream error: %s', err.message); this._emit('error', err); });
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
// -- Event Emitter ---------------------------------
|
|
@@ -171,7 +175,13 @@ class SSEStream
|
|
|
171
175
|
*/
|
|
172
176
|
_formatData(data)
|
|
173
177
|
{
|
|
174
|
-
|
|
178
|
+
let payload;
|
|
179
|
+
if (typeof data === 'object')
|
|
180
|
+
{
|
|
181
|
+
try { payload = JSON.stringify(data); }
|
|
182
|
+
catch (e) { payload = '[Serialization Error]'; }
|
|
183
|
+
}
|
|
184
|
+
else { payload = String(data); }
|
|
175
185
|
return payload.split('\n').map(line => `data: ${line}\n`).join('');
|
|
176
186
|
}
|
|
177
187
|
|
|
@@ -240,7 +250,9 @@ class SSEStream
|
|
|
240
250
|
comment(text)
|
|
241
251
|
{
|
|
242
252
|
if (this._closed) return this;
|
|
243
|
-
|
|
253
|
+
// Escape newlines to prevent SSE frame injection
|
|
254
|
+
const safe = String(text).split('\n').join('\n: ');
|
|
255
|
+
this._write(`: ${safe}\n\n`);
|
|
244
256
|
return this;
|
|
245
257
|
}
|
|
246
258
|
|
package/lib/ws/connection.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Implements RFC 6455 framing for text, binary, ping, pong, and close.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const log = require('../debug')('zero:ws');
|
|
8
|
+
|
|
7
9
|
/** Auto-incrementing connection ID counter. */
|
|
8
10
|
let _wsIdCounter = 0;
|
|
9
11
|
|
|
@@ -66,6 +68,7 @@ class WebSocketConnection
|
|
|
66
68
|
|
|
67
69
|
/** Unique connection identifier. */
|
|
68
70
|
this.id = 'ws_' + (++_wsIdCounter) + '_' + Date.now().toString(36);
|
|
71
|
+
log.info('connection opened id=%s ip=%s', this.id, socket.remoteAddress);
|
|
69
72
|
|
|
70
73
|
/** Current ready state. */
|
|
71
74
|
this.readyState = WS_READY_STATE.OPEN;
|
|
@@ -121,10 +124,11 @@ class WebSocketConnection
|
|
|
121
124
|
{
|
|
122
125
|
this.readyState = WS_READY_STATE.CLOSED;
|
|
123
126
|
this._clearPing();
|
|
127
|
+
log.info('connection closed id=%s', this.id);
|
|
124
128
|
this._emit('close', 1006, '');
|
|
125
129
|
}
|
|
126
130
|
});
|
|
127
|
-
socket.on('error', (err) => this._emit('error', err));
|
|
131
|
+
socket.on('error', (err) => { log.error('socket error id=%s: %s', this.id, err.message); this._emit('error', err); });
|
|
128
132
|
socket.on('drain', () => this._emit('drain'));
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -229,7 +233,14 @@ class WebSocketConnection
|
|
|
229
233
|
*/
|
|
230
234
|
sendJSON(obj, cb)
|
|
231
235
|
{
|
|
232
|
-
|
|
236
|
+
let json;
|
|
237
|
+
try { json = JSON.stringify(obj); }
|
|
238
|
+
catch (e)
|
|
239
|
+
{
|
|
240
|
+
this._emit('error', new Error('Failed to serialize JSON: ' + e.message));
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return this.send(json, { callback: cb });
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
/**
|
|
@@ -371,7 +382,12 @@ class WebSocketConnection
|
|
|
371
382
|
else if (payloadLen === 127)
|
|
372
383
|
{
|
|
373
384
|
if (this._buffer.length < 10) return;
|
|
374
|
-
|
|
385
|
+
// RFC 6455: 64-bit unsigned integer; read upper and lower 32-bit halves
|
|
386
|
+
const upper = this._buffer.readUInt32BE(2);
|
|
387
|
+
const lower = this._buffer.readUInt32BE(6);
|
|
388
|
+
// Reject frames where upper 32 bits are set (>4GB not supported)
|
|
389
|
+
if (upper > 0) { this.close(1009, 'Message too big'); this._buffer = Buffer.alloc(0); return; }
|
|
390
|
+
payloadLen = lower;
|
|
375
391
|
offset = 10;
|
|
376
392
|
}
|
|
377
393
|
|
package/lib/ws/handshake.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
const crypto = require('crypto');
|
|
9
9
|
const WebSocketConnection = require('./connection');
|
|
10
|
+
const log = require('../debug')('zero:ws');
|
|
10
11
|
|
|
11
12
|
/** RFC 6455 magic GUID used in the Sec-WebSocket-Accept hash. */
|
|
12
13
|
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
@@ -29,6 +30,7 @@ function handleUpgrade(req, socket, head, wsHandlers)
|
|
|
29
30
|
|
|
30
31
|
if (!entry)
|
|
31
32
|
{
|
|
33
|
+
log.warn('no WS handler for %s', urlPath);
|
|
32
34
|
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
33
35
|
socket.destroy();
|
|
34
36
|
return;
|
|
@@ -84,6 +86,7 @@ function handleUpgrade(req, socket, head, wsHandlers)
|
|
|
84
86
|
|
|
85
87
|
handshake += '\r\n';
|
|
86
88
|
socket.write(handshake);
|
|
89
|
+
log.info('upgrade complete for %s', urlPath);
|
|
87
90
|
|
|
88
91
|
// -- Parse query string ----------------------------
|
|
89
92
|
const qIdx = req.url.indexOf('?');
|
package/lib/ws/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module ws
|
|
3
3
|
* @description WebSocket support for zero-http.
|
|
4
|
-
* Exports the connection class and
|
|
4
|
+
* Exports the connection class, upgrade handler, and pool manager.
|
|
5
5
|
*/
|
|
6
6
|
const WebSocketConnection = require('./connection');
|
|
7
7
|
const handleUpgrade = require('./handshake');
|
|
8
|
+
const WebSocketPool = require('./room');
|
|
8
9
|
|
|
9
10
|
module.exports = {
|
|
10
11
|
WebSocketConnection,
|
|
11
12
|
handleUpgrade,
|
|
13
|
+
WebSocketPool,
|
|
12
14
|
};
|