tina4-nodejs 3.0.0-rc.2
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 +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- package/skills/tina4-maintainer.skill +0 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 MongoDB Session Handler — MongoDB wire protocol via raw TCP, zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Stores session data in MongoDB using the MongoDB wire protocol directly.
|
|
5
|
+
* No `mongodb` or `mongoose` npm package required.
|
|
6
|
+
*
|
|
7
|
+
* Configure via environment variables:
|
|
8
|
+
* TINA4_SESSION_MONGO_HOST (default: "127.0.0.1")
|
|
9
|
+
* TINA4_SESSION_MONGO_PORT (default: 27017)
|
|
10
|
+
* TINA4_SESSION_MONGO_URI (overrides host/port if set)
|
|
11
|
+
* TINA4_SESSION_MONGO_USERNAME (optional)
|
|
12
|
+
* TINA4_SESSION_MONGO_PASSWORD (optional)
|
|
13
|
+
* TINA4_SESSION_MONGO_DB (default: "tina4_sessions")
|
|
14
|
+
* TINA4_SESSION_MONGO_COLLECTION (default: "sessions")
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import type { SessionHandler } from "../session.js";
|
|
18
|
+
|
|
19
|
+
interface SessionData {
|
|
20
|
+
_created: number;
|
|
21
|
+
_accessed: number;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MongoSessionConfig {
|
|
26
|
+
host?: string;
|
|
27
|
+
port?: number;
|
|
28
|
+
uri?: string;
|
|
29
|
+
username?: string;
|
|
30
|
+
password?: string;
|
|
31
|
+
database?: string;
|
|
32
|
+
collection?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* MongoDB session handler using raw TCP (MongoDB wire protocol).
|
|
37
|
+
*
|
|
38
|
+
* Uses synchronous socket communication via child process — no external
|
|
39
|
+
* MongoDB client library required. Stores session data as BSON documents
|
|
40
|
+
* with TTL index support.
|
|
41
|
+
*/
|
|
42
|
+
export class MongoSessionHandler implements SessionHandler {
|
|
43
|
+
private host: string;
|
|
44
|
+
private port: number;
|
|
45
|
+
private uri: string;
|
|
46
|
+
private username: string;
|
|
47
|
+
private password: string;
|
|
48
|
+
private database: string;
|
|
49
|
+
private collection: string;
|
|
50
|
+
|
|
51
|
+
constructor(config?: MongoSessionConfig) {
|
|
52
|
+
this.host = config?.host
|
|
53
|
+
?? process.env.TINA4_SESSION_MONGO_HOST
|
|
54
|
+
?? "127.0.0.1";
|
|
55
|
+
this.port = config?.port
|
|
56
|
+
?? (process.env.TINA4_SESSION_MONGO_PORT
|
|
57
|
+
? parseInt(process.env.TINA4_SESSION_MONGO_PORT, 10)
|
|
58
|
+
: 27017);
|
|
59
|
+
this.uri = config?.uri
|
|
60
|
+
?? process.env.TINA4_SESSION_MONGO_URI
|
|
61
|
+
?? "";
|
|
62
|
+
this.username = config?.username
|
|
63
|
+
?? process.env.TINA4_SESSION_MONGO_USERNAME
|
|
64
|
+
?? "";
|
|
65
|
+
this.password = config?.password
|
|
66
|
+
?? process.env.TINA4_SESSION_MONGO_PASSWORD
|
|
67
|
+
?? "";
|
|
68
|
+
this.database = config?.database
|
|
69
|
+
?? process.env.TINA4_SESSION_MONGO_DB
|
|
70
|
+
?? "tina4_sessions";
|
|
71
|
+
this.collection = config?.collection
|
|
72
|
+
?? process.env.TINA4_SESSION_MONGO_COLLECTION
|
|
73
|
+
?? "sessions";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Execute a MongoDB command synchronously via a child process.
|
|
78
|
+
*
|
|
79
|
+
* Uses the MongoDB wire protocol (OP_MSG) to communicate with the server.
|
|
80
|
+
* For simplicity, we use the `runCommand` approach with JSON serialization
|
|
81
|
+
* of BSON documents using a minimal BSON encoder.
|
|
82
|
+
*/
|
|
83
|
+
private execSync(command: string, args: string): string {
|
|
84
|
+
const script = `
|
|
85
|
+
const net = require("node:net");
|
|
86
|
+
const host = ${JSON.stringify(this.uri ? this.parseUri().host : this.host)};
|
|
87
|
+
const port = ${this.uri ? this.parseUri().port : this.port};
|
|
88
|
+
const database = ${JSON.stringify(this.database)};
|
|
89
|
+
const collection = ${JSON.stringify(this.collection)};
|
|
90
|
+
const command = ${JSON.stringify(command)};
|
|
91
|
+
const args = ${JSON.stringify(args)};
|
|
92
|
+
|
|
93
|
+
// Minimal BSON encoder/decoder for session operations
|
|
94
|
+
function encodeBsonDocument(obj) {
|
|
95
|
+
const parts = [];
|
|
96
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
97
|
+
if (typeof value === "string") {
|
|
98
|
+
const keyBuf = Buffer.from(key + "\\0", "utf-8");
|
|
99
|
+
const valBuf = Buffer.from(value, "utf-8");
|
|
100
|
+
const entry = Buffer.alloc(1 + keyBuf.length + 4 + valBuf.length + 1);
|
|
101
|
+
entry.writeUInt8(2, 0); // string type
|
|
102
|
+
keyBuf.copy(entry, 1);
|
|
103
|
+
entry.writeInt32LE(valBuf.length + 1, 1 + keyBuf.length);
|
|
104
|
+
valBuf.copy(entry, 1 + keyBuf.length + 4);
|
|
105
|
+
entry.writeUInt8(0, entry.length - 1);
|
|
106
|
+
parts.push(entry);
|
|
107
|
+
} else if (typeof value === "number") {
|
|
108
|
+
const keyBuf = Buffer.from(key + "\\0", "utf-8");
|
|
109
|
+
if (Number.isInteger(value)) {
|
|
110
|
+
const entry = Buffer.alloc(1 + keyBuf.length + 4);
|
|
111
|
+
entry.writeUInt8(16, 0); // int32 type
|
|
112
|
+
keyBuf.copy(entry, 1);
|
|
113
|
+
entry.writeInt32LE(value, 1 + keyBuf.length);
|
|
114
|
+
parts.push(entry);
|
|
115
|
+
} else {
|
|
116
|
+
const entry = Buffer.alloc(1 + keyBuf.length + 8);
|
|
117
|
+
entry.writeUInt8(1, 0); // double type
|
|
118
|
+
keyBuf.copy(entry, 1);
|
|
119
|
+
entry.writeDoubleBE(value, 1 + keyBuf.length);
|
|
120
|
+
parts.push(entry);
|
|
121
|
+
}
|
|
122
|
+
} else if (typeof value === "object" && value !== null) {
|
|
123
|
+
const keyBuf = Buffer.from(key + "\\0", "utf-8");
|
|
124
|
+
const subDoc = encodeBsonDocument(value);
|
|
125
|
+
const entry = Buffer.alloc(1 + keyBuf.length + subDoc.length);
|
|
126
|
+
entry.writeUInt8(3, 0); // document type
|
|
127
|
+
keyBuf.copy(entry, 1);
|
|
128
|
+
subDoc.copy(entry, 1 + keyBuf.length);
|
|
129
|
+
parts.push(entry);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const body = Buffer.concat(parts);
|
|
133
|
+
const doc = Buffer.alloc(4 + body.length + 1);
|
|
134
|
+
doc.writeInt32LE(doc.length, 0);
|
|
135
|
+
body.copy(doc, 4);
|
|
136
|
+
doc.writeUInt8(0, doc.length - 1);
|
|
137
|
+
return doc;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Use MongoDB OP_MSG (opcode 2013) with runCommand
|
|
141
|
+
function buildOpMsg(dbName, cmd) {
|
|
142
|
+
const cmdDoc = Object.assign({ "$db": dbName }, cmd);
|
|
143
|
+
const body = encodeBsonDocument(cmdDoc);
|
|
144
|
+
|
|
145
|
+
// OP_MSG: flagBits(4) + kind(1) + body
|
|
146
|
+
const section = Buffer.alloc(1 + body.length);
|
|
147
|
+
section.writeUInt8(0, 0); // kind 0 = body
|
|
148
|
+
body.copy(section, 1);
|
|
149
|
+
|
|
150
|
+
const msgHeader = Buffer.alloc(16 + 4 + section.length);
|
|
151
|
+
msgHeader.writeInt32LE(msgHeader.length, 0); // messageLength
|
|
152
|
+
msgHeader.writeInt32LE(1, 4); // requestID
|
|
153
|
+
msgHeader.writeInt32LE(0, 8); // responseTo
|
|
154
|
+
msgHeader.writeInt32LE(2013, 12); // opCode = OP_MSG
|
|
155
|
+
msgHeader.writeUInt32LE(0, 16); // flagBits
|
|
156
|
+
section.copy(msgHeader, 20);
|
|
157
|
+
|
|
158
|
+
return msgHeader;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// For this simplified implementation, we use a TCP connection
|
|
162
|
+
// and send/receive raw OP_MSG frames
|
|
163
|
+
const sock = net.createConnection({ host, port }, () => {
|
|
164
|
+
let cmd;
|
|
165
|
+
const parsedArgs = JSON.parse(args);
|
|
166
|
+
|
|
167
|
+
if (command === "find") {
|
|
168
|
+
cmd = {
|
|
169
|
+
find: collection,
|
|
170
|
+
filter: parsedArgs.filter,
|
|
171
|
+
limit: 1
|
|
172
|
+
};
|
|
173
|
+
} else if (command === "update") {
|
|
174
|
+
cmd = {
|
|
175
|
+
update: collection,
|
|
176
|
+
updates: [{ q: parsedArgs.filter, u: { "$set": parsedArgs.data }, upsert: true }]
|
|
177
|
+
};
|
|
178
|
+
} else if (command === "delete") {
|
|
179
|
+
cmd = {
|
|
180
|
+
delete: collection,
|
|
181
|
+
deletes: [{ q: parsedArgs.filter, limit: 1 }]
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const msg = buildOpMsg(database, cmd);
|
|
186
|
+
sock.write(msg);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let buffer = Buffer.alloc(0);
|
|
190
|
+
sock.on("data", (chunk) => {
|
|
191
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
192
|
+
if (buffer.length >= 4) {
|
|
193
|
+
const msgLen = buffer.readInt32LE(0);
|
|
194
|
+
if (buffer.length >= msgLen) {
|
|
195
|
+
// Parse response — extract body section
|
|
196
|
+
// Header is 16 bytes + 4 bytes flagBits + 1 byte section kind + BSON doc
|
|
197
|
+
if (buffer.length > 21) {
|
|
198
|
+
const bsonStart = 21;
|
|
199
|
+
const bsonLen = buffer.readInt32LE(bsonStart);
|
|
200
|
+
const bsonDoc = buffer.subarray(bsonStart, bsonStart + bsonLen);
|
|
201
|
+
|
|
202
|
+
// Simple BSON parser — just look for the session data string
|
|
203
|
+
const rawStr = bsonDoc.toString("utf-8", 4);
|
|
204
|
+
|
|
205
|
+
if (command === "find") {
|
|
206
|
+
// Look for cursor.firstBatch documents
|
|
207
|
+
// For simplicity, extract JSON-like data from response
|
|
208
|
+
process.stdout.write(rawStr);
|
|
209
|
+
} else {
|
|
210
|
+
process.stdout.write("__OK__");
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
process.stdout.write("__EMPTY__");
|
|
214
|
+
}
|
|
215
|
+
sock.destroy();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
sock.on("error", (err) => {
|
|
221
|
+
process.stderr.write(err.message);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
setTimeout(() => { sock.destroy(); process.exit(1); }, 5000);
|
|
226
|
+
`;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const result = execFileSync(process.execPath, ["-e", script], {
|
|
230
|
+
encoding: "utf-8",
|
|
231
|
+
timeout: 8000,
|
|
232
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
233
|
+
});
|
|
234
|
+
return result;
|
|
235
|
+
} catch {
|
|
236
|
+
return "";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private parseUri(): { host: string; port: number } {
|
|
241
|
+
if (!this.uri) return { host: this.host, port: this.port };
|
|
242
|
+
try {
|
|
243
|
+
// mongodb://host:port/db
|
|
244
|
+
const match = this.uri.match(/mongodb:\/\/([^/:]+):?(\d+)?/);
|
|
245
|
+
if (match) {
|
|
246
|
+
return {
|
|
247
|
+
host: match[1],
|
|
248
|
+
port: match[2] ? parseInt(match[2], 10) : 27017,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
} catch { /* ignore */ }
|
|
252
|
+
return { host: this.host, port: this.port };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
read(sessionId: string): SessionData | null {
|
|
256
|
+
const result = this.execSync("find", JSON.stringify({
|
|
257
|
+
filter: { _id: sessionId },
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
if (!result || result === "__EMPTY__") return null;
|
|
261
|
+
|
|
262
|
+
// Try to extract session data from the BSON response
|
|
263
|
+
try {
|
|
264
|
+
// Look for the JSON data pattern in the response
|
|
265
|
+
const dataMatch = result.match(/"data"\s*:\s*(\{[^}]+\})/);
|
|
266
|
+
if (dataMatch) {
|
|
267
|
+
return JSON.parse(dataMatch[1]) as SessionData;
|
|
268
|
+
}
|
|
269
|
+
} catch { /* ignore */ }
|
|
270
|
+
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
write(sessionId: string, data: SessionData, _ttl: number): void {
|
|
275
|
+
this.execSync("update", JSON.stringify({
|
|
276
|
+
filter: { _id: sessionId },
|
|
277
|
+
data: { _id: sessionId, data: JSON.stringify(data), updatedAt: new Date().toISOString() },
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
destroy(sessionId: string): void {
|
|
282
|
+
this.execSync("delete", JSON.stringify({
|
|
283
|
+
filter: { _id: sessionId },
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 Valkey Session Handler — Valkey (Redis-compatible) via raw TCP, zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Same as the Redis handler but uses VALKEY-prefixed configuration variables.
|
|
5
|
+
* Valkey is a Redis-compatible key-value store fork.
|
|
6
|
+
*
|
|
7
|
+
* Configure via environment variables:
|
|
8
|
+
* TINA4_SESSION_VALKEY_HOST (default: "127.0.0.1")
|
|
9
|
+
* TINA4_SESSION_VALKEY_PORT (default: 6379)
|
|
10
|
+
* TINA4_SESSION_VALKEY_PASSWORD (optional)
|
|
11
|
+
* TINA4_SESSION_VALKEY_PREFIX (default: "tina4:session:")
|
|
12
|
+
* TINA4_SESSION_VALKEY_DB (default: 0)
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import type { SessionHandler } from "../session.js";
|
|
16
|
+
|
|
17
|
+
interface SessionData {
|
|
18
|
+
_created: number;
|
|
19
|
+
_accessed: number;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ValkeySessionConfig {
|
|
24
|
+
host?: string;
|
|
25
|
+
port?: number;
|
|
26
|
+
password?: string;
|
|
27
|
+
prefix?: string;
|
|
28
|
+
db?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Valkey session handler using raw TCP (RESP protocol).
|
|
33
|
+
*
|
|
34
|
+
* Uses synchronous socket communication — no external Valkey/Redis client required.
|
|
35
|
+
* Stores session data as JSON strings with Valkey TTL for automatic expiry.
|
|
36
|
+
*
|
|
37
|
+
* Valkey uses the same RESP protocol as Redis, so this handler is functionally
|
|
38
|
+
* identical to RedisSessionHandler but with VALKEY config variable names.
|
|
39
|
+
*/
|
|
40
|
+
export class ValkeySessionHandler implements SessionHandler {
|
|
41
|
+
private host: string;
|
|
42
|
+
private port: number;
|
|
43
|
+
private password: string;
|
|
44
|
+
private prefix: string;
|
|
45
|
+
private db: number;
|
|
46
|
+
|
|
47
|
+
constructor(config?: ValkeySessionConfig) {
|
|
48
|
+
this.host = config?.host
|
|
49
|
+
?? process.env.TINA4_SESSION_VALKEY_HOST
|
|
50
|
+
?? "127.0.0.1";
|
|
51
|
+
this.port = config?.port
|
|
52
|
+
?? (process.env.TINA4_SESSION_VALKEY_PORT
|
|
53
|
+
? parseInt(process.env.TINA4_SESSION_VALKEY_PORT, 10)
|
|
54
|
+
: 6379);
|
|
55
|
+
this.password = config?.password
|
|
56
|
+
?? process.env.TINA4_SESSION_VALKEY_PASSWORD
|
|
57
|
+
?? "";
|
|
58
|
+
this.prefix = config?.prefix
|
|
59
|
+
?? process.env.TINA4_SESSION_VALKEY_PREFIX
|
|
60
|
+
?? "tina4:session:";
|
|
61
|
+
this.db = config?.db
|
|
62
|
+
?? (process.env.TINA4_SESSION_VALKEY_DB
|
|
63
|
+
? parseInt(process.env.TINA4_SESSION_VALKEY_DB, 10)
|
|
64
|
+
: 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Execute a RESP command synchronously via a short-lived TCP connection.
|
|
69
|
+
*
|
|
70
|
+
* Returns the raw RESP response string.
|
|
71
|
+
*/
|
|
72
|
+
private execSync(args: string[]): string {
|
|
73
|
+
const script = `
|
|
74
|
+
const net = require("node:net");
|
|
75
|
+
const host = ${JSON.stringify(this.host)};
|
|
76
|
+
const port = ${this.port};
|
|
77
|
+
const password = ${JSON.stringify(this.password)};
|
|
78
|
+
const db = ${this.db};
|
|
79
|
+
const args = ${JSON.stringify(args)};
|
|
80
|
+
|
|
81
|
+
function buildCommand(a) {
|
|
82
|
+
let cmd = "*" + a.length + "\\r\\n";
|
|
83
|
+
for (const s of a) cmd += "$" + Buffer.byteLength(s) + "\\r\\n" + s + "\\r\\n";
|
|
84
|
+
return cmd;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseResp(buf) {
|
|
88
|
+
const str = buf.toString("utf-8");
|
|
89
|
+
if (str.startsWith("+")) return str.slice(1).split("\\r\\n")[0];
|
|
90
|
+
if (str.startsWith("-")) return "ERR:" + str.slice(1).split("\\r\\n")[0];
|
|
91
|
+
if (str.startsWith(":")) return str.slice(1).split("\\r\\n")[0];
|
|
92
|
+
if (str.startsWith("$-1")) return null;
|
|
93
|
+
if (str.startsWith("$")) {
|
|
94
|
+
const nl = str.indexOf("\\r\\n");
|
|
95
|
+
const len = parseInt(str.slice(1, nl), 10);
|
|
96
|
+
const start = nl + 2;
|
|
97
|
+
return str.slice(start, start + len);
|
|
98
|
+
}
|
|
99
|
+
return str;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sock = net.createConnection({ host, port }, () => {
|
|
103
|
+
let commands = "";
|
|
104
|
+
if (password) commands += buildCommand(["AUTH", password]);
|
|
105
|
+
if (db !== 0) commands += buildCommand(["SELECT", String(db)]);
|
|
106
|
+
commands += buildCommand(args);
|
|
107
|
+
sock.write(commands);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
let buffer = Buffer.alloc(0);
|
|
111
|
+
sock.on("data", (chunk) => {
|
|
112
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
113
|
+
});
|
|
114
|
+
sock.on("end", () => {
|
|
115
|
+
// Parse last response (skip AUTH/SELECT responses)
|
|
116
|
+
const lines = buffer.toString("utf-8").split("\\r\\n");
|
|
117
|
+
let responses = [];
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < lines.length) {
|
|
120
|
+
const line = lines[i];
|
|
121
|
+
if (!line) { i++; continue; }
|
|
122
|
+
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(":")) {
|
|
123
|
+
responses.push(line);
|
|
124
|
+
i++;
|
|
125
|
+
} else if (line.startsWith("$")) {
|
|
126
|
+
const len = parseInt(line.slice(1), 10);
|
|
127
|
+
if (len === -1) { responses.push(null); i++; }
|
|
128
|
+
else { responses.push(lines[i+1] || ""); i += 2; }
|
|
129
|
+
} else { i++; }
|
|
130
|
+
}
|
|
131
|
+
// The last response is our actual command result
|
|
132
|
+
const result = responses[responses.length - 1];
|
|
133
|
+
if (result === null) process.stdout.write("__NULL__");
|
|
134
|
+
else if (typeof result === "string" && result.startsWith("-")) process.stdout.write("__ERR__" + result);
|
|
135
|
+
else process.stdout.write(String(result ?? "__NULL__"));
|
|
136
|
+
});
|
|
137
|
+
sock.on("error", (err) => {
|
|
138
|
+
process.stderr.write(err.message);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
141
|
+
setTimeout(() => { sock.destroy(); process.exit(1); }, 3000);
|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = execFileSync(process.execPath, ["-e", script], {
|
|
146
|
+
encoding: "utf-8",
|
|
147
|
+
timeout: 5000,
|
|
148
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
149
|
+
});
|
|
150
|
+
if (result === "__NULL__") return "";
|
|
151
|
+
if (result.startsWith("__ERR__")) return "";
|
|
152
|
+
return result;
|
|
153
|
+
} catch {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private key(sessionId: string): string {
|
|
159
|
+
return `${this.prefix}${sessionId}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
read(sessionId: string): SessionData | null {
|
|
163
|
+
const raw = this.execSync(["GET", this.key(sessionId)]);
|
|
164
|
+
if (!raw) return null;
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(raw) as SessionData;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
write(sessionId: string, data: SessionData, ttl: number): void {
|
|
173
|
+
const json = JSON.stringify(data);
|
|
174
|
+
if (ttl > 0) {
|
|
175
|
+
this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
|
|
176
|
+
} else {
|
|
177
|
+
this.execSync(["SET", this.key(sessionId), json]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
destroy(sessionId: string): void {
|
|
182
|
+
this.execSync(["DEL", this.key(sessionId)]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, extname } from "node:path";
|
|
3
|
+
import type { Tina4Request, Tina4Response } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const MIME_TYPES: Record<string, string> = {
|
|
6
|
+
".html": "text/html; charset=utf-8",
|
|
7
|
+
".css": "text/css; charset=utf-8",
|
|
8
|
+
".js": "application/javascript; charset=utf-8",
|
|
9
|
+
".json": "application/json; charset=utf-8",
|
|
10
|
+
".png": "image/png",
|
|
11
|
+
".jpg": "image/jpeg",
|
|
12
|
+
".jpeg": "image/jpeg",
|
|
13
|
+
".gif": "image/gif",
|
|
14
|
+
".webp": "image/webp",
|
|
15
|
+
".svg": "image/svg+xml",
|
|
16
|
+
".ico": "image/x-icon",
|
|
17
|
+
".woff": "font/woff",
|
|
18
|
+
".woff2": "font/woff2",
|
|
19
|
+
".ttf": "font/ttf",
|
|
20
|
+
".txt": "text/plain; charset=utf-8",
|
|
21
|
+
".xml": "application/xml",
|
|
22
|
+
".pdf": "application/pdf",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function tryServeStatic(
|
|
26
|
+
staticDir: string,
|
|
27
|
+
req: Tina4Request,
|
|
28
|
+
res: Tina4Response
|
|
29
|
+
): boolean {
|
|
30
|
+
const url = req.url ?? "/";
|
|
31
|
+
const pathname = url.split("?")[0];
|
|
32
|
+
|
|
33
|
+
// Try exact file match, then index.html for directory requests
|
|
34
|
+
const candidates = [
|
|
35
|
+
join(staticDir, pathname),
|
|
36
|
+
join(staticDir, pathname, "index.html"),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const filePath of candidates) {
|
|
40
|
+
if (!existsSync(filePath)) continue;
|
|
41
|
+
|
|
42
|
+
const stat = statSync(filePath);
|
|
43
|
+
if (!stat.isFile()) continue;
|
|
44
|
+
|
|
45
|
+
// Prevent directory traversal
|
|
46
|
+
if (!filePath.startsWith(staticDir)) continue;
|
|
47
|
+
|
|
48
|
+
const ext = extname(filePath);
|
|
49
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
50
|
+
|
|
51
|
+
res.raw.setHeader("Content-Type", contentType);
|
|
52
|
+
res.raw.setHeader("Content-Length", stat.size);
|
|
53
|
+
res.raw.end(readFileSync(filePath));
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|