tina4-nodejs 3.13.141 → 3.13.142

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.
Files changed (38) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +438 -453
  4. package/packages/cli/src/bin.ts +4 -14
  5. package/packages/cli/src/commands/build.ts +2 -18
  6. package/packages/cli/src/commands/generate.ts +2 -22
  7. package/packages/cli/src/commands/lint.ts +3 -13
  8. package/packages/cli/src/commands/migrate.ts +2 -6
  9. package/packages/cli/src/commands/migrateRollback.ts +2 -6
  10. package/packages/cli/src/commands/migrateStatus.ts +2 -6
  11. package/packages/cli/src/commands/queue.ts +2 -30
  12. package/packages/cli/src/util.ts +90 -0
  13. package/packages/core/dist/index.js +215 -184
  14. package/packages/core/gallery/queue/src/lib/queueDb.ts +11 -0
  15. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +2 -5
  16. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +2 -5
  17. package/packages/core/src/devAdmin.ts +3 -12
  18. package/packages/core/src/dispatchPipeline.ts +2 -18
  19. package/packages/core/src/etag.ts +24 -0
  20. package/packages/core/src/feedback.ts +2 -11
  21. package/packages/core/src/session.ts +22 -66
  22. package/packages/core/src/sessionHandlers/respSessionHandler.ts +94 -0
  23. package/packages/core/src/sessionHandlers/valkeyHandler.ts +22 -73
  24. package/packages/core/src/static.ts +1 -13
  25. package/packages/core/src/upstreamResponse.ts +31 -0
  26. package/packages/core/src/websocket.ts +48 -7
  27. package/packages/orm/dist/index.js +215 -184
  28. package/packages/orm/src/graph/adapters/bolt.ts +8 -29
  29. package/packages/orm/src/graph/adapters/rowMapping.ts +57 -0
  30. package/packages/orm/src/graph/adapters/ultipa.ts +8 -30
  31. package/types/cli/src/util.d.ts +28 -0
  32. package/types/core/src/etag.d.ts +9 -0
  33. package/types/core/src/session.d.ts +2 -19
  34. package/types/core/src/sessionHandlers/respSessionHandler.d.ts +52 -0
  35. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +2 -26
  36. package/types/core/src/upstreamResponse.d.ts +10 -0
  37. package/types/core/src/websocket.d.ts +21 -0
  38. package/types/orm/src/graph/adapters/rowMapping.d.ts +26 -0
