tina4-nodejs 3.13.97 → 3.13.99
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 +60 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +10 -0
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- package/packages/core/src/errorOverlay.test.ts +0 -122
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join, extname } from "node:path";
|
|
1
|
+
import { readFileSync, realpathSync, statSync, type Stats } from "node:fs";
|
|
2
|
+
import { join, extname, sep } from "node:path";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
3
4
|
import type { Tina4Request, Tina4Response } from "./types.js";
|
|
4
5
|
|
|
6
|
+
/** Content-type prefixes that benefit from gzip. Mirrors the Python master's `_is_compressible`. */
|
|
7
|
+
const COMPRESSIBLE_PREFIXES = [
|
|
8
|
+
"text/",
|
|
9
|
+
"application/json",
|
|
10
|
+
"application/xml",
|
|
11
|
+
"application/javascript",
|
|
12
|
+
"image/svg",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** Whether a content type benefits from gzip compression (feature 40, CE-DEC-01). */
|
|
16
|
+
function isCompressible(contentType: string): boolean {
|
|
17
|
+
return COMPRESSIBLE_PREFIXES.some((prefix) => contentType.includes(prefix));
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
const MIME_TYPES: Record<string, string> = {
|
|
6
21
|
".html": "text/html; charset=utf-8",
|
|
7
22
|
".css": "text/css; charset=utf-8",
|
|
@@ -37,6 +52,22 @@ export function tryServeStatic(
|
|
|
37
52
|
: raw.split("?")[0];
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
// Security: refuse an up-level segment or a dotfile before touching the FS
|
|
56
|
+
// (defense in depth; the realpath confinement below is what actually stops a
|
|
57
|
+
// symlink escape). This also blocks the sibling-prefix vector reachable via the
|
|
58
|
+
// malformed-URL fallback, since that path carries `..` too.
|
|
59
|
+
if (hasHiddenSegment(pathname)) return false;
|
|
60
|
+
|
|
61
|
+
// Resolve the real static root ONCE. Resolving the dir AND the file keeps them
|
|
62
|
+
// consistent when the temp/base path itself is a symlink (e.g. macOS
|
|
63
|
+
// /var -> /private/var), which a startsWith on the raw dir would break.
|
|
64
|
+
let realDir: string;
|
|
65
|
+
try {
|
|
66
|
+
realDir = realpathSync(staticDir);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
40
71
|
// Try exact file match, then index.html for directory requests
|
|
41
72
|
const candidates = [
|
|
42
73
|
join(staticDir, pathname),
|
|
@@ -44,22 +75,38 @@ export function tryServeStatic(
|
|
|
44
75
|
];
|
|
45
76
|
|
|
46
77
|
for (const filePath of candidates) {
|
|
47
|
-
|
|
78
|
+
let realPath: string;
|
|
79
|
+
try {
|
|
80
|
+
realPath = realpathSync(filePath);
|
|
81
|
+
} catch {
|
|
82
|
+
continue; // missing path component
|
|
83
|
+
}
|
|
48
84
|
|
|
49
|
-
const stat = statSync(
|
|
85
|
+
const stat = statSync(realPath);
|
|
50
86
|
if (!stat.isFile()) continue;
|
|
51
87
|
|
|
52
|
-
//
|
|
53
|
-
|
|
88
|
+
// Confinement: realpath + trailing separator (ADR-0050) — a symlink pointing
|
|
89
|
+
// outside, a sibling-prefix dir (publicsecret) and a `..` escape all fail
|
|
90
|
+
// this. The trailing separator is what defeats the sibling-prefix match.
|
|
91
|
+
if (realPath !== realDir && !realPath.startsWith(realDir + sep)) continue;
|
|
54
92
|
|
|
55
|
-
|
|
93
|
+
// Never emit bytes from a dotfile, even when a symlink inside the public dir
|
|
94
|
+
// points AT one. Check the segments BELOW the public dir so a public dir that
|
|
95
|
+
// itself lives under a dot-directory is unaffected.
|
|
96
|
+
if (hasHiddenSegment(realPath.slice(realDir.length + 1), sep)) continue;
|
|
97
|
+
|
|
98
|
+
const ext = extname(realPath);
|
|
56
99
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
57
100
|
|
|
58
101
|
// Cheap validators from the file's size + mtime — no hashing needed. A weak
|
|
59
102
|
// ETag (W/) is correct here: two representations with the same size+mtime
|
|
60
|
-
// are treated as equivalent for caching.
|
|
61
|
-
//
|
|
62
|
-
|
|
103
|
+
// are treated as equivalent for caching. Format PINNED across all four
|
|
104
|
+
// frameworks (feature 40, CE-DEC-02): decimal `W/"<size>-<mtime>"`,
|
|
105
|
+
// integer-SECOND mtime (dropping the fractional-ms Node otherwise reports)
|
|
106
|
+
// — a client behind a reverse proxy sees an identical validator for the
|
|
107
|
+
// same file regardless of backend language. Last-Modified is
|
|
108
|
+
// second-resolution per HTTP.
|
|
109
|
+
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
|
|
63
110
|
const lastModified = stat.mtime.toUTCString();
|
|
64
111
|
|
|
65
112
|
// A static asset MAY be cached but MUST be revalidated before use, so a
|
|
@@ -78,15 +125,37 @@ export function tryServeStatic(
|
|
|
78
125
|
return true;
|
|
79
126
|
}
|
|
80
127
|
|
|
128
|
+
// Compression (feature 40, CE-DEC-01): gzip when eligible — a static
|
|
129
|
+
// asset gets the SAME treatment as a dynamic response. The ETag stays
|
|
130
|
+
// file-stat-based regardless (a weak validator is deliberately
|
|
131
|
+
// representation-independent — CE-ETAG-OVER-COMPRESSED), so compressing
|
|
132
|
+
// here never invalidates the ETag/Last-Modified already sent above.
|
|
133
|
+
let body: Buffer = readFileSync(realPath);
|
|
134
|
+
const acceptEncoding = conditionalHeader(req, "accept-encoding");
|
|
135
|
+
if (body.length > 1024 && acceptEncoding.includes("gzip") && isCompressible(contentType)) {
|
|
136
|
+
body = gzipSync(body, { level: 6 });
|
|
137
|
+
res.raw.setHeader("Content-Encoding", "gzip");
|
|
138
|
+
res.raw.setHeader("Vary", "Accept-Encoding");
|
|
139
|
+
}
|
|
140
|
+
|
|
81
141
|
res.raw.setHeader("Content-Type", contentType);
|
|
82
|
-
res.raw.setHeader("Content-Length",
|
|
83
|
-
res.raw.end(
|
|
142
|
+
res.raw.setHeader("Content-Length", body.length);
|
|
143
|
+
res.raw.end(body);
|
|
84
144
|
return true;
|
|
85
145
|
}
|
|
86
146
|
|
|
87
147
|
return false;
|
|
88
148
|
}
|
|
89
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Whether any separator-delimited segment of `path` is hidden (begins with a
|
|
152
|
+
* dot). Refuses a dotfile (`.env`, `.git/config`); a `..` segment also begins
|
|
153
|
+
* with a dot, so this doubles as a belt on traversal.
|
|
154
|
+
*/
|
|
155
|
+
function hasHiddenSegment(path: string, separator = "/"): boolean {
|
|
156
|
+
return path.split(separator).some((segment) => segment.length > 0 && segment.startsWith("."));
|
|
157
|
+
}
|
|
158
|
+
|
|
90
159
|
/**
|
|
91
160
|
* Read a conditional-request header off the incoming request. Works with the
|
|
92
161
|
* real `IncomingMessage` (`req.headers`, already lower-cased by Node) AND with
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tina4 Test Client — Test routes without starting a server.
|
|
3
3
|
*
|
|
4
|
+
* Builds a mock IncomingMessage/ServerResponse and dispatches them through
|
|
5
|
+
* the REAL Tina4 front controller (server.ts's `runDispatch`, over either the
|
|
6
|
+
* live server's DispatchContext when one is running in this process, or a
|
|
7
|
+
* standalone one bound to the given/default router when none is) — the same
|
|
8
|
+
* function every live socket connection runs. Everything a live request
|
|
9
|
+
* gets, an in-process test request gets: the session stage, global + per-
|
|
10
|
+
* route middleware in the live order (gate BEFORE route middleware, per
|
|
11
|
+
* ADR-0012), the secure-by-default auth gate, static files, template routes,
|
|
12
|
+
* the landing page, RFC 9110 OPTIONS/405 `Allow` responses, and the 404/500
|
|
13
|
+
* renderers.
|
|
14
|
+
*
|
|
15
|
+
* This used to re-implement the dispatch order itself — matching the route
|
|
16
|
+
* directly and running global/route middleware and the auth gate by hand —
|
|
17
|
+
* which meant the session stage never ran (a session-token auth regression
|
|
18
|
+
* was structurally unreachable) and route middleware ran BEFORE the gate
|
|
19
|
+
* (the live server's order is gate first, ADR-0012). Delegating to the real
|
|
20
|
+
* `runDispatch` closes both gaps for free, along with everything else the
|
|
21
|
+
* live pipeline does that this file never had to know about (feature 131,
|
|
22
|
+
* TC-DEC-01 — the same shape as the #PY2 auth fix and the Python/PHP/Ruby
|
|
23
|
+
* TestClients, which have always called their own real front controller:
|
|
24
|
+
* `core.server.app`, `Router::dispatch`, `RackApp#call`).
|
|
25
|
+
*
|
|
4
26
|
* Usage:
|
|
5
27
|
*
|
|
6
28
|
* import { TestClient } from "@tina4/core";
|
|
@@ -16,11 +38,8 @@
|
|
|
16
38
|
*/
|
|
17
39
|
import { IncomingMessage, ServerResponse } from "node:http";
|
|
18
40
|
import { Socket } from "node:net";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { defaultRouter, Router, runRouteMiddlewares } from "./router.js";
|
|
22
|
-
import { MiddlewareRunner, isMiddlewareClass } from "./middleware.js";
|
|
23
|
-
import { enforceRouteAuth } from "./authGate.js";
|
|
41
|
+
import { defaultRouter, Router } from "./router.js";
|
|
42
|
+
import { runDispatch, buildDispatchContext, getLiveDispatchContext, type DispatchContext } from "./server.js";
|
|
24
43
|
|
|
25
44
|
export class TestResponse {
|
|
26
45
|
public readonly status: number;
|
|
@@ -28,11 +47,39 @@ export class TestResponse {
|
|
|
28
47
|
public readonly headers: Record<string, string>;
|
|
29
48
|
public readonly contentType: string;
|
|
30
49
|
|
|
31
|
-
|
|
50
|
+
/** Every value sent per header name (lowercased), in emission order. */
|
|
51
|
+
private readonly headerList: Record<string, string[]>;
|
|
52
|
+
|
|
53
|
+
constructor(statusCode: number, headerList: Record<string, string[]>, body: string) {
|
|
32
54
|
this.status = statusCode;
|
|
33
55
|
this.body = body;
|
|
34
|
-
this.
|
|
35
|
-
|
|
56
|
+
this.headerList = headerList;
|
|
57
|
+
|
|
58
|
+
// `headers` stays the back-compat single-value view — the LAST value per
|
|
59
|
+
// name, the shape every existing reader already expects (TC-HEADER-
|
|
60
|
+
// COLLAPSE, TC-DEC-02: a duplicate response header, e.g. two Set-Cookie
|
|
61
|
+
// from two response.cookie() calls, used to collapse via a comma-join
|
|
62
|
+
// here, which is unsafe for Set-Cookie specifically since a cookie's own
|
|
63
|
+
// Expires attribute can itself contain a comma — getHeaderList() below is
|
|
64
|
+
// the one place every value is visible; headers[name] keeps collapsing).
|
|
65
|
+
const flat: Record<string, string> = {};
|
|
66
|
+
for (const [name, values] of Object.entries(headerList)) {
|
|
67
|
+
if (values.length > 0) flat[name] = values[values.length - 1]!;
|
|
68
|
+
}
|
|
69
|
+
this.headers = flat;
|
|
70
|
+
this.contentType = this.headers["content-type"] ?? "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every value sent for `name` (case-insensitive), in emission order.
|
|
75
|
+
*
|
|
76
|
+
* A header sent once returns a one-item array; a header never sent returns
|
|
77
|
+
* an empty array. This is the one place a duplicate response header (two
|
|
78
|
+
* `Set-Cookie`) is visible — `headers[name]` always collapses to the LAST
|
|
79
|
+
* value, same as before (TC-HEADER-COLLAPSE, TC-DEC-02).
|
|
80
|
+
*/
|
|
81
|
+
getHeaderList(name: string): string[] {
|
|
82
|
+
return this.headerList[name.toLowerCase()] ?? [];
|
|
36
83
|
}
|
|
37
84
|
|
|
38
85
|
/** Parse body as JSON. */
|
|
@@ -62,10 +109,39 @@ export interface RequestOptions {
|
|
|
62
109
|
}
|
|
63
110
|
|
|
64
111
|
export class TestClient {
|
|
65
|
-
|
|
112
|
+
/** An explicitly-injected router (test isolation); undefined means "use the live server's router, or defaultRouter". */
|
|
113
|
+
private readonly explicitRouter: Router | undefined;
|
|
114
|
+
private ctxPromise: Promise<DispatchContext> | null = null;
|
|
66
115
|
|
|
67
116
|
constructor(router?: Router) {
|
|
68
|
-
this.
|
|
117
|
+
this.explicitRouter = router;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve (and memoise) the DispatchContext this client dispatches
|
|
122
|
+
* through.
|
|
123
|
+
*
|
|
124
|
+
* An explicitly-injected router always gets its OWN standalone context
|
|
125
|
+
* (buildDispatchContext) — the test-isolation contract an injected router
|
|
126
|
+
* has always had: a dedicated Router never races with whatever else is
|
|
127
|
+
* registered on defaultRouter or a live server. With no injected router,
|
|
128
|
+
* the LIVE server's context wins when one is running in this process
|
|
129
|
+
* (getLiveDispatchContext — maximum fidelity, mirrors Ruby's
|
|
130
|
+
* `RackApp.current`), else a standalone context bound to defaultRouter.
|
|
131
|
+
*/
|
|
132
|
+
private context(): Promise<DispatchContext> {
|
|
133
|
+
if (this.ctxPromise) return this.ctxPromise;
|
|
134
|
+
|
|
135
|
+
if (!this.explicitRouter) {
|
|
136
|
+
const live = getLiveDispatchContext();
|
|
137
|
+
if (live) {
|
|
138
|
+
this.ctxPromise = Promise.resolve(live);
|
|
139
|
+
return this.ctxPromise;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.ctxPromise = buildDispatchContext(this.explicitRouter ?? defaultRouter);
|
|
144
|
+
return this.ctxPromise;
|
|
69
145
|
}
|
|
70
146
|
|
|
71
147
|
/** Send a GET request. */
|
|
@@ -93,7 +169,7 @@ export class TestClient {
|
|
|
93
169
|
return this._request("DELETE", path, options);
|
|
94
170
|
}
|
|
95
171
|
|
|
96
|
-
/** Build a mock request
|
|
172
|
+
/** Build a mock request/response pair and dispatch it through the REAL pipeline (runDispatch). */
|
|
97
173
|
private async _request(method: string, path: string, options?: RequestOptions): Promise<TestResponse> {
|
|
98
174
|
const { json, body, headers } = options ?? {};
|
|
99
175
|
|
|
@@ -126,7 +202,7 @@ export class TestClient {
|
|
|
126
202
|
const rawReq = new IncomingMessage(socket);
|
|
127
203
|
rawReq.method = method.toUpperCase();
|
|
128
204
|
rawReq.url = path;
|
|
129
|
-
rawReq.headers = { ...reqHeaders, host: "localhost:
|
|
205
|
+
rawReq.headers = { ...reqHeaders, host: "localhost:7148" };
|
|
130
206
|
|
|
131
207
|
// Push body data into the readable stream
|
|
132
208
|
if (rawBody) {
|
|
@@ -134,133 +210,47 @@ export class TestClient {
|
|
|
134
210
|
}
|
|
135
211
|
rawReq.push(null); // signal end of stream
|
|
136
212
|
|
|
137
|
-
// Create a mock ServerResponse that captures output
|
|
213
|
+
// Create a mock ServerResponse that captures output.
|
|
214
|
+
//
|
|
215
|
+
// A response over a real socket flips `writableEnded` only when Node's
|
|
216
|
+
// OWN write/end implementation actually runs, and this mock never calls
|
|
217
|
+
// it — write()/end() are fully replaced, since there is no real peer to
|
|
218
|
+
// stream bytes to. Left alone, `rawRes.writableEnded` therefore stays
|
|
219
|
+
// FALSE forever (confirmed empirically), even after this mock's own
|
|
220
|
+
// end() has "completed". The real pipeline checks `res.raw.writableEnded`
|
|
221
|
+
// in several places to decide whether a stage already answered the
|
|
222
|
+
// request — most importantly runMatchedRoute's trailing
|
|
223
|
+
// `if (!res.raw.writableEnded) res.raw.end();` — so a permanently-false
|
|
224
|
+
// reading causes a SECOND, redundant end() call after every matched
|
|
225
|
+
// route. That reaches compressionEtagIntercept's wrapped end(), whose own
|
|
226
|
+
// buffered chunks were never cleared from the first call, and it resends
|
|
227
|
+
// them: the captured body comes out DUPLICATED. `ended` is the real
|
|
228
|
+
// single source of truth here, exposed via an own-property override of
|
|
229
|
+
// `writableEnded` so every pipeline stage's check reads correctly, and
|
|
230
|
+
// write()/end() themselves become no-ops once it is set (idempotent,
|
|
231
|
+
// matching the real "write/end after end is a no-op" contract).
|
|
138
232
|
const rawRes = new ServerResponse(rawReq);
|
|
139
233
|
const chunks: Buffer[] = [];
|
|
140
|
-
|
|
141
|
-
|
|
234
|
+
let ended = false;
|
|
235
|
+
Object.defineProperty(rawRes, "writableEnded", { get: () => ended, configurable: true });
|
|
142
236
|
|
|
143
|
-
rawRes.write =
|
|
144
|
-
if (
|
|
237
|
+
rawRes.write = ((chunk?: any, ..._args: any[]): boolean => {
|
|
238
|
+
if (ended) return true;
|
|
239
|
+
if (chunk != null) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
145
240
|
return true;
|
|
146
|
-
} as typeof rawRes.write;
|
|
147
|
-
|
|
148
|
-
rawRes.end = function (chunk?: any, ...args: any[]): ServerResponse {
|
|
149
|
-
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
150
|
-
return rawRes;
|
|
151
|
-
} as typeof rawRes.end;
|
|
152
|
-
|
|
153
|
-
// Create Tina4 request/response wrappers
|
|
154
|
-
const req = createRequest(rawReq);
|
|
155
|
-
const res = createResponse(rawRes);
|
|
156
|
-
|
|
157
|
-
// Parse body (populates req.body)
|
|
158
|
-
await req.parseBody();
|
|
241
|
+
}) as typeof rawRes.write;
|
|
159
242
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const httpMethod = method.toUpperCase();
|
|
165
|
-
const match = this.router.match(httpMethod, cleanPath);
|
|
166
|
-
if (!match) {
|
|
167
|
-
// D6: dispatch through the REAL front-controller behaviour on a miss —
|
|
168
|
-
// never fabricate a body. This mirrors the live server tail
|
|
169
|
-
// (server.ts dispatch): RFC 9110 conformance first (a path registered
|
|
170
|
-
// under another method answers OPTIONS with 204 + Allow and any other
|
|
171
|
-
// method with 405 + Allow), then the framework's real 404. The old
|
|
172
|
-
// TestClient short-circuited to a hand-invented {"error":"Not found"}
|
|
173
|
-
// that the live server never sends, so a green test proved nothing about
|
|
174
|
-
// production (the same silent-success class as Python's pre-fix client).
|
|
175
|
-
const allowed = this.router.methodsAllowedForPath(cleanPath);
|
|
176
|
-
if (allowed.length > 0) {
|
|
177
|
-
const allowHeader = allowed.join(", ");
|
|
178
|
-
if (httpMethod === "OPTIONS") {
|
|
179
|
-
return new TestResponse(204, { allow: allowHeader, "content-length": "0" }, "");
|
|
180
|
-
}
|
|
181
|
-
const body405 = JSON.stringify({
|
|
182
|
-
error: "Method Not Allowed",
|
|
183
|
-
path: cleanPath,
|
|
184
|
-
method: httpMethod,
|
|
185
|
-
allow: allowed,
|
|
186
|
-
statusCode: 405,
|
|
187
|
-
});
|
|
188
|
-
return new TestResponse(405, { allow: allowHeader, "content-type": "application/json" }, body405);
|
|
189
|
-
}
|
|
190
|
-
// The framework's real 404. The live server renders an HTML error page
|
|
191
|
-
// when a project templatesDir/frondEngine exists and otherwise emits this
|
|
192
|
-
// exact JSON; a server-less TestClient has no project dir, so it produces
|
|
193
|
-
// the JSON fallback the live front controller itself falls back to.
|
|
194
|
-
// tina4: HTML-error-page + filesystem static serving are the only live
|
|
195
|
-
// front-controller steps a server-less client can't reproduce.
|
|
196
|
-
const body404 = JSON.stringify({
|
|
197
|
-
error: "Not Found",
|
|
198
|
-
statusCode: 404,
|
|
199
|
-
message: `No route found for ${httpMethod} ${cleanPath}`,
|
|
200
|
-
});
|
|
201
|
-
return new TestResponse(404, { "content-type": "application/json" }, body404);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Inject route params
|
|
205
|
-
req.params = match.params;
|
|
206
|
-
|
|
207
|
-
// Global class-based middleware (Router.use / MiddlewareRunner.use), run in
|
|
208
|
-
// the SAME order the live dispatcher uses (server.ts): beforeX hooks before
|
|
209
|
-
// the handler, afterX hooks after — even on a before-short-circuit. Empty
|
|
210
|
-
// when a test registers none, so this is additive/non-breaking.
|
|
211
|
-
const globalMiddleware = [
|
|
212
|
-
...new Set([...Router.getClassMiddlewares(), ...MiddlewareRunner.getGlobal()]),
|
|
213
|
-
];
|
|
214
|
-
if (globalMiddleware.length > 0) {
|
|
215
|
-
const [, , proceed] = await MiddlewareRunner.runBefore(globalMiddleware, req, res);
|
|
216
|
-
if (!proceed || res.raw.writableEnded) {
|
|
217
|
-
await MiddlewareRunner.runAfter(globalMiddleware, req, res);
|
|
218
|
-
if (!res.raw.writableEnded) res.raw.end();
|
|
219
|
-
return this._collect(rawRes, chunks, socket);
|
|
243
|
+
rawRes.end = ((chunk?: any, ..._args: any[]): ServerResponse => {
|
|
244
|
+
if (ended) return rawRes;
|
|
245
|
+
if (chunk != null && typeof chunk !== "function") {
|
|
246
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
220
247
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// same runner the live dispatcher uses. A route class's afterX hooks join
|
|
225
|
-
// the after pass below, exactly as in server.ts.
|
|
226
|
-
//
|
|
227
|
-
// ORDER DRIFT, stated rather than hidden: the live dispatcher runs the auth
|
|
228
|
-
// gate BEFORE the route's own middleware (ADR-0012 — middleware on a
|
|
229
|
-
// secured route must never process a request that is about to be rejected);
|
|
230
|
-
// here it still runs before the gate. Aligning the two is a behaviour
|
|
231
|
-
// change to the test surface and belongs in its own change.
|
|
232
|
-
const routeMiddlewareClasses = (match.middlewares ?? []).filter(isMiddlewareClass);
|
|
233
|
-
if (match.middlewares && match.middlewares.length > 0) {
|
|
234
|
-
const proceed = await runRouteMiddlewares(match.middlewares, req, res);
|
|
235
|
-
if (!proceed || res.raw.writableEnded) {
|
|
236
|
-
await MiddlewareRunner.runAfter([...globalMiddleware, ...routeMiddlewareClasses], req, res);
|
|
237
|
-
if (!res.raw.writableEnded) res.raw.end();
|
|
238
|
-
return this._collect(rawRes, chunks, socket);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
248
|
+
ended = true;
|
|
249
|
+
return rawRes;
|
|
250
|
+
}) as typeof rawRes.end;
|
|
241
251
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// .secure() route) with no valid token / formToken / session token 401s here
|
|
245
|
-
// exactly as it would in production. The TestClient used to skip this and run
|
|
246
|
-
// the handler directly, so a green test could hide a live 401 — the
|
|
247
|
-
// verification layer lied. A public route (GET, or a write marked .noAuth())
|
|
248
|
-
// passes straight through. When rejected, enforceRouteAuth has written a 401
|
|
249
|
-
// to res.raw, so the collection below reports it just like a handler response.
|
|
250
|
-
// (#PY2 parity)
|
|
251
|
-
const isDevAdmin = cleanPath.startsWith("/__dev");
|
|
252
|
-
const rejected = enforceRouteAuth(req, res, match, isDevAdmin);
|
|
253
|
-
|
|
254
|
-
// Execute handler (only if auth passed)
|
|
255
|
-
if (!rejected) {
|
|
256
|
-
await match.handler(req, res);
|
|
257
|
-
// Global + route-class afterX hooks (logging / post-processing),
|
|
258
|
-
// mirroring the live tail.
|
|
259
|
-
const afterMiddleware = [...globalMiddleware, ...routeMiddlewareClasses];
|
|
260
|
-
if (afterMiddleware.length > 0) {
|
|
261
|
-
await MiddlewareRunner.runAfter(afterMiddleware, req, res);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
252
|
+
const ctx = await this.context();
|
|
253
|
+
await runDispatch(ctx, rawReq, rawRes);
|
|
264
254
|
|
|
265
255
|
return this._collect(rawRes, chunks, socket);
|
|
266
256
|
}
|
|
@@ -268,13 +258,12 @@ export class TestClient {
|
|
|
268
258
|
/** Gather the captured status/headers/body into a TestResponse and free the socket. */
|
|
269
259
|
private _collect(rawRes: ServerResponse, chunks: Buffer[], socket: Socket): TestResponse {
|
|
270
260
|
const responseBody = Buffer.concat(chunks).toString();
|
|
271
|
-
const
|
|
261
|
+
const headerList: Record<string, string[]> = {};
|
|
272
262
|
for (const [name, value] of Object.entries(rawRes.getHeaders())) {
|
|
273
|
-
if (value
|
|
274
|
-
|
|
275
|
-
}
|
|
263
|
+
if (value === undefined) continue;
|
|
264
|
+
headerList[name.toLowerCase()] = Array.isArray(value) ? value.map(String) : [String(value)];
|
|
276
265
|
}
|
|
277
266
|
socket.destroy();
|
|
278
|
-
return new TestResponse(rawRes.statusCode,
|
|
267
|
+
return new TestResponse(rawRes.statusCode, headerList, responseBody);
|
|
279
268
|
}
|
|
280
269
|
}
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tina4 Node.js — Inline testing framework.
|
|
3
3
|
*
|
|
4
|
-
* Attach test
|
|
4
|
+
* Attach test expectations to functions and run them all at once.
|
|
5
5
|
*
|
|
6
|
-
* import { tests,
|
|
6
|
+
* import { tests, expectEqual, expectRaises, runAll } from "./testing.js";
|
|
7
7
|
*
|
|
8
8
|
* const add = tests(
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* expectEqual([5, 3], 8),
|
|
10
|
+
* expectRaises(Error, [null]),
|
|
11
11
|
* )(function add(a: number, b: number | null = null): number {
|
|
12
12
|
* if (b === null) throw new Error("b required");
|
|
13
13
|
* return a + b;
|
|
14
14
|
* });
|
|
15
15
|
*
|
|
16
16
|
* runAll();
|
|
17
|
+
*
|
|
18
|
+
* The builders are named expect* — DESCRIPTORS that record an expectation for the
|
|
19
|
+
* runner — deliberately distinct from the xUnit assert* on Tina4Test (test.ts),
|
|
20
|
+
* so importing the wrong surface can never silently change call semantics.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
// ── Types ──────────────────────────────────────────────────────────
|
|
@@ -44,26 +48,26 @@ const registry: RegistryEntry[] = [];
|
|
|
44
48
|
|
|
45
49
|
// ── Assertion builders ─────────────────────────────────────────────
|
|
46
50
|
|
|
47
|
-
/**
|
|
48
|
-
export function
|
|
51
|
+
/** Expect that calling the function with `args` returns `expected`. */
|
|
52
|
+
export function expectEqual(args: unknown[], expected: unknown): Assertion {
|
|
49
53
|
return { type: "equal", args, expected };
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
/**
|
|
53
|
-
export function
|
|
56
|
+
/** Expect that calling the function with `args` throws an instance of `errorClass`. */
|
|
57
|
+
export function expectRaises(
|
|
54
58
|
errorClass: new (...a: unknown[]) => Error,
|
|
55
59
|
args: unknown[],
|
|
56
60
|
): Assertion {
|
|
57
61
|
return { type: "raises", args, exception: errorClass };
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
/**
|
|
61
|
-
export function
|
|
64
|
+
/** Expect that calling the function with `args` returns a truthy value. */
|
|
65
|
+
export function expectTrue(args: unknown[]): Assertion {
|
|
62
66
|
return { type: "true", args };
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
/**
|
|
66
|
-
export function
|
|
69
|
+
/** Expect that calling the function with `args` returns a falsy value. */
|
|
70
|
+
export function expectFalse(args: unknown[]): Assertion {
|
|
67
71
|
return { type: "false", args };
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -21,20 +21,32 @@ export interface Tina4Request extends IncomingMessage {
|
|
|
21
21
|
/**
|
|
22
22
|
* Path params. Typed params arrive coerced: `{id:int}`/`{id:integer}` and
|
|
23
23
|
* `{p:float}`/`{p:number}` are JS `number`s; every other type and untyped
|
|
24
|
-
* `{id}` stay `string` (parity with Python/PHP/Ruby).
|
|
24
|
+
* `{id}` stay `string` (parity with Python/PHP/Ruby). ROUTE-ONLY — never
|
|
25
|
+
* the query string or body (REQ-PARAM-POLLUTION, 3.13.99). Writable: the
|
|
26
|
+
* router assigns it AFTER createRequest() builds the wire-derived fields
|
|
27
|
+
* below, once a route has matched.
|
|
25
28
|
*/
|
|
26
29
|
params: Record<string, string | number>;
|
|
27
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Core wire-derived fields below are `readonly` (REQ-IMMUTABILITY-DIVERGE,
|
|
32
|
+
* 3.13.99) — set once in createRequest() and never reassigned afterward,
|
|
33
|
+
* matching PHP's `readonly` properties and Ruby's writer-less attr_reader
|
|
34
|
+
* (the two languages already at this posture; this is TS-compile-time
|
|
35
|
+
* only, like PHP/Ruby's enforcement is at their own language boundary).
|
|
36
|
+
* `params`/`body`/`files`/`session`/`user` stay mutable: the router and
|
|
37
|
+
* middleware legitimately set them after construction.
|
|
38
|
+
*/
|
|
39
|
+
readonly query: Record<string, string>;
|
|
28
40
|
/**
|
|
29
41
|
* Request path only — no query string. Matches `request.path` in
|
|
30
42
|
* Python/PHP/Ruby. Example: `/users/42`.
|
|
31
43
|
*/
|
|
32
|
-
path: string;
|
|
44
|
+
readonly path: string;
|
|
33
45
|
/**
|
|
34
46
|
* Raw query string with no leading "?". Matches `request.query_string`
|
|
35
47
|
* (Python/Ruby) and `request.queryString` (PHP). Example: `"page=2"`.
|
|
36
48
|
*/
|
|
37
|
-
queryString: string;
|
|
49
|
+
readonly queryString: string;
|
|
38
50
|
/**
|
|
39
51
|
* Full absolute URL — `scheme://host[:port]/path[?query]`.
|
|
40
52
|
* Honours X-Forwarded-Proto / X-Forwarded-Host. Matches PHP/Ruby/Python parity.
|
|
@@ -42,19 +54,19 @@ export interface Tina4Request extends IncomingMessage {
|
|
|
42
54
|
* Note: this overrides Node's native `IncomingMessage.url` (which contains
|
|
43
55
|
* only path+query). Inside Tina4 handlers, `req.url` is always the full URL.
|
|
44
56
|
*/
|
|
45
|
-
url: string;
|
|
57
|
+
readonly url: string;
|
|
46
58
|
body: unknown;
|
|
47
|
-
ip: string;
|
|
59
|
+
readonly ip: string;
|
|
48
60
|
/**
|
|
49
61
|
* Raw socket peer address - NEVER honours X-Forwarded-For (which any
|
|
50
62
|
* caller can spoof), so it can be trusted for security decisions.
|
|
51
63
|
* Empty for in-process / synthetic requests. Parity with Python's
|
|
52
64
|
* request.remote_ip and PHP's Request::$remoteIp.
|
|
53
65
|
*/
|
|
54
|
-
remoteIp: string;
|
|
66
|
+
readonly remoteIp: string;
|
|
55
67
|
files: Record<string, UploadedFile | UploadedFile[]>;
|
|
56
|
-
cookies: Record<string, string>;
|
|
57
|
-
contentType: string;
|
|
68
|
+
readonly cookies: Record<string, string>;
|
|
69
|
+
readonly contentType: string;
|
|
58
70
|
/**
|
|
59
71
|
* NULL when the session backend was unusable for this request (ADR-0021).
|
|
60
72
|
* The request path logs the failure and degrades rather than 500-ing, so a
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single Tina4 framework version resolver (feature 130, VERSION-DEC-01).
|
|
3
|
+
*
|
|
4
|
+
* Before this file, three surfaces inside @tina4/core each read their own copy
|
|
5
|
+
* of package.json independently: server.ts's `readPackageVersion()` was a
|
|
6
|
+
* FIXED `../../../package.json` (three levels up from wherever this file
|
|
7
|
+
* physically sits) with no fallback -- correct in the monorepo dev tree, but
|
|
8
|
+
* silently `"0.0.0"` the moment @tina4/core is relocated (a published npm
|
|
9
|
+
* install, a bundled dist/, a pnpm .pnpm store symlink) because the fixed
|
|
10
|
+
* depth no longer lands on a package.json at all. devAdmin.ts tried two fixed
|
|
11
|
+
* paths (`../../../package.json` then `../../package.json`) with the same
|
|
12
|
+
* "0.0.0" floor. mcp.ts's default dev MCP server never read a manifest at
|
|
13
|
+
* all -- its serverInfo.version was just the constructor's generic '1.0.0'
|
|
14
|
+
* default. Three readers, three ways to drift from the real version and from
|
|
15
|
+
* each other.
|
|
16
|
+
*
|
|
17
|
+
* The CLI (`packages/cli/src/bin.ts` `readCliVersion()`) already had the
|
|
18
|
+
* RIGHT algorithm: walk up from this file's own location to the NEAREST
|
|
19
|
+
* package.json that declares a version, rather than assuming a fixed depth.
|
|
20
|
+
* That is robust to being relocated because it does not care how many
|
|
21
|
+
* directories separate it from the root -- it finds whichever package.json is
|
|
22
|
+
* actually adjacent to wherever this code ended up running from (its own
|
|
23
|
+
* package's manifest in a published install, the monorepo root in the dev
|
|
24
|
+
* tree). This file ports that exact algorithm into @tina4/core so the THREE
|
|
25
|
+
* in-package readers collapse into ONE. (The CLI keeps its own small copy
|
|
26
|
+
* rather than importing this one: `packages/cli` deliberately avoids a
|
|
27
|
+
* top-level import of `@tina4/core` so a bare `tina4nodejs --help` does not
|
|
28
|
+
* pay to load the whole bundled core package -- see the port-takeover comment
|
|
29
|
+
* in bin.ts. Both copies run the identical walk-up, so in any real deployment
|
|
30
|
+
* layout they resolve to the same value.)
|
|
31
|
+
*
|
|
32
|
+
* Cheap and side-effect-free: filesystem reads only, no bootstrap.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Walk up from this file to the nearest package.json carrying a non-empty
|
|
40
|
+
* `version` field. Stops at the first hit (nearest wins), so a published
|
|
41
|
+
* `@tina4/core` install resolves its OWN package.json, and the monorepo dev
|
|
42
|
+
* tree resolves the workspace root's -- both the real, current version.
|
|
43
|
+
* Falls back to "0.0.0" only if none is found within the walk (a layout with
|
|
44
|
+
* no package.json anywhere in its ancestry at all).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveFrameworkVersion(): string {
|
|
47
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
for (let i = 0; i < 6; i++) {
|
|
49
|
+
const pkgPath = join(dir, "package.json");
|
|
50
|
+
if (existsSync(pkgPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
53
|
+
if (typeof pkg.version === "string" && pkg.version) return pkg.version;
|
|
54
|
+
} catch {
|
|
55
|
+
// keep walking -- a malformed package.json isn't ours
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parent = dirname(dir);
|
|
59
|
+
if (parent === dir) break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
return "0.0.0";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolved once at module load -- every @tina4/core surface imports this. */
|
|
66
|
+
export const TINA4_VERSION = resolveFrameworkVersion();
|