tina4-nodejs 3.13.70 → 3.13.72
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 +2 -2
- package/package.json +1 -1
- package/packages/core/public/js/tina4-dev-admin.js +203 -170
- package/packages/core/public/js/tina4-dev-admin.min.js +203 -170
- package/packages/core/src/devAdmin.ts +263 -38
- package/packages/core/src/request.ts +32 -6
- package/packages/frond/src/engine.ts +157 -4
|
@@ -550,6 +550,13 @@ export class DevAdmin {
|
|
|
550
550
|
{ method: "POST", pattern: "/__dev/api/supervise/cancel", handler: handleSuperviseStub },
|
|
551
551
|
// Execute — proxies to the framework_port+2000 Rust agent (SSE passthrough)
|
|
552
552
|
{ method: "POST", pattern: "/__dev/api/execute", handler: handleExecute },
|
|
553
|
+
// Framework-grounding MCP token config — self-contained (.env upsert)
|
|
554
|
+
{ method: "GET", pattern: "/__dev/api/grounding/status", handler: handleGroundingStatus },
|
|
555
|
+
{ method: "POST", pattern: "/__dev/api/grounding/token", handler: handleGroundingToken },
|
|
556
|
+
// Scaffold run chips — project-level operations (distinct from create)
|
|
557
|
+
{ method: "POST", pattern: "/__dev/api/migrate", handler: handleMigrate },
|
|
558
|
+
{ method: "POST", pattern: "/__dev/api/test", handler: handleTest },
|
|
559
|
+
{ method: "POST", pattern: "/__dev/api/seed/run", handler: handleSeedRun },
|
|
553
560
|
// File browser / editor
|
|
554
561
|
{ method: "GET", pattern: "/__dev/api/files", handler: handleFiles },
|
|
555
562
|
{ method: "GET", pattern: "/__dev/api/file", handler: handleFileRead },
|
|
@@ -922,27 +929,71 @@ const handleMessagesSearch: RouteHandler = (req, res) => {
|
|
|
922
929
|
|
|
923
930
|
// -- Queue handlers --
|
|
924
931
|
|
|
925
|
-
|
|
932
|
+
/**
|
|
933
|
+
* Map a file-backed QueueJob (LiteBackend on-disk shape) to the shared JS
|
|
934
|
+
* dev-admin format the SPA queue panel renders.
|
|
935
|
+
*/
|
|
936
|
+
function mapQueueJob(job: any, topic: string, status: string) {
|
|
937
|
+
return {
|
|
938
|
+
id: job?.id ?? "",
|
|
939
|
+
topic: job?.topic ?? topic,
|
|
940
|
+
status,
|
|
941
|
+
attempts: Number(job?.attempts ?? 0) || 0,
|
|
942
|
+
created_at: job?.createdAt ?? job?.created_at ?? "",
|
|
943
|
+
data: job?.payload ?? {},
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const handleQueue: RouteHandler = async (req, res) => {
|
|
926
948
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
949
|
+
const topic = url.searchParams.get("topic") ?? "default";
|
|
927
950
|
const statusFilter = url.searchParams.get("status") ?? "";
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
951
|
+
try {
|
|
952
|
+
const { Queue } = await import("./queue.js");
|
|
953
|
+
const queue = new Queue({ topic });
|
|
954
|
+
|
|
955
|
+
const stats = {
|
|
956
|
+
pending: queue.size("pending"),
|
|
957
|
+
completed: queue.size("completed"),
|
|
958
|
+
failed: queue.size("failed"),
|
|
959
|
+
reserved: queue.size("reserved"),
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
// Jobs by status — parity with Python's _api_queue: list PENDING by reading
|
|
963
|
+
// the on-disk queue files directly (data/queue/<topic>/*.queue-data), then
|
|
964
|
+
// fold in the file-backed failed() + deadLetters() jobs. This is why real
|
|
965
|
+
// persisted jobs now show even though DevQueue (in-memory) is empty.
|
|
966
|
+
const jobs: Array<ReturnType<typeof mapQueueJob>> = [];
|
|
967
|
+
|
|
968
|
+
if (!statusFilter || statusFilter === "pending") {
|
|
969
|
+
const queueDir = join(process.cwd(), "data", "queue", topic);
|
|
970
|
+
if (existsSync(queueDir)) {
|
|
971
|
+
for (const filename of readdirSync(queueDir).sort()) {
|
|
972
|
+
if (!filename.endsWith(".queue-data")) continue; // skips failed/ + reserved/ subdirs
|
|
973
|
+
try {
|
|
974
|
+
const job = JSON.parse(readFileSync(join(queueDir, filename), "utf-8"));
|
|
975
|
+
jobs.push(mapQueueJob(job, topic, "pending"));
|
|
976
|
+
} catch {
|
|
977
|
+
// skip corrupt files
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (!statusFilter || statusFilter === "failed") {
|
|
983
|
+
for (const j of queue.failed()) jobs.push(mapQueueJob(j, topic, "failed"));
|
|
984
|
+
}
|
|
985
|
+
if (!statusFilter || statusFilter === "dead") {
|
|
986
|
+
for (const j of queue.deadLetters()) jobs.push(mapQueueJob(j, topic, "dead_letter"));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
res.json({ stats, jobs });
|
|
990
|
+
} catch (e: any) {
|
|
991
|
+
res.json({
|
|
992
|
+
stats: { pending: 0, completed: 0, failed: 0, reserved: 0 },
|
|
993
|
+
jobs: [],
|
|
994
|
+
error: String(e?.message ?? e),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
946
997
|
};
|
|
947
998
|
|
|
948
999
|
const handleQueueTopics: RouteHandler = (_req, res) => {
|
|
@@ -970,23 +1021,20 @@ const handleQueueTopics: RouteHandler = (_req, res) => {
|
|
|
970
1021
|
}
|
|
971
1022
|
};
|
|
972
1023
|
|
|
973
|
-
const handleQueueDeadLetters: RouteHandler = (req, res) => {
|
|
1024
|
+
const handleQueueDeadLetters: RouteHandler = async (req, res) => {
|
|
974
1025
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
975
1026
|
const topic = url.searchParams.get("topic") ?? "default";
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
data: j.payload ?? {},
|
|
988
|
-
}));
|
|
989
|
-
res.json({ jobs, count: jobs.length, topic });
|
|
1027
|
+
// Parity with Python's _api_queue_dead_letters: read the file-backed
|
|
1028
|
+
// dead-letter store (data/queue/<topic>/failed/*.queue-data) so real
|
|
1029
|
+
// exhausted-retry jobs surface, not just the empty in-memory DevQueue.
|
|
1030
|
+
try {
|
|
1031
|
+
const { Queue } = await import("./queue.js");
|
|
1032
|
+
const queue = new Queue({ topic });
|
|
1033
|
+
const jobs = queue.deadLetters().map((j) => mapQueueJob(j, topic, "dead_letter"));
|
|
1034
|
+
res.json({ jobs, count: jobs.length, topic });
|
|
1035
|
+
} catch (e: any) {
|
|
1036
|
+
res.json({ jobs: [], count: 0, topic, error: String(e?.message ?? e) });
|
|
1037
|
+
}
|
|
990
1038
|
};
|
|
991
1039
|
|
|
992
1040
|
const handleQueueRetry: RouteHandler = (_req, res) => {
|
|
@@ -1052,17 +1100,53 @@ const handleMailboxClear: RouteHandler = (req, res) => {
|
|
|
1052
1100
|
res.json({ cleared: true, folder: folder ?? "all" });
|
|
1053
1101
|
};
|
|
1054
1102
|
|
|
1055
|
-
// -- Database handlers
|
|
1103
|
+
// -- Database handlers --
|
|
1056
1104
|
|
|
1057
|
-
|
|
1105
|
+
/**
|
|
1106
|
+
* Quote a (optionally schema-qualified) table reference for safe interpolation.
|
|
1107
|
+
* Returns null for anything that isn't one or two plain SQL identifiers, so a
|
|
1108
|
+
* hostile ?name= can never reach the SELECT. Mirrors the identifier guard the
|
|
1109
|
+
* sqlite adapter uses for PRAGMA table_info.
|
|
1110
|
+
*/
|
|
1111
|
+
function quoteTableRef(name: string): string | null {
|
|
1112
|
+
const parts = name.split(".");
|
|
1113
|
+
if (parts.length === 0 || parts.length > 2) return null;
|
|
1114
|
+
for (const p of parts) {
|
|
1115
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(p)) return null;
|
|
1116
|
+
}
|
|
1117
|
+
return parts.map((p) => `"${p}"`).join(".");
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const handleTable: RouteHandler = async (req, res) => {
|
|
1058
1121
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1059
1122
|
const name = url.searchParams.get("name") ?? "";
|
|
1060
1123
|
if (!name) {
|
|
1061
1124
|
res.json({ error: "Missing table name parameter" });
|
|
1062
1125
|
return;
|
|
1063
1126
|
}
|
|
1064
|
-
//
|
|
1065
|
-
|
|
1127
|
+
// Real implementation — parity with Python's _api_table_info: return the
|
|
1128
|
+
// table's column list + a sample of up to 20 rows via the same DB adapter
|
|
1129
|
+
// `tables`/`query` use.
|
|
1130
|
+
try {
|
|
1131
|
+
const { getAdapter } = await import("../../orm/src/index.js");
|
|
1132
|
+
const db = getAdapter();
|
|
1133
|
+
const columns = db.columns(name);
|
|
1134
|
+
if (!columns.length) {
|
|
1135
|
+
res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const ref = quoteTableRef(name);
|
|
1139
|
+
if (!ref) {
|
|
1140
|
+
// Columns resolved but the name isn't a plain identifier we'll splice
|
|
1141
|
+
// into SQL — surface the schema without the sample rather than risk it.
|
|
1142
|
+
res.json({ table: name, columns, rows: [], count: 0, message: "Invalid table name for sampling" });
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const rows = db.fetch(`SELECT * FROM ${ref} LIMIT 20`);
|
|
1146
|
+
res.json({ table: name, columns, rows, count: rows.length });
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
res.json({ table: name, columns: [], rows: [], message: (e as Error)?.message ?? "Database not connected or table not found" });
|
|
1149
|
+
}
|
|
1066
1150
|
};
|
|
1067
1151
|
|
|
1068
1152
|
const handleTables: RouteHandler = async (_req, res) => {
|
|
@@ -1422,6 +1506,147 @@ const handleChat: RouteHandler = async (req, res) => {
|
|
|
1422
1506
|
await proxyToSupervisor(req, res, "/chat");
|
|
1423
1507
|
};
|
|
1424
1508
|
|
|
1509
|
+
// -- Framework-grounding (mcp.tina4.com) token config --
|
|
1510
|
+
//
|
|
1511
|
+
// TINA4_MCP_TOKEN grounds the coder against mcp.tina4.com's tina4_context.
|
|
1512
|
+
// These routes are self-contained: they read/write the token in the project
|
|
1513
|
+
// .env directly (no dependency on the Rust agent being up), matching the
|
|
1514
|
+
// python/php/ruby dev_admin. The agent still resolves the same TINA4_MCP_TOKEN
|
|
1515
|
+
// (process env → .env) when it runs.
|
|
1516
|
+
// GET /__dev/api/grounding/status → {configured, last4, url}
|
|
1517
|
+
// POST /__dev/api/grounding/token → upsert TINA4_MCP_TOKEN in .env
|
|
1518
|
+
const DEFAULT_MCP_URL = "https://mcp.tina4.com";
|
|
1519
|
+
|
|
1520
|
+
/** Resolve a var from the live process env, falling back to the project .env. */
|
|
1521
|
+
function resolveDevEnvVar(key: string): string {
|
|
1522
|
+
const live = process.env[key];
|
|
1523
|
+
if (live !== undefined && live !== "") return live;
|
|
1524
|
+
const envPath = join(process.cwd(), ".env");
|
|
1525
|
+
if (!existsSync(envPath)) return "";
|
|
1526
|
+
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
|
1527
|
+
const t = line.trim();
|
|
1528
|
+
if (!t || t.startsWith("#") || !t.includes("=")) continue;
|
|
1529
|
+
const eq = t.indexOf("=");
|
|
1530
|
+
if (t.slice(0, eq).trim() === key) return t.slice(eq + 1).trim();
|
|
1531
|
+
}
|
|
1532
|
+
return "";
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** Upsert KEY=value into the project .env (creates the file if absent). */
|
|
1536
|
+
function upsertDevEnvVar(key: string, value: string): void {
|
|
1537
|
+
const envPath = join(process.cwd(), ".env");
|
|
1538
|
+
const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : [];
|
|
1539
|
+
let found = false;
|
|
1540
|
+
const out: string[] = [];
|
|
1541
|
+
for (const line of lines) {
|
|
1542
|
+
const t = line.trim();
|
|
1543
|
+
if (!t || t.startsWith("#") || !t.includes("=")) { out.push(line); continue; }
|
|
1544
|
+
if (t.slice(0, t.indexOf("=")).trim() === key) { out.push(`${key}=${value}`); found = true; }
|
|
1545
|
+
else out.push(line);
|
|
1546
|
+
}
|
|
1547
|
+
if (!found) out.push(`${key}=${value}`);
|
|
1548
|
+
writeFileSync(envPath, out.join("\n").replace(/\n+$/, "") + "\n");
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
const handleGroundingStatus: RouteHandler = async (_req, res) => {
|
|
1552
|
+
const token = resolveDevEnvVar("TINA4_MCP_TOKEN");
|
|
1553
|
+
const url = resolveDevEnvVar("TINA4_MCP_URL") || DEFAULT_MCP_URL;
|
|
1554
|
+
res.json({ configured: token.length > 0, last4: token ? token.slice(-4) : "", url });
|
|
1555
|
+
};
|
|
1556
|
+
const handleGroundingToken: RouteHandler = async (req, res) => {
|
|
1557
|
+
const body = (req.body as Record<string, unknown>) || {};
|
|
1558
|
+
const token = String(body.token ?? "").trim();
|
|
1559
|
+
try {
|
|
1560
|
+
upsertDevEnvVar("TINA4_MCP_TOKEN", token);
|
|
1561
|
+
// Reflect into the live process env so a follow-up status reads back at once.
|
|
1562
|
+
process.env.TINA4_MCP_TOKEN = token;
|
|
1563
|
+
res.json({ ok: true, configured: token.length > 0, last4: token ? token.slice(-4) : "" });
|
|
1564
|
+
} catch (e) {
|
|
1565
|
+
res.json({ ok: false, error: (e as Error).message }, 500);
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
// -- Scaffold run chips: migrate / test / seed-all --
|
|
1570
|
+
//
|
|
1571
|
+
// The dev-admin's ▶ Migrate / ▶ Test / ▶ Seed chips are project-level
|
|
1572
|
+
// operations, distinct from the create endpoint (/scaffold/run). Each uses the
|
|
1573
|
+
// framework's own machinery so behaviour matches the CLI.
|
|
1574
|
+
|
|
1575
|
+
/** POST /__dev/api/migrate — apply pending migrations via the ORM's migrate(). */
|
|
1576
|
+
const handleMigrate: RouteHandler = async (_req, res) => {
|
|
1577
|
+
try {
|
|
1578
|
+
const orm = await import("../../orm/src/index.js");
|
|
1579
|
+
const result = await orm.migrate();
|
|
1580
|
+
res.json({
|
|
1581
|
+
ok: result.failed.length === 0,
|
|
1582
|
+
applied: result.applied,
|
|
1583
|
+
skipped: result.skipped,
|
|
1584
|
+
failed: result.failed,
|
|
1585
|
+
});
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
res.json({ ok: false, error: (e as Error).message }, 500);
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
/** POST /__dev/api/seed/run — seed every discovered model, FK-ordered. */
|
|
1592
|
+
const handleSeedRun: RouteHandler = async (req, res) => {
|
|
1593
|
+
const body = (req.body as Record<string, unknown>) || {};
|
|
1594
|
+
const count = parseInt(String(body.count ?? "10"), 10) || 10;
|
|
1595
|
+
try {
|
|
1596
|
+
const orm = await import("../../orm/src/index.js");
|
|
1597
|
+
// Models live in src/orm/ (primary) and src/models/ (fallback) — same dirs
|
|
1598
|
+
// the server discovers on startup.
|
|
1599
|
+
const dirs = ["src/orm", "src/models"].map((d) => resolve(process.cwd(), d)).filter((d) => existsSync(d));
|
|
1600
|
+
const classes: unknown[] = [];
|
|
1601
|
+
for (const dir of dirs) {
|
|
1602
|
+
for (const m of await orm.discoverModels(dir)) classes.push(m.modelClass);
|
|
1603
|
+
}
|
|
1604
|
+
if (classes.length === 0) {
|
|
1605
|
+
res.json({ ok: false, error: "No models found in src/orm/ or src/models/" }, 400);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
const summaries = await orm.seedModels(classes as never[], count);
|
|
1609
|
+
let seeded = 0, failed = 0;
|
|
1610
|
+
for (const s of Object.values(summaries) as Array<{ seeded: number; failed: number }>) {
|
|
1611
|
+
seeded += s.seeded; failed += s.failed;
|
|
1612
|
+
}
|
|
1613
|
+
res.json({ ok: failed === 0, seeded, failed, tables: Object.keys(summaries).length });
|
|
1614
|
+
} catch (e) {
|
|
1615
|
+
res.json({ ok: false, error: (e as Error).message }, 500);
|
|
1616
|
+
}
|
|
1617
|
+
};
|
|
1618
|
+
|
|
1619
|
+
/** POST /__dev/api/test — run the project test suite (`npm test`) and stream back
|
|
1620
|
+
* the combined output + exit code. Bounded timeout so a hung suite can't wedge
|
|
1621
|
+
* the dev server. */
|
|
1622
|
+
const handleTest: RouteHandler = async (_req, res) => {
|
|
1623
|
+
try {
|
|
1624
|
+
const { execFile } = await import("node:child_process");
|
|
1625
|
+
const { promisify } = await import("node:util");
|
|
1626
|
+
const run = promisify(execFile);
|
|
1627
|
+
try {
|
|
1628
|
+
const { stdout, stderr } = await run("npm", ["test"], {
|
|
1629
|
+
cwd: resolve(process.cwd()),
|
|
1630
|
+
timeout: 180_000,
|
|
1631
|
+
encoding: "utf-8",
|
|
1632
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1633
|
+
});
|
|
1634
|
+
res.json({ ok: true, code: 0, output: `${stdout}${stderr}` });
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
// Non-zero exit (failing tests) lands here — that's a valid, reportable
|
|
1637
|
+
// result, not a 500. Surface the output + code so the UI shows failures.
|
|
1638
|
+
const e = err as { code?: number; stdout?: string; stderr?: string; message?: string };
|
|
1639
|
+
res.json({
|
|
1640
|
+
ok: false,
|
|
1641
|
+
code: typeof e.code === "number" ? e.code : 1,
|
|
1642
|
+
output: `${e.stdout ?? ""}${e.stderr ?? ""}` || e.message || "tests failed",
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
} catch (e) {
|
|
1646
|
+
res.json({ ok: false, error: (e as Error).message }, 500);
|
|
1647
|
+
}
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1425
1650
|
// -- Threads handlers --
|
|
1426
1651
|
|
|
1427
1652
|
/**
|
|
@@ -46,10 +46,36 @@ export function createRequest(req: IncomingMessage): Tina4Request {
|
|
|
46
46
|
const host = (Array.isArray(xfHost) ? xfHost[0] : xfHost)
|
|
47
47
|
?? (req.headers.host ?? "localhost");
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// Parse the request-target into path + query. The WHATWG `URL` parser THROWS
|
|
50
|
+
// `ERR_INVALID_URL` on malformed targets like `//`, `///`, and `/\` — and this
|
|
51
|
+
// runs BEFORE the dispatch try/catch, with the uncaughtException net only wired
|
|
52
|
+
// under TINA4_DEBUG, so an unguarded throw crashes the worker in production
|
|
53
|
+
// (unauthenticated remote DoS — scanners send `//` routinely). Guard it: on a
|
|
54
|
+
// parse failure, derive the path/query straight from the raw target so routing
|
|
55
|
+
// proceeds to a normal 404 instead of taking the process down. (#33)
|
|
50
56
|
const query: Record<string, string> = {};
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
let path: string;
|
|
58
|
+
let queryString: string;
|
|
59
|
+
let fullUrl: string;
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(req.url ?? "/", `${proto}://${host}`);
|
|
62
|
+
for (const [key, value] of url.searchParams) {
|
|
63
|
+
query[key] = value;
|
|
64
|
+
}
|
|
65
|
+
path = url.pathname;
|
|
66
|
+
queryString = url.search.replace(/^\?/, "");
|
|
67
|
+
fullUrl = url.toString();
|
|
68
|
+
} catch {
|
|
69
|
+
const rawTarget = req.url ?? "/";
|
|
70
|
+
const questionIdx = rawTarget.indexOf("?");
|
|
71
|
+
path = questionIdx >= 0 ? rawTarget.slice(0, questionIdx) : rawTarget;
|
|
72
|
+
queryString = questionIdx >= 0 ? rawTarget.slice(questionIdx + 1) : "";
|
|
73
|
+
try {
|
|
74
|
+
for (const [key, value] of new URLSearchParams(queryString)) {
|
|
75
|
+
query[key] = value;
|
|
76
|
+
}
|
|
77
|
+
} catch { /* unparseable query — leave query empty, still route the path */ }
|
|
78
|
+
fullUrl = `${proto}://${host}${rawTarget}`;
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
tReq.params = {};
|
|
@@ -58,9 +84,9 @@ export function createRequest(req: IncomingMessage): Tina4Request {
|
|
|
58
84
|
// `path` is the URL path only; `queryString` is the raw query without "?".
|
|
59
85
|
// `url` is overridden from Node's IncomingMessage.url (path+query) to the
|
|
60
86
|
// full absolute URL — parity with PHP/Python/Ruby.
|
|
61
|
-
tReq.path =
|
|
62
|
-
tReq.queryString =
|
|
63
|
-
tReq.url =
|
|
87
|
+
tReq.path = path;
|
|
88
|
+
tReq.queryString = queryString;
|
|
89
|
+
tReq.url = fullUrl;
|
|
64
90
|
tReq.body = undefined;
|
|
65
91
|
tReq.files = {};
|
|
66
92
|
tReq.contentType = (req.headers["content-type"] ?? "") as string;
|
|
@@ -628,6 +628,41 @@ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
|
|
|
628
628
|
}
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
// Filter pipe: value | filter1 | filter2(args). In Twig `|` binds TIGHTER than
|
|
632
|
+
// concat (`~`), comparisons and arithmetic, but looser than member/function
|
|
633
|
+
// access — so it is resolved here, AFTER those operators have had a chance to
|
|
634
|
+
// split the expression. Handling it inside the expression evaluator (not only
|
|
635
|
+
// at the {{ }} output layer) makes filters work at any nesting depth: concat
|
|
636
|
+
// operands, ternary branches, and parenthesised sub-expressions. This fixes
|
|
637
|
+
// both the `|`-vs-`~` precedence bug and the "pipe inside parens returns empty"
|
|
638
|
+
// defect. (#171)
|
|
639
|
+
if (findOutsideQuotes(expr, "|") >= 0) {
|
|
640
|
+
const [baseExpr, filters] = parseFilterChain(expr);
|
|
641
|
+
if (filters.length > 0) {
|
|
642
|
+
const baseValue = evalExpr(baseExpr, context);
|
|
643
|
+
const applier = (context as {
|
|
644
|
+
__frond_apply_filters__?: (
|
|
645
|
+
value: unknown,
|
|
646
|
+
filters: [string, unknown[]][],
|
|
647
|
+
context: Record<string, unknown>,
|
|
648
|
+
) => unknown;
|
|
649
|
+
}).__frond_apply_filters__;
|
|
650
|
+
if (applier) {
|
|
651
|
+
return applier(baseValue, filters, context);
|
|
652
|
+
}
|
|
653
|
+
// No Frond instance in context (evalExpr used stand-alone) — apply the
|
|
654
|
+
// built-in filters directly so common cases still resolve.
|
|
655
|
+
let value = baseValue;
|
|
656
|
+
for (const [fname, rawArgs] of filters) {
|
|
657
|
+
if (fname === "raw" || fname === "safe") continue;
|
|
658
|
+
const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
|
|
659
|
+
const fn = BUILTIN_FILTERS[fname];
|
|
660
|
+
if (fn) value = fn(value, ...args);
|
|
661
|
+
}
|
|
662
|
+
return value;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
631
666
|
// Function call: name("arg1", "arg2") — supports dotted names like user.t("key")
|
|
632
667
|
const fnMatch = expr.match(FN_CALL_RE);
|
|
633
668
|
if (fnMatch) {
|
|
@@ -1131,12 +1166,17 @@ function wordwrap(text: string, width: number): string {
|
|
|
1131
1166
|
return lines.join("\n");
|
|
1132
1167
|
}
|
|
1133
1168
|
|
|
1134
|
-
function numberFormat(
|
|
1169
|
+
function numberFormat(
|
|
1170
|
+
value: unknown,
|
|
1171
|
+
decimals: number,
|
|
1172
|
+
decimalPoint = ".",
|
|
1173
|
+
thousandsSep = ",",
|
|
1174
|
+
): string {
|
|
1135
1175
|
const num = parseFloat(String(value));
|
|
1136
1176
|
const fixed = num.toFixed(decimals);
|
|
1137
1177
|
const [intPart, decPart] = fixed.split(".");
|
|
1138
|
-
const formatted = intPart.replace(THOUSANDS_RE,
|
|
1139
|
-
return decPart ? `${formatted}
|
|
1178
|
+
const formatted = intPart.replace(THOUSANDS_RE, thousandsSep);
|
|
1179
|
+
return decPart ? `${formatted}${decimalPoint}${decPart}` : formatted;
|
|
1140
1180
|
}
|
|
1141
1181
|
|
|
1142
1182
|
const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
@@ -1248,7 +1288,15 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1248
1288
|
return null;
|
|
1249
1289
|
});
|
|
1250
1290
|
},
|
|
1251
|
-
|
|
1291
|
+
// Twig signature: number_format(decimals=0, decimalPoint='.', thousandsSep=',').
|
|
1292
|
+
// 1-arg (or no-arg) calls keep the original output; args 2/3 enable localized
|
|
1293
|
+
// formats like `1.234,50`. (#170)
|
|
1294
|
+
number_format: (v, decimals, decimalPoint, thousandsSep) => numberFormat(
|
|
1295
|
+
v,
|
|
1296
|
+
decimals !== undefined ? parseInt(String(decimals), 10) : 0,
|
|
1297
|
+
decimalPoint !== undefined ? String(decimalPoint) : ".",
|
|
1298
|
+
thousandsSep !== undefined ? String(thousandsSep) : ",",
|
|
1299
|
+
),
|
|
1252
1300
|
date: (v, fmt) => dateFilter(v, fmt !== undefined ? String(fmt) : "%Y-%m-%d"),
|
|
1253
1301
|
truncate: (v, length) => {
|
|
1254
1302
|
const s = String(v);
|
|
@@ -1486,6 +1534,13 @@ export class Frond {
|
|
|
1486
1534
|
/** Token pre-compilation cache for string templates */
|
|
1487
1535
|
private compiledStrings = new Map<string, { tokens: Token[]; cachedAt: number }>();
|
|
1488
1536
|
|
|
1537
|
+
/**
|
|
1538
|
+
* Bound reference to `applyFilters`, stashed into the render context as
|
|
1539
|
+
* `__frond_apply_filters__` so the module-level `evalExpr` can resolve a
|
|
1540
|
+
* filter pipe using THIS instance's registered filters. Bound once. (#171)
|
|
1541
|
+
*/
|
|
1542
|
+
private readonly _applyFiltersBound = this.applyFilters.bind(this);
|
|
1543
|
+
|
|
1489
1544
|
getTemplateDir(): string { return this.templateDir; }
|
|
1490
1545
|
|
|
1491
1546
|
constructor(templateDir: string = "src/templates") {
|
|
@@ -1819,6 +1874,12 @@ export class Frond {
|
|
|
1819
1874
|
}
|
|
1820
1875
|
|
|
1821
1876
|
private renderTokens(tokens: Token[], context: Record<string, unknown>): string {
|
|
1877
|
+
// Expose this instance's filter engine to the module-level evalExpr so a
|
|
1878
|
+
// filter pipe resolves at any expression depth with the right (custom)
|
|
1879
|
+
// filters. The currently-rendering engine always wins — an included/macro
|
|
1880
|
+
// template rendered by another engine re-stamps its own here. (#171)
|
|
1881
|
+
(context as { __frond_apply_filters__?: unknown }).__frond_apply_filters__ = this._applyFiltersBound;
|
|
1882
|
+
|
|
1822
1883
|
const output: string[] = [];
|
|
1823
1884
|
let i = 0;
|
|
1824
1885
|
|
|
@@ -1948,6 +2009,78 @@ export class Frond {
|
|
|
1948
2009
|
return i;
|
|
1949
2010
|
}
|
|
1950
2011
|
|
|
2012
|
+
/**
|
|
2013
|
+
* Apply a parsed filter chain to an already-evaluated value. This is the
|
|
2014
|
+
* instance-aware filter engine used by `evalExpr` (via the
|
|
2015
|
+
* `__frond_apply_filters__` hook in the render context) so filters resolve
|
|
2016
|
+
* with this Frond's registered/custom filters at ANY nesting depth — inside
|
|
2017
|
+
* concat operands, ternary branches, and parenthesised sub-expressions — not
|
|
2018
|
+
* only at the top-level {{ }} output. Mirrors the filter loop in
|
|
2019
|
+
* `evalVarRaw`: `first`/`last` tail-paths, registered `this.filters`, and the
|
|
2020
|
+
* trailing-comparison form (`length != 1`). Auto-escaping stays the caller's
|
|
2021
|
+
* concern (`evalVarInner`). (#171)
|
|
2022
|
+
*/
|
|
2023
|
+
private applyFilters(
|
|
2024
|
+
value: unknown,
|
|
2025
|
+
filters: [string, unknown[]][],
|
|
2026
|
+
context: Record<string, unknown>,
|
|
2027
|
+
): unknown {
|
|
2028
|
+
for (const [fname, rawArgs] of filters) {
|
|
2029
|
+
const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
|
|
2030
|
+
if (fname === "raw" || fname === "safe") continue;
|
|
2031
|
+
|
|
2032
|
+
// Sandbox: a blocked filter is silently skipped (value passes through
|
|
2033
|
+
// unchanged) — same gate as evalVarInner. applyFilters is reached by the
|
|
2034
|
+
// folded filter pipe in evalExpr (`x|f ~ y`, #171), so without this gate a
|
|
2035
|
+
// non-allow-listed filter would run in sandbox mode.
|
|
2036
|
+
if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
|
|
2037
|
+
|
|
2038
|
+
const [realFname, tailPath] = splitFilterNameAndPath(fname);
|
|
2039
|
+
if (tailPath) {
|
|
2040
|
+
let applied = false;
|
|
2041
|
+
if (realFname === "first") {
|
|
2042
|
+
value = Array.isArray(value) ? value[0] ?? null : null;
|
|
2043
|
+
applied = true;
|
|
2044
|
+
} else if (realFname === "last") {
|
|
2045
|
+
value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
|
|
2046
|
+
applied = true;
|
|
2047
|
+
} else if (this.filters[realFname]) {
|
|
2048
|
+
value = this.filters[realFname](value, ...args);
|
|
2049
|
+
applied = true;
|
|
2050
|
+
}
|
|
2051
|
+
if (applied) {
|
|
2052
|
+
value = evalExpr("__frondFilterTmp." + tailPath, { __frondFilterTmp: value });
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
const fn = this.filters[fname];
|
|
2058
|
+
if (fn) {
|
|
2059
|
+
value = fn(value, ...args);
|
|
2060
|
+
} else {
|
|
2061
|
+
// The filter name may carry a trailing comparison operator, e.g.
|
|
2062
|
+
// "length != 1" — apply the real filter, then evaluate the comparison.
|
|
2063
|
+
const m = fname.match(FILTER_COMPARISON_RE);
|
|
2064
|
+
if (m) {
|
|
2065
|
+
const fn2 = this.filters[m[1]];
|
|
2066
|
+
if (fn2) value = fn2(value, ...args);
|
|
2067
|
+
const right = evalExpr(m[3].trim(), context);
|
|
2068
|
+
switch (m[2]) {
|
|
2069
|
+
case "!=": value = value !== right; break;
|
|
2070
|
+
case "==": value = value === right; break;
|
|
2071
|
+
case ">=": value = (value as number) >= (right as number); break;
|
|
2072
|
+
case "<=": value = (value as number) <= (right as number); break;
|
|
2073
|
+
case ">": value = (value as number) > (right as number); break;
|
|
2074
|
+
case "<": value = (value as number) < (right as number); break;
|
|
2075
|
+
}
|
|
2076
|
+
} else {
|
|
2077
|
+
value = evalExpr(fname, context);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
return value;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1951
2084
|
private evalVar(expr: string, context: Record<string, unknown>): unknown {
|
|
1952
2085
|
// Check for top-level ternary BEFORE splitting filters so that
|
|
1953
2086
|
// expressions like ``products|length != 1 ? "s" : ""`` work correctly.
|
|
@@ -1974,6 +2107,12 @@ export class Frond {
|
|
|
1974
2107
|
const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
|
|
1975
2108
|
if (fname === "raw" || fname === "safe") continue;
|
|
1976
2109
|
|
|
2110
|
+
// Sandbox: a blocked filter is silently skipped (value passes through
|
|
2111
|
+
// unchanged) — same gate as evalVarInner. evalVarRaw is reached by a
|
|
2112
|
+
// ternary condition (`x|f ? a : b`), evalComparison, and set, none of
|
|
2113
|
+
// which gated filters before, so a non-allow-listed filter could run.
|
|
2114
|
+
if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
|
|
2115
|
+
|
|
1977
2116
|
// Filter + property-access chain: `first.groupSummary` — apply
|
|
1978
2117
|
// the filter, then traverse the path on the result via a
|
|
1979
2118
|
// synthetic context so evalExpr's dotted resolution does the
|
|
@@ -2044,6 +2183,20 @@ export class Frond {
|
|
|
2044
2183
|
}
|
|
2045
2184
|
}
|
|
2046
2185
|
|
|
2186
|
+
// Concat precedence (#171): a top-level `~` means the whole expression is a
|
|
2187
|
+
// concatenation, and `|` binds TIGHTER than `~`. parseFilterChain above
|
|
2188
|
+
// wrongly glued the trailing `~ ...` onto the last filter, so evaluate the
|
|
2189
|
+
// WHOLE expression through evalExpr (which resolves the filter pipe at the
|
|
2190
|
+
// correct precedence, at any depth) and only auto-escape the result here.
|
|
2191
|
+
if (findOutsideQuotes(expr, "~") >= 0) {
|
|
2192
|
+
let concatValue = evalExpr(expr, context);
|
|
2193
|
+
if (concatValue instanceof SafeString) return concatValue.value;
|
|
2194
|
+
if (this._autoEscape && typeof concatValue === "string") {
|
|
2195
|
+
concatValue = htmlEscape(concatValue);
|
|
2196
|
+
}
|
|
2197
|
+
return concatValue;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2047
2200
|
let value = evalExpr(varName, context);
|
|
2048
2201
|
|
|
2049
2202
|
let isSafe = false;
|