tina4-nodejs 3.10.10 → 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.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.10 — a convention-over-configuration structural paradigm. **Not a framework.** The developer writes TypeScript; Tina4 is invisible infrastructure.
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.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
- throw new Error("NATS backplane is on the roadmap but not yet implemented.");
193
+ return new NATSBackplane(url);
116
194
  case "":
117
195
  return null;
118
196
  default:
@@ -196,10 +196,21 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
196
196
  return null;
197
197
  }
198
198
 
199
- let key: string | number = part.replace(/^['"]|['"]$/g, "");
200
- const asNum = parseInt(key, 10);
201
- if (!isNaN(asNum) && String(asNum) === key) {
202
- key = asNum;
199
+ let key: string | number;
200
+ // Check if this part came from bracket access and needs variable resolution
201
+ if ((part.startsWith('"') && part.endsWith('"')) ||
202
+ (part.startsWith("'") && part.endsWith("'"))) {
203
+ // Quoted string literal — strip quotes
204
+ key = part.slice(1, -1);
205
+ } else {
206
+ const asNum = parseInt(part, 10);
207
+ if (!isNaN(asNum) && String(asNum) === part) {
208
+ key = asNum;
209
+ } else {
210
+ // Try to resolve as a variable from context
211
+ const resolved = context[part];
212
+ key = resolved !== undefined ? String(resolved) : part;
213
+ }
203
214
  }
204
215
 
205
216
  if (typeof value === "object" && value !== null) {