redweb 0.7.7 → 0.8.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 CHANGED
@@ -22,8 +22,10 @@ const {
22
22
  BaseHttpServer, // Express app builder for advanced composition
23
23
  BaseHandler, // WebSocket message handler base
24
24
  sendJson, // Utility to stringify+send
25
+ HTTP_OPTIONS, // Defaults for HTTP servers
26
+ ENCODINGS, // json/urlencoded encoding names
25
27
  SOCKET_OPTIONS, // Defaults for socket servers
26
- METHODS // Express method helpers: get/post/put/delete
28
+ METHODS // Express method helpers
27
29
  } = require('redweb');
28
30
  ```
29
31
 
@@ -41,7 +43,10 @@ Options:
41
43
  - `listenCallback` (function): invoked after `.listen`.
42
44
  - `encoding` (`'json' | 'urlencoded'`): body parser selection.
43
45
  - `corsOptions`: passed to `cors`.
46
+ - `corsOptions: false`: disables the CORS middleware entirely.
44
47
  - `enableHtmxRendering` (boolean): render `.htmx` files with the built-in renderer.
48
+ - `exposeErrors` (boolean): include HTMX rendering details in responses; defaults to `false`.
49
+ - `logger`: an object with optional `log`, `warn`, and `error` methods. Pass `null` to disable library logging.
45
50
 
46
51
  Example:
47
52
 
@@ -79,6 +84,10 @@ const name = 'RedWeb';
79
84
 
80
85
  Requesting `/example.htmx` returns rendered HTML.
81
86
 
87
+ Templates are trusted server-side code. They may load relative modules within their configured public directory, execute for at most one second by default, and interpolate raw HTML. Never render user-supplied template files.
88
+
89
+ CORS remains permissive by default for backward compatibility. CORS is not authorization; configure `corsOptions`, add authentication middleware to `server.app`, or disable the middleware as appropriate.
90
+
82
91
  ## WebSocket servers
83
92
 
84
93
  `SocketServer` uses `ws` and routes connections to `SocketRoute` instances. Clients must send JSON containing a `type` that matches a handler name.
@@ -188,6 +197,7 @@ class ImageHandler extends BaseHandler {
188
197
  ### WebSocket route options
189
198
 
190
199
  `SocketRoute` accepts `websocketOptions`, which are passed to `new WebSocketServer(...)`. Use this for `ws` server settings such as `maxPayload` or `perMessageDeflate`.
200
+ Redweb controls `noServer`, `path`, `server`, and `port`; do not include them in `websocketOptions`. Route selection is performed once by Redweb so strict matching and optional root fallback behave consistently. Handshake authentication can use the `ws` `verifyClient` option, although authenticating in the surrounding HTTP upgrade flow is preferable for complex applications.
191
201
 
192
202
  ```js
