redweb 0.7.7 → 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.
@@ -1,9 +1,9 @@
1
1
  const express = require('express');
2
- const bodyParser = require('body-parser');
3
2
  const path = require('path');
4
3
  const cors = require('cors');
5
4
  const fs = require('fs');
6
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 HtmxRenderer = require('../htmx/HtmxRenderer'); // Import the HtmxRenderer
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,53 +31,109 @@ 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
42
  enableHtmxRendering: false, // New option for HTMX rendering
43
+ exposeErrors: false,
44
+ logger: console,
43
45
  };
44
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
+
45
83
  /**
46
84
  * Base HTTP Server
47
85
  * @param {RedWebOptions} options - Configuration options for RedWeb.
48
86
  * @return {Object} Express application instance.
49
87
  */
50
- function BaseHttpServer(options = {}) {
51
- this.options = { ...HTTP_OPTIONS, ...options };
52
- this.app = this.options.server || express();
53
- 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);
54
104
 
55
105
  // Middleware to parse request bodies based on the specified encoding
56
106
  if (this.encoding === ENCODINGS.json) {
57
- this.app.use(bodyParser.json());
58
- } else if (this.encoding === ENCODINGS.urlencoded) {
59
- this.app.use(bodyParser.urlencoded({ extended: true }));
107
+ this.app.use(express.json());
108
+ } else {
109
+ this.app.use(express.urlencoded({ extended: true }));
60
110
  }
61
111
 
62
- this.app.use(cors(this.options.corsOptions));
112
+ if (this.options.corsOptions !== false) {
113
+ this.app.use(cors(this.options.corsOptions));
114
+ }
63
115
 
64
116
  // Enable HTMX rendering if the flag is set
65
117
  if (this.enableHtmxRendering) {
66
118
  this.app.get('*.htmx', (req, res) => {
67
- // Find the file in one of the publicPaths
68
- const filePath = this.publicPaths
69
- .map(publicPath => path.join(process.cwd(), publicPath, req.path))
70
- .find(fullPath => fs.existsSync(fullPath)); // Check if the file exists
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));
71
126
 
72
- if (!filePath) {
73
- return res.status(404).send(`Error rendering HTMX file: Template file not found: ${req.path}`);
127
+ if (!match) {
128
+ return res.status(404).send('HTMX template not found');
74
129
  }
75
130
 
76
131
  try {
77
- const renderedContent = HtmxRenderer.render(filePath);
132
+ const renderedContent = HtmxRenderer.render(match.filePath, { rootDir: match.root });
78
133
  res.type('html').send(renderedContent);
79
134
  } catch (error) {
80
- res.status(500).send(`Error rendering HTMX file: ${error.message}`);
135
+ const message = this.exposeErrors ? `Error rendering HTMX file: ${error.message}` : 'Unable to render HTMX template';
136
+ res.status(500).send(message);
81
137
  }
82
138
  });
83
139
  }
