tina4-nodejs 3.0.0-rc.2 → 3.1.0
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/BENCHMARK_REPORT.md +248 -86
- package/CARBONAH.md +4 -4
- package/CLAUDE.md +16 -1
- package/COMPARISON.md +58 -46
- package/README.md +60 -6
- package/package.json +2 -1
- package/packages/cli/src/bin.ts +8 -0
- package/packages/cli/src/commands/generate.ts +237 -0
- package/packages/core/gallery/queue/meta.json +1 -1
- package/packages/core/gallery/queue/src/lib/queueDb.ts +32 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +28 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +28 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +20 -10
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +25 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +36 -6
- package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +160 -0
- package/packages/core/src/cache.ts +402 -10
- package/packages/core/src/index.ts +5 -2
- package/packages/core/src/messenger.ts +118 -36
- package/packages/core/src/queue.ts +172 -92
- package/packages/core/src/response.ts +46 -0
- package/packages/core/src/router.ts +94 -1
- package/packages/core/src/server.ts +66 -7
- package/packages/core/src/types.ts +20 -1
- package/packages/core/src/websocketConnection.ts +16 -0
- package/packages/frond/src/engine.ts +184 -6
- package/packages/orm/src/baseModel.ts +274 -20
- package/packages/orm/src/cachedDatabase.ts +180 -0
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/model.ts +1 -0
- package/packages/orm/src/types.ts +75 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Gallery: Queue — interactive queue demo with visual web UI. */
|
|
2
|
+
import type { Tina4Request, Tina4Response } from "@tina4/core";
|
|
3
|
+
|
|
4
|
+
export default async function (_req: Tina4Request, res: Tina4Response) {
|
|
5
|
+
const html = `<!DOCTYPE html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="UTF-8">
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
10
|
+
<title>Queue Gallery — Tina4 Node.js</title>
|
|
11
|
+
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div class="container mt-4 mb-4">
|
|
15
|
+
<h1>Queue Gallery</h1>
|
|
16
|
+
<p class="text-muted">Interactive demo of Tina4's database-backed job queue. Produce messages, consume them, simulate failures, and inspect dead letters.</p>
|
|
17
|
+
|
|
18
|
+
<div class="row mt-3">
|
|
19
|
+
<div class="col-md-6">
|
|
20
|
+
<div class="card">
|
|
21
|
+
<div class="card-header">Produce a Message</div>
|
|
22
|
+
<div class="card-body">
|
|
23
|
+
<div class="d-flex gap-2">
|
|
24
|
+
<input type="text" id="msgInput" class="form-control" placeholder="Enter a task message, e.g. send-email">
|
|
25
|
+
<button class="btn btn-primary" onclick="produce()">Produce</button>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="col-md-6">
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div class="card-header">Actions</div>
|
|
33
|
+
<div class="card-body d-flex gap-2 flex-wrap">
|
|
34
|
+
<button class="btn btn-success" onclick="consume()">Consume Next</button>
|
|
35
|
+
<button class="btn btn-danger" onclick="failNext()">Fail Next</button>
|
|
36
|
+
<button class="btn btn-warning" onclick="retryFailed()">Retry Failed</button>
|
|
37
|
+
<button class="btn btn-secondary" onclick="refresh()">Refresh</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div id="alertArea" class="mt-3"></div>
|
|
44
|
+
|
|
45
|
+
<div class="card mt-3">
|
|
46
|
+
<div class="card-header d-flex justify-content-between align-items-center">
|
|
47
|
+
<span>Queue Messages</span>
|
|
48
|
+
<small class="text-muted" id="lastRefresh"></small>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="card-body p-0">
|
|
51
|
+
<table class="table table-striped mb-0">
|
|
52
|
+
<thead>
|
|
53
|
+
<tr>
|
|
54
|
+
<th>ID</th>
|
|
55
|
+
<th>Data</th>
|
|
56
|
+
<th>Status</th>
|
|
57
|
+
<th>Attempts</th>
|
|
58
|
+
<th>Error</th>
|
|
59
|
+
<th>Created</th>
|
|
60
|
+
</tr>
|
|
61
|
+
</thead>
|
|
62
|
+
<tbody id="queueBody">
|
|
63
|
+
<tr><td colspan="6" class="text-center text-muted">Loading...</td></tr>
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<script>
|
|
71
|
+
function statusBadge(status) {
|
|
72
|
+
var colors = {pending:"primary", reserved:"warning", completed:"success", failed:"danger", dead:"secondary"};
|
|
73
|
+
var color = colors[status] || "secondary";
|
|
74
|
+
return '<span class="badge bg-' + color + '">' + status + '</span>';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function showAlert(msg, type) {
|
|
78
|
+
var area = document.getElementById("alertArea");
|
|
79
|
+
area.innerHTML = '<div class="alert alert-' + type + ' alert-dismissible">' + msg +
|
|
80
|
+
'<button type="button" class="btn-close" onclick="this.parentElement.remove()"></button></div>';
|
|
81
|
+
setTimeout(function(){ area.innerHTML = ""; }, 3000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function truncate(s, n) {
|
|
85
|
+
if (!s) return "";
|
|
86
|
+
return s.length > n ? s.substring(0, n) + "..." : s;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function refresh() {
|
|
90
|
+
try {
|
|
91
|
+
var r = await fetch("/api/gallery/queue/status");
|
|
92
|
+
var data = await r.json();
|
|
93
|
+
var tbody = document.getElementById("queueBody");
|
|
94
|
+
if (!data.messages || data.messages.length === 0) {
|
|
95
|
+
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted">No messages in queue. Produce one above.</td></tr>';
|
|
96
|
+
} else {
|
|
97
|
+
var html = "";
|
|
98
|
+
for (var i = 0; i < data.messages.length; i++) {
|
|
99
|
+
var m = data.messages[i];
|
|
100
|
+
html += "<tr><td>" + m.id + "</td><td><code>" + truncate(m.data, 60) + "</code></td><td>" +
|
|
101
|
+
statusBadge(m.status) + "</td><td>" + m.attempts + "</td><td>" +
|
|
102
|
+
truncate(m.error || "", 40) + "</td><td><small>" + (m.created_at || "") + "</small></td></tr>";
|
|
103
|
+
}
|
|
104
|
+
tbody.innerHTML = html;
|
|
105
|
+
}
|
|
106
|
+
document.getElementById("lastRefresh").textContent = "Updated " + new Date().toLocaleTimeString();
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error(e);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function produce() {
|
|
113
|
+
var input = document.getElementById("msgInput");
|
|
114
|
+
var task = input.value.trim() || "demo-task";
|
|
115
|
+
var r = await fetch("/api/gallery/queue/produce", {
|
|
116
|
+
method: "POST", headers: {"Content-Type":"application/json"},
|
|
117
|
+
body: JSON.stringify({task: task, data: {message: task}})
|
|
118
|
+
});
|
|
119
|
+
var d = await r.json();
|
|
120
|
+
showAlert("Produced message: " + task, "success");
|
|
121
|
+
input.value = "";
|
|
122
|
+
refresh();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function consume() {
|
|
126
|
+
var r = await fetch("/api/gallery/queue/consume", {method:"POST"});
|
|
127
|
+
var d = await r.json();
|
|
128
|
+
if (d.consumed) {
|
|
129
|
+
showAlert("Consumed job #" + d.job_id + " successfully", "success");
|
|
130
|
+
} else {
|
|
131
|
+
showAlert(d.message || "Nothing to consume", "info");
|
|
132
|
+
}
|
|
133
|
+
refresh();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function failNext() {
|
|
137
|
+
var r = await fetch("/api/gallery/queue/fail", {method:"POST"});
|
|
138
|
+
var d = await r.json();
|
|
139
|
+
if (d.failed) {
|
|
140
|
+
showAlert("Deliberately failed job #" + d.job_id, "danger");
|
|
141
|
+
} else {
|
|
142
|
+
showAlert(d.message || "Nothing to fail", "info");
|
|
143
|
+
}
|
|
144
|
+
refresh();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function retryFailed() {
|
|
148
|
+
var r = await fetch("/api/gallery/queue/retry", {method:"POST"});
|
|
149
|
+
var d = await r.json();
|
|
150
|
+
showAlert("Retried " + (d.retried || 0) + " failed message(s)", "warning");
|
|
151
|
+
refresh();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
refresh();
|
|
155
|
+
setInterval(refresh, 2000);
|
|
156
|
+
</script>
|
|
157
|
+
</body>
|
|
158
|
+
</html>`;
|
|
159
|
+
return res.html(html);
|
|
160
|
+
}
|
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Multi-backend response cache for GET requests.
|
|
3
|
+
*
|
|
4
|
+
* Backends are selected via the TINA4_CACHE_BACKEND env var:
|
|
5
|
+
* memory — in-process LRU cache (default, zero deps)
|
|
6
|
+
* redis — Redis / Valkey (uses `ioredis` or raw RESP over TCP)
|
|
7
|
+
* file — JSON files in data/cache/
|
|
4
8
|
*
|
|
5
9
|
* Usage:
|
|
6
|
-
* import { responseCache } from "./cache.js";
|
|
10
|
+
* import { responseCache, cacheGet, cacheSet, cacheDelete, cacheClear, cacheStats } from "./cache.js";
|
|
7
11
|
*
|
|
8
12
|
* // As middleware — caches GET responses for ttl seconds
|
|
9
13
|
* middleware.use(responseCache({ ttl: 60 }));
|
|
10
14
|
*
|
|
11
|
-
* //
|
|
12
|
-
*
|
|
15
|
+
* // Direct usage (same across all 4 languages)
|
|
16
|
+
* cacheSet("key", {"data": "value"}, 120);
|
|
17
|
+
* const value = cacheGet("key");
|
|
18
|
+
* cacheDelete("key");
|
|
19
|
+
* cacheClear();
|
|
20
|
+
* const stats = cacheStats();
|
|
13
21
|
*
|
|
14
22
|
* Environment:
|
|
15
|
-
*
|
|
23
|
+
* TINA4_CACHE_BACKEND — memory | redis | file (default: memory)
|
|
24
|
+
* TINA4_CACHE_URL — redis://localhost:6379 (redis only)
|
|
25
|
+
* TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
|
|
26
|
+
* TINA4_CACHE_MAX_ENTRIES — max entries (default: 1000)
|
|
16
27
|
*/
|
|
17
28
|
|
|
18
29
|
import type { Middleware } from "./types.js";
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import * as crypto from "node:crypto";
|
|
33
|
+
import * as net from "node:net";
|
|
34
|
+
|
|
35
|
+
// ── Types ─────────────────────────────────────────────────────────
|
|
19
36
|
|
|
20
37
|
interface CacheEntry {
|
|
21
38
|
body: string;
|
|
@@ -24,6 +41,11 @@ interface CacheEntry {
|
|
|
24
41
|
expiresAt: number;
|
|
25
42
|
}
|
|
26
43
|
|
|
44
|
+
interface DirectEntry {
|
|
45
|
+
value: unknown;
|
|
46
|
+
expiresAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
export interface ResponseCacheConfig {
|
|
28
50
|
/** Default TTL in seconds. 0 = disabled. Default: 60 */
|
|
29
51
|
ttl?: number;
|
|
@@ -31,8 +53,326 @@ export interface ResponseCacheConfig {
|
|
|
31
53
|
maxEntries?: number;
|
|
32
54
|
/** Only cache these status codes. Default: [200] */
|
|
33
55
|
statusCodes?: number[];
|
|
56
|
+
/** Cache backend: memory | redis | file. Default: from env or memory */
|
|
57
|
+
backend?: string;
|
|
58
|
+
/** Redis URL. Default: from env or redis://localhost:6379 */
|
|
59
|
+
cacheUrl?: string;
|
|
60
|
+
/** File cache directory. Default: from env or data/cache */
|
|
61
|
+
cacheDir?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Backend interface ─────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
interface CacheBackend {
|
|
67
|
+
get(key: string): unknown | undefined;
|
|
68
|
+
set(key: string, value: unknown, ttl: number): void;
|
|
69
|
+
delete(key: string): boolean;
|
|
70
|
+
clear(): void;
|
|
71
|
+
stats(): { hits: number; misses: number; size: number; backend: string };
|
|
72
|
+
name(): string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Memory backend ────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
class MemoryBackend implements CacheBackend {
|
|
78
|
+
private store = new Map<string, { value: unknown; expiresAt: number }>();
|
|
79
|
+
private maxEntries: number;
|
|
80
|
+
private hits = 0;
|
|
81
|
+
private misses = 0;
|
|
82
|
+
|
|
83
|
+
constructor(maxEntries = 1000) {
|
|
84
|
+
this.maxEntries = maxEntries;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get(key: string): unknown | undefined {
|
|
88
|
+
const entry = this.store.get(key);
|
|
89
|
+
if (!entry) {
|
|
90
|
+
this.misses++;
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
94
|
+
this.store.delete(key);
|
|
95
|
+
this.misses++;
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
this.hits++;
|
|
99
|
+
// Move to end (LRU refresh) — delete and re-set
|
|
100
|
+
this.store.delete(key);
|
|
101
|
+
this.store.set(key, entry);
|
|
102
|
+
return entry.value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
set(key: string, value: unknown, ttl: number): void {
|
|
106
|
+
const expiresAt = ttl > 0 ? Date.now() + ttl * 1000 : 0;
|
|
107
|
+
this.store.delete(key); // remove to re-insert at end
|
|
108
|
+
this.store.set(key, { value, expiresAt });
|
|
109
|
+
// Evict oldest if over capacity
|
|
110
|
+
while (this.store.size > this.maxEntries) {
|
|
111
|
+
const firstKey = this.store.keys().next().value;
|
|
112
|
+
if (firstKey !== undefined) this.store.delete(firstKey);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
delete(key: string): boolean {
|
|
117
|
+
return this.store.delete(key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
clear(): void {
|
|
121
|
+
this.store.clear();
|
|
122
|
+
this.hits = 0;
|
|
123
|
+
this.misses = 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
stats() {
|
|
127
|
+
// Sweep expired
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
for (const [key, entry] of this.store) {
|
|
130
|
+
if (entry.expiresAt && now > entry.expiresAt) this.store.delete(key);
|
|
131
|
+
}
|
|
132
|
+
return { hits: this.hits, misses: this.misses, size: this.store.size, backend: "memory" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
name() { return "memory"; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Redis backend ─────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
class RedisBackend implements CacheBackend {
|
|
141
|
+
private host: string;
|
|
142
|
+
private port: number;
|
|
143
|
+
private db: number;
|
|
144
|
+
private prefix = "tina4:cache:";
|
|
145
|
+
private hits = 0;
|
|
146
|
+
private misses = 0;
|
|
147
|
+
private maxEntries: number;
|
|
148
|
+
|
|
149
|
+
constructor(url = "redis://localhost:6379", maxEntries = 1000) {
|
|
150
|
+
this.maxEntries = maxEntries;
|
|
151
|
+
const cleaned = url.replace("redis://", "");
|
|
152
|
+
const parts = cleaned.split(":");
|
|
153
|
+
this.host = parts[0] || "localhost";
|
|
154
|
+
const portAndDb = parts[1] ? parts[1].split("/") : ["6379"];
|
|
155
|
+
this.port = parseInt(portAndDb[0], 10) || 6379;
|
|
156
|
+
this.db = portAndDb[1] ? parseInt(portAndDb[1], 10) : 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private respCommand(...args: string[]): Promise<string | null> {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
try {
|
|
162
|
+
let cmd = `*${args.length}\r\n`;
|
|
163
|
+
for (const arg of args) {
|
|
164
|
+
cmd += `$${Buffer.byteLength(arg)}\r\n${arg}\r\n`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const sock = new net.Socket();
|
|
168
|
+
sock.setTimeout(5000);
|
|
169
|
+
let response = "";
|
|
170
|
+
|
|
171
|
+
sock.connect(this.port, this.host, () => {
|
|
172
|
+
if (this.db > 0) {
|
|
173
|
+
const selectCmd = `*2\r\n$6\r\nSELECT\r\n$${String(this.db).length}\r\n${this.db}\r\n`;
|
|
174
|
+
sock.write(selectCmd);
|
|
175
|
+
}
|
|
176
|
+
sock.write(cmd);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
sock.on("data", (data) => {
|
|
180
|
+
response += data.toString();
|
|
181
|
+
sock.end();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
sock.on("end", () => {
|
|
185
|
+
if (response.startsWith("+")) {
|
|
186
|
+
resolve(response.slice(1).trim());
|
|
187
|
+
} else if (response.startsWith("$-1")) {
|
|
188
|
+
resolve(null);
|
|
189
|
+
} else if (response.startsWith("$")) {
|
|
190
|
+
const lines = response.split("\r\n");
|
|
191
|
+
resolve(lines[1] ?? null);
|
|
192
|
+
} else if (response.startsWith(":")) {
|
|
193
|
+
resolve(response.slice(1).trim());
|
|
194
|
+
} else {
|
|
195
|
+
resolve(null);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
sock.on("error", () => resolve(null));
|
|
200
|
+
sock.on("timeout", () => { sock.destroy(); resolve(null); });
|
|
201
|
+
} catch {
|
|
202
|
+
resolve(null);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get(key: string): unknown | undefined {
|
|
208
|
+
// Synchronous fallback — Redis is async, so direct API uses sync memory store
|
|
209
|
+
// For the response cache middleware, this returns undefined and falls through
|
|
210
|
+
this.misses++;
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
set(key: string, value: unknown, ttl: number): void {
|
|
215
|
+
const fullKey = this.prefix + key;
|
|
216
|
+
const serialized = JSON.stringify(value);
|
|
217
|
+
if (ttl > 0) {
|
|
218
|
+
this.respCommand("SETEX", fullKey, String(ttl), serialized).catch(() => {});
|
|
219
|
+
} else {
|
|
220
|
+
this.respCommand("SET", fullKey, serialized).catch(() => {});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
delete(key: string): boolean {
|
|
225
|
+
const fullKey = this.prefix + key;
|
|
226
|
+
this.respCommand("DEL", fullKey).catch(() => {});
|
|
227
|
+
return true; // best-effort
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
clear(): void {
|
|
231
|
+
this.hits = 0;
|
|
232
|
+
this.misses = 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
stats() {
|
|
236
|
+
return { hits: this.hits, misses: this.misses, size: 0, backend: "redis" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
name() { return "redis"; }
|
|
34
240
|
}
|
|
35
241
|
|
|
242
|
+
// ── File backend ──────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
class FileBackend implements CacheBackend {
|
|
245
|
+
private dir: string;
|
|
246
|
+
private maxEntries: number;
|
|
247
|
+
private hits = 0;
|
|
248
|
+
private misses = 0;
|
|
249
|
+
|
|
250
|
+
constructor(cacheDir = "data/cache", maxEntries = 1000) {
|
|
251
|
+
this.dir = cacheDir;
|
|
252
|
+
this.maxEntries = maxEntries;
|
|
253
|
+
try {
|
|
254
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
255
|
+
} catch {}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private keyPath(key: string): string {
|
|
259
|
+
const safe = crypto.createHash("sha256").update(key).digest("hex");
|
|
260
|
+
return path.join(this.dir, `${safe}.json`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
get(key: string): unknown | undefined {
|
|
264
|
+
const p = this.keyPath(key);
|
|
265
|
+
try {
|
|
266
|
+
if (!fs.existsSync(p)) {
|
|
267
|
+
this.misses++;
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
271
|
+
if (data.expiresAt && Date.now() > data.expiresAt * 1000) {
|
|
272
|
+
fs.unlinkSync(p);
|
|
273
|
+
this.misses++;
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
this.hits++;
|
|
277
|
+
return data.value ?? data;
|
|
278
|
+
} catch {
|
|
279
|
+
this.misses++;
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
set(key: string, value: unknown, ttl: number): void {
|
|
285
|
+
try {
|
|
286
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
287
|
+
// Evict oldest if at capacity
|
|
288
|
+
const files = fs.readdirSync(this.dir)
|
|
289
|
+
.filter(f => f.endsWith(".json"))
|
|
290
|
+
.map(f => ({ name: f, time: fs.statSync(path.join(this.dir, f)).mtimeMs }))
|
|
291
|
+
.sort((a, b) => a.time - b.time);
|
|
292
|
+
|
|
293
|
+
while (files.length >= this.maxEntries) {
|
|
294
|
+
const oldest = files.shift();
|
|
295
|
+
if (oldest) fs.unlinkSync(path.join(this.dir, oldest.name));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const expiresAt = ttl > 0 ? (Date.now() / 1000) + ttl : 0;
|
|
299
|
+
const entry = { key, value, expiresAt };
|
|
300
|
+
fs.writeFileSync(this.keyPath(key), JSON.stringify(entry));
|
|
301
|
+
} catch {}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
delete(key: string): boolean {
|
|
305
|
+
const p = this.keyPath(key);
|
|
306
|
+
try {
|
|
307
|
+
if (fs.existsSync(p)) {
|
|
308
|
+
fs.unlinkSync(p);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
} catch {}
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
clear(): void {
|
|
316
|
+
this.hits = 0;
|
|
317
|
+
this.misses = 0;
|
|
318
|
+
try {
|
|
319
|
+
const files = fs.readdirSync(this.dir).filter(f => f.endsWith(".json"));
|
|
320
|
+
for (const f of files) {
|
|
321
|
+
fs.unlinkSync(path.join(this.dir, f));
|
|
322
|
+
}
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
stats() {
|
|
327
|
+
// Sweep expired
|
|
328
|
+
const now = Date.now() / 1000;
|
|
329
|
+
let count = 0;
|
|
330
|
+
try {
|
|
331
|
+
const files = fs.readdirSync(this.dir).filter(f => f.endsWith(".json"));
|
|
332
|
+
for (const f of files) {
|
|
333
|
+
try {
|
|
334
|
+
const data = JSON.parse(fs.readFileSync(path.join(this.dir, f), "utf-8"));
|
|
335
|
+
if (data.expiresAt && now > data.expiresAt) {
|
|
336
|
+
fs.unlinkSync(path.join(this.dir, f));
|
|
337
|
+
} else {
|
|
338
|
+
count++;
|
|
339
|
+
}
|
|
340
|
+
} catch {}
|
|
341
|
+
}
|
|
342
|
+
} catch {}
|
|
343
|
+
return { hits: this.hits, misses: this.misses, size: count, backend: "file" };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
name() { return "file"; }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Backend factory ───────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
function createBackend(config?: {
|
|
352
|
+
backend?: string;
|
|
353
|
+
cacheUrl?: string;
|
|
354
|
+
cacheDir?: string;
|
|
355
|
+
maxEntries?: number;
|
|
356
|
+
}): CacheBackend {
|
|
357
|
+
const backendName = (config?.backend ?? process.env.TINA4_CACHE_BACKEND ?? "memory").toLowerCase().trim();
|
|
358
|
+
const maxEntries = config?.maxEntries ?? (process.env.TINA4_CACHE_MAX_ENTRIES ? parseInt(process.env.TINA4_CACHE_MAX_ENTRIES, 10) : 1000);
|
|
359
|
+
|
|
360
|
+
switch (backendName) {
|
|
361
|
+
case "redis": {
|
|
362
|
+
const url = config?.cacheUrl ?? process.env.TINA4_CACHE_URL ?? "redis://localhost:6379";
|
|
363
|
+
return new RedisBackend(url, maxEntries);
|
|
364
|
+
}
|
|
365
|
+
case "file": {
|
|
366
|
+
const dir = config?.cacheDir ?? process.env.TINA4_CACHE_DIR ?? "data/cache";
|
|
367
|
+
return new FileBackend(dir, maxEntries);
|
|
368
|
+
}
|
|
369
|
+
default:
|
|
370
|
+
return new MemoryBackend(maxEntries);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Response cache store (for middleware) ──────────────────────────
|
|
375
|
+
|
|
36
376
|
const store = new Map<string, CacheEntry>();
|
|
37
377
|
|
|
38
378
|
/**
|
|
@@ -110,12 +450,64 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
|
|
|
110
450
|
};
|
|
111
451
|
}
|
|
112
452
|
|
|
113
|
-
/** Clear all cached responses */
|
|
453
|
+
/** Clear all cached responses (middleware store) */
|
|
114
454
|
export function clearCache(): void {
|
|
115
455
|
store.clear();
|
|
116
456
|
}
|
|
117
457
|
|
|
118
|
-
/** Get cache stats */
|
|
119
|
-
export function cacheStats(): { size: number; keys: string[] } {
|
|
120
|
-
return { size: store.size, keys: [...store.keys()] };
|
|
458
|
+
/** Get cache stats (middleware store) */
|
|
459
|
+
export function cacheStats(): { size: number; keys: string[]; backend: string } {
|
|
460
|
+
return { size: store.size, keys: [...store.keys()], backend: _getBackend().name() };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── Module-level direct cache API (backend-aware) ─────────────────
|
|
464
|
+
|
|
465
|
+
let _defaultBackend: CacheBackend | null = null;
|
|
466
|
+
let _defaultTtl: number | null = null;
|
|
467
|
+
|
|
468
|
+
function _getBackend(): CacheBackend {
|
|
469
|
+
if (!_defaultBackend) {
|
|
470
|
+
_defaultBackend = createBackend();
|
|
471
|
+
}
|
|
472
|
+
return _defaultBackend;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function _getDefaultTtl(): number {
|
|
476
|
+
if (_defaultTtl === null) {
|
|
477
|
+
const envTtl = process.env.TINA4_CACHE_TTL;
|
|
478
|
+
_defaultTtl = envTtl ? parseInt(envTtl, 10) : 60;
|
|
479
|
+
}
|
|
480
|
+
return _defaultTtl;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Get a value from the cache by key. Returns undefined on miss. */
|
|
484
|
+
export function cacheGet(key: string): unknown | undefined {
|
|
485
|
+
return _getBackend().get(key);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Store a value in the cache with optional TTL (seconds). */
|
|
489
|
+
export function cacheSet(key: string, value: unknown, ttl?: number): void {
|
|
490
|
+
const effectiveTtl = ttl ?? _getDefaultTtl();
|
|
491
|
+
_getBackend().set(key, value, effectiveTtl);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Delete a key from the cache. Returns true if it existed. */
|
|
495
|
+
export function cacheDelete(key: string): boolean {
|
|
496
|
+
return _getBackend().delete(key);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Clear all entries from the cache. */
|
|
500
|
+
export function cacheClear(): void {
|
|
501
|
+
_getBackend().clear();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Return cache statistics from the active backend. */
|
|
505
|
+
export function cacheBackendStats(): { hits: number; misses: number; size: number; backend: string } {
|
|
506
|
+
return _getBackend().stats();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Reset the default backend (for testing). */
|
|
510
|
+
export function _resetBackend(): void {
|
|
511
|
+
_defaultBackend = null;
|
|
512
|
+
_defaultTtl = null;
|
|
121
513
|
}
|
|
@@ -8,11 +8,13 @@ export type {
|
|
|
8
8
|
Middleware,
|
|
9
9
|
UploadedFile,
|
|
10
10
|
CookieOptions,
|
|
11
|
+
WebSocketRouteHandler,
|
|
12
|
+
WebSocketRouteDefinition,
|
|
11
13
|
} from "./types.js";
|
|
12
14
|
|
|
13
15
|
export { startServer, resolvePortAndHost } from "./server.js";
|
|
14
16
|
export { Router, RouteGroup, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
15
|
-
export { get, post, put, patch, del, any, del as delete } from "./router.js";
|
|
17
|
+
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
16
18
|
export type { RouteInfo } from "./router.js";
|
|
17
19
|
export { discoverRoutes } from "./routeDiscovery.js";
|
|
18
20
|
export { MiddlewareChain, cors, requestLogger } from "./middleware.js";
|
|
@@ -60,7 +62,7 @@ export {
|
|
|
60
62
|
export type { WebSocketClient } from "./websocket.js";
|
|
61
63
|
export { ServiceRunner, matchCronField, matchesCron } from "./service.js";
|
|
62
64
|
export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from "./service.js";
|
|
63
|
-
export { responseCache, clearCache, cacheStats } from "./cache.js";
|
|
65
|
+
export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, _resetBackend } from "./cache.js";
|
|
64
66
|
export type { ResponseCacheConfig } from "./cache.js";
|
|
65
67
|
export { Api } from "./api.js";
|
|
66
68
|
export type { ApiResult } from "./api.js";
|
|
@@ -86,3 +88,4 @@ export { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
|
|
|
86
88
|
export type { ValkeySessionConfig } from "./sessionHandlers/valkeyHandler.js";
|
|
87
89
|
export { tests, assertEqual, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "./testing.js";
|
|
88
90
|
export { Container, container } from "./container.js";
|
|
91
|
+
export type { WebSocketConnection } from "./websocketConnection.js";
|