tina4-nodejs 3.13.95 → 3.13.97
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/CLAUDE.md +3 -4
- package/package.json +2 -1
- package/packages/cli/dist/bin.js +708 -1012
- package/packages/core/dist/index.js +588 -893
- package/packages/core/public/css/tina4.min.css +1 -1
- package/packages/core/src/index.ts +1 -3
- package/packages/core/src/messenger.ts +288 -96
- package/packages/core/src/queueBackends/kafkaBackend.ts +23 -2
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +29 -17
- package/packages/core/src/request.ts +28 -7
- package/packages/core/src/server.ts +135 -7
- package/packages/core/src/session.ts +8 -1
- package/packages/orm/dist/index.js +639 -944
- package/packages/orm/src/autoCrud.ts +12 -10
- package/packages/orm/src/database.ts +62 -58
- package/packages/orm/src/databaseResult.ts +44 -73
- package/packages/orm/src/index.ts +0 -3
- package/packages/orm/src/migration.ts +26 -8
- package/packages/orm/src/model.ts +4 -0
- package/packages/orm/src/queryBuilder.ts +12 -5
- package/packages/orm/src/types.ts +7 -74
- package/packages/swagger/dist/index.js +78 -20
- package/packages/swagger/src/generator.ts +172 -29
- package/types/core/src/index.d.ts +1 -3
- package/types/core/src/messenger.d.ts +45 -4
- package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +2 -1
- package/types/core/src/server.d.ts +0 -4
- package/types/core/src/session.d.ts +7 -0
- package/types/orm/src/database.d.ts +34 -30
- package/types/orm/src/databaseResult.d.ts +26 -36
- package/types/orm/src/index.d.ts +1 -2
- package/types/orm/src/migration.d.ts +4 -3
- package/types/orm/src/types.d.ts +7 -34
- package/packages/core/src/scss.ts +0 -623
- package/types/core/src/scss.d.ts +0 -19
|
@@ -471,16 +471,8 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
471
471
|
process.stdout.write(String(msgCount));
|
|
472
472
|
closeConnection();
|
|
473
473
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const qBuf = Buffer.from(queueName, "utf-8");
|
|
477
|
-
const purgePayload = Buffer.alloc(4 + qBuf.length);
|
|
478
|
-
purgePayload.writeUInt16BE(0, 0);
|
|
479
|
-
purgePayload.writeUInt8(qBuf.length, 2);
|
|
480
|
-
qBuf.copy(purgePayload, 3);
|
|
481
|
-
purgePayload.writeUInt8(0, 3 + qBuf.length); // no-wait=false
|
|
482
|
-
sendMethod(1, 50, 30, purgePayload);
|
|
483
|
-
}
|
|
474
|
+
// No "purge" operation: clear()/purge() refuse by name (ADR-0022),
|
|
475
|
+
// so nothing ever sends Queue.Purge and the drain path is gone.
|
|
484
476
|
}
|
|
485
477
|
else if (classId === 60 && methodId === 71) {
|
|
486
478
|
// Basic.Get-Ok — message body will follow in content frames
|
|
@@ -491,11 +483,6 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
491
483
|
process.stdout.write("__EMPTY__");
|
|
492
484
|
closeConnection();
|
|
493
485
|
}
|
|
494
|
-
else if (classId === 50 && methodId === 31) {
|
|
495
|
-
// Queue.Purge-Ok
|
|
496
|
-
process.stdout.write("__PURGED__");
|
|
497
|
-
closeConnection();
|
|
498
|
-
}
|
|
499
486
|
else if (classId === 10 && methodId === 50) {
|
|
500
487
|
// Connection.Close (server-initiated, e.g. a channel/protocol error)
|
|
501
488
|
// → send Connection.Close-Ok and exit non-zero so the caller sees the
|
|
@@ -633,7 +620,32 @@ export class RabbitMQBackend implements QueueBackend {
|
|
|
633
620
|
return isNaN(num) ? 0 : num;
|
|
634
621
|
}
|
|
635
622
|
|
|
636
|
-
clear(
|
|
637
|
-
|
|
623
|
+
clear(_queue: string): void {
|
|
624
|
+
// Not performable on RabbitMQ - throws naming the backend and the operation.
|
|
625
|
+
// clear() empties the queue, but RabbitMQ cannot address messages by status;
|
|
626
|
+
// the only thing it could do is queue.purge the WHOLE live queue. This used
|
|
627
|
+
// to do exactly that (execSync("purge", queue)), silently destroying every
|
|
628
|
+
// pending job. Draining a live broker on a status-addressed clear is data
|
|
629
|
+
// loss (ADR-0022 invariant 6), so it refuses by name instead. PHP, Python
|
|
630
|
+
// and Ruby already refuse; this brings the Node backend class in line.
|
|
631
|
+
throw new Error(
|
|
632
|
+
"The rabbitmq queue backend cannot perform clear(): RabbitMQ cannot " +
|
|
633
|
+
"address messages by status (basic.get pops the head of the queue), so " +
|
|
634
|
+
"a status-addressed clear would have to drain the entire live queue and " +
|
|
635
|
+
"destroy pending work. Use the file or mongodb backend.",
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
purge(_queue: string, _status?: string): number {
|
|
640
|
+
// Not performable on RabbitMQ - throws naming the backend and the operation.
|
|
641
|
+
// purge(status) removes jobs SELECTED BY STATUS; RabbitMQ has no status
|
|
642
|
+
// concept and could only drain the whole live queue. Refusing by name is
|
|
643
|
+
// the honest answer.
|
|
644
|
+
throw new Error(
|
|
645
|
+
"The rabbitmq queue backend cannot perform purge(): RabbitMQ cannot " +
|
|
646
|
+
"address messages by status (basic.get pops the head of the queue), so " +
|
|
647
|
+
"a status-addressed purge would have to drain the entire live queue and " +
|
|
648
|
+
"destroy pending work. Use the file or mongodb backend.",
|
|
649
|
+
);
|
|
638
650
|
}
|
|
639
651
|
}
|
|
@@ -160,19 +160,40 @@ async function parseBody(req: Tina4Request): Promise<void> {
|
|
|
160
160
|
const chunks: Buffer[] = [];
|
|
161
161
|
|
|
162
162
|
await new Promise<void>((resolve, reject) => {
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
// A RUNNING cap, checked per chunk.
|
|
164
|
+
//
|
|
165
|
+
// The content-length check above only sees what the client DECLARES. A
|
|
166
|
+
// chunked request declares nothing, so declaredLength is 0 and it sails
|
|
167
|
+
// through. Without this counter the body is buffered in full and only
|
|
168
|
+
// then measured, which means the limit cannot prevent the thing it exists
|
|
169
|
+
// to prevent. Measured against a 1MB limit: a 40MB chunked POST with no
|
|
170
|
+
// content-length was accepted whole and grew the server's RSS by exactly
|
|
171
|
+
// 40.0MB before it was refused.
|
|
172
|
+
//
|
|
173
|
+
// Same defect as PHP's unbounded read buffer, and the same fix: stop at
|
|
174
|
+
// the limit instead of measuring the damage afterwards.
|
|
175
|
+
let received = 0;
|
|
176
|
+
let refused = false;
|
|
177
|
+
req.on("data", (chunk: Buffer) => {
|
|
178
|
+
if (refused) return;
|
|
179
|
+
received += chunk.length;
|
|
180
|
+
if (received > TINA4_MAX_UPLOAD_SIZE) {
|
|
181
|
+
refused = true;
|
|
182
|
+
chunks.length = 0; // drop what we have; the request is dead
|
|
183
|
+
reject(new PayloadTooLargeError(received, TINA4_MAX_UPLOAD_SIZE));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
chunks.push(chunk);
|
|
187
|
+
});
|
|
188
|
+
req.on("end", () => {
|
|
189
|
+
if (!refused) resolve();
|
|
190
|
+
});
|
|
165
191
|
req.on("error", reject);
|
|
166
192
|
});
|
|
167
193
|
|
|
168
194
|
const raw = Buffer.concat(chunks);
|
|
169
195
|
if (raw.length === 0) return;
|
|
170
196
|
|
|
171
|
-
// Check actual body size against upload size limit
|
|
172
|
-
if (raw.length > TINA4_MAX_UPLOAD_SIZE) {
|
|
173
|
-
throw new PayloadTooLargeError(raw.length, TINA4_MAX_UPLOAD_SIZE);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
197
|
if (contentType.includes("multipart/form-data")) {
|
|
177
198
|
const boundary = extractBoundary(contentType);
|
|
178
199
|
if (boundary) {
|
|
@@ -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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
@@ -711,10 +711,17 @@ export class Session {
|
|
|
711
711
|
*
|
|
712
712
|
* session.flash("message", "Saved!") // set
|
|
713
713
|
* session.flash("message") // get + auto-remove → "Saved!"
|
|
714
|
+
* session.flash("message", null) // get + auto-remove (null is a GET sentinel)
|
|
715
|
+
*
|
|
716
|
+
* `null` — NOT just `undefined` — is the GET sentinel, so `flash(key, null)`
|
|
717
|
+
* READS and clears rather than STORING null. This matches the Python master
|
|
718
|
+
* (`if value is not None`), PHP (`if ($value !== null)`) and Ruby
|
|
719
|
+
* (`if value.nil?`): passing the language's "no value" literal means GET. A
|
|
720
|
+
* caller wanting to persist an explicit null should store it with `set()`.
|
|
714
721
|
*/
|
|
715
722
|
flash(key: string, value?: unknown): unknown {
|
|
716
723
|
const flashKey = `${FLASH_PREFIX}${key}`;
|
|
717
|
-
if (value !== undefined) {
|
|
724
|
+
if (value !== undefined && value !== null) {
|
|
718
725
|
// Set mode
|
|
719
726
|
this.set(flashKey, value);
|
|
720
727
|
return undefined;
|