@@ -85,12 +141,11 @@ function BaseHttpServer(options = {}) {
85
141
 
86
142
  // Serve static files from public paths
87
143
  this.publicPaths.forEach((publicPath) =>
88
- this.app.use(express.static(path.join(process.cwd(), publicPath)))
144
+ this.app.use(express.static(path.resolve(process.cwd(), publicPath)))
89
145
  );
90
146
 
91
147
  const catchAll = this.services.find((service) => service.serviceName === '*');
92
- if (catchAll) this.services.splice(this.services.indexOf(catchAll), 1);
93
- this.services.forEach((service) =>
148
+ this.services.filter((service) => service !== catchAll).forEach((service) =>
94
149
  this.app[service.method](service.serviceName, service.function)
95
150
  );
96
151
  if (catchAll) this.app[catchAll.method](catchAll.serviceName, catchAll.function);
@@ -103,5 +158,5 @@ module.exports = {
103
158
  BaseHttpServer,
104
159
  ENCODINGS,
105
160
  HTTP_OPTIONS,
106
- METHODS: { GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete' },
161
+ METHODS: { GET: 'get', POST: 'post', PUT: 'put', PATCH: 'patch', DELETE: 'delete', OPTIONS: 'options', HEAD: 'head', ALL: 'all' },
107
162
  };
@@ -1,18 +1,26 @@
1
- const http = require('http');
2
- const { BaseHttpServer } = require('./BaseHttpServer');
1
+ const http = require('http');
2
+ const { BaseHttpServer } = require('./BaseHttpServer');
3
+ const { listenServer, closeServer } = require('../serverLifecycle');
3
4
 
4
5
  /**
5
6
  * HTTP Server
6
7
  * @param {RedWebOptions} options - Configuration options for RedWeb.
7
8
  * @return {Object} Express application instance.
8
9
  */
9
- function HttpServer(options = {}) {
10
- BaseHttpServer.call(this, options);
11
- this.server = http.createServer(this.app);
12
- if (this.listen !== false) {
13
- this.server.listen(this.port, this.listenCallback ? this.listenCallback : () => console.log(`RedWeb HttpServer listening on port ${this.port}`));
14
- }
15
- return this;
16
- }
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
+ }
17
25
 
18
26
  module.exports = HttpServer;
@@ -1,22 +1,30 @@
1
1
  const https = require('https');
2
2
  const { BaseHttpServer } = require('./BaseHttpServer');
3
3
  const loadSslConfig = require('../sslConfig');
4
+ const { listenServer, closeServer } = require('../serverLifecycle');
4
5
 
5
6
  /**
6
7
  * HTTPS Server
7
8
  * @param {RedWebOptions} options - Configuration options for RedWeb.
8
9
  * @return {Object} Express application instance.
9
10
  */
10
- function HttpsServer(options = {}) {
11
- BaseHttpServer.call(this, options);
12
- const sslOptions = loadSslConfig(this.ssl);
13
- this.server = https.createServer(sslOptions, this.app);
14
- if (this.listen === false) {
15
- return this;
16
- }
17
-
18
- this.server.listen(this.port, this.listenCallback ? this.listenCallback : () => console.log(`RedWeb HttpsServer listening on port ${this.port}`));
19
- return this;
20
- }
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
+ }
21
29
 
22
30
  module.exports = HttpsServer;
@@ -0,0 +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
+ };
@@ -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 };
@@ -18,39 +18,47 @@ class BaseHandler {
18
18
  * @param {WebSocket & {sendJson: (message: Object) => void, broadcast: (message: Object) => void}} socket - The WebSocket connection that sent the message.
19
19
  * @param {any} message - The incoming message in parsed JSON.
20
20
  */
21
- handleMessage(socket, message) {
22
- this.onMessage(socket, message);
23
- }
24
-
25
- /**
26
- * Handles an incoming binary message.
27
- * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
28
- * @param {Buffer} buffer - The incoming binary message.
29
- */
30
- handleBinaryMessage(socket, buffer) {
31
- this.onBinaryMessage(socket, buffer);
32
- }
33
-
34
- /**
35
- * Method to be overriden to process messages.
36
- * @param {WebSocket} socket - The WebSocket connection that sent the message.
37
- * @param {any} message - The incoming message in parsed JSON.
21
+ async handleMessage(socket, message) {
22
+ const validationResult = await this.validateMessage(message, socket);
23
+ if (validationResult === false) {
24
+ throw new Error('Invalid message');
25
+ }
26
+ return this.onMessage(socket, message);
27
+ }
28
+
29
+ validateMessage() {
30
+ return true;
31
+ }
32
+
33
+ /**
34
+ * Handles an incoming binary message.
35
+ * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
36
+ * @param {Buffer} buffer - The incoming binary message.
38
37
  */
39
- onMessage(socket, message) {
40
- throw "Not yet implemented!";
41
- }
42
-
43
- /**
44
- * Method to be overriden to process binary messages.
45
- * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
46
- * @param {Buffer} buffer - The incoming binary message.
47
- */
48
- onBinaryMessage(socket, buffer) {
49
- socket.sendJson({ error: 'Binary messages are not supported by this handler' });
50
- }
51
-
52
- onInitialContact(socket) {
53
-
38
+ async handleBinaryMessage(socket, buffer) {
39
+ return this.onBinaryMessage(socket, buffer);
40
+ }
41
+
42
+ /**
43
+ * Method to be overriden to process messages.
44
+ * @param {WebSocket} socket - The WebSocket connection that sent the message.
45
+ * @param {any} message - The incoming message in parsed JSON.
46
+ */
47
+ onMessage(socket, message) {
48
+ throw new Error('onMessage must be implemented by the handler.');
49
+ }
50
+
51
+ /**
52
+ * Method to be overriden to process binary messages.
53
+ * @param {WebSocket & {sendJson: (message: Object) => void}} socket - The WebSocket connection that sent the message.
54
+ * @param {Buffer} buffer - The incoming binary message.
55
+ */
56
+ onBinaryMessage(socket, buffer) {
57
+ socket.sendJson({ error: 'Binary messages are not supported by this handler' });
58
+ }
59
+
60
+ onInitialContact(socket) {
61
+
54
62
  }
55
63
  }
56
64