@@ -38,3 +38,14 @@ export async function getQueueDb(): Promise<DatabaseAdapter> {
38
38
  export function now(): string {
39
39
  return new Date().toISOString();
40
40
  }
41
+
42
+ /**
43
+ * Claim the next pending message for a topic — highest priority first, then
44
+ * the oldest (lowest id). The `LIMIT 1` bounds the work: without it SQLite
45
+ * sorts the WHOLE pending backlog on every consume call before `fetchOne`
46
+ * reads a single row (carbonah E003, an unbounded query on a hot path). One
47
+ * row asked for, one row returned. Shared by the consume and fail routes so
48
+ * the claim query lives in exactly one place.
49
+ */
50
+ export const CLAIM_NEXT_PENDING_SQL =
51
+ "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC LIMIT 1";
@@ -8,17 +8,14 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
8
 
9
9
  /** Gallery: Queue — consume (complete) the next pending message. */
10
10
  import type { Tina4Request, Tina4Response } from "tina4-nodejs";
11
- import { getQueueDb, now } from "../../../../lib/queueDb.js";
11
+ import { getQueueDb, now, CLAIM_NEXT_PENDING_SQL } from "../../../../lib/queueDb.js";
12
12
 
13
13
  export default async function (_req: Tina4Request, res: Tina4Response) {
14
14
  try {
15
15
  const db = await getQueueDb();
16
16
  const ts = now();
17
17
 
18
- const row = db.fetchOne<Record<string, unknown>>(
19
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
20
- ["gallery-tasks", ts]
21
- );
18
+ const row = db.fetchOne<Record<string, unknown>>(CLAIM_NEXT_PENDING_SQL, ["gallery-tasks", ts]);
22
19
 
23
20
  if (!row) {
24
21
  return res.json({ consumed: false, message: "No pending messages to consume" });
@@ -8,17 +8,14 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
8
 
9
9
  /** Gallery: Queue — deliberately fail the next pending message. */
10
10
  import type { Tina4Request, Tina4Response } from "tina4-nodejs";
11
- import { getQueueDb, now } from "../../../../lib/queueDb.js";
11
+ import { getQueueDb, now, CLAIM_NEXT_PENDING_SQL } from "../../../../lib/queueDb.js";
12
12
 
13
13
  export default async function (_req: Tina4Request, res: Tina4Response) {
14
14
  try {
15
15
  const db = await getQueueDb();
16
16
  const ts = now();
17
17
 
18
- const row = db.fetchOne<Record<string, unknown>>(
19
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
20
- ["gallery-tasks", ts]
21
- );
18
+ const row = db.fetchOne<Record<string, unknown>>(CLAIM_NEXT_PENDING_SQL, ["gallery-tasks", ts]);
22
19
 
23
20
  if (!row) {
24
21
  return res.json({ failed: false, message: "No pending messages to fail" });
@@ -24,6 +24,7 @@ import { fileURLToPath } from "node:url";
24
24
  import type { Router } from "./router.js";
25
25
  import type { RouteHandler, Tina4Request } from "./types.js";
26
26
  import { DevMailbox } from "./devMailbox.js";
27
+ import { relayUpstreamResponse } from "./upstreamResponse.js";
27
28
  import { isTruthy } from "./dotenv.js";
28
29
  import { fullAnalysis, fileDetail, MetricsEngineError, metricsScanRoot } from "./metrics.js";
29
30
  import { registerFeedbackRoutes } from "./feedback.js";
@@ -1682,18 +1683,8 @@ async function proxyToSupervisor(
1682
1683
 
1683
1684
  clearTimeout(timer);
1684
1685
 
1685
- // JSON / other — drain the body and return as before.
1686
- const raw = await upstream.text();
1687
- const status = upstream.status || 200;
1688
- try {
1689
- res.json(JSON.parse(raw), status);
1690
- } catch {
1691
- // Non-JSON upstream — pass through as text with the same status.
1692
- res.raw.writeHead(status, {
1693
- "Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
1694
- });
1695
- res.raw.end(raw);
1696
- }
1686
+ // JSON / other — drain the body and relay with the upstream status.
1687
+ await relayUpstreamResponse(res, upstream);
1697
1688
  }
1698
1689
 
1699
1690
  // -- Framework-grounding (mcp.tina4.com) token config --
@@ -42,6 +42,7 @@ import { createHash } from "node:crypto";
42
42
  import type { Tina4Request } from "./types.js";
43
43
  import type { Session as SessionInstance } from "./session.js";
44
44
  import { Log } from "./logger.js";
45
+ import { etagMatches } from "./etag.js";
45
46
 
46
47
  /**
47
48
  * The prologue, in order. Exported as DATA so the pipeline can be asserted and
@@ -197,23 +198,6 @@ function isCompressibleContentType(contentType: string): boolean {
197
198
  return COMPRESSIBLE_PREFIXES.some((prefix) => contentType.includes(prefix));
198
199
  }
199
200
 
200
- /**
201
- * Match an If-None-Match header value against `etag` (RFC 7232 S3.2 weak
202
- * comparison): an optional W/ prefix is ignored on both sides, the header may
203
- * carry a comma-separated candidate list, and `*` matches any current
204
- * representation. Same algorithm as static.ts's own matcher (kept local here
205
- * rather than imported, since static.ts writes to the raw response directly
206
- * and never reaches this interceptor).
207
- */
208
- function etagMatchesInm(ifNoneMatch: string, etag: string): boolean {
209
- const strip = (tag: string) => tag.trim().replace(/^W\//, "");
210
- const target = strip(etag);
211
- return ifNoneMatch.split(",").some((candidate) => {
212
- const trimmed = candidate.trim();
213
- return trimmed === "*" || strip(trimmed) === target;
214
- });
215
- }
216
-
217
201
  function maybeCompressResponse(body: Buffer, rawReq: IncomingMessage, rawRes: ServerResponse): Buffer {
218
202
  const acceptEncoding = String(rawReq.headers["accept-encoding"] ?? "");
219
203
  const contentTypeHeader = rawRes.getHeader("content-type");
@@ -230,7 +214,7 @@ function maybeCompressResponse(body: Buffer, rawReq: IncomingMessage, rawRes: Se
230
214
 
231
215
  function responseIsNotModified(rawReq: IncomingMessage, rawRes: ServerResponse, etag: string): boolean {
232
216
  const ifNoneMatch = String(rawReq.headers["if-none-match"] ?? "");
233
- if (ifNoneMatch) return etagMatchesInm(ifNoneMatch, etag);
217
+ if (ifNoneMatch) return etagMatches(ifNoneMatch, etag);
234
218
  const lastModifiedHeader = rawRes.getHeader("last-modified");
235
219
  if (typeof lastModifiedHeader !== "string") return false;
236
220
  const ifModifiedSince = String(rawReq.headers["if-modified-since"] ?? "");
@@ -0,0 +1,24 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * Does an `If-None-Match` header match the response ETag?
11
+ *
12
+ * The header may be `*`, a single tag, or a comma-separated list; the `W/`
13
+ * weak prefix is stripped on both sides before comparing (RFC 7232 §2.3.2
14
+ * weak comparison). Shared by the static file server and the compression
15
+ * interceptor so both answer 304 the same way.
16
+ */
17
+ export function etagMatches(ifNoneMatch: string, etag: string): boolean {
18
+ const strip = (tag: string) => tag.trim().replace(/^W\//, "");
19
+ const target = strip(etag);
20
+ return ifNoneMatch.split(",").some((candidate) => {
21
+ const trimmed = candidate.trim();
22
+ return trimmed === "*" || strip(trimmed) === target;
23
+ });
24
+ }
@@ -36,6 +36,7 @@ import { fileURLToPath } from "node:url";
36
36
 
37
37
  import { authenticateRequest } from "./auth.js";
38
38
  import { supervisorBaseUrl } from "./devAdmin.js";
39
+ import { relayUpstreamResponse } from "./upstreamResponse.js";
39
40
  import type { RouteHandler, Tina4Request } from "./types.js";
40
41
  import type { Router } from "./router.js";
41
42
 
@@ -221,17 +222,7 @@ export const handleFeedbackTurn: RouteHandler = async (req, res) => {
221
222
  }
222
223
  clearTimeout(timer);
223
224
 
224
- const raw = await upstream.text();
225
- const status = upstream.status || 200;
226
- try {
227
- res.json(JSON.parse(raw), status);
228
- } catch {
229
- res.raw.writeHead(status, {
230
- "Content-Type":
231
- upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
232
- });
233
- res.raw.end(raw);
234
- }
225
+ await relayUpstreamResponse(res, upstream);
235
226
  };
236
227
 
237
228
  // Widget bundle lives at packages/core/src/__feedback/widget.js so that
@@ -37,7 +37,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdir
37
37
  import { join } from "node:path";
38
38
  import { Log } from "./logger.js";
39
39
  import { isTruthy } from "./dotenv.js";
40
- import { respCommandSync } from "./sessionHandlers/respClient.js";
40
+ import { RespSessionHandler } from "./sessionHandlers/respSessionHandler.js";
41
41
  import { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
42
42
  import { MemcachedSessionHandler } from "./sessionHandlers/memcachedHandler.js";
43
43
  import { MongoSessionHandler } from "./sessionHandlers/mongoHandler.js";
@@ -304,75 +304,31 @@ export class FileSessionHandler implements SessionHandler {
304
304
  *
305
305
  * Or pass via SessionConfig.
306
306
  */
307
- export class RedisSessionHandler implements SessionHandler {
308
- private host: string;
309
- private port: number;
310
- private password: string;
311
- private prefix: string;
312
- private db: number;
313
-
307
+ export class RedisSessionHandler extends RespSessionHandler {
314
308
  constructor(config?: SessionConfig) {
315
- this.host = config?.redisHost
316
- ?? process.env.TINA4_SESSION_REDIS_HOST
317
- ?? "127.0.0.1";
318
- this.port = config?.redisPort
319
- ?? (process.env.TINA4_SESSION_REDIS_PORT
320
- ? parseInt(process.env.TINA4_SESSION_REDIS_PORT, 10)
321
- : 6379);
322
- this.password = config?.redisPassword
323
- ?? process.env.TINA4_SESSION_REDIS_PASSWORD
324
- ?? "";
325
- this.prefix = config?.redisPrefix
326
- ?? process.env.TINA4_SESSION_REDIS_PREFIX
327
- ?? "tina4:session:";
328
- this.db = config?.redisDb
329
- ?? (process.env.TINA4_SESSION_REDIS_DB
330
- ? parseInt(process.env.TINA4_SESSION_REDIS_DB, 10)
331
- : 0);
332
- }
333
-
334
- /**
335
- * Execute a Redis command synchronously against the live server.
336
- *
337
- * Delegates to the shared {@link respCommandSync} transport: a genuine key miss
338
- * yields `""`, and a transport/connection FAILURE (server unreachable, rejected
339
- * AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
340
- * (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
341
- */
342
- private execSync(args: string[]): string {
343
- return respCommandSync(
344
- { host: this.host, port: this.port, password: this.password, db: this.db },
345
- args,
309
+ super(
310
+ {
311
+ host: config?.redisHost
312
+ ?? process.env.TINA4_SESSION_REDIS_HOST
313
+ ?? "127.0.0.1",
314
+ port: config?.redisPort
315
+ ?? (process.env.TINA4_SESSION_REDIS_PORT
316
+ ? parseInt(process.env.TINA4_SESSION_REDIS_PORT, 10)
317
+ : 6379),
318
+ password: config?.redisPassword
319
+ ?? process.env.TINA4_SESSION_REDIS_PASSWORD
320
+ ?? "",
321
+ prefix: config?.redisPrefix
322
+ ?? process.env.TINA4_SESSION_REDIS_PREFIX
323
+ ?? "tina4:session:",
324
+ db: config?.redisDb
325
+ ?? (process.env.TINA4_SESSION_REDIS_DB
326
+ ? parseInt(process.env.TINA4_SESSION_REDIS_DB, 10)
327
+ : 0),
328
+ },
346
329
  "Redis",
347
330
  );
348
331
  }
349
-
350
- private key(sessionId: string): string {
351
- return `${this.prefix}${sessionId}`;
352
- }
353
-
354
- read(sessionId: string): SessionData | null {
355
- const raw = this.execSync(["GET", this.key(sessionId)]);
356
- if (!raw) return null; // key miss — normal "no session yet", NOT an error
357
- try {
358
- return JSON.parse(raw) as SessionData;
359
- } catch {
360
- return null;
361
- }
362
- }
363
-
364
- write(sessionId: string, data: SessionData, ttl: number = 0): void {
365
- const json = JSON.stringify(data);
366
- if (ttl > 0) {
367
- this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
368
- } else {
369
- this.execSync(["SET", this.key(sessionId), json]);
370
- }
371
- }
372
-
373
- destroy(sessionId: string): void {
374
- this.execSync(["DEL", this.key(sessionId)]);
375
- }
376
332
  }
377
333
 
378
334
  // ── Flash data prefix ─────────────────────────────────────────────
@@ -0,0 +1,94 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ /**
10
+ * Tina4 RESP Session Handler base — the shared store logic behind Redis and
11
+ * Valkey, zero dependencies.
12
+ *
13
+ * Redis and Valkey speak the same RESP protocol, so the read/write/destroy
14
+ * transport is byte-for-byte identical between them — only the configuration
15
+ * variable names and the log label differ. That difference lives entirely in
16
+ * each subclass constructor; everything the session store actually DOES lives
17
+ * here, once. A subclass resolves its own host/port/password/prefix/db from
18
+ * config or environment, then hands a {@link RespConnection} plus a label to
19
+ * `super()`.
20
+ */
21
+ import type { SessionHandler } from "../session.js";
22
+ import { respCommandSync } from "./respClient.js";
23
+
24
+ interface SessionData {
25
+ _created: number;
26
+ _accessed: number;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /** Resolved connection details a RESP session handler needs. */
31
+ export interface RespConnection {
32
+ host: string;
33
+ port: number;
34
+ password: string;
35
+ prefix: string;
36
+ db: number;
37
+ }
38
+
39
+ /**
40
+ * RESP (Redis/Valkey) session handler using raw TCP — no external client.
41
+ *
42
+ * Stores session data as JSON strings with a server-side TTL for automatic
43
+ * expiry. A genuine key miss yields `null`; a transport/connection FAILURE
44
+ * (server unreachable, rejected AUTH, timeout) THROWS so the Session boundary
45
+ * can tell "not found" (silent) from "backend failed" (log-loud + degrade).
46
+ */
47
+ export abstract class RespSessionHandler implements SessionHandler {
48
+ protected constructor(
49
+ private readonly conn: RespConnection,
50
+ private readonly label: string,
51
+ ) {}
52
+
53
+ /**
54
+ * Execute a RESP command synchronously against the live server.
55
+ *
56
+ * Delegates to the shared {@link respCommandSync} transport: a genuine key
57
+ * miss yields `""`, and a transport/connection FAILURE THROWS so the caller
58
+ * can distinguish "not found" from "backend failed". Backend-failure parity.
59
+ */
60
+ private execSync(args: string[]): string {
61
+ return respCommandSync(
62
+ { host: this.conn.host, port: this.conn.port, password: this.conn.password, db: this.conn.db },
63
+ args,
64
+ this.label,
65
+ );
66
+ }
67
+
68
+ private key(sessionId: string): string {
69
+ return `${this.conn.prefix}${sessionId}`;
70
+ }
71
+
72
+ read(sessionId: string): SessionData | null {
73
+ const raw = this.execSync(["GET", this.key(sessionId)]);
74
+ if (!raw) return null; // key miss — normal "no session yet", NOT an error
75
+ try {
76
+ return JSON.parse(raw) as SessionData;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ write(sessionId: string, data: SessionData, ttl: number = 0): void {
83
+ const json = JSON.stringify(data);
84
+ if (ttl > 0) {
85
+ this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
86
+ } else {
87
+ this.execSync(["SET", this.key(sessionId), json]);
88
+ }
89
+ }
90
+
91
+ destroy(sessionId: string): void {
92
+ this.execSync(["DEL", this.key(sessionId)]);
93
+ }
94
+ }
@@ -19,14 +19,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
19
19
  * TINA4_SESSION_VALKEY_PREFIX (default: "tina4:session:")
20
20
  * TINA4_SESSION_VALKEY_DB (default: 0)
21
21
  */
22
- import type { SessionHandler } from "../session.js";
23
- import { respCommandSync } from "./respClient.js";
24
-
25
- interface SessionData {
26
- _created: number;
27
- _accessed: number;
28
- [key: string]: unknown;
29
- }
22
+ import { RespSessionHandler } from "./respSessionHandler.js";
30
23
 
31
24
  export interface ValkeySessionConfig {
32
25
  host?: string;
@@ -55,73 +48,29 @@ export interface ValkeySessionConfig {
55
48
  * Valkey uses the same RESP protocol as Redis, so this handler is functionally
56
49
  * identical to RedisSessionHandler but with VALKEY config variable names.
57
50
  */
58
- export class ValkeySessionHandler implements SessionHandler {
59
- private host: string;
60
- private port: number;
61
- private password: string;
62
- private prefix: string;
63
- private db: number;
64
-
51
+ export class ValkeySessionHandler extends RespSessionHandler {
65
52
  constructor(config?: ValkeySessionConfig) {
66
- this.host = config?.host
67
- ?? process.env.TINA4_SESSION_VALKEY_HOST
68
- ?? "127.0.0.1";
69
- this.port = config?.port
70
- ?? (process.env.TINA4_SESSION_VALKEY_PORT
71
- ? parseInt(process.env.TINA4_SESSION_VALKEY_PORT, 10)
72
- : 6379);
73
- this.password = config?.password
74
- ?? process.env.TINA4_SESSION_VALKEY_PASSWORD
75
- ?? "";
76
- this.prefix = config?.prefix
77
- ?? process.env.TINA4_SESSION_VALKEY_PREFIX
78
- ?? "tina4:session:";
79
- this.db = config?.db
80
- ?? (process.env.TINA4_SESSION_VALKEY_DB
81
- ? parseInt(process.env.TINA4_SESSION_VALKEY_DB, 10)
82
- : 0);
83
- }
84
-
85
- /**
86
- * Execute a RESP command synchronously against the live Valkey server.
87
- *
88
- * Delegates to the shared {@link respCommandSync} transport: a genuine key miss
89
- * yields `""`, and a transport/connection FAILURE (server unreachable, rejected
90
- * AUTH, timeout) THROWS so the Session boundary can distinguish "not found"
91
- * (silent) from "backend failed" (log-loud + degrade). Backend-failure parity.
92
- */
93
- private execSync(args: string[]): string {
94
- return respCommandSync(
95
- { host: this.host, port: this.port, password: this.password, db: this.db },
96
- args,
53
+ super(
54
+ {
55
+ host: config?.host
56
+ ?? process.env.TINA4_SESSION_VALKEY_HOST
57
+ ?? "127.0.0.1",
58
+ port: config?.port
59
+ ?? (process.env.TINA4_SESSION_VALKEY_PORT
60
+ ? parseInt(process.env.TINA4_SESSION_VALKEY_PORT, 10)
61
+ : 6379),
62
+ password: config?.password
63
+ ?? process.env.TINA4_SESSION_VALKEY_PASSWORD
64
+ ?? "",
65
+ prefix: config?.prefix
66
+ ?? process.env.TINA4_SESSION_VALKEY_PREFIX
67
+ ?? "tina4:session:",
68
+ db: config?.db
69
+ ?? (process.env.TINA4_SESSION_VALKEY_DB
70
+ ? parseInt(process.env.TINA4_SESSION_VALKEY_DB, 10)
71
+ : 0),
72
+ },
97
73
  "Valkey",
98
74
  );
99
75
  }
100
-
101
- private key(sessionId: string): string {
102
- return `${this.prefix}${sessionId}`;
103
- }
104
-
105
- read(sessionId: string): SessionData | null {
106
- const raw = this.execSync(["GET", this.key(sessionId)]);
107
- if (!raw) return null; // key miss — normal "no session yet", NOT an error
108
- try {
109
- return JSON.parse(raw) as SessionData;
110
- } catch {
111
- return null;
112
- }
113
- }
114
-
115
- write(sessionId: string, data: SessionData, ttl: number): void {
116
- const json = JSON.stringify(data);
117
- if (ttl > 0) {
118
- this.execSync(["SETEX", this.key(sessionId), String(ttl), json]);
119
- } else {
120
- this.execSync(["SET", this.key(sessionId), json]);
121
- }
122
- }
123
-
124
- destroy(sessionId: string): void {
125
- this.execSync(["DEL", this.key(sessionId)]);
126
- }
127
76
  }
@@ -10,6 +10,7 @@ import { readFileSync, realpathSync, statSync, type Stats } from "node:fs";
10
10
  import { join, extname, sep } from "node:path";
11
11
  import { gzipSync } from "node:zlib";
12
12
  import type { Tina4Request, Tina4Response } from "./types.js";
13
+ import { etagMatches } from "./etag.js";
13
14
 
14
15
  /** Content-type prefixes that benefit from gzip. Mirrors the Python master's `_is_compressible`. */
15
16
  const COMPRESSIBLE_PREFIXES = [
@@ -200,16 +201,3 @@ function isNotModified(req: Tina4Request, etag: string, stat: Stats): boolean {
200
201
  return false;
201
202
  }
202
203
 
203
- /**
204
- * Weak comparison of an `If-None-Match` value against our ETag. The header may
205
- * be `*`, a single tag, or a comma-separated list; the `W/` weak prefix is
206
- * stripped on both sides before comparing (RFC 7232 §2.3.2 weak comparison).
207
- */
208
- function etagMatches(ifNoneMatch: string, etag: string): boolean {
209
- const strip = (tag: string) => tag.trim().replace(/^W\//, "");
210
- const target = strip(etag);
211
- return ifNoneMatch.split(",").some((candidate) => {
212
- const trimmed = candidate.trim();
213
- return trimmed === "*" || strip(trimmed) === target;
214
- });
215
- }
@@ -0,0 +1,31 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ import type { Tina4Response } from "./types.js";
10
+
11
+ /**
12
+ * Relay a drained upstream (proxied) response to the client.
13
+ *
14
+ * The body is read to a string once, then returned as JSON when it parses and
15
+ * as raw text with the upstream Content-Type otherwise, keeping the upstream
16
+ * status either way. Shared by the dev-admin supervisor proxy and the feedback
17
+ * agent proxy so both pass a backend response through identically.
18
+ */
19
+ export async function relayUpstreamResponse(res: Tina4Response, upstream: Response): Promise<void> {
20
+ const raw = await upstream.text();
21
+ const status = upstream.status || 200;
22
+ try {
23
+ res.json(JSON.parse(raw), status);
24
+ } catch {
25
+ // Non-JSON upstream — pass through as text with the same status.
26
+ res.raw.writeHead(status, {
27
+ "Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
28
+ });
29
+ res.raw.end(raw);
30
+ }
31
+ }
@@ -321,6 +321,33 @@ export function parseFrame(
321
321
  return { fin: !!fin, opcode, payload: Buffer.from(payload), bytesConsumed: offset + payloadLen };
322
322
  }
323
323
 
324
+ /**
325
+ * Reassembles fragmented messages (RFC 6455 section 5.4). A browser sends a large message - a pasted image in a chat,
326
+ * base64 inside JSON - as a TEXT or BINARY frame with FIN clear, then CONTINUATION frames; control frames may arrive
327
+ * between the pieces. Returns a complete message when one is done, a control frame as it comes, and null while a
328
+ * message is still arriving. A CONTINUATION with no message started is dropped. Mirrors the Python master
329
+ * (tina4_python.websocket _handle_frame). One per connection.
330
+ */
331
+ export class MessageAssembler {
332
+ private parts: Buffer[] = [];
333
+ private opcode = 0;
334
+
335
+ push(frame: { fin: boolean; opcode: number; payload: Buffer }): { opcode: number; payload: Buffer } | null {
336
+ if (frame.opcode >= 0x8) return { opcode: frame.opcode, payload: frame.payload }; // control frames are never fragmented
337
+ if (frame.opcode === OP_CONTINUATION) {
338
+ if (!this.opcode) return null;
339
+ this.parts.push(frame.payload);
340
+ if (!frame.fin) return null;
341
+ const whole = { opcode: this.opcode, payload: Buffer.concat(this.parts) };
342
+ this.parts = []; this.opcode = 0;
343
+ return whole;
344
+ }
345
+ if (frame.fin) { this.parts = []; this.opcode = 0; return { opcode: frame.opcode, payload: frame.payload }; }
346
+ this.opcode = frame.opcode; this.parts = [frame.payload];
347
+ return null;
348
+ }
349
+ }
350
+
324
351
  // ── WebSocket Server ─────────────────────────────────────────
325
352
 
326
353
  export class WebSocketServer {
@@ -854,6 +881,13 @@ export class WebSocketServer {
854
881
  });
855
882
  }
856
883
 
884
+ private readonly assemblers = new WeakMap<WebSocketClient, MessageAssembler>();
885
+ private assemblerFor(client: WebSocketClient): MessageAssembler {
886
+ let a = this.assemblers.get(client);
887
+ if (!a) { a = new MessageAssembler(); this.assemblers.set(client, a); }
888
+ return a;
889
+ }
890
+
857
891
  private processBuffer(
858
892
  client: WebSocketClient,
859
893
  buffer: Buffer,
@@ -868,17 +902,20 @@ export class WebSocketServer {
868
902
  remaining = remaining.subarray(frame.bytesConsumed);
869
903
  client.lastActivity = Date.now(); // mark activity for the idle reaper
870
904
 
871
- switch (frame.opcode) {
905
+ // a fragmented message is delivered once, whole (MessageAssembler)
906
+ const whole = this.assemblerFor(client).push(frame);
907
+ if (!whole) continue;
908
+ switch (whole.opcode) {
872
909
  case OP_TEXT:
873
- this.emit("message", client, frame.payload.toString("utf-8"));
910
+ this.emit("message", client, whole.payload.toString("utf-8"));
874
911
  break;
875
912
 
876
913
  case OP_BINARY:
877
- this.emit("message", client, frame.payload);
914
+ this.emit("message", client, whole.payload);
878
915
  break;
879
916
 
880
917
  case OP_PING: {
881
- const pongFrame = buildFrame(OP_PONG, frame.payload);
918
+ const pongFrame = buildFrame(OP_PONG, whole.payload);
882
919
  try {
883
920
  client.socket.write(pongFrame);
884
921
  } catch {
@@ -1243,15 +1280,19 @@ export function serveWebSocketRoute(req: IncomingMessage, socket: Socket, head:
1243
1280
  .finally(() => wsRouteManager.remove(conn.id));
1244
1281
  };
1245
1282
 
1283
+ const assembler = new MessageAssembler();
1246
1284
  socket.on("data", (chunk: Buffer) => {
1247
1285
  buffer = Buffer.concat([buffer, chunk]);
1248
1286
  while (buffer.length > 0) {
1249
1287
  const frame = parseFrame(buffer);
1250
1288
  if (!frame) break;
1251
1289
  buffer = buffer.subarray(frame.bytesConsumed);
1252
- switch (frame.opcode) {
1290
+ // a fragmented message is delivered once, whole (MessageAssembler)
1291
+ const whole = assembler.push(frame);
1292
+ if (!whole) continue;
1293
+ switch (whole.opcode) {
1253
1294
  case OP_TEXT: {
1254
- const text = frame.payload.toString("utf-8");
1295
+ const text = whole.payload.toString("utf-8");
1255
1296
  void Promise.resolve()
1256
1297
  .then(() => (conn._onMessage ? conn._onMessage(text) : handler(conn, "message", text)))
1257
1298
  .catch((e) => Log.error(`WebSocket message handler error: ${(e as Error).message}`));
@@ -1259,7 +1300,7 @@ export function serveWebSocketRoute(req: IncomingMessage, socket: Socket, head:
1259
1300
  }
1260
1301
  case OP_PING:
1261
1302
  try {
1262
- socket.write(buildFrame(OP_PONG, frame.payload));
1303
+ socket.write(buildFrame(OP_PONG, whole.payload));
1263
1304
  } catch {
1264
1305
  /* client gone */
1265
1306
  }