@web-ts-toolkit/express-runtime 0.40.1 → 0.42.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.js CHANGED
@@ -34,12 +34,49 @@ __export(index_exports, {
34
34
  createServerlessHandler: () => createServerlessHandler,
35
35
  defaultRequestHook: () => defaultRequestHook,
36
36
  normalizePort: () => normalizePort,
37
- startLocalServer: () => startLocalServer
37
+ parsePortValue: () => parsePortValue,
38
+ startLocalServer: () => startLocalServer,
39
+ validateFiniteInteger: () => validateFiniteInteger
38
40
  });
39
41
  module.exports = __toCommonJS(index_exports);
40
42
  var import_node_http = __toESM(require("http"));
41
43
  var import_express = __toESM(require("express"));
42
44
  var import_serverless_http = __toESM(require("serverless-http"));
45
+
46
+ // src/numeric-validation.ts
47
+ var MAX_INTEGER_OPTION_VALUE = Number.MAX_SAFE_INTEGER;
48
+ function validateFiniteInteger(value, options) {
49
+ const min = options.min ?? Number.MIN_SAFE_INTEGER;
50
+ const max = options.max ?? MAX_INTEGER_OPTION_VALUE;
51
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) {
52
+ throw new Error(`Invalid ${options.name}: ${String(value)}. Must be a finite integer in ${min}..${max}.`);
53
+ }
54
+ return value;
55
+ }
56
+ function parsePortValue(value, name) {
57
+ if (typeof value === "number") {
58
+ return validateFiniteInteger(value, { name, min: 0, max: 65535 });
59
+ }
60
+ if (value.trim() === "") {
61
+ throw new Error(
62
+ `Invalid ${name}: ${JSON.stringify(value)}. Must be a port number in 0..65535 or a named pipe path.`
63
+ );
64
+ }
65
+ if (value.trim() !== value) {
66
+ throw new Error(
67
+ `Invalid ${name}: ${JSON.stringify(value)}. Numeric ports must not contain surrounding whitespace.`
68
+ );
69
+ }
70
+ if (/^(0|[1-9]\d*)$/.test(value)) {
71
+ return validateFiniteInteger(Number(value), { name, min: 0, max: 65535 });
72
+ }
73
+ if (/^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i.test(value) || /^[+-]?(?:infinity|nan)$/i.test(value)) {
74
+ throw new Error(`Invalid ${name}: ${value}. Numeric ports must be canonical decimal integers in 0..65535.`);
75
+ }
76
+ return value;
77
+ }
78
+
79
+ // src/index.ts
43
80
  var defaultLogger = {
44
81
  log: (...args) => console.log(...args),
45
82
  error: (...args) => console.error(...args),
@@ -68,8 +105,15 @@ function applyRouters(app, options) {
68
105
  app.use(path, mount.handler);
69
106
  }
70
107
  }
