tina4-nodejs 3.2.1 → 3.5.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/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +13 -1
- package/packages/cli/src/commands/migrate.ts +19 -5
- package/packages/cli/src/commands/migrateCreate.ts +29 -28
- package/packages/cli/src/commands/migrateRollback.ts +59 -0
- package/packages/cli/src/commands/migrateStatus.ts +62 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
- package/packages/core/public/js/tina4js.min.js +47 -0
- package/packages/core/src/auth.ts +44 -10
- package/packages/core/src/devAdmin.ts +14 -16
- package/packages/core/src/index.ts +10 -3
- package/packages/core/src/middleware.ts +232 -2
- package/packages/core/src/queue.ts +127 -25
- package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
- package/packages/core/src/request.ts +3 -3
- package/packages/core/src/router.ts +115 -51
- package/packages/core/src/server.ts +47 -3
- package/packages/core/src/session.ts +29 -1
- package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
- package/packages/core/src/types.ts +12 -6
- package/packages/core/src/websocket.ts +11 -2
- package/packages/core/src/websocketConnection.ts +4 -2
- package/packages/frond/src/engine.ts +66 -1
- package/packages/orm/src/autoCrud.ts +17 -12
- package/packages/orm/src/baseModel.ts +99 -21
- package/packages/orm/src/database.ts +197 -69
- package/packages/orm/src/databaseResult.ts +207 -0
- package/packages/orm/src/index.ts +6 -3
- package/packages/orm/src/migration.ts +296 -71
- package/packages/orm/src/model.ts +1 -0
- package/packages/orm/src/types.ts +1 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Tina4 Session — Pluggable session backends, zero core dependencies.
|
|
3
3
|
*
|
|
4
4
|
* File-based sessions by default. Redis backend available via raw TCP (no ioredis needed).
|
|
5
|
+
* Database (SQLite) backend available via better-sqlite3.
|
|
5
6
|
*
|
|
6
7
|
* import { Session, RedisSessionHandler } from "@tina4/core";
|
|
7
8
|
*
|
|
@@ -14,6 +15,10 @@
|
|
|
14
15
|
* redisPort: 6379,
|
|
15
16
|
* });
|
|
16
17
|
*
|
|
18
|
+
* // Database backend (SQLite via better-sqlite3)
|
|
19
|
+
* const session = new Session("database");
|
|
20
|
+
* // or: new Session("db");
|
|
21
|
+
*
|
|
17
22
|
* const id = session.start();
|
|
18
23
|
* session.set("user", { name: "Alice" });
|
|
19
24
|
* session.get("user"); // { name: "Alice" }
|
|
@@ -27,7 +32,7 @@ import { execFileSync } from "node:child_process";
|
|
|
27
32
|
// ── Types ─────────────────────────────────────────────────────────
|
|
28
33
|
|
|
29
34
|
export interface SessionConfig {
|
|
30
|
-
/** Session backend type: "file" or "
|
|
35
|
+
/** Session backend type: "file", "redis", "valkey", "mongo", "database" (or "db") */
|
|
31
36
|
backend?: string;
|
|
32
37
|
/** File storage path (default: "data/sessions") */
|
|
33
38
|
path?: string;
|
|
@@ -297,6 +302,11 @@ export class Session {
|
|
|
297
302
|
case "redis":
|
|
298
303
|
this.handler = new RedisSessionHandler(config);
|
|
299
304
|
break;
|
|
305
|
+
case "redis-npm": {
|
|
306
|
+
const { RedisNpmSessionHandler } = require("./sessionHandlers/redisHandler.js");
|
|
307
|
+
this.handler = new RedisNpmSessionHandler(config);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
300
310
|
case "valkey": {
|
|
301
311
|
const { ValkeySessionHandler } = require("./sessionHandlers/valkeyHandler.js");
|
|
302
312
|
this.handler = new ValkeySessionHandler(config);
|
|
@@ -308,6 +318,12 @@ export class Session {
|
|
|
308
318
|
this.handler = new MongoSessionHandler(config);
|
|
309
319
|
break;
|
|
310
320
|
}
|
|
321
|
+
case "database":
|
|
322
|
+
case "db": {
|
|
323
|
+
const { DatabaseSessionHandler } = require("./sessionHandlers/databaseHandler.js");
|
|
324
|
+
this.handler = new DatabaseSessionHandler(config);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
311
327
|
case "file":
|
|
312
328
|
default:
|
|
313
329
|
this.handler = new FileSessionHandler(config?.path);
|
|
@@ -382,6 +398,18 @@ export class Session {
|
|
|
382
398
|
this.save();
|
|
383
399
|
}
|
|
384
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Clear all session data without destroying the session.
|
|
403
|
+
* The session ID and cookie remain — only the data is wiped.
|
|
404
|
+
*/
|
|
405
|
+
clear(): void {
|
|
406
|
+
if (!this.data) return;
|
|
407
|
+
for (const key of Object.keys(this.data)) {
|
|
408
|
+
delete this.data[key];
|
|
409
|
+
}
|
|
410
|
+
this.save();
|
|
411
|
+
}
|
|
412
|
+
|
|
385
413
|
/**
|
|
386
414
|
* Destroy the entire session.
|
|
387
415
|
*/
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Database Session Handler — SQLite via better-sqlite3, zero extra dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Uses the same `better-sqlite3` library the ORM already depends on.
|
|
5
|
+
* Stores sessions in a `tina4_session` table with JSON data and expiry.
|
|
6
|
+
*
|
|
7
|
+
* Configure via environment variables:
|
|
8
|
+
* DATABASE_URL (default: "sqlite:///data/tina4_sessions.db")
|
|
9
|
+
*
|
|
10
|
+
* The handler dynamically imports `better-sqlite3` and throws a clear
|
|
11
|
+
* error if the package is not installed.
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import type { SessionHandler } from "../session.js";
|
|
15
|
+
|
|
16
|
+
const _require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
interface SessionData {
|
|
19
|
+
_created: number;
|
|
20
|
+
_accessed: number;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DatabaseSessionConfig {
|
|
25
|
+
/** SQLite database file path (default: extracted from DATABASE_URL or "data/tina4_sessions.db") */
|
|
26
|
+
dbPath?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Database session handler using better-sqlite3 (synchronous SQLite).
|
|
31
|
+
*
|
|
32
|
+
* Stores session data as JSON in a `tina4_session` table.
|
|
33
|
+
* Expiry is checked on read; expired rows are cleaned up lazily.
|
|
34
|
+
*/
|
|
35
|
+
export class DatabaseSessionHandler implements SessionHandler {
|
|
36
|
+
private db: any;
|
|
37
|
+
private initialized = false;
|
|
38
|
+
|
|
39
|
+
constructor(config?: DatabaseSessionConfig) {
|
|
40
|
+
const dbPath = config?.dbPath ?? this.resolveDbPath();
|
|
41
|
+
|
|
42
|
+
let Database: any;
|
|
43
|
+
try {
|
|
44
|
+
Database = _require("better-sqlite3");
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"DatabaseSessionHandler requires 'better-sqlite3'. " +
|
|
48
|
+
"Install it with: npm install better-sqlite3"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.db = new Database(dbPath);
|
|
53
|
+
this.db.pragma("journal_mode = WAL");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the database file path from DATABASE_URL or use the default.
|
|
58
|
+
*/
|
|
59
|
+
private resolveDbPath(): string {
|
|
60
|
+
const url = process.env.DATABASE_URL;
|
|
61
|
+
if (url && url.startsWith("sqlite://")) {
|
|
62
|
+
// sqlite:///path/to/db or sqlite://./relative/path
|
|
63
|
+
return url.replace(/^sqlite:\/\//, "");
|
|
64
|
+
}
|
|
65
|
+
return "data/tina4_sessions.db";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ensure the session table exists (called once on first use).
|
|
70
|
+
*/
|
|
71
|
+
private ensureTable(): void {
|
|
72
|
+
if (this.initialized) return;
|
|
73
|
+
this.db.exec(`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS tina4_session (
|
|
75
|
+
session_id TEXT PRIMARY KEY,
|
|
76
|
+
data TEXT NOT NULL,
|
|
77
|
+
expires_at REAL NOT NULL
|
|
78
|
+
)
|
|
79
|
+
`);
|
|
80
|
+
this.initialized = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
read(sessionId: string): SessionData | null {
|
|
84
|
+
this.ensureTable();
|
|
85
|
+
|
|
86
|
+
const row = this.db
|
|
87
|
+
.prepare("SELECT data, expires_at FROM tina4_session WHERE session_id = ?")
|
|
88
|
+
.get(sessionId) as { data: string; expires_at: number } | undefined;
|
|
89
|
+
|
|
90
|
+
if (!row) return null;
|
|
91
|
+
|
|
92
|
+
// Check expiry
|
|
93
|
+
const now = Date.now() / 1000;
|
|
94
|
+
if (row.expires_at < now) {
|
|
95
|
+
// Expired — clean up and return null
|
|
96
|
+
this.destroy(sessionId);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(row.data) as SessionData;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
write(sessionId: string, data: SessionData, ttl: number): void {
|
|
108
|
+
this.ensureTable();
|
|
109
|
+
|
|
110
|
+
const json = JSON.stringify(data);
|
|
111
|
+
const expiresAt = (Date.now() / 1000) + (ttl > 0 ? ttl : 3600);
|
|
112
|
+
|
|
113
|
+
const existing = this.db
|
|
114
|
+
.prepare("SELECT 1 FROM tina4_session WHERE session_id = ?")
|
|
115
|
+
.get(sessionId);
|
|
116
|
+
|
|
117
|
+
if (existing) {
|
|
118
|
+
this.db
|
|
119
|
+
.prepare("UPDATE tina4_session SET data = ?, expires_at = ? WHERE session_id = ?")
|
|
120
|
+
.run(json, expiresAt, sessionId);
|
|
121
|
+
} else {
|
|
122
|
+
this.db
|
|
123
|
+
.prepare("INSERT INTO tina4_session (session_id, data, expires_at) VALUES (?, ?, ?)")
|
|
124
|
+
.run(sessionId, json, expiresAt);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
destroy(sessionId: string): void {
|
|
129
|
+
this.ensureTable();
|
|
130
|
+
this.db
|
|
131
|
+
.prepare("DELETE FROM tina4_session WHERE session_id = ?")
|
|
132
|
+
.run(sessionId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Redis Session Handler — Redis via `redis` npm package (optional dependency).
|
|
3
|
+
*
|
|
4
|
+
* Provides a session handler backed by the official `redis` npm package,
|
|
5
|
+
* complementing the built-in raw-TCP RedisSessionHandler in session.ts.
|
|
6
|
+
*
|
|
7
|
+
* This handler uses synchronous child-process execution (same pattern as
|
|
8
|
+
* valkeyHandler.ts) so it fits the synchronous SessionHandler interface
|
|
9
|
+
* without requiring async refactoring.
|
|
10
|
+
*
|
|
11
|
+
* Configure via environment variables:
|
|
12
|
+
* TINA4_SESSION_REDIS_HOST (default: "127.0.0.1")
|
|
13
|
+
* TINA4_SESSION_REDIS_PORT (default: 6379)
|
|
14
|
+
* TINA4_SESSION_REDIS_URL (optional — full redis:// URL, overrides host/port)
|
|
15
|
+
* TINA4_SESSION_REDIS_PASSWORD (optional)
|
|
16
|
+
* TINA4_SESSION_REDIS_PREFIX (default: "tina4:session:")
|
|
17
|
+
* TINA4_SESSION_REDIS_DB (default: 0)
|
|
18
|
+
*/
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import type { SessionHandler } from "../session.js";
|
|
21
|
+
|
|
22
|
+
interface SessionData {
|
|
23
|
+
_created: number;
|
|
24
|
+
_accessed: number;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RedisNpmSessionConfig {
|
|
29
|
+
host?: string;
|
|
30
|
+
port?: number;
|
|
31
|
+
url?: string;
|
|
32
|
+
password?: string;
|
|
33
|
+
prefix?: string;
|
|
34
|
+
db?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Redis session handler using the `redis` npm package.
|
|
39
|
+
*
|
|
40
|
+
* Falls back to raw TCP (RESP protocol) if the `redis` package is not
|
|
41
|
+
* installed, matching the approach used by the Valkey handler.
|
|
42
|
+
*
|
|
43
|
+
* Stores session data as JSON strings with Redis TTL for automatic expiry.
|
|
44
|
+
*/
|
|
45
|
+
export class RedisNpmSessionHandler implements SessionHandler {
|
|
46
|
+
private host: string;
|
|
47
|
+
private port: number;
|
|
48
|
+
private url: string;
|
|
49
|
+
private password: string;
|
|
50
|
+
private prefix: string;
|
|
51
|
+
private db: number;
|
|
52
|
+
|
|
53
|
+
constructor(config?: RedisNpmSessionConfig) {
|
|
54
|
+
this.url = config?.url
|
|
55
|
+
?? process.env.TINA4_SESSION_REDIS_URL
|
|
56
|
+
?? "";
|
|
57
|
+
this.host = config?.host
|
|
58
|
+
?? process.env.TINA4_SESSION_REDIS_HOST
|
|
59
|
+
?? "127.0.0.1";
|
|
60
|
+
this.port = config?.port
|
|
61
|
+
?? (process.env.TINA4_SESSION_REDIS_PORT
|
|
62
|
+
? parseInt(process.env.TINA4_SESSION_REDIS_PORT, 10)
|
|
63
|
+
: 6379);
|
|
64
|
+
this.password = config?.password
|
|
65
|
+
?? process.env.TINA4_SESSION_REDIS_PASSWORD
|
|
66
|
+
?? "";
|
|
67
|
+
this.prefix = config?.prefix
|
|
68
|
+
?? process.env.TINA4_SESSION_REDIS_PREFIX
|
|
69
|
+
?? "tina4:session:";
|
|
70
|
+
this.db = config?.db
|
|
71
|
+
?? (process.env.TINA4_SESSION_REDIS_DB
|
|
72
|
+
? parseInt(process.env.TINA4_SESSION_REDIS_DB, 10)
|
|
73
|
+
: 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Execute a Redis command synchronously via a short-lived child process.
|
|
78
|
+
*
|
|
79
|
+
* Attempts to use the `redis` npm package first. If unavailable, falls
|
|
80
|
+
* back to raw TCP (RESP protocol) — same as valkeyHandler.ts.
|
|
81
|
+
*/
|
|
82
|
+
private execSync(args: string[]): string {
|
|
83
|
+
const script = `
|
|
84
|
+
const net = require("node:net");
|
|
85
|
+
const host = ${JSON.stringify(this.url || this.host)};
|
|
86
|
+
const port = ${this.port};
|
|
87
|
+
const password = ${JSON.stringify(this.password)};
|
|
88
|
+
const db = ${this.db};
|
|
89
|
+
const useUrl = ${JSON.stringify(!!this.url)};
|
|
90
|
+
const url = ${JSON.stringify(this.url)};
|
|
91
|
+
const args = ${JSON.stringify(args)};
|
|
92
|
+
|
|
93
|
+
// Try the redis npm package first
|
|
94
|
+
let redisAvailable = false;
|
|
95
|
+
try {
|
|
96
|
+
require.resolve("redis");
|
|
97
|
+
redisAvailable = true;
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
if (redisAvailable) {
|
|
101
|
+
const redis = require("redis");
|
|
102
|
+
(async () => {
|
|
103
|
+
try {
|
|
104
|
+
const clientOpts = useUrl
|
|
105
|
+
? { url }
|
|
106
|
+
: { socket: { host, port }, password: password || undefined, database: db };
|
|
107
|
+
const client = redis.createClient(clientOpts);
|
|
108
|
+
client.on("error", () => {});
|
|
109
|
+
await client.connect();
|
|
110
|
+
|
|
111
|
+
const cmd = args[0].toUpperCase();
|
|
112
|
+
let result;
|
|
113
|
+
if (cmd === "GET") {
|
|
114
|
+
result = await client.get(args[1]);
|
|
115
|
+
} else if (cmd === "SET") {
|
|
116
|
+
result = await client.set(args[1], args[2]);
|
|
117
|
+
} else if (cmd === "SETEX") {
|
|
118
|
+
result = await client.setEx(args[1], parseInt(args[2], 10), args[3]);
|
|
119
|
+
} else if (cmd === "DEL") {
|
|
120
|
+
result = await client.del(args[1]);
|
|
121
|
+
}
|
|
122
|
+
await client.quit();
|
|
123
|
+
|
|
124
|
+
if (result === null || result === undefined) {
|
|
125
|
+
process.stdout.write("__NULL__");
|
|
126
|
+
} else {
|
|
127
|
+
process.stdout.write(String(result));
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
process.stderr.write(err.message);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
} else {
|
|
135
|
+
// Fallback: raw TCP RESP protocol (no redis package needed)
|
|
136
|
+
const actualHost = useUrl ? (() => {
|
|
137
|
+
try { const u = new URL(url); return u.hostname || "127.0.0.1"; } catch { return "127.0.0.1"; }
|
|
138
|
+
})() : host;
|
|
139
|
+
const actualPort = useUrl ? (() => {
|
|
140
|
+
try { const u = new URL(url); return parseInt(u.port, 10) || 6379; } catch { return 6379; }
|
|
141
|
+
})() : port;
|
|
142
|
+
|
|
143
|
+
function buildCommand(a) {
|
|
144
|
+
let cmd = "*" + a.length + "\\r\\n";
|
|
145
|
+
for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
146
|
+
return cmd;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sock = net.createConnection({ host: actualHost, port: actualPort }, () => {
|
|
150
|
+
let commands = "";
|
|
151
|
+
if (password) commands += buildCommand(["AUTH", password]);
|
|
152
|
+
if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
|
|
153
|
+
commands += buildCommand(args);
|
|
154
|
+
sock.write(commands);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
let buffer = Buffer.alloc(0);
|
|
158
|
+
sock.on("data", (chunk) => {
|
|
159
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
160
|
+
});
|
|
161
|
+
sock.on("end", () => {
|
|
162
|
+
const lines = buffer.toString("utf-8").split("\\r\\n");
|
|
163
|
+
let responses = [];
|
|
164
|
+
let i = 0;
|
|
165
|
+
while (i < lines.length) {
|
|
166
|
+
const line = lines[i];
|
|
167
|
+
if (!line) { i++; continue; }
|
|
168
|
+
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
|
|
169
|
+
responses.push(line);
|
|
170
|
+
i++;
|
|
171
|
+
} else if (line.startsWith("$")) {
|
|
172
|
+
const len = parseInt(line.slice(1), 10);
|
|
173
|
+
if (len === -1) { responses.push(null); i++; }
|
|
174
|
+
else { responses.push(lines[i+1] || ""); i += 2; }
|
|
175
|
+
} else { i++; }
|
|
176
|
+
}
|
|
177
|
+
const result = responses[responses.length - 1];
|
|
178
|
+
if (result === null) process.stdout.write("__NULL__");
|
|
179
|
+
else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
|
|
180
|
+
else process.stdout.write(String(result ?? "__NULL__"));
|
|
181
|
+
});
|
|
182
|
+
sock.on("error", (err) => {
|
|
183
|
+
process.stderr.write(err.message);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
});
|
|
186
|
+
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
187
|
+
}
|
|
188
|
+
`;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const result = execFileSync(process.execPath, ["-e", script], {
|
|
192
|
+
encoding: "utf-8",
|
|
193
|
+
timeout: 5000,
|
|
194
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
195
|
+
});
|
|
196
|
+
if (result === "__NULL__") return "";
|
|
197
|
+
if (result.startsWith("__ERR__")) return "";
|
|
198
|
+
return result;
|
|
199
|
+
} catch {
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private key(sessionId: string): string {
|
|
205
|
+
return `${this.prefix}${sessionId}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
read(sessionId: string): SessionData | null {
|
|
209
|
+
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
210
|
+
if (!raw) return null;
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(raw) as SessionData;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
write(sessionId: string, data: SessionData, ttl: number): void {
|
|
219
|
+
const json = JSON.stringify(data);
|
|
220
|
+
if (ttl > 0) {
|
|
221
|
+
this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
|
|
222
|
+
} else {
|
|
223
|
+
this.execSync(["SET", this.key(sessionId), json]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
destroy(sessionId: string): void {
|
|
228
|
+
this.execSync(["DEL", this.key(sessionId)]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -3,8 +3,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
3
3
|
export interface UploadedFile {
|
|
4
4
|
fieldName: string;
|
|
5
5
|
filename: string;
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
type: string;
|
|
7
|
+
content: Buffer;
|
|
8
8
|
size: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -69,6 +69,10 @@ export interface RouteDefinition {
|
|
|
69
69
|
middlewares?: Middleware[];
|
|
70
70
|
/** Template file to render when handler returns a plain object */
|
|
71
71
|
template?: string;
|
|
72
|
+
/** Whether this route requires bearer-token authentication */
|
|
73
|
+
secure?: boolean;
|
|
74
|
+
/** Whether this route's response should be cached */
|
|
75
|
+
cached?: boolean;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export interface RouteMeta {
|
|
@@ -103,12 +107,14 @@ export type Middleware = (
|
|
|
103
107
|
|
|
104
108
|
/**
|
|
105
109
|
* Handler for WebSocket routes.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
110
|
+
* connection — object with send/broadcast/close methods and route params.
|
|
111
|
+
* event — one of "open", "message", or "close".
|
|
112
|
+
* data — the incoming text message (only present for "message" events).
|
|
108
113
|
*/
|
|
109
114
|
export type WebSocketRouteHandler = (
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
connection: import("./websocketConnection.js").WebSocketConnection,
|
|
116
|
+
event: "open" | "message" | "close",
|
|
117
|
+
data: string,
|
|
112
118
|
) => void | Promise<void>;
|
|
113
119
|
|
|
114
120
|
export interface WebSocketRouteDefinition {
|
|
@@ -52,6 +52,8 @@ export interface WebSocketClient {
|
|
|
52
52
|
ip: string;
|
|
53
53
|
connectedAt: number;
|
|
54
54
|
closed: boolean;
|
|
55
|
+
/** The URL path this client connected on (e.g. "/chat", "/notifications"). */
|
|
56
|
+
path: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
type EventHandler = (...args: unknown[]) => void;
|
|
@@ -185,14 +187,20 @@ export class WebSocketServer {
|
|
|
185
187
|
|
|
186
188
|
/**
|
|
187
189
|
* Broadcast a message to all connected clients.
|
|
190
|
+
*
|
|
191
|
+
* When `path` is provided, only clients connected on that specific path
|
|
192
|
+
* receive the message (matching PHP's WebSocket::broadcast behaviour).
|
|
193
|
+
* When `path` is omitted/undefined, all clients receive the message
|
|
194
|
+
* (backward compatible).
|
|
188
195
|
*/
|
|
189
|
-
broadcast(message: string, excludeIds?: string[]): void {
|
|
196
|
+
broadcast(message: string, excludeIds?: string[], path?: string): void {
|
|
190
197
|
const frame = buildFrame(OP_TEXT, Buffer.from(message, "utf-8"));
|
|
191
198
|
const exclude = new Set(excludeIds ?? []);
|
|
192
199
|
|
|
193
200
|
for (const [id, client] of this.clients) {
|
|
194
201
|
if (exclude.has(id)) continue;
|
|
195
202
|
if (client.closed) continue;
|
|
203
|
+
if (path !== undefined && client.path !== path) continue;
|
|
196
204
|
try {
|
|
197
205
|
client.socket.write(frame);
|
|
198
206
|
} catch {
|
|
@@ -310,7 +318,7 @@ export class WebSocketServer {
|
|
|
310
318
|
|
|
311
319
|
socket.write(response);
|
|
312
320
|
|
|
313
|
-
// Create client
|
|
321
|
+
// Create client — track the URL path for path-scoped broadcast
|
|
314
322
|
const clientId = randomUUID().slice(0, 8);
|
|
315
323
|
const client: WebSocketClient = {
|
|
316
324
|
id: clientId,
|
|
@@ -318,6 +326,7 @@ export class WebSocketServer {
|
|
|
318
326
|
ip: (socket.remoteAddress ?? "unknown"),
|
|
319
327
|
connectedAt: Date.now(),
|
|
320
328
|
closed: false,
|
|
329
|
+
path: req.url ?? "/",
|
|
321
330
|
};
|
|
322
331
|
|
|
323
332
|
this.clients.set(clientId, client);
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
export interface WebSocketConnection {
|
|
6
6
|
/** Unique connection identifier */
|
|
7
7
|
id: string;
|
|
8
|
-
/** Send a message to this connection */
|
|
8
|
+
/** Send a message to this connection only */
|
|
9
9
|
send(message: string): void;
|
|
10
|
-
/** Broadcast a message to all
|
|
10
|
+
/** Broadcast a message to all connections on the same path (path-scoped) */
|
|
11
11
|
broadcast(message: string): void;
|
|
12
12
|
/** Close this connection */
|
|
13
13
|
close(): void;
|
|
14
14
|
/** The WebSocket route path this connection is on */
|
|
15
15
|
path: string;
|
|
16
|
+
/** Route parameters extracted from `{param}` segments in the path */
|
|
17
|
+
params: Record<string, string>;
|
|
16
18
|
}
|
|
@@ -757,8 +757,16 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
757
757
|
slug: (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
|
758
758
|
md5: (v) => createHash("md5").update(String(v)).digest("hex"),
|
|
759
759
|
sha256: (v) => createHash("sha256").update(String(v)).digest("hex"),
|
|
760
|
-
base64_encode: (v) => Buffer.from(String(v)).toString("base64"),
|
|
760
|
+
base64_encode: (v) => Buffer.isBuffer(v) ? v.toString("base64") : Buffer.from(String(v)).toString("base64"),
|
|
761
761
|
base64_decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
|
|
762
|
+
data_uri: (v) => {
|
|
763
|
+
if (v && typeof v === "object" && "content" in v) {
|
|
764
|
+
const ct = (v as any).type ?? "application/octet-stream";
|
|
765
|
+
const raw = Buffer.isBuffer((v as any).content) ? (v as any).content : Buffer.from(String((v as any).content));
|
|
766
|
+
return `data:${ct};base64,${raw.toString("base64")}`;
|
|
767
|
+
}
|
|
768
|
+
return String(v);
|
|
769
|
+
},
|
|
762
770
|
url_encode: (v) => encodeURIComponent(String(v)),
|
|
763
771
|
format: (v, ...args) => {
|
|
764
772
|
let s = String(v);
|
|
@@ -1155,6 +1163,63 @@ export class Frond {
|
|
|
1155
1163
|
}
|
|
1156
1164
|
|
|
1157
1165
|
private evalVar(expr: string, context: Record<string, unknown>): unknown {
|
|
1166
|
+
// Check for top-level ternary BEFORE splitting filters so that
|
|
1167
|
+
// expressions like ``products|length != 1 ? "s" : ""`` work correctly.
|
|
1168
|
+
const ternaryIdx = findTernary(expr);
|
|
1169
|
+
if (ternaryIdx !== -1) {
|
|
1170
|
+
const condPart = expr.slice(0, ternaryIdx).trim();
|
|
1171
|
+
const rest = expr.slice(ternaryIdx + 1);
|
|
1172
|
+
const colonIdx = findColon(rest);
|
|
1173
|
+
if (colonIdx !== -1) {
|
|
1174
|
+
const truePart = rest.slice(0, colonIdx).trim();
|
|
1175
|
+
const falsePart = rest.slice(colonIdx + 1).trim();
|
|
1176
|
+
const cond = this.evalVarRaw(condPart, context);
|
|
1177
|
+
return cond ? this.evalVar(truePart, context) : this.evalVar(falsePart, context);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return this.evalVarInner(expr, context);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private evalVarRaw(expr: string, context: Record<string, unknown>): unknown {
|
|
1185
|
+
const [varName, filters] = parseFilterChain(expr);
|
|
1186
|
+
let value = evalExpr(varName, context);
|
|
1187
|
+
for (const [fname, args] of filters) {
|
|
1188
|
+
if (fname === "raw" || fname === "safe") continue;
|
|
1189
|
+
const fn = this.filters[fname];
|
|
1190
|
+
if (fn) {
|
|
1191
|
+
value = fn(value, ...args);
|
|
1192
|
+
} else {
|
|
1193
|
+
// The filter name may include a trailing comparison operator,
|
|
1194
|
+
// e.g. "length != 1". Extract the real filter name and the
|
|
1195
|
+
// comparison suffix, apply the filter, then evaluate the comparison.
|
|
1196
|
+
const m = fname.match(/^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/);
|
|
1197
|
+
if (m) {
|
|
1198
|
+
const realFilter = m[1];
|
|
1199
|
+
const op = m[2];
|
|
1200
|
+
const rightExpr = m[3].trim();
|
|
1201
|
+
const fn2 = this.filters[realFilter];
|
|
1202
|
+
if (fn2) {
|
|
1203
|
+
value = fn2(value, ...args);
|
|
1204
|
+
}
|
|
1205
|
+
const right = evalExpr(rightExpr, context);
|
|
1206
|
+
switch (op) {
|
|
1207
|
+
case "!=": value = value !== right; break;
|
|
1208
|
+
case "==": value = value === right; break;
|
|
1209
|
+
case ">=": value = (value as number) >= (right as number); break;
|
|
1210
|
+
case "<=": value = (value as number) <= (right as number); break;
|
|
1211
|
+
case ">": value = (value as number) > (right as number); break;
|
|
1212
|
+
case "<": value = (value as number) < (right as number); break;
|
|
1213
|
+
}
|
|
1214
|
+
} else {
|
|
1215
|
+
value = evalExpr(fname, context);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return value;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
private evalVarInner(expr: string, context: Record<string, unknown>): unknown {
|
|
1158
1223
|
const [varName, filters] = parseFilterChain(expr);
|
|
1159
1224
|
|
|
1160
1225
|
// Sandbox: check variable access
|