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 +34 -3
- package/index.d.ts +76 -34
- package/index.js +6 -3
- package/package.json +15 -9
- package/src/htmx/HtmxRenderer.js +13 -5
- package/src/http/BaseHttpServer.js +102 -47
- package/src/http/HttpServer.js +10 -2
- package/src/http/HttpsServer.js +11 -3
- package/src/serverLifecycle.js +46 -0
- package/src/ws/BaseHandler.js +13 -5
- package/src/ws/BaseSocketServer.js +89 -21
- package/src/ws/DefaultHandler.js +5 -6
- package/src/ws/DefaultRoute.js +8 -7
- package/src/ws/SecureSocketServer.js +3 -6
- package/src/ws/SocketRoute.js +195 -77
- package/src/ws/SocketServer.js +2 -5
- package/src/ws/shutdown.js +33 -0
- package/src/ws/util.js +30 -9
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
|
|
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
|
-
- `
|
|
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
|
|
50
|
-
|
|
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:
|
|
71
|
-
sendJson: (message: object) => void;
|
|
72
|
-
broadcast: (message: object) => void;
|
|
73
|
-
},
|
|
99
|
+
socket: RedWebSocket,
|
|
74
100
|
message: any
|
|
75
|
-
):
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
114
|
+
services: SocketService[];
|
|
115
|
+
clients: Map<string, RedWebSocket>;
|
|
88
116
|
allowDuplicateConnections?: boolean;
|
|
89
|
-
websocketOptions?:
|
|
117
|
+
websocketOptions?: SocketRouteConfig['websocketOptions'];
|
|
90
118
|
|
|
91
119
|
constructor(config: SocketRouteConfig);
|
|
92
120
|
|
|
93
|
-
addHandler(handler: new () => BaseHandler):
|
|
94
|
-
|
|
95
|
-
|
|
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):
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A way to quickly set up an express server",
|
|
5
5
|
"main": "index.js",
|
|
6
|
-
"
|
|
7
|
-
|
|
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
|
-
"
|
|
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/
|
|
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
|
}
|
package/src/htmx/HtmxRenderer.js
CHANGED
|
@@ -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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
58
|
-
} else
|
|
59
|
-
this.app.use(
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
};
|
package/src/http/HttpServer.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/http/HttpsServer.js
CHANGED
|
@@ -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
|
|
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
|
+
};
|