108
+ function createDefaultErrorHandler(logger) {
109
+ return (error, _req, _res, next) => {
110
+ logger.error("Unhandled Express error:", error);
111
+ next(error);
112
+ };
113
+ }
71
114
  function createExpressApp(options = {}) {
72
115
  const app = (0, import_express.default)();
116
+ const logger = options.logger ?? defaultLogger;
73
117
  applySettings(app, options);
74
118
  applyMiddlewareList(app, options.preMiddleware);
75
119
  if (options.json !== false) {
@@ -86,6 +130,8 @@ function createExpressApp(options = {}) {
86
130
  }
87
131
  if (options.errorHandler) {
88
132
  app.use(options.errorHandler);
133
+ } else {
134
+ app.use(createDefaultErrorHandler(logger));
89
135
  }
90
136
  return app;
91
137
  }
@@ -97,17 +143,43 @@ function defaultRequestHook(req, maxBodyBytes = 1024 * 1024, logger = defaultLog
97
143
  logger.debug?.(" Skipping oversized serverless body for content-type parsing");
98
144
  return;
99
145
  }
100
- try {
101
- const bodyStr = req.body.toString("utf8");
102
- const contentType = (req.headers?.["content-type"] ?? "").toLowerCase();
103
- if (contentType.startsWith("application/json")) {
146
+ const bodyStr = req.body.toString("utf8");
147
+ const contentType = getHeaderValue(req.headers, "content-type");
148
+ if (isJsonMediaType(contentType)) {
149
+ if (isReadableRequest(req)) {
150
+ return;
151
+ }
152
+ try {
104
153
  req.body = JSON.parse(bodyStr);
105
- } else {
106
- req.body = bodyStr;
154
+ } catch (_error) {
155
+ void _error;
107
156
  }
108
- } catch (error) {
109
- logger.error("Failed to parse serverless request body:", error);
157
+ return;
110
158
  }
159
+ req.body = bodyStr;
160
+ }
161
+ function getHeaderValue(headers, name) {
162
+ if (!headers) return "";
163
+ const direct = headers[name];
164
+ if (direct !== void 0) return Array.isArray(direct) ? direct.join(", ") : direct;
165
+ const found = Object.entries(headers).find(([key]) => key.toLowerCase() === name);
166
+ const value = found?.[1];
167
+ if (value === void 0) return "";
168
+ return Array.isArray(value) ? value.join(", ") : value;
169
+ }
170
+ function getMediaType(contentType) {
171
+ return contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
172
+ }
173
+ function isJsonMediaType(contentType) {
174
+ const mediaType = getMediaType(contentType);
175
+ if (mediaType === "application/json") return true;
176
+ if (!mediaType.startsWith("application/")) return false;
177
+ const subtype = mediaType.slice("application/".length);
178
+ return subtype.endsWith("+json") && subtype.length > "+json".length;
179
+ }
180
+ function isReadableRequest(req) {
181
+ const candidate = req;
182
+ return req instanceof import_node_http.default.IncomingMessage || typeof candidate.pipe === "function" && typeof candidate.on === "function" && typeof candidate.read === "function";
111
183
  }
112
184
  function createServerlessHandler(app, options = {}) {
113
185
  const logger = options.logger ?? defaultLogger;
@@ -122,10 +194,20 @@ function createServerlessHandler(app, options = {}) {
122
194
  }
123
195
  const apiHandler = (0, import_serverless_http.default)(app, baseOptions);
124
196
  let initialized = null;
197
+ let initSettled = false;
125
198
  const ensureInit = () => {
126
199
  if (!initialized) {
127
200
  logger.debug?.("Serverless cold start: running init");
128
- initialized = options.init ? options.init() : Promise.resolve();
201
+ initSettled = false;
202
+ initialized = Promise.resolve().then(() => options.init?.()).then(
203
+ () => {
204
+ initSettled = true;
205
+ },
206
+ (error) => {
207
+ initSettled = true;
208
+ throw error;
209
+ }
210
+ );
129
211
  }
130
212
  return initialized;
131
213
  };
@@ -134,13 +216,18 @@ function createServerlessHandler(app, options = {}) {
134
216
  return apiHandler(event, context);
135
217
  };
136
218
  handler.reset = () => {
219
+ if (initialized && !initSettled) {
220
+ logger.debug?.("Serverless init reset ignored while initialization is pending");
221
+ return;
222
+ }
137
223
  initialized = null;
224
+ initSettled = false;
138
225
  };
139
226
  return handler;
140
227
  }
141
228
  var DEFAULT_SIGNALS = ["SIGINT", "SIGTERM"];
142
229
  var DEFAULT_SHUTDOWN_TIMEOUT = 5e3;
143
- function normalizePort(val) {
230
+ function normalizePort(val, name = "port") {
144
231
  if (val === void 0 || val === "") {
145
232
  const envPort = process.env.PORT;
146
233
  if (envPort === void 0 || envPort === "") {
@@ -148,17 +235,7 @@ function normalizePort(val) {
148
235
  }
149
236
  val = envPort;
150
237
  }
151
- if (typeof val === "string") {
152
- const parsed = Number(val);
153
- if (Number.isNaN(parsed)) {
154
- return val;
155
- }
156
- val = parsed;
157
- }
158
- if (!Number.isFinite(val) || val < 0 || val > 65535) {
159
- throw new Error(`Invalid port: ${String(val)}`);
160
- }
161
- return val;
238
+ return parsePortValue(val, name);
162
239
  }
163
240
  function defaultOnError(error, port, logger) {
164
241
  if (error.syscall !== "listen") {
@@ -179,54 +256,217 @@ function startLocalServer(app, options = {}) {
179
256
  const logger = options.logger ?? defaultLogger;
180
257
  const port = normalizePort(options.port);
181
258
  const host = options.host ?? process.env.HOST ?? "0.0.0.0";
182
- const shutdownTimeout = options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT;
259
+ const shutdownTimeout = validateFiniteInteger(options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT, {
260
+ name: "shutdownTimeout",
261
+ min: 0,
262
+ max: MAX_INTEGER_OPTION_VALUE
263
+ });
183
264
  const server = import_node_http.default.createServer(app);
184
265
  app.set("port", port);
185
- const onError = (error) => {
266
+ let state = "initializing";
267
+ let shutdownPromise = null;
268
+ let readySettled = false;
269
+ let readyResolve;
270
+ let readyReject;
271
+ const ready = new Promise((resolve, reject) => {
272
+ readyResolve = () => {
273
+ if (!readySettled) {
274
+ readySettled = true;
275
+ resolve();
276
+ }
277
+ };
278
+ readyReject = (err) => {
279
+ if (!readySettled) {
280
+ readySettled = true;
281
+ reject(err);
282
+ }
283
+ };
284
+ });
285
+ ready.catch(() => {
286
+ });
287
+ const ownedSignalHandlers = /* @__PURE__ */ new Map();
288
+ const cleanupSignalHandlers = () => {
289
+ for (const [sig, handler] of ownedSignalHandlers.entries()) {
290
+ process.removeListener(sig, handler);
291
+ }
292
+ ownedSignalHandlers.clear();
293
+ };
294
+ const handleListenError = (error) => {
295
+ if (state === "stopping" || state === "stopped" || state === "failed") {
296
+ logger.error("Server error after terminal state:", error);
297
+ return;
298
+ }
299
+ if (state === "initializing") {
300
+ state = "failed";
301
+ cleanupSignalHandlers();
302
+ readyReject(error);
303
+ if (options.onError) {
304
+ try {
305
+ options.onError(error);
306
+ } catch (e) {
307
+ logger.error(e);
308
+ }
309
+ } else {
310
+ try {
311
+ defaultOnError(error, port, logger);
312
+ } catch (e) {
313
+ logger.error(e);
314
+ }
315
+ }
316
+ try {
317
+ server.close();
318
+ } catch (_err) {
319
+ void _err;
320
+ }
321
+ return;
322
+ }
186
323
  if (options.onError) {
187
- options.onError(error);
324
+ try {
325
+ options.onError(error);
326
+ } catch (e) {
327
+ logger.error(e);
328
+ }
188
329
  } else {
189
- defaultOnError(error, port, logger);
330
+ try {
331
+ defaultOnError(error, port, logger);
332
+ } catch (e) {
333
+ logger.error(e);
334
+ }
190
335
  }
191
336
  };
192
337
  const onListening = () => {
338
+ if (state === "stopping" || state === "stopped" || state === "failed") {
339
+ try {
340
+ server.close();
341
+ } catch (_err) {
342
+ void _err;
343
+ }
344
+ return;
345
+ }
346
+ state = "listening";
193
347
  const addr = server.address();
194
348
  const bind = typeof addr === "string" ? `pipe ${addr}` : `port ${addr?.port}`;
195
- logger.log(`Server running at http://${host}:${port}/ (${bind})`);
196
- options.onListening?.();
349
+ const actualPort = typeof addr === "object" && addr !== null ? addr.port : port;
350
+ if (typeof addr === "object" && addr !== null) {
351
+ logger.log(`Server running at http://${host}:${actualPort}/ (${bind})`);
352
+ } else if (typeof addr === "string") {
353
+ logger.log(`Server running at pipe ${addr} (${bind})`);
354
+ } else {
355
+ logger.log(`Server running at http://${host}:${port}/ (${bind})`);
356
+ }
357
+ try {
358
+ options.onListening?.();
359
+ } catch (e) {
360
+ logger.error("onListening hook failed:", e);
361
+ }
362
+ readyResolve();
363
+ };
364
+ const handleClose = () => {
365
+ if (state === "stopping") {
366
+ return;
367
+ }
368
+ if (state === "listening") {
369
+ state = "stopped";
370
+ cleanupSignalHandlers();
371
+ return;
372
+ }
373
+ if (state === "initializing" && !readySettled) {
374
+ state = "stopped";
375
+ cleanupSignalHandlers();
376
+ readyReject(new Error("Server closed before listening"));
377
+ return;
378
+ }
379
+ if (state === "initializing") {
380
+ state = "stopped";
381
+ cleanupSignalHandlers();
382
+ }
197
383
  };
198
- server.on("error", onError);
384
+ server.on("error", handleListenError);
199
385
  server.on("listening", onListening);
200
- const shutdown = async () => {
386
+ server.on("close", handleClose);
387
+ const doShutdown = async () => {
388
+ if (state === "stopping" || state === "stopped") {
389
+ return;
390
+ }
391
+ if (state === "failed") {
392
+ state = "stopped";
393
+ cleanupSignalHandlers();
394
+ return;
395
+ }
396
+ state = "stopping";
397
+ if (!readySettled) {
398
+ readyReject(new Error("Server shutdown before listening"));
399
+ }
400
+ cleanupSignalHandlers();
201
401
  logger.log("Shutting down...");
402
+ await new Promise((resolve) => {
403
+ if (!server.listening) {
404
+ resolve();
405
+ return;
406
+ }
407
+ let settled = false;
408
+ const done = () => {
409
+ if (settled) return;
410
+ settled = true;
411
+ clearTimeout(timer);
412
+ resolve();
413
+ };
414
+ const timer = setTimeout(() => {
415
+ try {
416
+ server.closeAllConnections?.();
417
+ } catch (_err) {
418
+ void _err;
419
+ }
420
+ done();
421
+ }, shutdownTimeout);
422
+ timer.unref?.();
423
+ try {
424
+ server.close((err) => {
425
+ if (err) {
426
+ logger.error("Server close error:", err);
427
+ }
428
+ done();
429
+ });
430
+ } catch (err) {
431
+ logger.error("Server close error:", err);
432
+ done();
433
+ }
434
+ });
435
+ let shutdownError;
202
436
  try {
203
437
  if (options.onShutdown) {
204
438
  await options.onShutdown();
205
439
  }
206
440
  } catch (err) {
207
441
  logger.error("onShutdown hook failed:", err);
442
+ shutdownError = err;
208
443
  }
209
- await new Promise((resolve) => {
210
- const timer = setTimeout(() => {
211
- server.closeAllConnections?.();
212
- resolve();
213
- }, shutdownTimeout);
214
- server.close((err) => {
215
- clearTimeout(timer);
216
- if (err) {
217
- logger.error("Server close error:", err);
218
- }
219
- resolve();
220
- });
221
- });
444
+ if (shutdownError) {
445
+ state = "failed";
446
+ if (options.exitAfterShutdown) {
447
+ process.exit(1);
448
+ }
449
+ throw shutdownError;
450
+ }
451
+ state = "stopped";
222
452
  if (options.exitAfterShutdown) {
223
453
  process.exit(0);
224
454
  }
225
455
  };
456
+ const shutdown = () => {
457
+ if (shutdownPromise) return shutdownPromise;
458
+ shutdownPromise = doShutdown();
459
+ return shutdownPromise;
460
+ };
226
461
  if (options.signals !== false) {
227
462
  const list = options.signals === void 0 || options.signals === true ? DEFAULT_SIGNALS : options.signals;
228
463
  for (const sig of list) {
229
- process.once(sig, () => void shutdown());
464
+ const handler = () => {
465
+ void shutdown().catch(() => {
466
+ });
467
+ };
468
+ ownedSignalHandlers.set(sig, handler);
469
+ process.once(sig, handler);
230
470
  }
231
471
  }
232
472
  const start = async () => {
@@ -234,19 +474,44 @@ function startLocalServer(app, options = {}) {
234
474
  if (options.init) {
235
475
  await options.init();
236
476
  }
477
+ if (state === "stopping" || state === "stopped" || state === "failed") {
478
+ return;
479
+ }
237
480
  if (typeof port === "number") {
238
481
  server.listen(port, host);
239
482
  } else {
240
483
  server.listen(port);
241
484
  }
242
485
  } catch (err) {
243
- server.emit("error", err);
486
+ if (state === "stopping" || state === "stopped") {
487
+ logger.error("Init failed after shutdown started:", err);
488
+ return;
489
+ }
490
+ state = "failed";
491
+ cleanupSignalHandlers();
492
+ const error = err;
493
+ readyReject(error);
494
+ if (options.onError) {
495
+ try {
496
+ options.onError(error);
497
+ } catch (e) {
498
+ logger.error(e);
499
+ }
500
+ } else {
501
+ logger.error("Init failed:", error);
502
+ }
503
+ try {
504
+ server.close();
505
+ } catch (_err) {
506
+ void _err;
507
+ }
244
508
  }
245
509
  };
246
510
  void start();
247
511
  return {
248
512
  server,
249
- shutdown
513
+ shutdown,
514
+ ready
250
515
  };
251
516
  }
252
517
  // Annotate the CommonJS export names for ESM import in node:
@@ -255,5 +520,7 @@ function startLocalServer(app, options = {}) {
255
520
  createServerlessHandler,
256
521
  defaultRequestHook,
257
522
  normalizePort,
258
- startLocalServer
523
+ parsePortValue,
524
+ startLocalServer,
525
+ validateFiniteInteger
259
526
  });
package/index.mjs CHANGED
@@ -3,12 +3,16 @@ import {
3
3
  createServerlessHandler,
4
4
  defaultRequestHook,
5
5
  normalizePort,
6
- startLocalServer
7
- } from "./chunk-SOW7OLVN.mjs";
6
+ parsePortValue,
7
+ startLocalServer,
8
+ validateFiniteInteger
9
+ } from "./chunk-VPFBKM2K.mjs";
8
10
  export {
9
11
  createExpressApp,
10
12
  createServerlessHandler,
11
13
  defaultRequestHook,
12
14
  normalizePort,
13
- startLocalServer
15
+ parsePortValue,
16
+ startLocalServer,
17
+ validateFiniteInteger
14
18
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-runtime",
3
3
  "description": "Express app factory plus serverless handler and local dev server helpers",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-runtime",
5
- "version": "0.40.1",
5
+ "version": "0.42.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -20,13 +20,21 @@
20
20
  },
21
21
  "exports": {
22
22
  ".": {
23
- "types": "./index.d.ts",
23
+ "types": {
24
+ "import": "./index.d.mts",
25
+ "require": "./index.d.ts",
26
+ "default": "./index.d.ts"
27
+ },
24
28
  "import": "./index.mjs",
25
29
  "require": "./index.js",
26
30
  "default": "./index.js"
27
31
  },
28
32
  "./cli": {
29
- "types": "./cli-api.d.ts",
33
+ "types": {
34
+ "import": "./cli-api.d.mts",
35
+ "require": "./cli-api.d.ts",
36
+ "default": "./cli-api.d.ts"
37
+ },
30
38
  "import": "./cli-api.mjs",
31
39
  "require": "./cli-api.js",
32
40
  "default": "./cli-api.js"
@@ -40,6 +48,7 @@
40
48
  "tsup": "^8.5.1"
41
49
  },
42
50
  "peerDependencies": {
51
+ "@types/express": "^5.0.0",
43
52
  "express": ">=5.0.0"
44
53
  },
45
54
  "files": [