tina4-nodejs 3.10.11 → 3.10.12
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
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.10.12)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.10.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.10.12 — a convention-over-configuration structural paradigm. **Not a framework.** The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tina4-nodejs",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
|
|
6
6
|
"keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
|
|
@@ -98,3 +98,5 @@ export { Container, container } from "./container.js";
|
|
|
98
98
|
export { Validator } from "./validator.js";
|
|
99
99
|
export type { ValidationError } from "./validator.js";
|
|
100
100
|
export type { WebSocketConnection } from "./websocketConnection.js";
|
|
101
|
+
export { RedisBackplane, NATSBackplane, createBackplane } from "./websocketBackplane.js";
|
|
102
|
+
export type { WebSocketBackplane } from "./websocketBackplane.js";
|
|
@@ -594,6 +594,12 @@ ${reset}
|
|
|
594
594
|
const origEnd = rawRes.end.bind(rawRes);
|
|
595
595
|
rawRes.end = function (...args: any[]) {
|
|
596
596
|
sess.save();
|
|
597
|
+
|
|
598
|
+
// Probabilistic garbage collection (~1% of requests)
|
|
599
|
+
if (Math.floor(Math.random() * 100) === 0) {
|
|
600
|
+
try { sess.gc(); } catch { /* GC failure is non-critical */ }
|
|
601
|
+
}
|
|
602
|
+
|
|
597
603
|
const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
|
|
598
604
|
if (newSid && newSid !== existingSid && !rawRes.headersSent) {
|
|
599
605
|
const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* session.destroy();
|
|
26
26
|
*/
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
28
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { execFileSync } from "node:child_process";
|
|
31
31
|
import { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
@@ -70,6 +70,8 @@ export interface SessionHandler {
|
|
|
70
70
|
read(sessionId: string): SessionData | null;
|
|
71
71
|
write(sessionId: string, data: SessionData, ttl: number): void;
|
|
72
72
|
destroy(sessionId: string): void;
|
|
73
|
+
/** Garbage-collect expired sessions. Optional — Redis/Valkey/Mongo handle TTL natively. */
|
|
74
|
+
gc?(maxLifetime: number): void;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
// ── File Session Handler ──────────────────────────────────────────
|
|
@@ -115,6 +117,28 @@ export class FileSessionHandler implements SessionHandler {
|
|
|
115
117
|
if (existsSync(filePath)) unlinkSync(filePath);
|
|
116
118
|
} catch { /* ignore */ }
|
|
117
119
|
}
|
|
120
|
+
|
|
121
|
+
gc(maxLifetime: number): void {
|
|
122
|
+
if (!existsSync(this.storagePath)) return;
|
|
123
|
+
const now = Math.floor(Date.now() / 1000);
|
|
124
|
+
try {
|
|
125
|
+
const files = readdirSync(this.storagePath);
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
if (!file.endsWith(".json")) continue;
|
|
128
|
+
const fullPath = join(this.storagePath, file);
|
|
129
|
+
try {
|
|
130
|
+
const raw = readFileSync(fullPath, "utf-8");
|
|
131
|
+
const data = JSON.parse(raw) as SessionData;
|
|
132
|
+
if (data._accessed && (now - data._accessed) > maxLifetime) {
|
|
133
|
+
unlinkSync(fullPath);
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Corrupt file — remove it
|
|
137
|
+
try { unlinkSync(fullPath); } catch { /* ignore */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch { /* ignore */ }
|
|
141
|
+
}
|
|
118
142
|
}
|
|
119
143
|
|
|
120
144
|
// ── Redis Session Handler (raw TCP, zero dependencies) ────────────
|
|
@@ -499,6 +523,16 @@ export class Session {
|
|
|
499
523
|
return this.sessionId;
|
|
500
524
|
}
|
|
501
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Run garbage collection on the session backend.
|
|
528
|
+
* Removes expired file/database sessions. Redis/Valkey/Mongo handle TTL natively.
|
|
529
|
+
*/
|
|
530
|
+
gc(): void {
|
|
531
|
+
if (this.handler.gc) {
|
|
532
|
+
this.handler.gc(this.ttl);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
502
536
|
// ── Private ───────────────────────────────────────────────────
|
|
503
537
|
|
|
504
538
|
private save(): void {
|
|
@@ -119,4 +119,12 @@ export class DatabaseSessionHandler implements SessionHandler {
|
|
|
119
119
|
.prepare("DELETE FROM tina4_session WHERE session_id = ?")
|
|
120
120
|
.run(sessionId);
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
gc(_maxLifetime: number): void {
|
|
124
|
+
this.ensureTable();
|
|
125
|
+
const now = Date.now() / 1000;
|
|
126
|
+
this.db
|
|
127
|
+
.prepare("DELETE FROM tina4_session WHERE expires_at > 0 AND expires_at < ?")
|
|
128
|
+
.run(now);
|
|
129
|
+
}
|
|
122
130
|
}
|
|
@@ -98,6 +98,84 @@ export class RedisBackplane implements WebSocketBackplane {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* NATS pub/sub backplane.
|
|
103
|
+
*
|
|
104
|
+
* Requires the `nats` package (`npm install nats`). The import is deferred
|
|
105
|
+
* so the rest of Tina4 works fine without it installed — an error is thrown
|
|
106
|
+
* only when this class is actually instantiated.
|
|
107
|
+
*
|
|
108
|
+
* NATS is async-native. The subscription listener runs via the NATS client's
|
|
109
|
+
* built-in async iteration.
|
|
110
|
+
*/
|
|
111
|
+
export class NATSBackplane implements WebSocketBackplane {
|
|
112
|
+
private nc: any;
|
|
113
|
+
private url: string;
|
|
114
|
+
private subs: Map<string, any> = new Map();
|
|
115
|
+
private ready: Promise<void>;
|
|
116
|
+
|
|
117
|
+
constructor(url?: string) {
|
|
118
|
+
this.url = url ?? process.env.TINA4_WS_BACKPLANE_URL ?? "nats://localhost:4222";
|
|
119
|
+
|
|
120
|
+
this.ready = (async () => {
|
|
121
|
+
let nats: any;
|
|
122
|
+
try {
|
|
123
|
+
nats = await import("nats");
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"The 'nats' package is required for NATSBackplane. " +
|
|
127
|
+
"Install it with: npm install nats"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.nc = await nats.connect({ servers: this.url });
|
|
132
|
+
console.log(`[Tina4] NATSBackplane connected to ${this.url}`);
|
|
133
|
+
})();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async publish(channel: string, message: string): Promise<void> {
|
|
137
|
+
await this.ready;
|
|
138
|
+
const { StringCodec } = await import("nats");
|
|
139
|
+
const sc = StringCodec();
|
|
140
|
+
this.nc.publish(channel, sc.encode(message));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
|
|
144
|
+
await this.ready;
|
|
145
|
+
const { StringCodec } = await import("nats");
|
|
146
|
+
const sc = StringCodec();
|
|
147
|
+
const sub = this.nc.subscribe(channel);
|
|
148
|
+
this.subs.set(channel, sub);
|
|
149
|
+
|
|
150
|
+
// Process messages in the background via async iteration
|
|
151
|
+
(async () => {
|
|
152
|
+
for await (const msg of sub) {
|
|
153
|
+
try {
|
|
154
|
+
callback(sc.decode(msg.data));
|
|
155
|
+
} catch { /* ignore callback errors */ }
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async unsubscribe(channel: string): Promise<void> {
|
|
161
|
+
const sub = this.subs.get(channel);
|
|
162
|
+
if (sub) {
|
|
163
|
+
sub.unsubscribe();
|
|
164
|
+
this.subs.delete(channel);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async close(): Promise<void> {
|
|
169
|
+
for (const sub of this.subs.values()) {
|
|
170
|
+
sub.unsubscribe();
|
|
171
|
+
}
|
|
172
|
+
this.subs.clear();
|
|
173
|
+
if (this.nc) {
|
|
174
|
+
await this.nc.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
101
179
|
/**
|
|
102
180
|
* Factory that reads TINA4_WS_BACKPLANE and returns the appropriate
|
|
103
181
|
* backplane instance, or `null` if no backplane is configured.
|
|
@@ -112,7 +190,7 @@ export function createBackplane(url?: string): WebSocketBackplane | null {
|
|
|
112
190
|
case "redis":
|
|
113
191
|
return new RedisBackplane(url);
|
|
114
192
|
case "nats":
|
|
115
|
-
|
|
193
|
+
return new NATSBackplane(url);
|
|
116
194
|
case "":
|
|
117
195
|
return null;
|
|
118
196
|
default:
|