193
203
  class ClipboardRoute extends SocketRoute {
@@ -204,6 +214,16 @@ class ClipboardRoute extends SocketRoute {
204
214
  }
205
215
  ```
206
216
 
217
+ Other route options:
218
+
219
+ - `trustProxy`: use the first `X-Forwarded-For` value as the connection identity. Enable this only behind a trusted proxy.
220
+ - `getClientKey(req)`: provide application-specific connection identity logic instead of IP-based identity.
221
+ - `exposeErrors`: return handler exception messages to clients; defaults to `false`.
222
+ - `logger`: route logger with optional `log`, `warn`, and `error` methods; pass `null` to disable it.
223
+ - `shutdownTimeoutMs`: grace period before non-cooperating peers are terminated during shutdown; defaults to `1000`.
224
+
225
+ `BaseHandler.validateMessage(message, socket)` may return `false` or a promise resolving to `false` to reject a message. Text and binary handlers may be asynchronous; rejected promises are caught and converted to safe error responses.
226
+
207
227
  ### Sharing an HTTP/HTTPS server
208
228
 
209
229
  Use `listen: false` on `HttpServer` to build the Express app and Node server without binding a port. Then pass `httpServer.server` to `SocketServer`. When `SocketServer` receives a prebuilt `server`, it attaches upgrade handling but does not call `.listen()` unless you explicitly set `listen: true`.
@@ -268,9 +288,20 @@ Helpers: `add`, `remove(itemOrId, byKey = 'id')`, `all()`, `count()`.
268
288
  - HTTP defaults: port `80`, bind `0.0.0.0`, `listen: true`.
269
289
  - WebSocket defaults: port `3000`, single connection per IP unless `allowDuplicateConnections` is set.
270
290
  - `SocketServer` owns and listens on its own server by default; if you pass `server`, you own calling `.listen()` unless you also pass `listen: true`.
291
+ - Upgrade paths are matched strictly by default. Set `fallbackToRoot: true` for legacy behavior that sends unmatched paths to `/`.
271
292
  - If you do not supply `routes`, `SocketServer` registers a default route at `/` with `DefaultHandler` (it expects messages with `type: 'DefaultHandler'`).
272
- - `BaseSocketServer.shutdown()` closes all routes, services, and the underlying server.
293
+ - `shutdown()` closes routes and services. It closes an owned listener, but leaves a supplied listener running unless `closeServerOnShutdown: true` is set.
294
+ - Shutdown is best-effort: all hooks, clients, routes, and owned listeners are processed before collected cleanup errors are reported.
295
+ - `HttpServer` and `HttpsServer` expose an idempotent async `shutdown()` helper.
296
+
297
+ ## 0.8 migration notes
298
+
299
+ - Unmatched WebSocket paths are rejected unless `fallbackToRoot: true` is configured.
300
+ - Handler exception details are hidden unless `exposeErrors: true` is configured.
301
+ - Shutting down a WebSocket server no longer closes a caller-supplied HTTP/HTTPS server by default.
302
+ - `bind` is now honored by HTTP, HTTPS, WebSocket, and secure WebSocket listeners.
303
+ - `shutdown()` is asynchronous; await it when deterministic cleanup matters.
273
304
 
274
305
  ## Developing
275
306
 
276
- - Run tests with `npm test` (Jest).
307
+ - Run tests with `npm test` (Jest). The suite includes mock-free HTTP, HTTPS, WebSocket, and secure WebSocket integration tests plus unit tests, with 100% coverage enforced for statements, branches, functions, and lines.
package/index.d.ts CHANGED
@@ -4,6 +4,7 @@ declare module 'redweb' {
4
4
  import { Server as NodeHttpServer } from 'http';
5
5
  import { WebSocket, ServerOptions } from 'ws';
6
6
  import { Buffer } from 'buffer';
7
+ import { EventEmitter } from 'events';
7
8
 
8
9
  /** ─────────────────── HTTP / CORE ─────────────────── */
9
10
 
@@ -19,19 +20,41 @@ declare module 'redweb' {
19
20
  encoding?: RedWebEncoding;
20
21
  ssl?: { key: string; cert: string };
21
22
  server?: Application;
22
- corsOptions?: CorsOptions;
23
- enableHtmxRendering?: boolean;
24
- }
23
+ corsOptions?: CorsOptions | false;
24
+ enableHtmxRendering?: boolean;
25
+ exposeErrors?: boolean;
26
+ logger?: RedWebLogger | null;
27
+ }
28
+
29
+ export type RedWebSocket = WebSocket & {
30
+ clientKey: string;
31
+ __redwebClientKey: string;
32
+ remoteAddress: string;
33
+ isAssigned: boolean;
34
+ sendJson(data: unknown): boolean;
35
+ broadcast(data: unknown): number;
36
+ };
25
37
 
26
38
  /** ─────────────────── SOCKET SERVER ─────────────────── */
27
39
 
28
40
  export interface SocketServerOptions {
29
41
  server?: NodeHttpServer;
30
42
  port?: number;
43
+ bind?: string;
31
44
  listen?: boolean;
32
45
  routes?: Array<new () => SocketRoute>;
33
- ssl?: { key: string; cert: string };
34
- }
46
+ ssl?: { key: string; cert: string };
47
+ fallbackToRoot?: boolean;
48
+ closeServerOnShutdown?: boolean;
49
+ listenCallback?: () => void;
50
+ logger?: RedWebLogger | null;
51
+ }
52
+
53
+ export interface RedWebLogger {
54
+ log?(message?: any, ...optionalParams: any[]): void;
55
+ warn?(message?: any, ...optionalParams: any[]): void;
56
+ error?(message?: any, ...optionalParams: any[]): void;
57
+ }
35
58
 
36
59
  /** ─────────────────── ROUTES & HANDLERS ─────────────────── */
37
60
 
@@ -40,14 +63,20 @@ declare module 'redweb' {
40
63
  handlers: Array<new () => BaseHandler>;
41
64
  services?: Array<new () => SocketService>;
42
65
  allowDuplicateConnections?: boolean;
43
- websocketOptions?: ServerOptions;
66
+ websocketOptions?: Omit<ServerOptions, 'noServer' | 'path' | 'server' | 'port'>;
67
+ trustProxy?: boolean;
68
+ getClientKey?: (request: import('http').IncomingMessage) => string;
69
+ exposeErrors?: boolean;
70
+ logger?: RedWebLogger | null;
71
+ shutdownTimeoutMs?: number;
44
72
  }
45
73
 
46
74
  /** Socket‑side autonomous service (game loops, timers, etc.) */
47
- export abstract class SocketService {
48
- name: string;
49
- tickRateMs?: number;
50
- protected _tickHandle?: NodeJS.Timeout;
75
+ export abstract class SocketService {
76
+ name: string;
77
+ tickRateMs: number | null;
78
+ route: SocketRoute;
79
+ protected _tickHandle: NodeJS.Timeout | null;
51
80
 
52
81
  constructor(name: string, tickRateMs?: number);
53
82
 
@@ -67,50 +96,56 @@ declare module 'redweb' {
67
96
  constructor(name: string);
68
97
 
69
98
  handleMessage(
70
- socket: WebSocket & {
71
- sendJson: (message: object) => void;
72
- broadcast: (message: object) => void;
73
- },
99
+ socket: RedWebSocket,
74
100
  message: any
75
- ): void;
76
-
77
- onMessage(socket: WebSocket, message: any): void;
78
- acceptsBinary?(socket: WebSocket, buffer: Buffer): boolean;
79
- handleBinaryMessage(socket: WebSocket, buffer: Buffer): void;
80
- onBinaryMessage(socket: WebSocket, buffer: Buffer): void;
81
- onInitialContact(socket: WebSocket): void;
101
+ ): Promise<unknown>;
102
+
103
+ validateMessage(message: any, socket: RedWebSocket): boolean | Promise<boolean>;
104
+ onMessage(socket: RedWebSocket, message: any): unknown;
105
+ acceptsBinary?(socket: RedWebSocket, buffer: Buffer): boolean;
106
+ handleBinaryMessage(socket: RedWebSocket, buffer: Buffer): Promise<unknown>;
107
+ onBinaryMessage(socket: RedWebSocket, buffer: Buffer): unknown;
108
+ onInitialContact(socket: RedWebSocket, request?: import('http').IncomingMessage): unknown;
82
109
  }
83
110
 
84
- export class SocketRoute {
111
+ export class SocketRoute {
85
112
  path: string;
86
113
  handlers: BaseHandler[];
87
- clients: Map<string, WebSocket>;
114
+ services: SocketService[];
115
+ clients: Map<string, RedWebSocket>;
88
116
  allowDuplicateConnections?: boolean;
89
- websocketOptions?: ServerOptions;
117
+ websocketOptions?: SocketRouteConfig['websocketOptions'];
90
118
 
91
119
  constructor(config: SocketRouteConfig);
92
120
 
93
- addHandler(handler: new () => BaseHandler): void;
94
- handleMessage(sock: WebSocket, data: any): void;
95
- handleBinaryMessage(socket: WebSocket, buffer: Buffer): void;
121
+ addHandler(handler: new () => BaseHandler): boolean;
122
+ resolveRemoteAddress(request: import('http').IncomingMessage): string;
123
+ connectionOpenCallback(socket: RedWebSocket, request?: import('http').IncomingMessage): unknown;
124
+ connectionCloseCallback?(socket: RedWebSocket): unknown;
125
+ handleMessage(sock: RedWebSocket, data: any): Promise<boolean>;
126
+ handleBinaryMessage(socket: RedWebSocket, buffer: Buffer): Promise<boolean>;
127
+ shutdown(): Promise<void>;
96
128
  }
97
129
 
98
130
  /** ─────────────────── SERVER BASE ─────────────────── */
99
131
 
100
132
  export class BaseSocketServer {
101
- clients: Map<string, WebSocket>;
102
133
  server: NodeHttpServer;
103
- routes: SocketRoute[];
134
+ routes: SocketRoute[];
135
+ ownsServer: boolean;
104
136
 
105
137
  constructor(server: NodeHttpServer, options?: SocketServerOptions);
106
138
 
107
- addRoute(route: new () => SocketRoute): void;
139
+ addRoute(route: new () => SocketRoute): SocketRoute;
140
+ shutdown(): Promise<void>;
108
141
  }
109
142
 
110
- /** ─────────────────── REGISTRY & UTIL TYPES ─────────────────── */
143
+ /** ─────────────────── REGISTRY & UTIL TYPES ─────────────────── */
144
+
145
+ export function sendJson(socket: WebSocket, data: unknown): boolean;
111
146
 
112
147
  export interface SocketWrapper {
113
- socket: WebSocket;
148
+ socket: RedWebSocket;
114
149
  id: string;
115
150
  send: (type: string, payload: Record<string, any>) => void;
116
151
  getSanitized?(): Record<string, any>;
@@ -159,14 +194,17 @@ declare module 'redweb' {
159
194
  app: Application;
160
195
  server?: NodeHttpServer;
161
196
  constructor(options?: RedWebOptions);
197
+ shutdown?(): Promise<void>;
162
198
  }
163
199
 
164
200
  export class HttpServer extends BaseHttpServer {
165
201
  constructor(options?: RedWebOptions);
202
+ shutdown(): Promise<void>;
166
203
  }
167
204
 
168
205
  export class HttpsServer extends BaseHttpServer {
169
206
  constructor(options?: RedWebOptions);
207
+ shutdown(): Promise<void>;
170
208
  }
171
209
 
172
210
  /** ─────────────────── CONSTANTS ─────────────────── */
@@ -174,8 +212,12 @@ declare module 'redweb' {
174
212
  export const METHODS: {
175
213
  GET: 'get';
176
214
  POST: 'post';
177
- PUT: 'put';
178
- DELETE: 'delete';
215
+ PUT: 'put';
216
+ PATCH: 'patch';
217
+ DELETE: 'delete';
218
+ OPTIONS: 'options';
219
+ HEAD: 'head';
220
+ ALL: 'all';
179
221
  };
180
222
 
181
223
  export const ENCODINGS: {
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { BaseHttpServer, METHODS } = require('./src/http');
2
+ const { ENCODINGS, HTTP_OPTIONS } = require('./src/http/BaseHttpServer');
2
3
  const { sendJson } = require('./src/ws/util');
3
4
  const { SocketServer, SecureSocketServer, SOCKET_OPTIONS, SocketRoute, SocketService, SocketRegistry } = require('./src/ws');
4
5
  const { BaseHandler } = require('./src/ws/BaseHandler');
@@ -15,6 +16,8 @@ module.exports = {
15
16
  SocketService,
16
17
  SocketRegistry,
17
18
  sendJson,
18
- SOCKET_OPTIONS,
19
- METHODS
20
- };
19
+ SOCKET_OPTIONS,
20
+ HTTP_OPTIONS,
21
+ ENCODINGS,
22
+ METHODS
23
+ };
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "redweb",
3
- "version": "0.7.7",
3
+ "version": "0.8.0",
4
4
  "description": "A way to quickly set up an express server",
5
5
  "main": "index.js",
6
- "scripts": {
7
- "test": "npx jest"
6
+ "types": "index.d.ts",
7
+ "scripts": {
8
+ "pretest": "tsc -p tests/types/tsconfig.json",
9
+ "test": "npx jest"
8
10
  },
9
11
  "files": [
10
12
  "src/*",
@@ -13,15 +15,19 @@
13
15
  "keywords": [],
14
16
  "author": "",
15
17
  "license": "ISC",
16
- "dependencies": {
17
- "cors": "^2.8.5",
18
+ "dependencies": {
19
+ "@types/express": "^4.17.21",
20
+ "@types/cors": "2.8.19",
21
+ "@types/node": "20.19.24",
22
+ "@types/ws": "^8.18.1",
23
+ "cors": "^2.8.5",
18
24
  "express": "^4.19.2",
19
25
  "ws": "^8.17.0"
20
26
  },
21
- "devDependencies": {
22
- "@types/express": "^4.17.21",
23
- "@types/jest": "^29.5.12",
27
+ "devDependencies": {
28
+ "@types/jest": "^29.5.12",
24
29
  "jest": "^29.7.0",
25
- "supertest": "^7.0.0"
30
+ "supertest": "^7.0.0",
31
+ "typescript": "^5.9.3"
26
32
  }
27
33
  }
@@ -8,7 +8,7 @@ class HtmxRenderer {
8
8
  * @param {string} filePath - Path to the .htmx file.
9
9
  * @returns {string} Rendered HTML string with normalized whitespace.
10
10
  */
11
- static render(filePath) {
11
+ static render(filePath, { rootDir = path.dirname(path.resolve(filePath)), timeoutMs = 1000 } = {}) {
12
12
  if (!fs.existsSync(filePath)) {
13
13
  throw new Error(`Template file not found: ${filePath}`);
14
14
  }
@@ -32,9 +32,17 @@ class HtmxRenderer {
32
32
  `;
33
33
 
34
34
  // Create a custom require function that resolves paths relative to the template
35
- const customRequire = (modulePath) => {
36
- const absolutePath = path.resolve(path.dirname(filePath), modulePath);
37
- return require(absolutePath);
35
+ const resolvedRoot = path.resolve(rootDir);
36
+ const customRequire = (modulePath) => {
37
+ if (typeof modulePath !== 'string' || !modulePath.startsWith('.')) {
38
+ throw new Error('Templates may only require relative modules');
39
+ }
40
+ const absolutePath = path.resolve(path.dirname(filePath), modulePath);
41
+ const relative = path.relative(resolvedRoot, absolutePath);
42
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
43
+ throw new Error('Template module is outside the allowed root');
44
+ }
45
+ return require(absolutePath);
38
46
  };
39
47
 
40
48
  // Execute the script in a sandbox
@@ -48,7 +56,7 @@ class HtmxRenderer {
48
56
  vm.createContext(sandbox);
49
57
 
50
58
  // Get the rendered output
51
- let result = script.runInContext(sandbox);
59
+ let result = script.runInContext(sandbox, { timeout: timeoutMs });
52
60
 
53
61
  // Normalize spaces but preserve those in content
54
62
  result = result
@@ -1,9 +1,9 @@
1
- const express = require('express');
2
- const bodyParser = require('body-parser');
3
- const path = require('path');
1
+ const express = require('express');
2
+ const path = require('path');
4
3
  const cors = require('cors');
5
4
  const fs = require('fs');
6
- const HtmxRenderer = require('../htmx/HtmxRenderer'); // Import the HtmxRenderer module
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
@@ -39,8 +39,46 @@ const HTTP_OPTIONS = {
39
39
  ssl: null,
40
40
  server: undefined,
41
41
  corsOptions: undefined,
42
- enableHtmxRendering: false, // New option for HTMX rendering
43
- };
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
+ }
44
82
 
45
83
  /**
46
84
  * Base HTTP Server
@@ -48,60 +86,77 @@ const HTTP_OPTIONS = {
48
86
  * @return {Object} Express application instance.
49
87
  */
50
88
  function BaseHttpServer(options = {}) {
51
- this.options = { ...HTTP_OPTIONS, ...options };
52
- this.app = this.options.server || express();
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
+ }
53
103
  Object.assign(this, this.options);
54
104
 
55
105
  // Middleware to parse request bodies based on the specified encoding
56
- 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 }));
60
- }
61
-
62
- this.app.use(cors(this.options.corsOptions));
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
+ }
63
115
 
