tina4-nodejs 3.13.95 → 3.13.96

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.
@@ -378,9 +378,29 @@ function openBrowser(url: string) {
378
378
  * for backwards compatibility.
379
379
  */
380
380
  export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } {
381
- const port = config?.port
382
- ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined)
383
- ?? 7148;
381
+ // Port: explicit config > TINA4_PORT > PORT (deprecated) > default.
382
+ //
383
+ // This read PORT and nothing else, so TINA4_PORT - the name the CLI
384
+ // documents and prefers, and the one devAdmin.ts itself reads first - was
385
+ // IGNORED on the path that binds the socket. Setting it did nothing and said
386
+ // nothing.
387
+ //
388
+ // Bare PORT stays honoured so no deployment breaks, and warns so the
389
+ // migration happens. Removal is 3.14.
390
+ const tina4Port = process.env.TINA4_PORT;
391
+ const legacyPort = process.env.PORT;
392
+ let port: number;
393
+ if (config?.port !== undefined) {
394
+ port = config.port;
395
+ } else if (tina4Port && /^\d+$/.test(tina4Port)) {
396
+ port = parseInt(tina4Port, 10);
397
+ } else if (legacyPort && /^\d+$/.test(legacyPort)) {
398
+ port = parseInt(legacyPort, 10);
399
+ warnDeprecatedPort(port);
400
+ } else {
401
+ port = 7148;
402
+ }
403
+
384
404
  const host = config?.host
385
405
  ?? process.env.TINA4_HOST
386
406
  ?? process.env.HOST
@@ -388,6 +408,22 @@ export function resolvePortAndHost(config?: { port?: number; host?: string }): {
388
408
  return { port, host };
389
409
  }
390
410
 
411
+ /**
412
+ * Warn ONCE that bare PORT was used instead of TINA4_PORT.
413
+ *
414
+ * Once, because resolvePortAndHost can be called more than once per process
415
+ * and a warning repeated on every call is a warning people filter out.
416
+ */
417
+ let portDeprecationWarned = false;
418
+ function warnDeprecatedPort(port: number): void {
419
+ if (portDeprecationWarned) return;
420
+ portDeprecationWarned = true;
421
+ Log.warning(
422
+ `PORT is deprecated and will be removed in 3.14 - use TINA4_PORT instead ` +
423
+ `(binding port ${port} from PORT)`,
424
+ );
425
+ }
426
+
391
427
  /**
392
428
  * Whether the boot banner should be suppressed. Set TINA4_SUPPRESS=true to
393
429
  * silence the ASCII-art banner and route table on startup — useful in CI,
@@ -795,6 +831,67 @@ let _serverHandle: { close: () => void; router: Router; port: number } | null =
795
831
  * Start the Tina4 HTTP server.
796
832
  * Thin wrapper around startServer() for cross-framework parity with PHP and Ruby.
797
833
  */
