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.
package/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  declare module 'redweb' {
2
2
  import { Application } from 'express';
3
3
  import { CorsOptions } from 'cors';
4
- import { Server as NodeHttpServer } from 'http';
5
- import { WebSocket, ServerOptions } from 'ws';
6
- import { Buffer } from 'buffer';
4
+ import { Server as NodeHttpServer } from 'http';
5
+ import { WebSocket, ServerOptions } from 'ws';
6
+ import { Buffer } from 'buffer';
7
+ import { EventEmitter } from 'events';
7
8
 
8
9
  /** ─────────────────── HTTP / CORE ─────────────────── */
9
10
 
@@ -12,42 +13,217 @@ declare module 'redweb' {
12
13
  export interface RedWebOptions {
13
14
  port?: number;
14
15
  bind?: string;
15
- publicPaths?: string[];
16
- services?: Array<{ serviceName: string; method: string; function: Function }>;
17
- listen?: boolean;
18
- listenCallback?: () => void;
16
+ publicPaths?: string[];
17
+ services?: Array<{ serviceName: string; method: string; function: Function }>;
18
+ listen?: boolean;
19
+ listenCallback?: () => void;
19
20
  encoding?: RedWebEncoding;
20
21
  ssl?: { key: string; cert: string };
21
22
  server?: Application;
22
- corsOptions?: CorsOptions;
23
+ corsOptions?: CorsOptions | false;
23
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
+ context?: RedWebConnectionContext;
37
+ joinRoom?(roomId: string): boolean;
38
+ leaveRoom?(roomId: string): boolean;
39
+ roomBroadcast?(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
40
+ createSession?(sessionId: string, data: unknown): boolean;
41
+ resumeSession?(sessionId: string): unknown | null;
42
+ publishEvent?(type: string, payload: unknown): Promise<boolean>;
43
+ sendEvent?(type: string, payload: unknown, metadata?: ProtocolMetadata): boolean;
44
+ sendProtocolError?(code: string, message: string, metadata?: ProtocolMetadata): boolean;
45
+ sendBinaryEvent?(value: unknown): Promise<boolean>;
46
+ };
47
+
48
+ export interface RedWebConnectionContext {
49
+ connectionId: string;
50
+ principal: unknown;
51
+ session: unknown | null;
52
+ metadata: Record<string, unknown>;
53
+ signal?: AbortSignal;
54
+ protocol?: Readonly<{ version: string }>;
55
+ }
56
+
57
+ export interface AdmissionContext {
58
+ signal: AbortSignal;
59
+ networkIdentity: string;
60
+ route: SocketRoute;
61
+ }
62
+
63
+ export interface AdmissionOptions {
64
+ authenticate?: (
65
+ request: import('http').IncomingMessage,
66
+ context: AdmissionContext
67
+ ) => unknown | false | Promise<unknown | false>;
68
+ origins?: string[] | ((
69
+ origin: string | undefined,
70
+ request: import('http').IncomingMessage
71
+ ) => boolean | Promise<boolean>);
72
+ place?: (
73
+ principal: unknown,
74
+ request: import('http').IncomingMessage,
75
+ context: AdmissionContext
76
+ ) => string | false | null | undefined | Promise<string | false | null | undefined>;
77
+ allowedPlacementOrigins?: string[];
78
+ allowInsecurePlacement?: boolean;
79
+ timeoutMs?: number;
80
+ }
81
+
82
+ export interface MessageRateLimit {
83
+ capacity: number;
84
+ refillPerSecond: number;
85
+ action?: 'drop' | 'disconnect';
86
+ }
87
+
88
+ export interface TransportLimits {
89
+ maxConnections?: number;
90
+ maxBufferedBytes?: number;
91
+ maxPendingMessages?: number;
92
+ messageRate?: MessageRateLimit;
93
+ slowConsumerAction?: 'drop' | 'disconnect';
94
+ }
95
+
96
+ export interface HeartbeatOptions {
97
+ intervalMs: number;
98
+ timeoutMs: number;
99
+ }
100
+
101
+ export interface RoomOptions {
102
+ maxRooms?: number;
103
+ maxMembersPerRoom?: number;
104
+ maxRoomsPerConnection?: number;
105
+ maxRoomIdLength?: number;
106
+ }
107
+
108
+ export interface SessionOptions {
109
+ ttlMs?: number;
110
+ maxSessions?: number;
111
+ maxSessionIdLength?: number;
112
+ sweepIntervalMs?: number;
113
+ }
114
+
115
+ export interface MetricsSink {
116
+ increment?(name: string, value: number, attributes: Readonly<{ route: string }>): void | Promise<void>;
117
+ gauge?(name: string, value: number, attributes: Readonly<{ route: string }>): void | Promise<void>;
118
+ observe?(name: string, value: number, attributes: Readonly<{ route: string }>): void | Promise<void>;
119
+ }
120
+
121
+ export interface DistributionEvent<T = unknown> {
122
+ id: string;
123
+ source: string;
124
+ type: string;
125
+ payload: T;
126
+ }
127
+
128
+ export interface DistributionAdapter {
129
+ start?(signal?: AbortSignal): void | Promise<void>;
130
+ publish(channel: string, serializedEvent: string, signal?: AbortSignal): void | Promise<void>;
131
+ subscribe(
132
+ channel: string,
133
+ onEvent: (serializedEvent: string | DistributionEvent) => void,
134
+ signal?: AbortSignal
135
+ ): void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;
136
+ unsubscribe?(channel: string, signal?: AbortSignal): void | Promise<void>;
137
+ close?(signal?: AbortSignal): void | Promise<void>;
138
+ }
139
+
140
+ export interface DistributionOptions {
141
+ adapter: DistributionAdapter;
142
+ channel: string;
143
+ nodeId?: string;
144
+ maxEventBytes?: number;
145
+ maxSeenEvents?: number;
146
+ seenTtlMs?: number;
147
+ lifecycleTimeoutMs?: number;
148
+ publishTimeoutMs?: number;
149
+ maxConcurrentPublishes?: number;
150
+ maxConcurrentEvents?: number;
151
+ required?: boolean;
152
+ onEvent(event: DistributionEvent, route: SocketRoute): void | Promise<void>;
153
+ }
154
+
155
+ export interface ProtocolMetadata {
156
+ requestId?: string;
157
+ sequence?: number;
158
+ }
159
+
160
+ export interface ProtocolBinaryCodec {
161
+ maxBytes?: number;
162
+ encode(value: unknown, context: RedWebConnectionContext): Buffer | Uint8Array | ArrayBuffer | Promise<Buffer | Uint8Array | ArrayBuffer>;
163
+ decode(buffer: Buffer, context: RedWebConnectionContext): unknown | Promise<unknown>;
164
+ }
165
+
166
+ export interface ProtocolOptions {
167
+ versions: string[];
168
+ required?: boolean;
169
+ queryParameter?: string;
170
+ header?: string;
171
+ binary?: false | ProtocolBinaryCodec;
24
172
  }
25
173
 
26
174
  /** ─────────────────── SOCKET SERVER ─────────────────── */
27
175
 
28
176
  export interface SocketServerOptions {
29
- server?: NodeHttpServer;
30
- port?: number;
31
- listen?: boolean;
32
- routes?: Array<new () => SocketRoute>;
177
+ server?: NodeHttpServer;
178
+ port?: number;
179
+ bind?: string;
180
+ listen?: boolean;
181
+ routes?: Array<new () => SocketRoute>;
33
182
  ssl?: { key: string; cert: string };
183
+ fallbackToRoot?: boolean;
184
+ closeServerOnShutdown?: boolean;
185
+ listenCallback?: () => void;
186
+ logger?: RedWebLogger | null;
187
+ }
188
+
189
+ export interface RedWebLogger {
190
+ log?(message?: any, ...optionalParams: any[]): void;
191
+ warn?(message?: any, ...optionalParams: any[]): void;
192
+ error?(message?: any, ...optionalParams: any[]): void;
34
193
  }
35
194
 
36
195
  /** ─────────────────── ROUTES & HANDLERS ─────────────────── */
37
196
 
38
197
  export interface SocketRouteConfig {
39
198
  path: string;
40
- handlers: Array<new () => BaseHandler>;
41
- services?: Array<new () => SocketService>;
42
- allowDuplicateConnections?: boolean;
43
- websocketOptions?: ServerOptions;
199
+ handlers: Array<new () => BaseHandler>;
200
+ services?: Array<new () => SocketService>;
201
+ allowDuplicateConnections?: boolean;
202
+ websocketOptions?: Omit<ServerOptions, 'noServer' | 'path' | 'server' | 'port'>;
203
+ trustProxy?: boolean;
204
+ getClientKey?: (request: import('http').IncomingMessage) => string;
205
+ exposeErrors?: boolean;
206
+ logger?: RedWebLogger | null;
207
+ shutdownTimeoutMs?: number;
208
+ admission?: AdmissionOptions | AdmissionOptions['authenticate'];
209
+ limits?: TransportLimits;
210
+ orderedMessages?: boolean;
211
+ heartbeat?: HeartbeatOptions;
212
+ rooms?: boolean | RoomOptions;
213
+ sessions?: boolean | SessionOptions;
214
+ metrics?: MetricsSink;
215
+ distribution?: false | DistributionOptions;
216
+ drainHandlers?: boolean;
217
+ protocol?: false | ProtocolOptions;
218
+ maxPendingUpgrades?: number;
44
219
  }
45
220
 
46
221
  /** Socket‑side autonomous service (game loops, timers, etc.) */
47
222
  export abstract class SocketService {
48
223
  name: string;
49
- tickRateMs?: number;
50
- protected _tickHandle?: NodeJS.Timeout;
224
+ tickRateMs: number | null;
225
+ route: SocketRoute;
226
+ protected _tickHandle: NodeJS.Timeout | null;
51
227
 
52
228
  constructor(name: string, tickRateMs?: number);
53
229
 
@@ -55,62 +231,116 @@ declare module 'redweb' {
55
231
  onInit(route: SocketRoute): void;
56
232
 
57
233
  /** Optional recurring tick (respecting tickRateMs) */
58
- onTick?(): void;
234
+ onTick?(...args: any[]): unknown;
59
235
 
60
236
  /** Called on process shutdown / route removal */
61
237
  onShutdown(): void;
62
238
  }
63
239
 
240
+ export abstract class FixedStepService extends SocketService {
241
+ maxCatchUpTicks: number;
242
+ maxRetainedLagMs: number;
243
+ tick: number;
244
+ accumulatorMs: number;
245
+
246
+ constructor(name: string, tickRateMs: number, maxCatchUpTicks?: number, maxRetainedLagMs?: number);
247
+ onTick?(stepMs: number, tick: number): void | Promise<void>;
248
+ onLagDropped?(droppedLagMs: number): void;
249
+ pulse(): Promise<void>;
250
+ onShutdown(): Promise<void>;
251
+ }
252
+
64
253
  /** Message handler, triggered by client messages */
65
254
  export class BaseHandler {
66
255
  name: string;
67
256
  constructor(name: string);
68
257
 
69
258
  handleMessage(
70
- socket: WebSocket & {
71
- sendJson: (message: object) => void;
72
- broadcast: (message: object) => void;
73
- },
259
+ socket: RedWebSocket,
74
260
  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;
82
- }
261
+ ): Promise<unknown>;
262
+
263
+ validateMessage(message: any, socket: RedWebSocket): boolean | Promise<boolean>;
264
+ onMessage(socket: RedWebSocket, message: any): unknown;
265
+ acceptsBinary?(socket: RedWebSocket, buffer: Buffer): boolean;
266
+ handleBinaryMessage(socket: RedWebSocket, buffer: Buffer): Promise<unknown>;
267
+ onBinaryMessage(socket: RedWebSocket, buffer: Buffer): unknown;
268
+ onInitialContact(socket: RedWebSocket, request?: import('http').IncomingMessage): unknown;
269
+ }
83
270
 
84
271
  export class SocketRoute {
85
272
  path: string;
86
- handlers: BaseHandler[];
87
- clients: Map<string, WebSocket>;
88
- allowDuplicateConnections?: boolean;
89
- websocketOptions?: ServerOptions;
90
-
91
- constructor(config: SocketRouteConfig);
92
-
93
- addHandler(handler: new () => BaseHandler): void;
94
- handleMessage(sock: WebSocket, data: any): void;
95
- handleBinaryMessage(socket: WebSocket, buffer: Buffer): void;
96
- }
273
+ handlers: BaseHandler[];
274
+ services: SocketService[];
275
+ clients: Map<string, RedWebSocket>;
276
+ rooms: RoomRegistry | null;
277
+ sessions: SessionRegistry | null;
278
+ distribution: unknown | null;
279
+ protocolPolicy: unknown | null;
280
+ draining: boolean;
281
+ allowDuplicateConnections?: boolean;
282
+ websocketOptions?: SocketRouteConfig['websocketOptions'];
283
+
284
+ constructor(config: SocketRouteConfig);
285
+
286
+ addHandler(handler: new () => BaseHandler): boolean;
287
+ resolveRemoteAddress(request: import('http').IncomingMessage): string;
288
+ connectionOpenCallback(socket: RedWebSocket, request?: import('http').IncomingMessage): unknown;
289
+ connectionCloseCallback?(socket: RedWebSocket): unknown;
290
+ handleMessage(sock: RedWebSocket, data: any): Promise<boolean>;
291
+ handleBinaryMessage(socket: RedWebSocket, buffer: Buffer): Promise<boolean>;
292
+ beginDrain(): boolean;
293
+ isReady(): boolean;
294
+ publish(type: string, payload: unknown): Promise<boolean>;
295
+ shutdown(): Promise<void>;
296
+ }
97
297
 
98
298
  /** ─────────────────── SERVER BASE ─────────────────── */
99
299
 
100
300
  export class BaseSocketServer {
101
- clients: Map<string, WebSocket>;
102
- server: NodeHttpServer;
301
+ server: NodeHttpServer;
103
302
  routes: SocketRoute[];
303
+ ownsServer: boolean;
104
304
 
105
- constructor(server: NodeHttpServer, options?: SocketServerOptions);
305
+ constructor(server: NodeHttpServer, options?: SocketServerOptions);
106
306
 
107
- addRoute(route: new () => SocketRoute): void;
307
+ addRoute(route: new () => SocketRoute): SocketRoute;
308
+ isReady(): boolean;
309
+ beginDrain(): boolean;
310
+ shutdown(): Promise<void>;
108
311
  }
109
312
 
110
313
  /** ─────────────────── REGISTRY & UTIL TYPES ─────────────────── */
111
314
 
315
+ export function sendJson(socket: WebSocket, data: unknown): boolean;
316
+
317
+ export class RoomRegistry {
318
+ constructor(options?: RoomOptions);
319
+ join(roomId: string, socket: RedWebSocket): boolean;
320
+ leave(roomId: string, socket: RedWebSocket): boolean;
321
+ leaveAll(socket: RedWebSocket): number;
322
+ members(roomId: string): RedWebSocket[];
323
+ has(roomId: string, socket: RedWebSocket): boolean;
324
+ broadcast(roomId: string, data: unknown, options?: { except?: RedWebSocket }): number;
325
+ clear(): void;
326
+ close(): boolean;
327
+ readonly size: number;
328
+ }
329
+
330
+ export class SessionRegistry<T = unknown> {
331
+ constructor(options?: SessionOptions, logger?: RedWebLogger | null);
332
+ create(sessionId: string, data: T, socket?: RedWebSocket): boolean;
333
+ resume(sessionId: string, socket: RedWebSocket): T | null;
334
+ release(socket: RedWebSocket): boolean;
335
+ remove(sessionId: string): boolean;
336
+ get(sessionId: string): T | undefined;
337
+ sweep(): void;
338
+ stop(): void;
339
+ readonly size: number;
340
+ }
341
+
112
342
  export interface SocketWrapper {
113
- socket: WebSocket;
343
+ socket: RedWebSocket;
114
344
  id: string;
115
345
  send: (type: string, payload: Record<string, any>) => void;
116
346
  getSanitized?(): Record<string, any>;
@@ -155,19 +385,22 @@ declare module 'redweb' {
155
385
  constructor(options?: SocketServerOptions);
156
386
  }
157
387
 
158
- export class BaseHttpServer {
159
- app: Application;
160
- server?: NodeHttpServer;
161
- constructor(options?: RedWebOptions);
162
- }
163
-
164
- export class HttpServer extends BaseHttpServer {
165
- constructor(options?: RedWebOptions);
166
- }
167
-
168
- export class HttpsServer extends BaseHttpServer {
169
- constructor(options?: RedWebOptions);
170
- }
388
+ export class BaseHttpServer {
389
+ app: Application;
390
+ server?: NodeHttpServer;
391
+ constructor(options?: RedWebOptions);
392
+ shutdown?(): Promise<void>;
393
+ }
394
+
395
+ export class HttpServer extends BaseHttpServer {
396
+ constructor(options?: RedWebOptions);
397
+ shutdown(): Promise<void>;
398
+ }
399
+
400
+ export class HttpsServer extends BaseHttpServer {
401
+ constructor(options?: RedWebOptions);
402
+ shutdown(): Promise<void>;
403
+ }
171
404
 
172
405
  /** ─────────────────── CONSTANTS ─────────────────── */
173
406
 
@@ -175,7 +408,11 @@ declare module 'redweb' {
175
408
  GET: 'get';
176
409
  POST: 'post';
177
410
  PUT: 'put';
411
+ PATCH: 'patch';
178
412
  DELETE: 'delete';
413
+ OPTIONS: 'options';
414
+ HEAD: 'head';
415
+ ALL: 'all';
179
416
  };
180
417
 
181
418
  export const ENCODINGS: {
@@ -185,4 +422,15 @@ declare module 'redweb' {
185
422
 
186
423
  export const HTTP_OPTIONS: RedWebOptions;
187
424
  export const SOCKET_OPTIONS: SocketServerOptions;
425
+
426
+ export const ERROR_CODES: Readonly<{
427
+ INVALID_MESSAGE: 'INVALID_MESSAGE';
428
+ UNKNOWN_HANDLER: 'UNKNOWN_HANDLER';
429
+ HANDLER_FAILED: 'HANDLER_FAILED';
430
+ BINARY_UNSUPPORTED: 'BINARY_UNSUPPORTED';
431
+ RATE_LIMITED: 'RATE_LIMITED';
432
+ QUEUE_FULL: 'QUEUE_FULL';
433
+ CAPACITY_REACHED: 'CAPACITY_REACHED';
434
+ INITIALIZATION_FAILED: 'INITIALIZATION_FAILED';
435
+ }>;
188
436
  }
package/index.js CHANGED
@@ -1,20 +1,38 @@
1
- const { BaseHttpServer, METHODS } = require('./src/http');
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
- const { SocketServer, SecureSocketServer, SOCKET_OPTIONS, SocketRoute, SocketService, SocketRegistry } = require('./src/ws');
4
+ const {
5
+ SocketServer,
6
+ SecureSocketServer,
7
+ SOCKET_OPTIONS,
8
+ SocketRoute,
9
+ SocketService,
10
+ FixedStepService,
11
+ SocketRegistry,
12
+ RoomRegistry,
13
+ SessionRegistry,
14
+ ERROR_CODES,
15
+ } = require('./src/ws');
4
16
  const { BaseHandler } = require('./src/ws/BaseHandler');
5
17
  const HttpServer = require('./src/http/HttpServer');
6
18
  const HttpsServer = require('./src/http/HttpsServer');
7
19
  module.exports = {
8
- HttpServer,
9
- HttpsServer,
10
- BaseHttpServer,
11
- SocketServer,
20
+ HttpServer,
21
+ HttpsServer,
22
+ BaseHttpServer,
23
+ SocketServer,
12
24
  SecureSocketServer,
13
25
  BaseHandler,
14
26
  SocketRoute,
15
27
  SocketService,
28
+ FixedStepService,
16
29
  SocketRegistry,
30
+ RoomRegistry,
31
+ SessionRegistry,
32
+ ERROR_CODES,
17
33
  sendJson,
18
34
  SOCKET_OPTIONS,
35
+ HTTP_OPTIONS,
36
+ ENCODINGS,
19
37
  METHODS
20
38
  };
package/package.json CHANGED
@@ -1,27 +1,46 @@
1
1
  {
2
2
  "name": "redweb",
3
- "version": "0.7.7",
3
+ "version": "0.9.0",
4
4
  "description": "A way to quickly set up an express server",
5
5
  "main": "index.js",
6
+ "types": "index.d.ts",
6
7
  "scripts": {
8
+ "pretest": "node scripts/generate-protocol-types.js --check && tsc -p tests/types/tsconfig.json",
9
+ "generate:protocol-types": "node scripts/generate-protocol-types.js",
10
+ "verify:overhead": "node scripts/verify-disabled-overhead.js",
11
+ "verify:soak": "node --expose-gc scripts/verify-soak.js",
12
+ "verify:memory": "node scripts/verify-memory-overhead.js",
13
+ "verify:load": "node scripts/verify-load.js",
14
+ "verify:recovery": "node --expose-gc scripts/verify-recovery.js",
7
15
  "test": "npx jest"
8
16
  },
9
17
  "files": [
10
18
  "src/*",
11
- "index.d.ts"
19
+ "client.js",
20
+ "client.d.ts",
21
+ "CHANGELOG.md",
22
+ "index.d.ts",
23
+ "docs"
12
24
  ],
13
25
  "keywords": [],
14
26
  "author": "",
15
27
  "license": "ISC",
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
16
31
  "dependencies": {
32
+ "@types/cors": "2.8.19",
33
+ "@types/express": "^4.17.21",
34
+ "@types/node": "20.19.24",
35
+ "@types/ws": "^8.18.1",
17
36
  "cors": "^2.8.5",
18
- "express": "^4.19.2",
19
- "ws": "^8.17.0"
37
+ "express": "^4.22.2",
38
+ "ws": "^8.21.3"
20
39
  },
21
40
  "devDependencies": {
22
- "@types/express": "^4.17.21",
23
41
  "@types/jest": "^29.5.12",
24
42
  "jest": "^29.7.0",
25
- "supertest": "^7.0.0"
43
+ "supertest": "^7.0.0",
44
+ "typescript": "^5.9.3"
26
45
  }
27
46
  }
@@ -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,8 +32,16 @@ class HtmxRenderer {
32
32
  `;
33
33
 
34
34
  // Create a custom require function that resolves paths relative to the template
35
+ const resolvedRoot = path.resolve(rootDir);
35
36
  const customRequire = (modulePath) => {
37
+ if (typeof modulePath !== 'string' || !modulePath.startsWith('.')) {
38
+ throw new Error('Templates may only require relative modules');
39
+ }
36
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
+ }
37
45
  return require(absolutePath);
38
46
  };
39
47
 
@@ -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