64
116
  // Enable HTMX rendering if the flag is set
65
117
  if (this.enableHtmxRendering) {
66
- 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
71
-
72
- if (!filePath) {
73
- return res.status(404).send(`Error rendering HTMX file: Template file not found: ${req.path}`);
74
- }
75
-
76
- try {
77
- const renderedContent = HtmxRenderer.render(filePath);
78
- res.type('html').send(renderedContent);
79
- } catch (error) {
80
- res.status(500).send(`Error rendering HTMX file: ${error.message}`);
81
- }
82
- });
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
+ });
83
139
  }
84
140
 
85
141
 
86
- // Serve static files from public paths
87
- this.publicPaths.forEach((publicPath) =>
88
- this.app.use(express.static(path.join(process.cwd(), publicPath)))
89
- );
142
+ // Serve static files from public paths
143
+ this.publicPaths.forEach((publicPath) =>
144
+ this.app.use(express.static(path.resolve(process.cwd(), publicPath)))
145
+ );
90
146
 
91
- const catchAll = this.services.find((service) => service.serviceName === '*');
92
- if (catchAll) this.services.splice(this.services.indexOf(catchAll), 1);
93
- this.services.forEach((service) =>
94
- this.app[service.method](service.serviceName, service.function)
95
- );
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
+ );
96
151
  if (catchAll) this.app[catchAll.method](catchAll.serviceName, catchAll.function);
