tina4-nodejs 3.13.38 → 3.13.39
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 +16 -3
- package/README.md +6 -6
- package/package.json +1 -1
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/devAdmin.ts +36 -19
- package/packages/core/src/index.ts +8 -3
- package/packages/core/src/logger.ts +84 -27
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/metrics.ts +330 -70
- package/packages/core/src/middleware.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +97 -0
- package/packages/core/src/router.ts +54 -6
- package/packages/core/src/server.ts +113 -20
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +419 -9
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/orm/src/baseModel.ts +167 -22
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/migration.ts +149 -22
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
package/CLAUDE.md
CHANGED
|
@@ -120,11 +120,11 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
|
|
|
120
120
|
- `auth.ts` — Authentication helpers
|
|
121
121
|
- `cache.ts` — In-memory caching
|
|
122
122
|
- `session.ts` — Session management with pluggable handlers. `TINA4_SESSION_SAMESITE` env var (default: Lax)
|
|
123
|
-
- `websocket.ts` — WebSocket support with backplane for scaling via Redis/NATS pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`). The backplane is wired for real: `broadcast`/`broadcastToRoom`/`sendTo` deliver to LOCAL connections first (resiliently — a dead/slow client is pruned, never aborting the loop) then publish an envelope `{src,kind,exclude,room,path,text|b64}` to the shared channel `tina4:ws` (identical wire shape across all 4 frameworks). The subscribe callback relays directly on the event loop with an origin guard (drop our own `src` echo — no double-delivery) and never re-publishes (no cluster loop); binary messages ride as base64. Security/ops knobs (all opt-in, non-breaking): `TINA4_WS_ALLOWED_ORIGINS` (comma-separated origin allow-list enforced on upgrade — empty/unset = allow all), `TINA4_WS_IDLE_TIMEOUT` (seconds; 0/unset disables the idle-connection reaper), `TINA4_WS_MAX_BACKLOG` (bytes; a slow client whose socket write backlog exceeds this is dropped rather than buffered without bound). Rooms API: `wss.joinRoom(clientId, room)`, `wss.leaveRoom(clientId, room)`, `wss.broadcastToRoom(room, msg, excludeIds?)`, `wss.getRoomConnections(room)`, `wss.roomCount(room)`, `wss.getClientRooms(clientId)`. `originAllowed(headers)` is exported for upgrade-path checks.
|
|
123
|
+
- `websocket.ts` — WebSocket support with backplane for scaling via Redis/NATS pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`). The backplane is wired for real: `broadcast`/`broadcastToRoom`/`sendTo` deliver to LOCAL connections first (resiliently — a dead/slow client is pruned, never aborting the loop) then publish an envelope `{src,kind,exclude,room,path,text|b64}` to the shared channel `tina4:ws` (identical wire shape across all 4 frameworks). The subscribe callback relays directly on the event loop with an origin guard (drop our own `src` echo — no double-delivery) and never re-publishes (no cluster loop); binary messages ride as base64. Security/ops knobs (all opt-in, non-breaking): `TINA4_WS_ALLOWED_ORIGINS` (comma-separated origin allow-list enforced on upgrade — empty/unset = allow all), `TINA4_WS_IDLE_TIMEOUT` (seconds; 0/unset disables the idle-connection reaper), `TINA4_WS_MAX_BACKLOG` (bytes; a slow client whose socket write backlog exceeds this is dropped rather than buffered without bound). Rooms API: `wss.joinRoom(clientId, room)`, `wss.leaveRoom(clientId, room)`, `wss.broadcastToRoom(room, msg, excludeIds?)`, `wss.getRoomConnections(room)`, `wss.roomCount(room)`, `wss.getClientRooms(clientId)`. `originAllowed(headers)` is exported for upgrade-path checks. **Per-route auth (v3.13.39, Python master b5976d4):** a WS route is PUBLIC by default (mirrors GET). Mark it secured imperatively — `Router.websocket(path, fn, { secured: true })` / chain `.secure()` on the returned `WsRouteRef` / `WebSocketServer.route(path, fn, { secured: true })` — OR decorator-style via a `_secured` flag on the handler (works in either declaration order). A secured route enforces a valid JWT on the upgrade, AFTER the origin allow-list and BEFORE accept: missing/invalid → reject with HTTP 401 (never accept); public routes always pass (non-breaking). Three token transports (`wsToken(headers, query, subprotocol)`): (1) `Authorization: Bearer <jwt>` header (server/CLI/mobile); (2) the `Sec-WebSocket-Protocol` subprotocol `"bearer, <jwt>"` (browsers — `new WebSocket()` can't set headers; the server echoes `bearer` back as the accepted subprotocol); (3) `?token=<jwt>` query param. Validated via the same `validToken` (Auth) the HTTP routes use. The verified payload is exposed as `connection.auth` (`null` on public routes). `wsAuthorized(route, headers, query, subprotocol) -> [payload, ok]` is the gate. **The integrated server (`server.ts`) now dispatches user WS routes:** its `upgrade` handler routes a non-`/__dev_reload` upgrade through `serveWebSocketRoute(req, socket, head)`, which matches the WS route table, enforces per-route auth, then drives the real open/message/close lifecycle on the connection (`wsRouteManager`) — parity with Python/PHP/Ruby (previously only `/__dev_reload` was wired, so user WS routes never reached a live connection).
|
|
124
124
|
- `queue.ts` — Queue system with pluggable backends
|
|
125
125
|
- `graphql.ts` — GraphQL engine. **Hardening:** selection-set nesting is bounded by `TINA4_GRAPHQL_MAX_DEPTH` (default `50`; `<= 0` disables; exposed as the public `gql.maxDepth` field + `graphqlMaxDepth()` helper). Depth increments on every recursive entry — sub-selections, fragment spreads, AND inline fragments — so an over-deep query or a circular fragment fails with `Query exceeds maximum depth of N` instead of overflowing the stack (top-level starts at depth 1). A resolver exception is logged via `Log.error` and the detail is surfaced to the client **only** under `TINA4_DEBUG` (`isDebugMode()`); otherwise it returns a generic `Internal server error` (path preserved) so internal state never leaks.
|
|
126
126
|
- `i18n.ts` — Internationalization / localization
|
|
127
|
-
- `logger.ts` — Structured logging
|
|
127
|
+
- `logger.ts` — Structured logging. Five first-class severity levels: `debug`(0) < `info`(1) < `warning`(2) < `error`(3) < `critical`(4). `critical` is the HIGHEST level, NOT a relabelled `error`, and renders magenta. `Log.critical()` ALWAYS emits like every other level — subject only to `TINA4_LOG_LEVEL` (which it always clears) and teed to the log file whenever a file is being written. There is NO enable toggle: the old `TINA4_LOG_CRITICAL` opt-in was retired in v3.13.39 (the env var is no longer read), so a critical log is never a silent no-op. `Log.isEnabled("critical")` is ordinary threshold logic (`4 >= configured min`). **Dev/prod-aware default file output (v3.13.39, Python master 4c6d881):** stdout is ALWAYS on. When `TINA4_LOG_OUTPUT` is unset (default), the log FILE (`logs/tina4.log`) is written ONLY in development (`TINA4_DEBUG` truthy); in production / containers (`TINA4_DEBUG` falsy) the logger is stdout-only — no file to bloat the writable layer / disk (12-factor: logs on stdout for the platform to capture). Explicit `TINA4_LOG_OUTPUT=file`/`both`, OR an explicit `TINA4_LOG_FILE` path, always forces a file (explicit wins). `readEnv()` resolves all of this into a single `fileEnabled` flag that gates the file writer. Full parity with Python master.
|
|
128
128
|
- `rateLimiter.ts` — Rate limiting middleware
|
|
129
129
|
- `dotenv.ts` — `.env` file loading
|
|
130
130
|
- `health.ts` — Health check endpoint
|
|
@@ -775,6 +775,8 @@ const adults = User.query().where("age > ?", [18]).orderBy("name").get();
|
|
|
775
775
|
|
|
776
776
|
SQL-file based migrations under `migrations/`. The framework runs pending migrations on startup; the helpers here are for programmatic control (CLI, scripts, tests).
|
|
777
777
|
|
|
778
|
+
**Auto-run on startup (`TINA4_AUTO_MIGRATE`, default on).** `startServer()` calls `autoMigrateOnStartup()` (in `server.ts`) AFTER `initDatabase()`/model sync and BEFORE the server listens. When a `migrations/` folder with at least one `.sql` file exists, `TINA4_AUTO_MIGRATE` is not falsy (default `"true"`; `false`/`0`/`no`/`off` disable), and a DB adapter is resolvable, it runs the existing `migrate()` runner so the schema is current with no manual `tina4 migrate` step. It is **non-breaking**: a failure is logged via `Log.error` and the service still starts (a bad migration must never take the backend down). The runner is wrapped in try/catch and `autoMigrateOnStartup()` never rejects/throws out of the boot path. Set `TINA4_AUTO_MIGRATE=false` to disable (e.g. multi-instance production that migrates as a separate deploy step — concurrent first-apply can race). The explicit `tina4 migrate` CLI (`packages/cli/src/commands/migrate.ts`) is unaffected and stays **fail-fast** (`process.exit(1)` on a statement error) so CI keeps the non-zero exit code.
|
|
779
|
+
|
|
778
780
|
```typescript
|
|
779
781
|
import {
|
|
780
782
|
migrate, rollback, status, createMigration, syncModels,
|
|
@@ -788,7 +790,16 @@ await createMigration("add users table"); // scaffolds migrations/<ts>_add_use
|
|
|
788
790
|
syncModels(discoveredModels); // auto-create tables / add columns from `static fields`
|
|
789
791
|
```
|
|
790
792
|
|
|
791
|
-
|
|
793
|
+
### How migrations work internally
|
|
794
|
+
|
|
795
|
+
- SQL files live in `migrations/`, named `NNNNNN_description.sql` (sequential) or `YYYYMMDDHHMMSS_description.sql` (timestamp), and are split on the `;` delimiter.
|
|
796
|
+
- Files are applied in **numeric-prefix order** (`9_` before `10_` — a plain lexical sort misorders unpadded prefixes because `"10" < "9"`). A file with no numeric/timestamp prefix sorts **after** the numbered ones (lexically) and logs a `Log.warning` — its order is undefined.
|
|
797
|
+
- State is tracked by **row existence** in `tina4_migration` (id, name, batch, applied_at), auto-created per engine: a migration runs once — if a row with its `name` exists it is skipped. A FAILED migration is **never** recorded (no row is written, nothing is deleted) — fix the bad file and re-run; the `migrate()` summary's `failed[]` carries the failure.
|
|
798
|
+
- **Each migration FILE is wrapped in its own transaction.** On a failure the file rolls back and `migrate()` **STOPS** — later files are never applied on top of a missing earlier one (parity with Python/PHP/Ruby). Already-applied files stay applied. The explicit `tina4 migrate` CLI surfaces a non-empty `failed[]` as a non-zero exit; startup auto-migration logs it and the service still boots (see `TINA4_AUTO_MIGRATE` above).
|
|
799
|
+
- **Atomicity caveat:** per-file transactions are truly atomic only on engines with **transactional DDL (PostgreSQL)**. MySQL, Firebird, and SQLite auto-commit DDL, so a multi-statement migration that fails midway on those engines leaves earlier statements applied — keep one logical change per file. `CREATE TABLE` and `ALTER TABLE ... ADD` are made idempotent on Firebird/MSSQL (existence-checked via `RDB$RELATION_FIELDS` / `tableExists`) so a re-run with a raw `CREATE`/`ADD` does not error "object already exists"; SQLite/MySQL/PostgreSQL support `IF NOT EXISTS` and are left to the engine. Only a genuine already-exists is skipped — every other error still raises.
|
|
800
|
+
- The stored-proc block delimiters (`$$ … $$` / `// … //`) are extracted before splitting, but a `//` preceded by a colon is **not** treated as a delimiter, so a URL (`https://…`) or any `://` literal inside a migration is never swallowed as an opaque block.
|
|
801
|
+
|
|
802
|
+
Schema sync (`syncModels`) runs alongside SQL migrations on boot.
|
|
792
803
|
|
|
793
804
|
## Module: Frond (`packages/frond/src/engine.ts`)
|
|
794
805
|
|
|
@@ -821,6 +832,8 @@ frond.unsandbox();
|
|
|
821
832
|
|
|
822
833
|
Zero-dep HTTP client over `node:http` / `node:https`. Used by integrations, queue producers, health checks, and tests.
|
|
823
834
|
|
|
835
|
+
**Retry/backoff (opt-in, default off):** pass `maxRetries` (default `0`) and `retryBackoff` (default `0.5`s base, exponential) in the options bag — `new Api(url, { bearerToken, maxRetries: 3, retryBackoff: 0.5 })`. Retries a transport error (`http_code` null) or a retryable status (429/500/502/503/504); 4xx is never retried (a retried non-idempotent request may be re-sent, so retries are opt-in). Node's `node:http`/`node:https` doesn't auto-follow redirects, so there's no cross-host Authorization-leak surface (the redirect auth-strip is Python-only).
|
|
836
|
+
|
|
824
837
|
```typescript
|
|
825
838
|
import { Api } from "@tina4/core";
|
|
826
839
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
## Quick Start
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
# With the Tina4 CLI (recommended
|
|
20
|
+
# With the Tina4 CLI (recommended, enables SCSS + live reload)
|
|
21
21
|
cargo install tina4 # or grab a binary from https://github.com/tina4stack/tina4/releases
|
|
22
22
|
tina4 init nodejs ./my-app
|
|
23
23
|
cd my-app && tina4 serve
|
|
@@ -57,12 +57,12 @@ export default class User {
|
|
|
57
57
|
| Category | Features |
|
|
58
58
|
|----------|----------|
|
|
59
59
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
60
|
-
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird
|
|
60
|
+
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
61
61
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
62
62
|
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
63
63
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
64
64
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
65
|
-
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters
|
|
65
|
+
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
66
66
|
| **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
|
|
67
67
|
| **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
|
|
68
68
|
| **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
|
|
@@ -86,14 +86,14 @@ npx tina4nodejs generate model <name>
|
|
|
86
86
|
|
|
87
87
|
## Performance
|
|
88
88
|
|
|
89
|
-
Benchmarked with `wrk
|
|
89
|
+
Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
90
90
|
|
|
91
91
|
| Framework | JSON req/s | Deps | Features |
|
|
92
92
|
|-----------|-----------|------|----------|
|
|
93
93
|
| Raw `node:http` | 91,110 | 0 | 1 |
|
|
94
94
|
| **Tina4 Node.js** | **84,771** | 0 | 55 |
|
|
95
95
|
|
|
96
|
-
Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in features
|
|
96
|
+
Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in features, a zero-overhead architecture.
|
|
97
97
|
|
|
98
98
|
**Across all 4 Tina4 implementations:**
|
|
99
99
|
|
|
@@ -107,7 +107,7 @@ Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in f
|
|
|
107
107
|
|
|
108
108
|
## Cross-Framework Parity
|
|
109
109
|
|
|
110
|
-
Tina4 ships identical features across four languages
|
|
110
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
|
|
111
111
|
|
|
112
112
|
| | Python | PHP | Ruby | Node.js |
|
|
113
113
|
|---|--------|-----|------|---------|
|
package/package.json
CHANGED
package/packages/core/src/api.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface ApiResult {
|
|
|
18
18
|
error: string | null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* HTTP statuses that warrant an automatic retry when `maxRetries` > 0:
|
|
23
|
+
* rate-limit (429) plus the transient server-side 5xx family. 4xx client
|
|
24
|
+
* errors (401, 404, …) are NOT retried — a repeat won't succeed.
|
|
25
|
+
*/
|
|
26
|
+
const RETRY_STATUSES: ReadonlySet<number> = new Set([429, 500, 502, 503, 504]);
|
|
27
|
+
|
|
21
28
|
/**
|
|
22
29
|
* Constructor options for {@link Api}. Used as the second argument to
|
|
23
30
|
* `new Api(url, { ... })` — cross-framework parity with Python
|
|
@@ -33,6 +40,16 @@ export interface ApiOptions {
|
|
|
33
40
|
username?: string;
|
|
34
41
|
password?: string;
|
|
35
42
|
headers?: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Maximum automatic retries on a transient failure (default 0 = off, so
|
|
45
|
+
* existing callers are unaffected). When > 0, a transport error or a
|
|
46
|
+
* retryable status (429/5xx) is retried up to this many times with
|
|
47
|
+
* exponential backoff. NOTE: a retried non-idempotent request (POST/…)
|
|
48
|
+
* may be re-sent — retries are opt-in for that reason.
|
|
49
|
+
*/
|
|
50
|
+
maxRetries?: number;
|
|
51
|
+
/** Base backoff in seconds, doubling each attempt (default 0.5). */
|
|
52
|
+
retryBackoff?: number;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
export class Api {
|
|
@@ -41,6 +58,8 @@ export class Api {
|
|
|
41
58
|
private timeout: number;
|
|
42
59
|
private authHeader: string;
|
|
43
60
|
private ignoreSsl: boolean;
|
|
61
|
+
private maxRetries: number;
|
|
62
|
+
private retryBackoff: number;
|
|
44
63
|
|
|
45
64
|
/**
|
|
46
65
|
* Construct an Api client.
|
|
@@ -60,6 +79,14 @@ export class Api {
|
|
|
60
79
|
* Bearer wins over basic-auth when both passed. `verifySsl: false` is
|
|
61
80
|
* the positive form of `ignoreSsl: true`; `ignoreSsl` wins when both
|
|
62
81
|
* supplied for backward compatibility.
|
|
82
|
+
*
|
|
83
|
+
* `maxRetries` (default 0 = off) enables automatic retry with
|
|
84
|
+
* exponential backoff (`retryBackoff` seconds base, doubling each
|
|
85
|
+
* attempt) on a transport error or a retryable status (429/5xx). A
|
|
86
|
+
* retried non-idempotent request (POST/…) may be re-sent — retries are
|
|
87
|
+
* opt-in for that reason.
|
|
88
|
+
*
|
|
89
|
+
* new Api("https://api.example.com", { maxRetries: 3, retryBackoff: 0.5 });
|
|
63
90
|
*/
|
|
64
91
|
constructor(
|
|
65
92
|
baseUrl: string = "",
|
|
@@ -68,6 +95,9 @@ export class Api {
|
|
|
68
95
|
) {
|
|
69
96
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
70
97
|
this.headers = {};
|
|
98
|
+
// Retry defaults: off (0) so existing callers are unaffected.
|
|
99
|
+
this.maxRetries = 0;
|
|
100
|
+
this.retryBackoff = 0.5;
|
|
71
101
|
|
|
72
102
|
// Options-bag form — second arg is an object literal
|
|
73
103
|
if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
|
|
@@ -75,6 +105,8 @@ export class Api {
|
|
|
75
105
|
this.authHeader = opts.authHeader ?? "";
|
|
76
106
|
this.timeout = opts.timeout ?? timeout;
|
|
77
107
|
this.ignoreSsl = (opts.ignoreSsl ?? false) || (opts.verifySsl === false);
|
|
108
|
+
this.maxRetries = Math.max(0, opts.maxRetries ?? 0);
|
|
109
|
+
this.retryBackoff = opts.retryBackoff ?? 0.5;
|
|
78
110
|
|
|
79
111
|
// Bearer wins over basic-auth when both are passed
|
|
80
112
|
if (opts.bearerToken != null) {
|
|
@@ -189,7 +221,38 @@ export class Api {
|
|
|
189
221
|
return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
|
|
190
222
|
}
|
|
191
223
|
|
|
192
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Execute the request with opt-in retry/backoff.
|
|
226
|
+
*
|
|
227
|
+
* With `maxRetries` > 0, a transport failure (`http_code` null) or a
|
|
228
|
+
* retryable status (429/5xx) is retried up to `maxRetries` times with
|
|
229
|
+
* exponential backoff; any other outcome (2xx, 3xx, other 4xx) returns
|
|
230
|
+
* at once. A retried non-idempotent request may be re-sent — retries
|
|
231
|
+
* are opt-in for that reason.
|
|
232
|
+
*/
|
|
233
|
+
private async execute(
|
|
234
|
+
method: string,
|
|
235
|
+
url: string,
|
|
236
|
+
body?: unknown,
|
|
237
|
+
contentType: string = "application/json",
|
|
238
|
+
): Promise<ApiResult> {
|
|
239
|
+
const attempts = this.maxRetries + 1;
|
|
240
|
+
let result: ApiResult = { http_code: null, body: null, headers: {}, error: null };
|
|
241
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
242
|
+
result = await this.attempt(method, url, body, contentType);
|
|
243
|
+
const code = result.http_code;
|
|
244
|
+
const retryable = code === null || RETRY_STATUSES.has(code);
|
|
245
|
+
if (!retryable || attempt === attempts - 1) {
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const delayMs = this.retryBackoff * Math.pow(2, attempt) * 1000;
|
|
249
|
+
await new Promise<void>((r) => setTimeout(r, delayMs));
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A single HTTP attempt — returns the standardized result. */
|
|
255
|
+
private attempt(
|
|
193
256
|
method: string,
|
|
194
257
|
url: string,
|
|
195
258
|
body?: unknown,
|
|
@@ -19,7 +19,7 @@ import { DevMailbox } from "./devMailbox.js";
|
|
|
19
19
|
import { isTruthy } from "./dotenv.js";
|
|
20
20
|
import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
|
|
21
21
|
import { registerFeedbackRoutes } from "./feedback.js";
|
|
22
|
-
import { getDefaultDevServer } from "./mcp.js";
|
|
22
|
+
import { getDefaultDevServer, mcpEnabled } from "./mcp.js";
|
|
23
23
|
|
|
24
24
|
const cpuCount = osCpus().length;
|
|
25
25
|
|
|
@@ -561,18 +561,6 @@ export class DevAdmin {
|
|
|
561
561
|
{ method: "POST", pattern: "/__dev/api/deps/install", handler: handleDepsInstall },
|
|
562
562
|
// Git status
|
|
563
563
|
{ method: "GET", pattern: "/__dev/api/git/status", handler: handleGitStatus },
|
|
564
|
-
// MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
|
|
565
|
-
{ method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
|
|
566
|
-
{ method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
|
|
567
|
-
// MCP JSON-RPC + SSE endpoints that REAL MCP clients (Claude Code/Desktop)
|
|
568
|
-
// speak. POST /__dev/mcp[/message] -> JSON-RPC handleMessage; GET
|
|
569
|
-
// /__dev/mcp/sse -> SSE handshake announcing the message endpoint. Mounted
|
|
570
|
-
// through the same dispatch as the REST shim above and gated by the same
|
|
571
|
-
// /__dev public-route rule. Mirrors the Python v3 fix (POST /__dev/mcp +
|
|
572
|
-
// /__dev/mcp/message, GET /__dev/mcp/sse).
|
|
573
|
-
{ method: "POST", pattern: "/__dev/mcp", handler: handleMcpMessage },
|
|
574
|
-
{ method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpMessage },
|
|
575
|
-
{ method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
|
|
576
564
|
// Scaffolding
|
|
577
565
|
{ method: "GET", pattern: "/__dev/api/scaffold", handler: handleScaffoldList },
|
|
578
566
|
{ method: "POST", pattern: "/__dev/api/scaffold/run", handler: handleScaffoldRun },
|
|
@@ -610,12 +598,41 @@ export class DevAdmin {
|
|
|
610
598
|
});
|
|
611
599
|
}
|
|
612
600
|
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
601
|
+
// MCP exposure is gated SEPARATELY from the rest of the dev dashboard.
|
|
602
|
+
// The MCP dev tools expose powerful operations (DB query, file read/WRITE,
|
|
603
|
+
// route listing), so they must NOT auto-expose on a non-localhost
|
|
604
|
+
// TINA4_DEBUG=true deployment. mcpEnabled() honours an explicit TINA4_MCP on
|
|
605
|
+
// any host, else requires TINA4_DEBUG AND (localhost OR TINA4_MCP_REMOTE) —
|
|
606
|
+
// full parity with Python master tina4_python.mcp.is_enabled(). When the
|
|
607
|
+
// gate is closed, neither the REST shim, the JSON-RPC/SSE endpoints, nor the
|
|
608
|
+
// default dev MCP server (with its dev tools) are registered.
|
|
609
|
+
if (mcpEnabled()) {
|
|
610
|
+
const mcpRoutes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
|
|
611
|
+
// MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
|
|
612
|
+
{ method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
|
|
613
|
+
{ method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
|
|
614
|
+
// MCP JSON-RPC + SSE endpoints that REAL MCP clients (Claude Code/Desktop)
|
|
615
|
+
// speak. POST /__dev/mcp[/message] -> JSON-RPC handleMessage; GET
|
|
616
|
+
// /__dev/mcp/sse -> SSE handshake announcing the message endpoint. Mirrors
|
|
617
|
+
// the Python v3 fix (POST /__dev/mcp + /__dev/mcp/message, GET /__dev/mcp/sse).
|
|
618
|
+
{ method: "POST", pattern: "/__dev/mcp", handler: handleMcpMessage },
|
|
619
|
+
{ method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpMessage },
|
|
620
|
+
{ method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
|
|
621
|
+
];
|
|
622
|
+
for (const route of mcpRoutes) {
|
|
623
|
+
router.addRoute({
|
|
624
|
+
method: route.method,
|
|
625
|
+
pattern: route.pattern,
|
|
626
|
+
handler: route.handler,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Ensure the default /__dev/mcp MCP server exists with its dev tools
|
|
631
|
+
// registered. This is the single shared instance behind both the REST shim
|
|
632
|
+
// and the JSON-RPC + SSE endpoints registered above, so tools/list and the
|
|
633
|
+
// REST shim return tools immediately, before any first call.
|
|
634
|
+
getDefaultDevServer();
|
|
635
|
+
}
|
|
619
636
|
}
|
|
620
637
|
|
|
621
638
|
/**
|
|
@@ -15,7 +15,7 @@ export type {
|
|
|
15
15
|
|
|
16
16
|
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
|
|
17
17
|
export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
|
|
18
|
-
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
|
|
18
|
+
export { Router, RouteGroup, RouteRef, WsRouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
|
|
19
19
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
20
20
|
export type { RouteInfo } from "./router.js";
|
|
21
21
|
export { discoverRoutes } from "./routeDiscovery.js";
|
|
@@ -64,6 +64,7 @@ export {
|
|
|
64
64
|
WebSocketServer,
|
|
65
65
|
devReloadWs,
|
|
66
66
|
computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame, originAllowed,
|
|
67
|
+
wsToken, wsAuthorized, offeredBearerSubprotocol, serveWebSocketRoute, wsRouteManager,
|
|
67
68
|
OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
|
|
68
69
|
CLOSE_NORMAL, CLOSE_GOING_AWAY, CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION,
|
|
69
70
|
} from "./websocket.js";
|
|
@@ -100,8 +101,12 @@ export type { ImapMessage, ImapFullMessage } from "./messenger.js";
|
|
|
100
101
|
export { LiteBackend } from "./queueBackends/liteBackend.js";
|
|
101
102
|
export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";
|
|
102
103
|
export type { RabbitMQConfig } from "./queueBackends/rabbitmqBackend.js";
|
|
103
|
-
export { KafkaBackend } from "./queueBackends/kafkaBackend.js";
|
|
104
|
-
export type {
|
|
104
|
+
export { KafkaBackend, kafkaSecurityConfig } from "./queueBackends/kafkaBackend.js";
|
|
105
|
+
export type {
|
|
106
|
+
KafkaConfig,
|
|
107
|
+
KafkaSecurityConfig,
|
|
108
|
+
KafkaClientConfig,
|
|
109
|
+
} from "./queueBackends/kafkaBackend.js";
|
|
105
110
|
export { MongoBackend } from "./queueBackends/mongoBackend.js";
|
|
106
111
|
export type { MongoConfig as MongoQueueConfig } from "./queueBackends/mongoBackend.js";
|
|
107
112
|
export { DatabaseSessionHandler } from "./sessionHandlers/databaseHandler.js";
|
|
@@ -136,18 +136,23 @@ function resolveLogFilePath(logDir: string, logFile: string): string {
|
|
|
136
136
|
/**
|
|
137
137
|
* Structured logger for Tina4.
|
|
138
138
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
139
|
+
* Development (TINA4_DEBUG=true): colorized human-readable to stdout + file.
|
|
140
|
+
* Production (TINA4_DEBUG not truthy): clean structured JSON to stdout ONLY —
|
|
141
|
+
* no log file by default (writing logs/tina4.log inside a container bloats the
|
|
142
|
+
* writable layer + disk; 12-factor wants logs on stdout). stdout is ALWAYS on.
|
|
143
|
+
*
|
|
144
|
+
* Default file-output rule (TINA4_LOG_OUTPUT unset): the log FILE is written
|
|
145
|
+
* only in development. An explicit TINA4_LOG_OUTPUT=file/both, OR an explicit
|
|
146
|
+
* TINA4_LOG_FILE path, always forces a file (explicit wins).
|
|
141
147
|
*
|
|
142
148
|
* Env vars:
|
|
143
|
-
* TINA4_LOG_FILE — explicit log file (absolute or relative). Empty = use TINA4_LOG_DIR + tina4.log
|
|
149
|
+
* TINA4_LOG_FILE — explicit log file (absolute or relative). Setting it forces a file even in production. Empty = use TINA4_LOG_DIR + tina4.log
|
|
144
150
|
* TINA4_LOG_DIR — directory for log files (default: "logs")
|
|
145
151
|
* TINA4_LOG_FORMAT — "text" | "json" (default: "text")
|
|
146
|
-
* TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout")
|
|
147
|
-
* TINA4_LOG_CRITICAL — "true" to enable CRITICAL level shortcut (default: "false")
|
|
152
|
+
* TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout" → file only in dev)
|
|
148
153
|
* TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
|
|
149
154
|
* TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
|
|
150
|
-
* TINA4_LOG_LEVEL — minimum console level (default: "
|
|
155
|
+
* TINA4_LOG_LEVEL — minimum console level: DEBUG | INFO | WARNING | ERROR | CRITICAL (default: "INFO")
|
|
151
156
|
*
|
|
152
157
|
* Rotation is stdlib roll-your-own:
|
|
153
158
|
* - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
|
|
@@ -171,10 +176,11 @@ export class Log {
|
|
|
171
176
|
minLevel: number;
|
|
172
177
|
format: "text" | "json";
|
|
173
178
|
output: "stdout" | "file" | "both";
|
|
174
|
-
|
|
179
|
+
fileEnabled: boolean;
|
|
175
180
|
} {
|
|
176
181
|
const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
|
|
177
|
-
const
|
|
182
|
+
const explicitFile = (process.env.TINA4_LOG_FILE ?? "").trim();
|
|
183
|
+
const logFile = explicitFile || DEFAULT_LOG_FILE;
|
|
178
184
|
|
|
179
185
|
const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
|
|
180
186
|
let rotateSize = DEFAULT_ROTATE_SIZE;
|
|
@@ -203,9 +209,57 @@ export class Log {
|
|
|
203
209
|
if (out === "file") output = "file";
|
|
204
210
|
else if (out === "both") output = "both";
|
|
205
211
|
|
|
206
|
-
|
|
212
|
+
// v3.13.39: dev/prod-aware default file output (Python master, 4c6d881).
|
|
213
|
+
// When TINA4_LOG_OUTPUT is unset (default "stdout"), the log FILE is written
|
|
214
|
+
// only in development (TINA4_DEBUG truthy). In production / containers the
|
|
215
|
+
// logger is stdout-only — writing logs/tina4.log inside a container just
|
|
216
|
+
// bloats the writable layer + disk, and 12-factor wants logs on stdout for
|
|
217
|
+
// the platform to capture. Explicit TINA4_LOG_OUTPUT=file/both OR an explicit
|
|
218
|
+
// TINA4_LOG_FILE path always wins (explicit forces a file regardless of env).
|
|
219
|
+
let fileEnabled: boolean;
|
|
220
|
+
if (output === "file" || output === "both") {
|
|
221
|
+
fileEnabled = true;
|
|
222
|
+
} else if (explicitFile !== "") {
|
|
223
|
+
fileEnabled = true;
|
|
224
|
+
} else {
|
|
225
|
+
fileEnabled = !Log.isProduction();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, fileEnabled };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The single console-threshold predicate: does a message at `level` clear
|
|
233
|
+
* the configured minimum console level? This is the ONE place level
|
|
234
|
+
* comparison lives — both the live log() gate and the public isEnabled()
|
|
235
|
+
* predicate call it, so they can never disagree about what actually prints.
|
|
236
|
+
*/
|
|
237
|
+
private static passesThreshold(level: LogLevel, minLevel: number): boolean {
|
|
238
|
+
return (LEVEL_PRIORITY[level] ?? 0) >= minLevel;
|
|
239
|
+
}
|
|
207
240
|
|
|
208
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Return true if a message at `level` would pass the configured minimum
|
|
243
|
+
* console level (TINA4_LOG_LEVEL) — the same threshold that gates stdout.
|
|
244
|
+
*
|
|
245
|
+
* This reflects CONSOLE (stdout) visibility only. The log file always
|
|
246
|
+
* records every level regardless of this threshold, so don't use it to
|
|
247
|
+
* decide whether something gets persisted — use it to skip building an
|
|
248
|
+
* expensive payload that would not be shown:
|
|
249
|
+
*
|
|
250
|
+
* if (Log.isEnabled("debug")) {
|
|
251
|
+
* Log.debug("state", expensiveSnapshot());
|
|
252
|
+
* }
|
|
253
|
+
*
|
|
254
|
+
* `level` is case-insensitive. "critical" is the highest severity (priority
|
|
255
|
+
* 4 > error 3) and flows through the ordinary threshold check like every
|
|
256
|
+
* other level — there is no toggle. It reuses the same passesThreshold()
|
|
257
|
+
* check the logger itself uses, so it never drifts from what print does.
|
|
258
|
+
*/
|
|
259
|
+
static isEnabled(level: string): boolean {
|
|
260
|
+
const cfg = Log.readEnv();
|
|
261
|
+
const lvl = (level ?? "").toUpperCase() as LogLevel;
|
|
262
|
+
return Log.passesThreshold(lvl, cfg.minLevel);
|
|
209
263
|
}
|
|
210
264
|
|
|
211
265
|
/**
|
|
@@ -257,9 +311,12 @@ export class Log {
|
|
|
257
311
|
}
|
|
258
312
|
|
|
259
313
|
/**
|
|
260
|
-
* Log a critical message.
|
|
261
|
-
*
|
|
262
|
-
*
|
|
314
|
+
* Log a critical message. CRITICAL is the highest severity (priority 4 >
|
|
315
|
+
* error 3) and ALWAYS emits like every other level — subject only to the
|
|
316
|
+
* console threshold, which it always passes at normal levels — and is always
|
|
317
|
+
* persisted to the log file (Node tees every level to a single tina4.log;
|
|
318
|
+
* critical 4 >= warning 2 so it would be in error.log on a split-file model).
|
|
319
|
+
* Matches Python master parity — there is no enable toggle.
|
|
263
320
|
*/
|
|
264
321
|
static critical(message: string, data?: unknown): void {
|
|
265
322
|
Log.log("CRITICAL", message, data);
|
|
@@ -355,9 +412,6 @@ export class Log {
|
|
|
355
412
|
private static log(level: LogLevel, message: string, data?: unknown): void {
|
|
356
413
|
const cfg = Log.readEnv();
|
|
357
414
|
|
|
358
|
-
// Critical level is opt-in; treat as no-op when disabled.
|
|
359
|
-
if (level === "CRITICAL" && !cfg.criticalEnabled) return;
|
|
360
|
-
|
|
361
415
|
const entry: LogEntry = {
|
|
362
416
|
timestamp: Log.timestamp(),
|
|
363
417
|
level,
|
|
@@ -393,7 +447,7 @@ export class Log {
|
|
|
393
447
|
const fileLine =
|
|
394
448
|
cfg.format === "json" || Log.isProduction() ? JSON.stringify(entry) : humanLine;
|
|
395
449
|
|
|
396
|
-
const shouldLog = (
|
|
450
|
+
const shouldLog = Log.passesThreshold(level, cfg.minLevel);
|
|
397
451
|
|
|
398
452
|
// Console output. v3.13.14: stdout is NOT suppressed in production —
|
|
399
453
|
// containers read PID 1 stdout (docker logs / k8s) and the old
|
|
@@ -410,17 +464,20 @@ export class Log {
|
|
|
410
464
|
}
|
|
411
465
|
}
|
|
412
466
|
|
|
413
|
-
// File output
|
|
414
|
-
// (production default) or honoured per output mode.
|
|
467
|
+
// File output (v3.13.39 — Python master, 4c6d881): gated on cfg.fileEnabled.
|
|
415
468
|
//
|
|
416
|
-
// output=stdout (default): file in dev
|
|
417
|
-
//
|
|
418
|
-
//
|
|
469
|
+
// output=stdout (default): file ONLY in dev (TINA4_DEBUG truthy);
|
|
470
|
+
// production / containers are stdout-only — no
|
|
471
|
+
// file to bloat the writable layer / disk.
|
|
472
|
+
// output=file file only — no console (always writes a file)
|
|
473
|
+
// output=both file + console (always writes a file)
|
|
474
|
+
// explicit TINA4_LOG_FILE always writes a file (explicit wins)
|
|
419
475
|
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
476
|
+
// readEnv() resolves all of the above into cfg.fileEnabled, so flipping
|
|
477
|
+
// that one flag gates the whole file writer (the only persisted sink).
|
|
478
|
+
if (cfg.fileEnabled) {
|
|
479
|
+
const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
|
|
480
|
+
Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
|
|
481
|
+
}
|
|
425
482
|
}
|
|
426
483
|
}
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -202,18 +202,35 @@ function envTruthy(val: string | undefined): boolean {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* Whether the built-in MCP
|
|
205
|
+
* Whether the built-in MCP dev tools / `/__dev/mcp` endpoint should be enabled.
|
|
206
206
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
207
|
+
* Resolution order (highest priority first), matching Python master
|
|
208
|
+
* `tina4_python.mcp.is_enabled()`:
|
|
209
|
+
* 1. `TINA4_MCP` — explicit on/off override, honoured on ANY host. An
|
|
210
|
+
* explicit truthy value opts a remote / debug-disabled deployment in
|
|
211
|
+
* (e.g. for a remote AI assistant); an explicit falsy value force-disables
|
|
212
|
+
* it everywhere.
|
|
213
|
+
* 2. `TINA4_DEBUG=true` — implicit on for dev, but LOCALHOST-ONLY unless
|
|
214
|
+
* `TINA4_MCP_REMOTE=true`. The MCP dev tools expose powerful operations
|
|
215
|
+
* (DB query, file read/WRITE, route listing), so they never auto-expose on
|
|
216
|
+
* a non-localhost host without an explicit opt-in.
|
|
217
|
+
* 3. Otherwise off.
|
|
218
|
+
*
|
|
219
|
+
* Wired in v3.13.39: previously this was `TINA4_MCP` else `TINA4_DEBUG`, with
|
|
220
|
+
* `isLocalhost()` unused for the gate and `TINA4_MCP_REMOTE` read by zero code
|
|
221
|
+
* — so the documented localhost guard was not actually enforced and a
|
|
222
|
+
* non-localhost `TINA4_DEBUG=true` deployment auto-exposed the dev tools.
|
|
210
223
|
*/
|
|
211
224
|
export function mcpEnabled(): boolean {
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
return envTruthy(
|
|
225
|
+
const explicit = process.env.TINA4_MCP;
|
|
226
|
+
if (explicit !== undefined && explicit.trim() !== "") {
|
|
227
|
+
return envTruthy(explicit);
|
|
228
|
+
}
|
|
229
|
+
if (!envTruthy(process.env.TINA4_DEBUG)) {
|
|
230
|
+
return false;
|
|
215
231
|
}
|
|
216
|
-
|
|
232
|
+
// Dev auto-enable: localhost only, unless explicitly opted into remote.
|
|
233
|
+
return isLocalhost() || envTruthy(process.env.TINA4_MCP_REMOTE);
|
|
217
234
|
}
|
|
218
235
|
|
|
219
236
|
/**
|