tina4-nodejs 3.13.37 → 3.13.39
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 +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -22,32 +22,45 @@ export class MiddlewareChain {
|
|
|
22
22
|
this.middlewares.push(fn);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Run the chain in REGISTRATION order — each middleware runs exactly once,
|
|
27
|
+
* in the order it was attached via use(). The chain advances from ONE
|
|
28
|
+
* source only: `next()`. (The old runner double-advanced — a for-loop index
|
|
29
|
+
* AND next() both incremented — so every other middleware was silently
|
|
30
|
+
* skipped. Fixed by driving the chain purely by next(), mirroring Python's
|
|
31
|
+
* _make_mw_continuation Russian-doll continuation.)
|
|
32
|
+
*
|
|
33
|
+
* A middleware may stop the chain by:
|
|
34
|
+
* - not calling next() (it owns the response), or
|
|
35
|
+
* - ending the response (res.raw.writableEnded).
|
|
36
|
+
* Returns true when the whole chain ran to completion (handler may proceed),
|
|
37
|
+
* false when it was short-circuited.
|
|
38
|
+
*/
|
|
25
39
|
async run(req: Tina4Request, res: Tina4Response): Promise<boolean> {
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
const dispatch = async (i: number): Promise<void> => {
|
|
41
|
+
if (i >= this.middlewares.length) return;
|
|
42
|
+
let advanced = false;
|
|
28
43
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
const next = (): void => {
|
|
45
|
+
advanced = true;
|
|
46
|
+
};
|
|
32
47
|
|
|
33
|
-
|
|
34
|
-
const prevIndex = index;
|
|
35
|
-
await this.middlewares[index](req, res, next);
|
|
48
|
+
await this.middlewares[i](req, res, next);
|
|
36
49
|
|
|
37
|
-
//
|
|
38
|
-
if (res.raw.writableEnded)
|
|
39
|
-
completed = false;
|
|
40
|
-
break;
|
|
41
|
-
}
|
|
50
|
+
// The middleware owns the response — stop the chain.
|
|
51
|
+
if (res.raw.writableEnded) return;
|
|
42
52
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
// next() was called → advance to the following middleware (exactly one
|
|
54
|
+
// step). next() not called → the middleware short-circuited; stop here.
|
|
55
|
+
if (advanced) {
|
|
56
|
+
await dispatch(i + 1);
|
|
47
57
|
}
|
|
48
|
-
}
|
|
58
|
+
};
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
await dispatch(0);
|
|
61
|
+
|
|
62
|
+
// Completed (handler may proceed) iff no middleware ended the response.
|
|
63
|
+
return !res.raw.writableEnded;
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
|
|
@@ -64,6 +77,39 @@ export class MiddlewareChain {
|
|
|
64
77
|
* If a "before" method returns a response whose status code is >= 400
|
|
65
78
|
* the chain short-circuits and runBefore returns shouldContinue = false.
|
|
66
79
|
*/
|
|
80
|
+
/**
|
|
81
|
+
* Produce the deterministic clean 500 for a throwing class-based middleware
|
|
82
|
+
* (M2). Mirrors Python's _middleware_500: LOG via Log.error (class + method +
|
|
83
|
+
* error type + message — never silent) then return a 500 with the exact JSON
|
|
84
|
+
* body shape shared across all four frameworks. The worker never crashes and
|
|
85
|
+
* no unhandled exception leaks.
|
|
86
|
+
*/
|
|
87
|
+
function middleware500(
|
|
88
|
+
res: Tina4Response,
|
|
89
|
+
mwClass: any,
|
|
90
|
+
methodName: string,
|
|
91
|
+
error: unknown,
|
|
92
|
+
): Tina4Response {
|
|
93
|
+
const clsName = mwClass?.name ?? mwClass?.constructor?.name ?? "Middleware";
|
|
94
|
+
const err = error as { name?: string; message?: string };
|
|
95
|
+
const type = err?.name ?? (error as object)?.constructor?.name ?? "Error";
|
|
96
|
+
const message = err?.message ?? String(error);
|
|
97
|
+
try {
|
|
98
|
+
Log.error(`Middleware ${clsName}.${methodName} raised ${type}: ${message}`);
|
|
99
|
+
} catch {
|
|
100
|
+
/* never let a broken logger swallow the 500 */
|
|
101
|
+
}
|
|
102
|
+
// res is callable (json) in real Response; tolerate either shape.
|
|
103
|
+
if (typeof (res as any).json === "function") {
|
|
104
|
+
(res as any).json({ error: "Internal Server Error", status: 500 }, 500);
|
|
105
|
+
} else if (typeof (res as any) === "function") {
|
|
106
|
+
(res as any)({ error: "Internal Server Error", status: 500 }, 500);
|
|
107
|
+
} else if (typeof (res as any).status === "function") {
|
|
108
|
+
(res as any).status(500);
|
|
109
|
+
}
|
|
110
|
+
return res;
|
|
111
|
+
}
|
|
112
|
+
|
|
67
113
|
export class MiddlewareRunner {
|
|
68
114
|
/** Globally registered middleware classes (parity with PHP/Ruby/Python orchestrators). */
|
|
69
115
|
private static globalMiddleware: any[] = [];
|
|
@@ -90,16 +136,43 @@ export class MiddlewareRunner {
|
|
|
90
136
|
}
|
|
91
137
|
|
|
92
138
|
/**
|
|
93
|
-
*
|
|
94
|
-
* in order
|
|
95
|
-
*
|
|
139
|
+
* Discover the before-prefixed / after-prefixed method names on a
|
|
140
|
+
* middleware class in DEFINITION order (M1).
|
|
141
|
+
* `Object.getOwnPropertyNames` returns a class's own
|
|
142
|
+
* static method names in source-declaration order — we deliberately do NOT
|
|
143
|
+
* sort() them, so within a class the hooks run in the order they were
|
|
144
|
+
* written (parity with Python walking __dict__, PHP get_class_methods, Ruby
|
|
145
|
+
* instance_methods(false)). Cross-class order is the natural iteration of
|
|
146
|
+
* the registered classes = REGISTRATION order.
|
|
147
|
+
*/
|
|
148
|
+
private static methodNames(cls: any, prefix: string): string[] {
|
|
149
|
+
return Object.getOwnPropertyNames(cls).filter(
|
|
150
|
+
(name) => typeof cls[name] === "function" && name.startsWith(prefix),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Execute every beforeX static method found on the supplied classes.
|
|
156
|
+
*
|
|
157
|
+
* ORDER (M1): cross-class = REGISTRATION order (the order classes were
|
|
158
|
+
* attached via Router.use / MiddlewareRunner.use); within a class =
|
|
159
|
+
* DEFINITION order (source order, never alphabetical). before_* run before
|
|
160
|
+
* the handler.
|
|
161
|
+
*
|
|
162
|
+
* THROW (M2): each before* call is wrapped — a throwing middleware is
|
|
163
|
+
* LOGGED and produces a deterministic clean 500 (it never crashes the
|
|
164
|
+
* worker / leaks an unhandled exception), and the chain short-circuits
|
|
165
|
+
* (skip = true, handler skipped).
|
|
96
166
|
*
|
|
97
|
-
* Short-circuits when a before
|
|
167
|
+
* Short-circuits (skip = true, handler skipped) when a before* sets a
|
|
168
|
+
* status >= 400 or ends/500s the response.
|
|
98
169
|
*
|
|
99
170
|
* ASYNC — each hook is awaited so middleware can perform async work (e.g.
|
|
100
171
|
* the distributed responseCache before-hook awaiting `backend.get`). Awaiting
|
|
101
172
|
* a synchronous hook that returns an array is harmless (the array resolves
|
|
102
173
|
* immediately), so existing sync hooks keep working unchanged.
|
|
174
|
+
*
|
|
175
|
+
* Returns [req, res, shouldContinue].
|
|
103
176
|
*/
|
|
104
177
|
static async runBefore(
|
|
105
178
|
classes: any[],
|
|
@@ -107,13 +180,16 @@ export class MiddlewareRunner {
|
|
|
107
180
|
res: Tina4Response,
|
|
108
181
|
): Promise<[Tina4Request, Tina4Response, boolean]> {
|
|
109
182
|
for (const cls of classes) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
183
|
+
for (const method of MiddlewareRunner.methodNames(cls, "before")) {
|
|
184
|
+
try {
|
|
185
|
+
const result = await cls[method](req, res);
|
|
186
|
+
if (Array.isArray(result)) {
|
|
187
|
+
[req, res] = result as [Tina4Request, Tina4Response];
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Throw → logged clean 500, skip the handler (deterministic).
|
|
191
|
+
res = middleware500(res, cls, method, error);
|
|
192
|
+
return [req, res, false];
|
|
117
193
|
}
|
|
118
194
|
// Short-circuit if the middleware set an error status
|
|
119
195
|
if (res.raw.statusCode >= 400 || res.raw.writableEnded) {
|
|
@@ -125,8 +201,19 @@ export class MiddlewareRunner {
|
|
|
125
201
|
}
|
|
126
202
|
|
|
127
203
|
/**
|
|
128
|
-
* Execute every afterX static method found on the supplied classes
|
|
129
|
-
*
|
|
204
|
+
* Execute every afterX static method found on the supplied classes.
|
|
205
|
+
*
|
|
206
|
+
* ORDER (M1): cross-class = REGISTRATION order; within a class = DEFINITION
|
|
207
|
+
* order. after_* run after the handler.
|
|
208
|
+
*
|
|
209
|
+
* THROW (M2): each after* call is wrapped — a throwing after middleware is
|
|
210
|
+
* LOGGED and produces a clean 500, then the remaining after* STILL run
|
|
211
|
+
* (they may add headers / logging). No unhandled exception leaks.
|
|
212
|
+
*
|
|
213
|
+
* AFTER-ON-4xx RULE (M2): after_* ALWAYS run, even when a before_*
|
|
214
|
+
* short-circuited with status >= 400 and the handler was skipped — so they
|
|
215
|
+
* can still add headers / logging. The dispatcher calls runAfter
|
|
216
|
+
* unconditionally after the before/handler block (see server.ts).
|
|
130
217
|
*
|
|
131
218
|
* ASYNC — each hook is awaited (e.g. the responseCache after-hook awaiting
|
|
132
219
|
* `backend.set`). Awaiting a synchronous hook is harmless, so existing sync
|
|
@@ -138,13 +225,16 @@ export class MiddlewareRunner {
|
|
|
138
225
|
res: Tina4Response,
|
|
139
226
|
): Promise<[Tina4Request, Tina4Response]> {
|
|
140
227
|
for (const cls of classes) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
for (const method of MiddlewareRunner.methodNames(cls, "after")) {
|
|
229
|
+
try {
|
|
230
|
+
const result = await cls[method](req, res);
|
|
231
|
+
if (Array.isArray(result)) {
|
|
232
|
+
[req, res] = result as [Tina4Request, Tina4Response];
|
|
233
|
+
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// Throw → logged clean 500, but remaining after* STILL run.
|
|
236
|
+
res = middleware500(res, cls, method, error);
|
|
237
|
+
continue;
|
|
148
238
|
}
|
|
149
239
|
}
|
|
150
240
|
}
|
|
@@ -255,7 +345,7 @@ export class CorsMiddleware {
|
|
|
255
345
|
const allowedHeaders = process.env.TINA4_CORS_HEADERS
|
|
256
346
|
?? "Content-Type,Authorization,X-Request-ID";
|
|
257
347
|
|
|
258
|
-
const credentials = process.env.TINA4_CORS_CREDENTIALS ?? "
|
|
348
|
+
const credentials = process.env.TINA4_CORS_CREDENTIALS ?? "false";
|
|
259
349
|
|
|
260
350
|
const maxAge = process.env.TINA4_CORS_MAX_AGE
|
|
261
351
|
? parseInt(process.env.TINA4_CORS_MAX_AGE, 10)
|
|
@@ -487,7 +487,7 @@ export const Plan = {
|
|
|
487
487
|
}),
|
|
488
488
|
signal: AbortSignal.timeout(120_000),
|
|
489
489
|
});
|
|
490
|
-
result = await resp.json()
|
|
490
|
+
result = (await resp.json()) as Record<string, unknown>;
|
|
491
491
|
} catch (e) {
|
|
492
492
|
return { ok: false, error: `AI backend unreachable: ${(e as Error).message}` };
|
|
493
493
|
}
|
|
@@ -71,7 +71,7 @@ export interface QueueBackendInterface {
|
|
|
71
71
|
push(queue: string, payload: unknown, delay?: number): string;
|
|
72
72
|
pop(queue: string): QueueJob | null;
|
|
73
73
|
size(queue: string): number;
|
|
74
|
-
clear(queue: string):
|
|
74
|
+
clear(queue: string): void;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// ── Queue ────────────────────────────────────────────────────
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* TINA4_KAFKA_BROKERS (override; default: "localhost:9092")
|
|
10
10
|
* TINA4_KAFKA_GROUP_ID (default: "tina4_consumer_group")
|
|
11
11
|
*
|
|
12
|
+
* TLS/SASL (each read as TINA4_KAFKA_<NAME> first, then bare KAFKA_<NAME>):
|
|
13
|
+
* TINA4_KAFKA_SECURITY_PROTOCOL — e.g. SSL / SASL_SSL (default: PLAINTEXT)
|
|
14
|
+
* TINA4_KAFKA_SSL_CA_LOCATION — CA cert path for TLS brokers/proxies
|
|
15
|
+
* TINA4_KAFKA_SASL_MECHANISM / TINA4_KAFKA_SASL_USERNAME / TINA4_KAFKA_SASL_PASSWORD — optional SASL
|
|
16
|
+
*
|
|
12
17
|
* Precedence for brokers: specific TINA4_KAFKA_BROKERS var (if set)
|
|
13
18
|
* > value derived from TINA4_QUEUE_URL > existing default.
|
|
14
19
|
*/
|
|
@@ -24,6 +29,62 @@ export interface KafkaConfig {
|
|
|
24
29
|
groupId?: string;
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
/**
|
|
33
|
+
* librdkafka-style SSL/SASL client config (a TLS broker/proxy). Mirrors the
|
|
34
|
+
* keys produced by Python's `KafkaConnector._security_config`. Every key is
|
|
35
|
+
* optional — an unset env var leaves the key OUT (librdkafka defaults to the
|
|
36
|
+
* PLAINTEXT protocol with no SASL).
|
|
37
|
+
*/
|
|
38
|
+
export interface KafkaSecurityConfig {
|
|
39
|
+
"security.protocol"?: string;
|
|
40
|
+
"ssl.ca.location"?: string;
|
|
41
|
+
"sasl.mechanism"?: string;
|
|
42
|
+
"sasl.username"?: string;
|
|
43
|
+
"sasl.password"?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolved producer/consumer config — brokers, client id, and security keys. */
|
|
47
|
+
export interface KafkaClientConfig extends KafkaSecurityConfig {
|
|
48
|
+
"bootstrap.servers": string;
|
|
49
|
+
"client.id": string;
|
|
50
|
+
"group.id"?: string;
|
|
51
|
+
"auto.offset.reset"?: string;
|
|
52
|
+
"enable.auto.commit"?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the SSL/SASL client config from the environment (for a TLS broker or
|
|
57
|
+
* proxy in front of Kafka). Each setting is read from the Tina4-namespaced env
|
|
58
|
+
* var FIRST (`TINA4_KAFKA_SECURITY_PROTOCOL` …) and falls back to the bare
|
|
59
|
+
* librdkafka-convention name (`KAFKA_SECURITY_PROTOCOL` …) that many Kafka
|
|
60
|
+
* deployments already set. Honours security.protocol (e.g. SSL, SASL_SSL),
|
|
61
|
+
* ssl.ca.location, and optional SASL (mechanism / username / password). Unset
|
|
62
|
+
* keys are omitted so librdkafka keeps its PLAINTEXT defaults.
|
|
63
|
+
*
|
|
64
|
+
* Exported for testing/introspection — and exact parity with Python's
|
|
65
|
+
* `_security_config` (same key set, same precedence, same omit-when-unset).
|
|
66
|
+
*/
|
|
67
|
+
export function kafkaSecurityConfig(
|
|
68
|
+
env: NodeJS.ProcessEnv = process.env
|
|
69
|
+
): KafkaSecurityConfig {
|
|
70
|
+
// rdkafka key -> env suffix (read as TINA4_KAFKA_<suffix>, then KAFKA_<suffix>)
|
|
71
|
+
const mapping: [keyof KafkaSecurityConfig, string][] = [
|
|
72
|
+
["security.protocol", "SECURITY_PROTOCOL"],
|
|
73
|
+
["ssl.ca.location", "SSL_CA_LOCATION"],
|
|
74
|
+
["sasl.mechanism", "SASL_MECHANISM"],
|
|
75
|
+
["sasl.username", "SASL_USERNAME"],
|
|
76
|
+
["sasl.password", "SASL_PASSWORD"],
|
|
77
|
+
];
|
|
78
|
+
const config: KafkaSecurityConfig = {};
|
|
79
|
+
for (const [rdk, suffix] of mapping) {
|
|
80
|
+
const value = env[`TINA4_KAFKA_${suffix}`] || env[`KAFKA_${suffix}`];
|
|
81
|
+
if (value) {
|
|
82
|
+
config[rdk] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return config;
|
|
86
|
+
}
|
|
87
|
+
|
|
27
88
|
export interface QueueBackend {
|
|
28
89
|
push(queue: string, payload: unknown, delay?: number): string;
|
|
29
90
|
pop(queue: string): QueueJob | null;
|
|
@@ -77,6 +138,42 @@ export class KafkaBackend implements QueueBackend {
|
|
|
77
138
|
return { brokers: this.brokers, groupId: this.groupId };
|
|
78
139
|
}
|
|
79
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Resolved SSL/SASL client config from the environment (PLAINTEXT default).
|
|
143
|
+
* Mirrors Python's `KafkaConnector._security_config`.
|
|
144
|
+
*/
|
|
145
|
+
securityConfig(): KafkaSecurityConfig {
|
|
146
|
+
return kafkaSecurityConfig();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Full producer config — brokers + client id + the resolved security block.
|
|
151
|
+
* The security keys are applied to BOTH producer and consumer (matching
|
|
152
|
+
* Python's `_connect_confluent`).
|
|
153
|
+
*/
|
|
154
|
+
producerConfig(): KafkaClientConfig {
|
|
155
|
+
return {
|
|
156
|
+
"bootstrap.servers": this.brokers,
|
|
157
|
+
"client.id": "tina4-nodejs",
|
|
158
|
+
...this.securityConfig(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Full consumer config — brokers + client id + group id + the SAME resolved
|
|
164
|
+
* security block applied to the producer.
|
|
165
|
+
*/
|
|
166
|
+
consumerConfig(): KafkaClientConfig {
|
|
167
|
+
return {
|
|
168
|
+
"bootstrap.servers": this.brokers,
|
|
169
|
+
"client.id": "tina4-nodejs",
|
|
170
|
+
"group.id": this.groupId,
|
|
171
|
+
"auto.offset.reset": "earliest",
|
|
172
|
+
"enable.auto.commit": false,
|
|
173
|
+
...this.securityConfig(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
80
177
|
/**
|
|
81
178
|
* Parse broker string into host:port.
|
|
82
179
|
*/
|
|
@@ -328,7 +425,7 @@ export class KafkaBackend implements QueueBackend {
|
|
|
328
425
|
const id = randomUUID();
|
|
329
426
|
const now = new Date().toISOString();
|
|
330
427
|
|
|
331
|
-
const job
|
|
428
|
+
const job = {
|
|
332
429
|
id,
|
|
333
430
|
payload,
|
|
334
431
|
status: "pending",
|
|
@@ -172,7 +172,7 @@ export class RateLimiter {
|
|
|
172
172
|
|
|
173
173
|
/** Apply rate limiting to a request/response pair. Sets headers and 429 if exceeded. */
|
|
174
174
|
apply(request: Tina4Request, response: Tina4Response): [Tina4Request, Tina4Response] {
|
|
175
|
-
const ip =
|
|
175
|
+
const ip = request.ip ?? "unknown";
|
|
176
176
|
const result = this.check(ip);
|
|
177
177
|
|
|
178
178
|
response.header("X-RateLimit-Limit", String(result.limit));
|
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import type { ServerResponse } from "node:http";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
|
+
import { once } from "node:events";
|
|
4
5
|
import type { Tina4Response, CookieOptions } from "./types.js";
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort close of a streaming source on client disconnect or a mid-stream
|
|
9
|
+
* error. Async generators expose `.return()`; some custom iterables expose
|
|
10
|
+
* `.close()`/`.aclose()`. Any failure here is swallowed — cleanup is advisory.
|
|
11
|
+
*/
|
|
12
|
+
async function closeSource(source: AsyncIterable<unknown>): Promise<void> {
|
|
13
|
+
const s = source as {
|
|
14
|
+
return?: () => unknown;
|
|
15
|
+
close?: () => unknown;
|
|
16
|
+
aclose?: () => unknown;
|
|
17
|
+
};
|
|
18
|
+
try {
|
|
19
|
+
if (typeof s.return === "function") await s.return();
|
|
20
|
+
else if (typeof s.aclose === "function") await s.aclose();
|
|
21
|
+
else if (typeof s.close === "function") await s.close();
|
|
22
|
+
} catch {
|
|
23
|
+
/* cleanup is best-effort */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
6
27
|
/** Cache Frond instances by template directory to avoid repeated instantiation. */
|
|
7
28
|
const _frondCache = new Map<string, InstanceType<any>>();
|
|
8
29
|
|
|
@@ -116,8 +137,15 @@ function toJsonable(data: unknown): unknown {
|
|
|
116
137
|
export function createResponse(res: ServerResponse): Tina4Response {
|
|
117
138
|
|
|
118
139
|
// ── Guard: prevent writing after headers are sent ──
|
|
119
|
-
const safeEnd = (
|
|
120
|
-
if (
|
|
140
|
+
const safeEnd = (chunk?: string | Buffer, encoding?: BufferEncoding) => {
|
|
141
|
+
if (res.headersSent) return;
|
|
142
|
+
if (chunk === undefined) {
|
|
143
|
+
res.end();
|
|
144
|
+
} else if (encoding === undefined) {
|
|
145
|
+
res.end(chunk);
|
|
146
|
+
} else {
|
|
147
|
+
res.end(chunk, encoding);
|
|
148
|
+
}
|
|
121
149
|
};
|
|
122
150
|
const safeSetHeader = (name: string, value: string | number | readonly string[]) => {
|
|
123
151
|
if (!res.headersSent) res.setHeader(name, value);
|
|
@@ -354,12 +382,68 @@ export function createResponse(res: ServerResponse): Tina4Response {
|
|
|
354
382
|
"X-Accel-Buffering": "no",
|
|
355
383
|
});
|
|
356
384
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
385
|
+
// True once the client has gone away (socket destroyed) or the response
|
|
386
|
+
// has been finished — keep checking so we bail cleanly mid-stream rather
|
|
387
|
+
// than writing into a dead socket or buffering forever.
|
|
388
|
+
const streamClosed = (): boolean =>
|
|
389
|
+
res.writableEnded || (res.socket?.destroyed ?? false);
|
|
390
|
+
|
|
391
|
+
// Keep-alive heartbeat: periodically write a ':' SSE comment line on a
|
|
392
|
+
// long-lived stream so proxies/load-balancers don't reap an idle but
|
|
393
|
+
// healthy connection. Opt-out via TINA4_SSE_HEARTBEAT=0 (any non-positive
|
|
394
|
+
// value disables it). The interval is unref'd so it never holds the
|
|
395
|
+
// process open on its own.
|
|
396
|
+
const heartbeatSeconds = parseFloat(process.env.TINA4_SSE_HEARTBEAT ?? "15");
|
|
397
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
398
|
+
if (Number.isFinite(heartbeatSeconds) && heartbeatSeconds > 0) {
|
|
399
|
+
heartbeat = setInterval(() => {
|
|
400
|
+
if (streamClosed()) return;
|
|
401
|
+
try {
|
|
402
|
+
res.write(": keep-alive\n\n");
|
|
403
|
+
} catch {
|
|
404
|
+
/* write race with a closing socket — the loop's guard handles it */
|
|
405
|
+
}
|
|
406
|
+
}, heartbeatSeconds * 1000);
|
|
407
|
+
heartbeat.unref?.();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const stopHeartbeat = (): void => {
|
|
411
|
+
if (heartbeat !== null) {
|
|
412
|
+
clearInterval(heartbeat);
|
|
413
|
+
heartbeat = null;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
for await (const chunk of source) {
|
|
419
|
+
// Client disconnected mid-stream — stop cleanly. Closing the source
|
|
420
|
+
// (best-effort) lets the producer release resources.
|
|
421
|
+
if (streamClosed()) {
|
|
422
|
+
await closeSource(source);
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
const data = typeof chunk === "string" ? chunk : chunk.toString();
|
|
426
|
+
const ok = res.write(data);
|
|
427
|
+
// Slow-client backpressure: when write() returns false the kernel
|
|
428
|
+
// buffer is full. Wait for it to drain before pulling the next chunk
|
|
429
|
+
// so we don't unboundedly buffer ahead of a client that can't keep up.
|
|
430
|
+
if (!ok && !streamClosed()) {
|
|
431
|
+
await once(res, "drain").catch(() => {
|
|
432
|
+
/* socket errored/closed while waiting — loop guard handles it */
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
// The generator/source itself raised mid-stream. Log and stop cleanly —
|
|
438
|
+
// end the stream rather than crashing the request handler/worker.
|
|
439
|
+
const { Log } = await import("./logger.js");
|
|
440
|
+
Log.error(`SSE/stream source error: ${err instanceof Error ? err.message : String(err)}`);
|
|
441
|
+
await closeSource(source);
|
|
442
|
+
} finally {
|
|
443
|
+
stopHeartbeat();
|
|
360
444
|
}
|
|
361
445
|
|
|
362
|
-
res.end();
|
|
446
|
+
if (!res.writableEnded) res.end();
|
|
363
447
|
return response;
|
|
364
448
|
};
|
|
365
449
|
|
|
@@ -67,6 +67,23 @@ interface CompiledRoute {
|
|
|
67
67
|
template?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Thin reference to a registered WebSocket route, enabling chained modifiers
|
|
72
|
+
* — the WS analogue of {@link RouteRef}.
|
|
73
|
+
*
|
|
74
|
+
* Usage:
|
|
75
|
+
* router.websocket("/ws/secure", handler).secure();
|
|
76
|
+
*/
|
|
77
|
+
export class WsRouteRef {
|
|
78
|
+
constructor(private route: WebSocketRouteDefinition) {}
|
|
79
|
+
|
|
80
|
+
/** Mark this WS route as requiring a valid JWT on the upgrade handshake. */
|
|
81
|
+
secure(): this {
|
|
82
|
+
this.route.authRequired = true;
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
/**
|
|
71
88
|
* Thin reference to a registered route, enabling chained modifiers.
|
|
72
89
|
*
|
|
@@ -412,11 +429,29 @@ export class Router {
|
|
|
412
429
|
|
|
413
430
|
/**
|
|
414
431
|
* Register a WebSocket route.
|
|
432
|
+
*
|
|
433
|
+
* A WS route is PUBLIC by default (mirrors GET). It can be marked secured in
|
|
434
|
+
* EITHER way the HTTP routes support:
|
|
435
|
+
* • imperatively — `websocket(path, fn, { secured: true })`, or chain the
|
|
436
|
+
* returned ref: `websocket(path, fn).secure()`;
|
|
437
|
+
* • decorator-style — a `_secured` flag on the handler function, set in
|
|
438
|
+
* either order relative to registration (the ref keeps a back-reference
|
|
439
|
+
* to the route so a later `.secure()` / `_secured` still flips it).
|
|
440
|
+
*
|
|
441
|
+
* When secured, the upgrade handshake requires a valid JWT (Authorization
|
|
442
|
+
* header / `bearer` subprotocol / `?token=`) or the upgrade is rejected.
|
|
415
443
|
*/
|
|
416
|
-
websocket(path: string, handler: WebSocketRouteHandler):
|
|
444
|
+
websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
|
|
417
445
|
// Remove existing ws route with same pattern (for hot-reload)
|
|
418
446
|
this.wsRoutes = this.wsRoutes.filter((r) => r.pattern !== path);
|
|
419
|
-
|
|
447
|
+
const route: WebSocketRouteDefinition = {
|
|
448
|
+
pattern: path,
|
|
449
|
+
handler,
|
|
450
|
+
// Public unless explicitly secured via options OR a handler `_secured` flag.
|
|
451
|
+
authRequired: Boolean(options?.secured ?? handler._secured ?? false),
|
|
452
|
+
};
|
|
453
|
+
this.wsRoutes.push(route);
|
|
454
|
+
return new WsRouteRef(route);
|
|
420
455
|
}
|
|
421
456
|
|
|
422
457
|
/**
|
|
@@ -503,8 +538,21 @@ export class Router {
|
|
|
503
538
|
/**
|
|
504
539
|
* Register a WebSocket route on the default global router.
|
|
505
540
|
*/
|
|
506
|
-
static websocket(path: string, handler: WebSocketRouteHandler):
|
|
507
|
-
defaultRouter.websocket(path, handler);
|
|
541
|
+
static websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
|
|
542
|
+
return defaultRouter.websocket(path, handler, options);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Match a WebSocket upgrade path against routes on the default global router.
|
|
547
|
+
* Returns the matched route definition (with its `authRequired` flag) or null.
|
|
548
|
+
*/
|
|
549
|
+
static matchWebSocket(pathname: string): WebSocketRouteDefinition | null {
|
|
550
|
+
return defaultRouter.matchWebSocket(pathname);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** All WebSocket route definitions on the default global router. */
|
|
554
|
+
static getWebSocketRoutes(): WebSocketRouteDefinition[] {
|
|
555
|
+
return defaultRouter.getWebSocketRoutes();
|
|
508
556
|
}
|
|
509
557
|
|
|
510
558
|
/**
|
|
@@ -615,10 +663,10 @@ export class RouteGroup {
|
|
|
615
663
|
constructor(
|
|
616
664
|
private router: Router,
|
|
617
665
|
private prefix: string,
|
|
618
|
-
private groupMiddlewares?:
|
|
666
|
+
private groupMiddlewares?: MiddlewareSpec[],
|
|
619
667
|
) {}
|
|
620
668
|
|
|
621
|
-
private mergeMiddlewares(routeMiddlewares?:
|
|
669
|
+
private mergeMiddlewares(routeMiddlewares?: MiddlewareSpec[]): MiddlewareSpec[] | undefined {
|
|
622
670
|
const group = this.groupMiddlewares ?? [];
|
|
623
671
|
const route = routeMiddlewares ?? [];
|
|
624
672
|
const merged = [...group, ...route];
|
|
@@ -816,8 +864,8 @@ export function any(path: string, handler: RouteHandler, middlewares?: Middlewar
|
|
|
816
864
|
return defaultRouter.any(path, handler, middlewares, meta);
|
|
817
865
|
}
|
|
818
866
|
|
|
819
|
-
export function websocket(path: string, handler: WebSocketRouteHandler):
|
|
820
|
-
defaultRouter.websocket(path, handler);
|
|
867
|
+
export function websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
|
|
868
|
+
return defaultRouter.websocket(path, handler, options);
|
|
821
869
|
}
|
|
822
870
|
|
|
823
871
|
// Re-export "del" as "delete" for developer convenience (use: import { delete as del } from "@tina4/core")
|