834
+ /**
835
+ * Watch for a handler that occupies the event loop, and say so.
836
+ *
837
+ * Node runs ONE loop. An `await`ing handler yields it and blocks nobody -
838
+ * measured, /fast answers in 0.030s while a route awaits a 2s timer. A
839
+ * CPU-BOUND handler does not yield, and everything else waits: the same /fast
840
+ * took 1.575s during a 2s busy loop.
841
+ *
842
+ * That is inherent to a single-loop runtime, not a bug to engineer away. PHP
843
+ * fixed its equivalent by forking per request because `sleep()` is the obvious
844
+ * thing to write there and it blocks; in JavaScript the obvious thing is
845
+ * `await`, which does not. So the exposure here is narrower - CPU-bound work
846
+ * and synchronous I/O - and the honest fix is to make it VISIBLE rather than
847
+ * to move handlers onto threads a closure cannot cross.
848
+ *
849
+ * The mechanism is loop lag: a timer set for TICK_MS fires late by however
850
+ * long the loop was blocked. If that lateness passes the threshold, something
851
+ * held the loop and the developer wants to know which.
852
+ *
853
+ * A 100ms repeating timer is the classic way to pin a process open forever, so
854
+ * there are two guards against it: close() stops the timer, and the timer is
855
+ * unref'd. Measured: either one alone is enough, and the signal path exits
856
+ * regardless of both. They are kept together because they cost nothing and
857
+ * cover different exits - close() covers the in-process handle, unref() covers
858
+ * a path that never reaches close() at all.
859
+ */
860
+ const LOOP_WATCHDOG_TICK_MS = 100;
861
+
862
+ function startLoopWatchdog(): { stop: () => void } {
863
+ const raw = (process.env.TINA4_LOOP_LAG_WARN_MS ?? "").trim();
864
+ // 0 or a negative value disables it; a non-numeric value falls to the
865
+ // default rather than silently disabling a diagnostic.
866
+ const threshold = /^\d+$/.test(raw) ? parseInt(raw, 10) : 250;
867
+ if (threshold <= 0) {
868
+ return { stop: () => {} };
869
+ }
870
+
871
+ let last = Date.now();
872
+ let warned = 0;
873
+ const timer = setInterval(() => {
874
+ const now = Date.now();
875
+ const lag = now - last - LOOP_WATCHDOG_TICK_MS;
876
+ last = now;
877
+ if (lag < threshold) return;
878
+
879
+ // Rate-limited: a handler that blocks on every request would otherwise
880
+ // produce a wall of identical warnings, which people filter out.
881
+ warned++;
882
+ if (warned > 5 && warned % 20 !== 0) return;
883
+ Log.warning(
884
+ `Event loop blocked for ${lag}ms. Node serves every request on one loop, ` +
885
+ `so a handler doing CPU-bound work or synchronous I/O stalls all the ` +
886
+ `others for that long. Move the work to Tina4's queue, or await it. ` +
887
+ `Set TINA4_LOOP_LAG_WARN_MS to change the ${threshold}ms threshold, or 0 to silence.`,
888
+ );
889
+ }, LOOP_WATCHDOG_TICK_MS);
890
+ timer.unref();
891
+
892
+ return { stop: () => clearInterval(timer) };
893
+ }
894
+
798
895
  export async function start(config?: Tina4Config): Promise<{ close: () => void; router: Router; port: number }> {
799
896
  const isManaged = process.argv.includes('--managed');
800
897
  if (!isManaged && process.env.TINA4_OVERRIDE_CLIENT !== 'true') {
@@ -1390,8 +1487,18 @@ export async function startServer(config?: Tina4Config): Promise<{
1390
1487
  const host = resolved.host;
1391
1488
  let port = resolved.port;
1392
1489
 
1393
- // Claim the requested port — kill whatever is on it if needed
1394
- port = findAvailablePort(port);
1490
+ // Claim the requested port — kill whatever is on it if needed.
1491
+ //
1492
+ // NOT in a cluster worker. A worker does not own the port: the primary binds
1493
+ // it once and hands the handle down through cluster's IPC. A worker running
1494
+ // this finds the port "in use" (the primary is holding it) and KILLS the
1495
+ // process holding it, which is its own parent. Every worker did that, then
1496
+ // died itself with `write EPIPE` from cluster._getServer because the primary
1497
+ // it needed to ask for the socket was gone. Cluster mode never served a
1498
+ // single request.
1499
+ if (!cluster.isWorker) {
1500
+ port = findAvailablePort(port);
1501
+ }
1395
1502
 
1396
1503
  // Cluster mode for production: fork workers based on CPU count
1397
1504
  // Only when --production is explicitly set (via TINA4_PRODUCTION env var)
@@ -1703,8 +1810,26 @@ ${reset}
1703
1810
  await middleware.run(req, res);
1704
1811
  if (res.raw.writableEnded) return;
1705
1812
 
1706
- // Parse request body
1707
- await req.parseBody();
1813
+ // Parse request body.
1814
+ //
1815
+ // A body that breaks a documented limit is the client's error, not the
1816
+ // server's. PayloadTooLargeError already carried `statusCode = 413` and
1817
+ // nothing read it, so an oversized upload answered 500 - which tells the
1818
+ // caller to retry the exact request that will fail again.
1819
+ try {
1820
+ await req.parseBody();
1821
+ } catch (err) {
1822
+ const status = (err as { statusCode?: number })?.statusCode;
1823
+ if (typeof status === "number" && status >= 400 && status < 500) {
1824
+ if (!rawRes.writableEnded) {
1825
+ rawRes.statusCode = status;
1826
+ rawRes.setHeader("content-type", "application/json");
1827
+ rawRes.end(JSON.stringify({ error: (err as Error).message }));
1828
+ }
1829
+ return;
1830
+ }
1831
+ throw err;
1832
+ }
1708
1833
 
1709
1834
  const pathname = req.path;
1710
1835
 
@@ -2043,8 +2168,11 @@ ${reset}
2043
2168
  process.on("SIGTERM", onSigterm);
2044
2169
  process.on("SIGINT", onSigint);
2045
2170
 
2171
+ const loopWatchdog = startLoopWatchdog();
2172
+
2046
2173
  resolvePromise({
2047
2174
  close: () => {
2175
+ loopWatchdog.stop();
2048
2176
  // An explicit close() is not a signal shutdown: drop the handlers so
2049
2177
  // a test that starts many servers in one process does not pile up
2050
2178
  // listeners (and trip Node's MaxListeners warning).