97
152
 
98
153
  return this;
99
154
  }
100
155
 
101
156
 
102
- module.exports = {
103
- BaseHttpServer,
104
- ENCODINGS,
105
- HTTP_OPTIONS,
106
- METHODS: { GET: 'get', POST: 'post', PUT: 'put', DELETE: 'delete' },
107
- };
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
+ };
@@ -1,16 +1,24 @@
1
1
  const http = require('http');
2
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
+ function HttpServer(options) {
10
11
  BaseHttpServer.call(this, options);
11
12
  this.server = http.createServer(this.app);
13
+ this.shutdown = () => closeServer(this.server);
12
14
  if (this.listen !== false) {
13
- this.server.listen(this.port, this.listenCallback ? this.listenCallback : () => console.log(`RedWeb HttpServer listening on port ${this.port}`));
15
+ listenServer(this.server, {
16
+ port: this.port,
17
+ bind: this.bind,
18
+ callback: this.listenCallback,
19
+ logger: this.logger,
20
+ name: 'HttpServer',
21
+ });
14
22
  }
15
23
  return this;
16
24
  }
@@ -1,21 +1,29 @@
1
1
  const https = require('https');
2
2
  const { BaseHttpServer } = require('./BaseHttpServer');
3
- const loadSslConfig = require('../sslConfig');
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
+ function HttpsServer(options) {
11
12
  BaseHttpServer.call(this, options);
12
13
  const sslOptions = loadSslConfig(this.ssl);
13
14
  this.server = https.createServer(sslOptions, this.app);
15
+ this.shutdown = () => closeServer(this.server);
14
16
  if (this.listen === false) {
15
17
  return this;
16
18
  }
17
19
 
18
- this.server.listen(this.port, this.listenCallback ? this.listenCallback : () => console.log(`RedWeb HttpsServer listening on port ${this.port}`));
20
+ listenServer(this.server, {
21
+ port: this.port,
22
+ bind: this.bind,
23
+ callback: this.listenCallback,
24
+ logger: this.logger,
25
+ name: 'HttpsServer',
26
+ });
19
27
  return this;
20
28
  }
21
29
 
@@ -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
+ };