redweb 0.8.0 → 0.9.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/CHANGELOG.md +11 -0
- package/README.md +458 -307
- package/client.d.ts +42 -0
- package/client.js +55 -0
- package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
- package/docs/PRODUCTION_READINESS.md +68 -0
- package/docs/VERIFICATION_EVIDENCE.md +20 -0
- package/index.d.ts +320 -114
- package/index.js +27 -12
- package/package.json +28 -15
- package/src/htmx/HtmxRenderer.js +13 -13
- package/src/http/BaseHttpServer.js +112 -112
- package/src/http/HttpServer.js +18 -18
- package/src/http/HttpsServer.js +20 -20
- package/src/serverLifecycle.js +46 -46
- package/src/ws/AdmissionPolicy.js +145 -0
- package/src/ws/BaseHandler.js +40 -40
- package/src/ws/BaseSocketServer.js +195 -100
- package/src/ws/DefaultHandler.js +5 -5
- package/src/ws/DefaultRoute.js +8 -8
- package/src/ws/DistributionBridge.js +271 -0
- package/src/ws/FixedStepService.js +74 -0
- package/src/ws/HeartbeatMonitor.js +75 -0
- package/src/ws/Metrics.js +34 -0
- package/src/ws/ProtocolPolicy.js +130 -0
- package/src/ws/RoomRegistry.js +117 -0
- package/src/ws/RouteRuntime.js +146 -0
- package/src/ws/SecureSocketServer.js +9 -9
- package/src/ws/SessionRegistry.js +135 -0
- package/src/ws/SocketRoute.js +523 -254
- package/src/ws/SocketServer.js +8 -8
- package/src/ws/TaskQueue.js +64 -0
- package/src/ws/TokenBucket.js +31 -0
- package/src/ws/TransportPolicy.js +68 -0
- package/src/ws/index.js +7 -2
- package/src/ws/protocol-schema.json +13 -0
- package/src/ws/protocol-validation.js +21 -0
- package/src/ws/shutdown.js +33 -33
- package/src/ws/util.js +38 -30
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
const express = require('express');
|
|
2
|
-
const path = require('path');
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
3
|
const cors = require('cors');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
-
const HtmxRenderer = require('../htmx/HtmxRenderer'); // Import the HtmxRenderer module
|
|
6
|
-
const { validateListenerOptions } = require('../serverLifecycle');
|
|
5
|
+
const HtmxRenderer = require('../htmx/HtmxRenderer'); // Import the HtmxRenderer module
|
|
6
|
+
const { validateListenerOptions } = require('../serverLifecycle');
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @typedef {'json' | 'urlencoded'} RedWebEncoding
|
|
@@ -15,14 +15,14 @@ const { validateListenerOptions } = require('../serverLifecycle');
|
|
|
15
15
|
* @property {number} [port=80] - The port number to bind the server.
|
|
16
16
|
* @property {string} [bind='0.0.0.0'] - The bind address for the server.
|
|
17
17
|
* @property {string[]} [publicPaths=['./public']] - An array of paths to serve static files from.
|
|
18
|
-
* @property {Array<{serviceName: string, method: string, function: Function}>} [services=[]] - An array of services with their endpoints and handlers.
|
|
19
|
-
* @property {boolean} [listen=true] - Whether HttpServer/HttpsServer should automatically start listening.
|
|
20
|
-
* @property {Function} [listenCallback] - Callback function to execute once the server starts listening.
|
|
18
|
+
* @property {Array<{serviceName: string, method: string, function: Function}>} [services=[]] - An array of services with their endpoints and handlers.
|
|
19
|
+
* @property {boolean} [listen=true] - Whether HttpServer/HttpsServer should automatically start listening.
|
|
20
|
+
* @property {Function} [listenCallback] - Callback function to execute once the server starts listening.
|
|
21
21
|
* @property {RedWebEncoding} [encoding='json'] - The encoding type for the request bodies ('json' or 'urlencoded').
|
|
22
22
|
* @property {Object} [ssl] - SSL configuration for HTTPS server.
|
|
23
23
|
* @property {string} [ssl.key] - Path to the SSL key file.
|
|
24
24
|
* @property {string} [ssl.cert] - Path to the SSL certificate file.
|
|
25
|
-
* @property {import('express').Application} [server] - Existing Express application to configure.
|
|
25
|
+
* @property {import('express').Application} [server] - Existing Express application to configure.
|
|
26
26
|
* @property {import('cors').CorsOptions} [corsOptions] - The CORS Options.
|
|
27
27
|
* @property {boolean} [enableHtmxRendering=false] - Enable dynamic HTMX file rendering.
|
|
28
28
|
*/
|
|
@@ -31,132 +31,132 @@ const ENCODINGS = { json: 'json', urlencoded: 'urlencoded' };
|
|
|
31
31
|
const HTTP_OPTIONS = {
|
|
32
32
|
port: 80,
|
|
33
33
|
bind: '0.0.0.0',
|
|
34
|
-
publicPaths: ['./public'],
|
|
35
|
-
services: [],
|
|
36
|
-
listen: true,
|
|
37
|
-
listenCallback: undefined,
|
|
34
|
+
publicPaths: ['./public'],
|
|
35
|
+
services: [],
|
|
36
|
+
listen: true,
|
|
37
|
+
listenCallback: undefined,
|
|
38
38
|
encoding: ENCODINGS.json,
|
|
39
39
|
ssl: null,
|
|
40
40
|
server: undefined,
|
|
41
41
|
corsOptions: undefined,
|
|
42
|
-
enableHtmxRendering: false, // New option for HTMX rendering
|
|
43
|
-
exposeErrors: false,
|
|
44
|
-
logger: console,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
function assertOptions(options) {
|
|
48
|
-
validateListenerOptions(options);
|
|
49
|
-
if (!Object.values(ENCODINGS).includes(options.encoding)) {
|
|
50
|
-
throw new TypeError('`encoding` must be either "json" or "urlencoded".');
|
|
51
|
-
}
|
|
52
|
-
if (!Array.isArray(options.publicPaths)) {
|
|
53
|
-
throw new TypeError('`publicPaths` must be an array.');
|
|
54
|
-
}
|
|
55
|
-
if (options.publicPaths.some(publicPath => typeof publicPath !== 'string' || !publicPath)) {
|
|
56
|
-
throw new TypeError('Every public path must be a non-empty string.');
|
|
57
|
-
}
|
|
58
|
-
if (!Array.isArray(options.services)) {
|
|
59
|
-
throw new TypeError('`services` must be an array.');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
options.services.forEach((service) => {
|
|
63
|
-
if (!service || typeof service.serviceName !== 'string' || !service.serviceName) {
|
|
64
|
-
throw new TypeError('Every service must have a non-empty `serviceName`.');
|
|
65
|
-
}
|
|
66
|
-
if (!['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all'].includes(service.method)) {
|
|
67
|
-
throw new TypeError(`Unsupported HTTP service method: ${service.method}`);
|
|
68
|
-
}
|
|
69
|
-
if (typeof service.function !== 'function') {
|
|
70
|
-
throw new TypeError(`Service ${service.serviceName} must provide a function.`);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
if (options.services.filter(service => service.serviceName === '*').length > 1) {
|
|
74
|
-
throw new TypeError('Only one catch-all service may be registered.');
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function isWithin(root, candidate) {
|
|
79
|
-
const relative = path.relative(root, candidate);
|
|
80
|
-
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
81
|
-
}
|
|
42
|
+
enableHtmxRendering: false, // New option for HTMX rendering
|
|
43
|
+
exposeErrors: false,
|
|
44
|
+
logger: console,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function assertOptions(options) {
|
|
48
|
+
validateListenerOptions(options);
|
|
49
|
+
if (!Object.values(ENCODINGS).includes(options.encoding)) {
|
|
50
|
+
throw new TypeError('`encoding` must be either "json" or "urlencoded".');
|
|
51
|
+
}
|
|
52
|
+
if (!Array.isArray(options.publicPaths)) {
|
|
53
|
+
throw new TypeError('`publicPaths` must be an array.');
|
|
54
|
+
}
|
|
55
|
+
if (options.publicPaths.some(publicPath => typeof publicPath !== 'string' || !publicPath)) {
|
|
56
|
+
throw new TypeError('Every public path must be a non-empty string.');
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(options.services)) {
|
|
59
|
+
throw new TypeError('`services` must be an array.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
options.services.forEach((service) => {
|
|
63
|
+
if (!service || typeof service.serviceName !== 'string' || !service.serviceName) {
|
|
64
|
+
throw new TypeError('Every service must have a non-empty `serviceName`.');
|
|
65
|
+
}
|
|
66
|
+
if (!['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'all'].includes(service.method)) {
|
|
67
|
+
throw new TypeError(`Unsupported HTTP service method: ${service.method}`);
|
|
68
|
+
}
|
|
69
|
+
if (typeof service.function !== 'function') {
|
|
70
|
+
throw new TypeError(`Service ${service.serviceName} must provide a function.`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
if (options.services.filter(service => service.serviceName === '*').length > 1) {
|
|
74
|
+
throw new TypeError('Only one catch-all service may be registered.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isWithin(root, candidate) {
|
|
79
|
+
const relative = path.relative(root, candidate);
|
|
80
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
81
|
+
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* Base HTTP Server
|
|
85
85
|
* @param {RedWebOptions} options - Configuration options for RedWeb.
|
|
86
86
|
* @return {Object} Express application instance.
|
|
87
87
|
*/
|
|
88
|
-
function BaseHttpServer(options = {}) {
|
|
89
|
-
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
90
|
-
throw new TypeError('HTTP server options must be an object.');
|
|
91
|
-
}
|
|
92
|
-
const mergedOptions = { ...HTTP_OPTIONS, ...options };
|
|
93
|
-
assertOptions(mergedOptions);
|
|
94
|
-
this.options = {
|
|
95
|
-
...mergedOptions,
|
|
96
|
-
publicPaths: [...mergedOptions.publicPaths],
|
|
97
|
-
services: [...mergedOptions.services],
|
|
98
|
-
};
|
|
99
|
-
this.app = this.options.server === undefined ? express() : this.options.server;
|
|
100
|
-
if (typeof this.app.use !== 'function') {
|
|
101
|
-
throw new TypeError('`server` must be an Express-compatible application.');
|
|
102
|
-
}
|
|
103
|
-
Object.assign(this, this.options);
|
|
88
|
+
function BaseHttpServer(options = {}) {
|
|
89
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
90
|
+
throw new TypeError('HTTP server options must be an object.');
|
|
91
|
+
}
|
|
92
|
+
const mergedOptions = { ...HTTP_OPTIONS, ...options };
|
|
93
|
+
assertOptions(mergedOptions);
|
|
94
|
+
this.options = {
|
|
95
|
+
...mergedOptions,
|
|
96
|
+
publicPaths: [...mergedOptions.publicPaths],
|
|
97
|
+
services: [...mergedOptions.services],
|
|
98
|
+
};
|
|
99
|
+
this.app = this.options.server === undefined ? express() : this.options.server;
|
|
100
|
+
if (typeof this.app.use !== 'function') {
|
|
101
|
+
throw new TypeError('`server` must be an Express-compatible application.');
|
|
102
|
+
}
|
|
103
|
+
Object.assign(this, this.options);
|
|
104
104
|
|
|
105
105
|
// Middleware to parse request bodies based on the specified encoding
|
|
106
|
-
if (this.encoding === ENCODINGS.json) {
|
|
107
|
-
this.app.use(express.json());
|
|
108
|
-
} else {
|
|
109
|
-
this.app.use(express.urlencoded({ extended: true }));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (this.options.corsOptions !== false) {
|
|
113
|
-
this.app.use(cors(this.options.corsOptions));
|
|
114
|
-
}
|
|
106
|
+
if (this.encoding === ENCODINGS.json) {
|
|
107
|
+
this.app.use(express.json());
|
|
108
|
+
} else {
|
|
109
|
+
this.app.use(express.urlencoded({ extended: true }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (this.options.corsOptions !== false) {
|
|
113
|
+
this.app.use(cors(this.options.corsOptions));
|
|
114
|
+
}
|
|
115
115
|
|
|
116
116
|
// Enable HTMX rendering if the flag is set
|
|
117
117
|
if (this.enableHtmxRendering) {
|
|
118
|
-
this.app.get('*.htmx', (req, res) => {
|
|
119
|
-
const match = this.publicPaths
|
|
120
|
-
.map(publicPath => {
|
|
121
|
-
const root = path.resolve(process.cwd(), publicPath);
|
|
122
|
-
const filePath = path.resolve(root, `.${req.path}`);
|
|
123
|
-
return { root, filePath };
|
|
124
|
-
})
|
|
125
|
-
.find(({ root, filePath }) => isWithin(root, filePath) && fs.existsSync(filePath));
|
|
126
|
-
|
|
127
|
-
if (!match) {
|
|
128
|
-
return res.status(404).send('HTMX template not found');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const renderedContent = HtmxRenderer.render(match.filePath, { rootDir: match.root });
|
|
133
|
-
res.type('html').send(renderedContent);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
const message = this.exposeErrors ? `Error rendering HTMX file: ${error.message}` : 'Unable to render HTMX template';
|
|
136
|
-
res.status(500).send(message);
|
|
137
|
-
}
|
|
138
|
-
});
|
|
118
|
+
this.app.get('*.htmx', (req, res) => {
|
|
119
|
+
const match = this.publicPaths
|
|
120
|
+
.map(publicPath => {
|
|
121
|
+
const root = path.resolve(process.cwd(), publicPath);
|
|
122
|
+
const filePath = path.resolve(root, `.${req.path}`);
|
|
123
|
+
return { root, filePath };
|
|
124
|
+
})
|
|
125
|
+
.find(({ root, filePath }) => isWithin(root, filePath) && fs.existsSync(filePath));
|
|
126
|
+
|
|
127
|
+
if (!match) {
|
|
128
|
+
return res.status(404).send('HTMX template not found');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const renderedContent = HtmxRenderer.render(match.filePath, { rootDir: match.root });
|
|
133
|
+
res.type('html').send(renderedContent);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const message = this.exposeErrors ? `Error rendering HTMX file: ${error.message}` : 'Unable to render HTMX template';
|
|
136
|
+
res.status(500).send(message);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
|
|
142
|
-
// Serve static files from public paths
|
|
143
|
-
this.publicPaths.forEach((publicPath) =>
|
|
144
|
-
this.app.use(express.static(path.resolve(process.cwd(), publicPath)))
|
|
145
|
-
);
|
|
142
|
+
// Serve static files from public paths
|
|
143
|
+
this.publicPaths.forEach((publicPath) =>
|
|
144
|
+
this.app.use(express.static(path.resolve(process.cwd(), publicPath)))
|
|
145
|
+
);
|
|
146
146
|
|
|
147
|
-
const catchAll = this.services.find((service) => service.serviceName === '*');
|
|
148
|
-
this.services.filter((service) => service !== catchAll).forEach((service) =>
|
|
149
|
-
this.app[service.method](service.serviceName, service.function)
|
|
150
|
-
);
|
|
147
|
+
const catchAll = this.services.find((service) => service.serviceName === '*');
|
|
148
|
+
this.services.filter((service) => service !== catchAll).forEach((service) =>
|
|
149
|
+
this.app[service.method](service.serviceName, service.function)
|
|
150
|
+
);
|
|
151
151
|
if (catchAll) this.app[catchAll.method](catchAll.serviceName, catchAll.function);
|
|
152
152
|
|
|
153
153
|
return this;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
module.exports = {
|
|
158
|
-
BaseHttpServer,
|
|
159
|
-
ENCODINGS,
|
|
160
|
-
HTTP_OPTIONS,
|
|
161
|
-
METHODS: { GET: 'get', POST: 'post', PUT: 'put', PATCH: 'patch', DELETE: 'delete', OPTIONS: 'options', HEAD: 'head', ALL: 'all' },
|
|
162
|
-
};
|
|
157
|
+
module.exports = {
|
|
158
|
+
BaseHttpServer,
|
|
159
|
+
ENCODINGS,
|
|
160
|
+
HTTP_OPTIONS,
|
|
161
|
+
METHODS: { GET: 'get', POST: 'post', PUT: 'put', PATCH: 'patch', DELETE: 'delete', OPTIONS: 'options', HEAD: 'head', ALL: 'all' },
|
|
162
|
+
};
|
package/src/http/HttpServer.js
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const { BaseHttpServer } = require('./BaseHttpServer');
|
|
3
|
-
const { listenServer, closeServer } = require('../serverLifecycle');
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const { BaseHttpServer } = require('./BaseHttpServer');
|
|
3
|
+
const { listenServer, closeServer } = require('../serverLifecycle');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* HTTP Server
|
|
7
7
|
* @param {RedWebOptions} options - Configuration options for RedWeb.
|
|
8
8
|
* @return {Object} Express application instance.
|
|
9
9
|
*/
|
|
10
|
-
function HttpServer(options) {
|
|
11
|
-
BaseHttpServer.call(this, options);
|
|
12
|
-
this.server = http.createServer(this.app);
|
|
13
|
-
this.shutdown = () => closeServer(this.server);
|
|
14
|
-
if (this.listen !== false) {
|
|
15
|
-
listenServer(this.server, {
|
|
16
|
-
port: this.port,
|
|
17
|
-
bind: this.bind,
|
|
18
|
-
callback: this.listenCallback,
|
|
19
|
-
logger: this.logger,
|
|
20
|
-
name: 'HttpServer',
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
return this;
|
|
24
|
-
}
|
|
10
|
+
function HttpServer(options) {
|
|
11
|
+
BaseHttpServer.call(this, options);
|
|
12
|
+
this.server = http.createServer(this.app);
|
|
13
|
+
this.shutdown = () => closeServer(this.server);
|
|
14
|
+
if (this.listen !== false) {
|
|
15
|
+
listenServer(this.server, {
|
|
16
|
+
port: this.port,
|
|
17
|
+
bind: this.bind,
|
|
18
|
+
callback: this.listenCallback,
|
|
19
|
+
logger: this.logger,
|
|
20
|
+
name: 'HttpServer',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
25
|
|
|
26
26
|
module.exports = HttpServer;
|
package/src/http/HttpsServer.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
const https = require('https');
|
|
2
2
|
const { BaseHttpServer } = require('./BaseHttpServer');
|
|
3
|
-
const loadSslConfig = require('../sslConfig');
|
|
4
|
-
const { listenServer, closeServer } = require('../serverLifecycle');
|
|
3
|
+
const loadSslConfig = require('../sslConfig');
|
|
4
|
+
const { listenServer, closeServer } = require('../serverLifecycle');
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* HTTPS Server
|
|
8
8
|
* @param {RedWebOptions} options - Configuration options for RedWeb.
|
|
9
9
|
* @return {Object} Express application instance.
|
|
10
10
|
*/
|
|
11
|
-
function HttpsServer(options) {
|
|
12
|
-
BaseHttpServer.call(this, options);
|
|
13
|
-
const sslOptions = loadSslConfig(this.ssl);
|
|
14
|
-
this.server = https.createServer(sslOptions, this.app);
|
|
15
|
-
this.shutdown = () => closeServer(this.server);
|
|
16
|
-
if (this.listen === false) {
|
|
17
|
-
return this;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
listenServer(this.server, {
|
|
21
|
-
port: this.port,
|
|
22
|
-
bind: this.bind,
|
|
23
|
-
callback: this.listenCallback,
|
|
24
|
-
logger: this.logger,
|
|
25
|
-
name: 'HttpsServer',
|
|
26
|
-
});
|
|
27
|
-
return this;
|
|
28
|
-
}
|
|
11
|
+
function HttpsServer(options) {
|
|
12
|
+
BaseHttpServer.call(this, options);
|
|
13
|
+
const sslOptions = loadSslConfig(this.ssl);
|
|
14
|
+
this.server = https.createServer(sslOptions, this.app);
|
|
15
|
+
this.shutdown = () => closeServer(this.server);
|
|
16
|
+
if (this.listen === false) {
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
listenServer(this.server, {
|
|
21
|
+
port: this.port,
|
|
22
|
+
bind: this.bind,
|
|
23
|
+
callback: this.listenCallback,
|
|
24
|
+
logger: this.logger,
|
|
25
|
+
name: 'HttpsServer',
|
|
26
|
+
});
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
29
|
|
|
30
30
|
module.exports = HttpsServer;
|
package/src/serverLifecycle.js
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
function listenServer(server, { port, bind, callback, logger = console, name = 'Server' }) {
|
|
2
|
-
const onListening = callback || (() => logger?.log?.(`RedWeb ${name} listening on ${bind}:${port}`));
|
|
3
|
-
server.listen(port, bind, onListening);
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
function validateListenerOptions(options) {
|
|
7
|
-
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
8
|
-
throw new TypeError('`port` must be an integer between 0 and 65535.');
|
|
9
|
-
}
|
|
10
|
-
if (typeof options.bind !== 'string' || !options.bind) {
|
|
11
|
-
throw new TypeError('`bind` must be a non-empty string.');
|
|
12
|
-
}
|
|
13
|
-
if (typeof options.listen !== 'boolean') {
|
|
14
|
-
throw new TypeError('`listen` must be a boolean.');
|
|
15
|
-
}
|
|
16
|
-
if (options.listenCallback !== undefined && typeof options.listenCallback !== 'function') {
|
|
17
|
-
throw new TypeError('`listenCallback` must be a function.');
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function settleTasks(tasks) {
|
|
22
|
-
const results = await Promise.allSettled(tasks.map(task => Promise.resolve().then(task)));
|
|
23
|
-
return results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function throwCleanupErrors(errors, message) {
|
|
27
|
-
if (!errors.length) return;
|
|
28
|
-
const aggregate = new Error(message);
|
|
29
|
-
aggregate.errors = errors;
|
|
30
|
-
throw aggregate;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function closeServer(server) {
|
|
34
|
-
return new Promise((resolve, reject) => {
|
|
35
|
-
if (!server?.listening) return resolve();
|
|
36
|
-
server.close((error) => error ? reject(error) : resolve());
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
module.exports = {
|
|
41
|
-
listenServer,
|
|
42
|
-
closeServer,
|
|
43
|
-
settleTasks,
|
|
44
|
-
throwCleanupErrors,
|
|
45
|
-
validateListenerOptions,
|
|
46
|
-
};
|
|
1
|
+
function listenServer(server, { port, bind, callback, logger = console, name = 'Server' }) {
|
|
2
|
+
const onListening = callback || (() => logger?.log?.(`RedWeb ${name} listening on ${bind}:${port}`));
|
|
3
|
+
server.listen(port, bind, onListening);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function validateListenerOptions(options) {
|
|
7
|
+
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
8
|
+
throw new TypeError('`port` must be an integer between 0 and 65535.');
|
|
9
|
+
}
|
|
10
|
+
if (typeof options.bind !== 'string' || !options.bind) {
|
|
11
|
+
throw new TypeError('`bind` must be a non-empty string.');
|
|
12
|
+
}
|
|
13
|
+
if (typeof options.listen !== 'boolean') {
|
|
14
|
+
throw new TypeError('`listen` must be a boolean.');
|
|
15
|
+
}
|
|
16
|
+
if (options.listenCallback !== undefined && typeof options.listenCallback !== 'function') {
|
|
17
|
+
throw new TypeError('`listenCallback` must be a function.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function settleTasks(tasks) {
|
|
22
|
+
const results = await Promise.allSettled(tasks.map(task => Promise.resolve().then(task)));
|
|
23
|
+
return results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function throwCleanupErrors(errors, message) {
|
|
27
|
+
if (!errors.length) return;
|
|
28
|
+
const aggregate = new Error(message);
|
|
29
|
+
aggregate.errors = errors;
|
|
30
|
+
throw aggregate;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function closeServer(server) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (!server?.listening) return resolve();
|
|
36
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
listenServer,
|
|
42
|
+
closeServer,
|
|
43
|
+
settleTasks,
|
|
44
|
+
throwCleanupErrors,
|
|
45
|
+
validateListenerOptions,
|
|
46
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const ADMISSION_CONTEXT = Symbol('redweb.admissionContext');
|
|
2
|
+
const PLACEMENT_REDIRECT = Symbol('redweb.placementRedirect');
|
|
3
|
+
const ADMISSION_SETTLEMENT = Symbol('redweb.admissionSettlement');
|
|
4
|
+
|
|
5
|
+
class AdmissionPolicy {
|
|
6
|
+
constructor(options) {
|
|
7
|
+
const config = typeof options === 'function' ? { authenticate: options } : options;
|
|
8
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
9
|
+
throw new TypeError('`admission` must be a function or an object.');
|
|
10
|
+
}
|
|
11
|
+
const {
|
|
12
|
+
authenticate,
|
|
13
|
+
origins,
|
|
14
|
+
place,
|
|
15
|
+
timeoutMs = 5000,
|
|
16
|
+
allowedPlacementOrigins,
|
|
17
|
+
allowInsecurePlacement = false,
|
|
18
|
+
} = config;
|
|
19
|
+
if (authenticate !== undefined && typeof authenticate !== 'function') {
|
|
20
|
+
throw new TypeError('`admission.authenticate` must be a function.');
|
|
21
|
+
}
|
|
22
|
+
if (!(Array.isArray(origins) || typeof origins === 'function' || origins === undefined)) {
|
|
23
|
+
throw new TypeError('`admission.origins` must be an array or a function.');
|
|
24
|
+
}
|
|
25
|
+
if (place !== undefined && typeof place !== 'function') {
|
|
26
|
+
throw new TypeError('`admission.place` must be a function.');
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(origins) && origins.some(origin => typeof origin !== 'string' || !origin)) {
|
|
29
|
+
throw new TypeError('Every `admission.origins` entry must be a non-empty string.');
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1) {
|
|
32
|
+
throw new TypeError('`admission.timeoutMs` must be a positive integer.');
|
|
33
|
+
}
|
|
34
|
+
if (typeof allowInsecurePlacement !== 'boolean') {
|
|
35
|
+
throw new TypeError('`admission.allowInsecurePlacement` must be a boolean.');
|
|
36
|
+
}
|
|
37
|
+
if (allowedPlacementOrigins !== undefined && !Array.isArray(allowedPlacementOrigins)) {
|
|
38
|
+
throw new TypeError('`admission.allowedPlacementOrigins` must be an array.');
|
|
39
|
+
}
|
|
40
|
+
this.allowedPlacementOrigins = allowedPlacementOrigins?.map(origin => this.validatePlacementOrigin(origin));
|
|
41
|
+
if (this.allowedPlacementOrigins && new Set(this.allowedPlacementOrigins).size !== this.allowedPlacementOrigins.length) {
|
|
42
|
+
throw new TypeError('`admission.allowedPlacementOrigins` entries must be unique.');
|
|
43
|
+
}
|
|
44
|
+
if (!authenticate && !origins && !place) {
|
|
45
|
+
throw new TypeError('`admission` requires `authenticate`, `origins`, or `place`.');
|
|
46
|
+
}
|
|
47
|
+
this.authenticate = authenticate;
|
|
48
|
+
this.origins = origins;
|
|
49
|
+
this.place = place;
|
|
50
|
+
this.timeoutMs = timeoutMs;
|
|
51
|
+
this.allowInsecurePlacement = allowInsecurePlacement;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async authorize(request, rawSocket, route, externalSignal) {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const onClose = () => controller.abort();
|
|
57
|
+
const onExternalAbort = () => controller.abort();
|
|
58
|
+
rawSocket.once('close', onClose);
|
|
59
|
+
if (externalSignal?.aborted) controller.abort();
|
|
60
|
+
else externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
62
|
+
timer.unref();
|
|
63
|
+
try {
|
|
64
|
+
const evaluation = Promise.resolve().then(() => this.evaluate(request, route, controller.signal));
|
|
65
|
+
request[ADMISSION_SETTLEMENT] = evaluation.then(() => undefined, () => undefined);
|
|
66
|
+
const cancelled = new Promise((_, reject) => {
|
|
67
|
+
controller.signal.addEventListener('abort', () => reject(new Error('Admission cancelled.')), { once: true });
|
|
68
|
+
});
|
|
69
|
+
const result = await Promise.race([
|
|
70
|
+
evaluation,
|
|
71
|
+
cancelled,
|
|
72
|
+
]);
|
|
73
|
+
if (result === false || rawSocket.destroyed || controller.signal.aborted) return false;
|
|
74
|
+
if (result.redirect) {
|
|
75
|
+
request[PLACEMENT_REDIRECT] = result.redirect;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
request[ADMISSION_CONTEXT] = { principal: result.principal };
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
rawSocket.off?.('close', onClose);
|
|
85
|
+
externalSignal?.removeEventListener?.('abort', onExternalAbort);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
validatePlacementOrigin(origin) {
|
|
90
|
+
if (typeof origin !== 'string' || !origin) {
|
|
91
|
+
throw new TypeError('Placement origins must be non-empty strings.');
|
|
92
|
+
}
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = new URL(origin);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new TypeError('Placement origins must be valid ws or wss origins.');
|
|
98
|
+
}
|
|
99
|
+
if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
100
|
+
throw new TypeError('Placement origins must be ws or wss origins without credentials, paths, queries, or fragments.');
|
|
101
|
+
}
|
|
102
|
+
return parsed.origin;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
isSafeRedirect(value) {
|
|
106
|
+
if (!value || value.length > 2048 || /[\r\n]/.test(value)) return false;
|
|
107
|
+
try {
|
|
108
|
+
const parsed = new URL(value);
|
|
109
|
+
if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) return false;
|
|
110
|
+
if (parsed.protocol === 'ws:' && !this.allowInsecurePlacement) return false;
|
|
111
|
+
return !this.allowedPlacementOrigins || this.allowedPlacementOrigins.includes(parsed.origin);
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async evaluate(request, route, signal) {
|
|
118
|
+
if (!await this.acceptsOrigin(request)) return false;
|
|
119
|
+
const context = {
|
|
120
|
+
signal,
|
|
121
|
+
networkIdentity: route.resolveRemoteAddress(request),
|
|
122
|
+
route,
|
|
123
|
+
};
|
|
124
|
+
const principal = this.authenticate ? await this.authenticate(request, context) : undefined;
|
|
125
|
+
if (principal === false) return false;
|
|
126
|
+
if (!this.place) return { principal };
|
|
127
|
+
const placement = await this.place(principal, request, context);
|
|
128
|
+
if (placement === false) return false;
|
|
129
|
+
if (typeof placement === 'string') {
|
|
130
|
+
return this.isSafeRedirect(placement) ? { redirect: placement } : false;
|
|
131
|
+
}
|
|
132
|
+
return { principal };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async acceptsOrigin(request) {
|
|
136
|
+
if (!this.origins) return true;
|
|
137
|
+
const origin = request?.headers?.origin;
|
|
138
|
+
if (typeof this.origins === 'function') {
|
|
139
|
+
return Boolean(await this.origins(origin, request));
|
|
140
|
+
}
|
|
141
|
+
return typeof origin === 'string' && this.origins.includes(origin);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { AdmissionPolicy, ADMISSION_CONTEXT, PLACEMENT_REDIRECT, ADMISSION_SETTLEMENT };
|