tina4-nodejs 3.13.92 → 3.13.94
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 +16 -3
- package/README.md +1 -1
- package/package.json +12 -9
- package/packages/cli/dist/bin.js +1260 -969
- package/packages/core/dist/index.js +1260 -969
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/index.ts +2 -2
- package/packages/core/src/messenger.ts +72 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +108 -12
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +9 -3
- package/packages/core/src/sessionHandlers/redisHandler.ts +18 -5
- package/packages/core/src/sessionHandlers/respClient.ts +5 -1
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +3055 -2764
- package/packages/orm/src/adapters/sqlite.ts +4 -1
- package/packages/orm/src/database.ts +108 -8
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +154 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +160 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +65 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +93 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +123 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +77 -0
- package/types/core/src/middleware.d.ts +207 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +219 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +119 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +97 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +355 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +131 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +256 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +42 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +24 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +61 -0
- package/types/core/src/sessionHandlers/redisHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/types.d.ts +219 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +376 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +138 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +70 -0
- package/types/orm/src/adapters/mysql.d.ts +66 -0
- package/types/orm/src/adapters/odbc.d.ts +97 -0
- package/types/orm/src/adapters/postgres.d.ts +85 -0
- package/types/orm/src/adapters/sqlite.d.ts +56 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +391 -0
- package/types/orm/src/cachedDatabase.d.ts +177 -0
- package/types/orm/src/database.d.ts +609 -0
- package/types/orm/src/databaseResult.d.ts +85 -0
- package/types/orm/src/docstore.d.ts +182 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +40 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +173 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +134 -0
- package/types/orm/src/types.d.ts +138 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
|
@@ -15,8 +15,6 @@ import { join } from "node:path";
|
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
|
|
17
17
|
import type { SendResult, EmailMessage } from "./messenger.js";
|
|
18
|
-
import { Messenger } from "./messenger.js";
|
|
19
|
-
import { isTruthy } from "./dotenv.js";
|
|
20
18
|
|
|
21
19
|
// ── DevMailbox ───────────────────────────────────────────────
|
|
22
20
|
|
|
@@ -38,20 +36,35 @@ export class DevMailbox {
|
|
|
38
36
|
|
|
39
37
|
/**
|
|
40
38
|
* Capture an email to the dev mailbox instead of sending it.
|
|
39
|
+
*
|
|
40
|
+
* The parameter order MATCHES Messenger.send() on purpose. It did not before:
|
|
41
|
+
* send()'s 5th positional was `text` and capture()'s was `cc`, so the same call
|
|
42
|
+
* meant different things depending on which door it came through -- that mismatch
|
|
43
|
+
* IS nodejs#42.
|
|
44
|
+
*
|
|
45
|
+
* BREAKING: `text` is now the 5th positional. A caller passing cc positionally
|
|
46
|
+
* must move it. Aligning the two signatures is the fix; leaving them apart would
|
|
47
|
+
* preserve the bug.
|
|
41
48
|
*/
|
|
42
49
|
capture(
|
|
43
50
|
to: string | string[],
|
|
44
51
|
subject: string,
|
|
45
52
|
body: string,
|
|
46
53
|
html: boolean = false,
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
text?: string,
|
|
55
|
+
cc: string | string[] = [],
|
|
56
|
+
bcc: string | string[] = [],
|
|
49
57
|
replyTo?: string,
|
|
50
58
|
attachments: string[] = [],
|
|
51
59
|
from?: string,
|
|
52
60
|
): SendResult {
|
|
53
61
|
const id = randomUUID();
|
|
54
62
|
const toList = Array.isArray(to) ? to : [to];
|
|
63
|
+
// Normalised HERE, at the boundary, so a message is well formed however it
|
|
64
|
+
// arrived. A dev mailbox that stores a malformed message and reports success
|
|
65
|
+
// defeats its own purpose -- it exists to show you what you WOULD have sent.
|
|
66
|
+
const ccList = Array.isArray(cc) ? cc : (cc ? [cc] : []);
|
|
67
|
+
const bccList = Array.isArray(bcc) ? bcc : (bcc ? [bcc] : []);
|
|
55
68
|
const now = new Date().toISOString();
|
|
56
69
|
|
|
57
70
|
const message: EmailMessage = {
|
|
@@ -59,11 +72,12 @@ export class DevMailbox {
|
|
|
59
72
|
type: "outbox",
|
|
60
73
|
from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
|
|
61
74
|
to: toList,
|
|
62
|
-
cc,
|
|
63
|
-
bcc,
|
|
75
|
+
cc: ccList,
|
|
76
|
+
bcc: bccList,
|
|
64
77
|
reply_to: replyTo,
|
|
65
78
|
subject,
|
|
66
79
|
body,
|
|
80
|
+
text,
|
|
67
81
|
html,
|
|
68
82
|
attachments,
|
|
69
83
|
date: now,
|
|
@@ -280,41 +294,3 @@ export class DevMailbox {
|
|
|
280
294
|
}
|
|
281
295
|
|
|
282
296
|
// ── Factory ──────────────────────────────────────────────────
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Create a Messenger or DevMailbox based on the environment.
|
|
286
|
-
*
|
|
287
|
-
* Returns DevMailbox when:
|
|
288
|
-
* - TINA4_DEBUG is "true", OR
|
|
289
|
-
* - No TINA4_MAIL_HOST is configured
|
|
290
|
-
*
|
|
291
|
-
* Returns a real Messenger otherwise (SMTP configured + not debug mode).
|
|
292
|
-
*
|
|
293
|
-
* This follows the factory pattern from PHP's MessengerFactory.
|
|
294
|
-
*/
|
|
295
|
-
export function createMessenger(): Messenger | DevMailbox {
|
|
296
|
-
const debug = process.env.TINA4_DEBUG;
|
|
297
|
-
const smtpHost = process.env.TINA4_MAIL_HOST;
|
|
298
|
-
|
|
299
|
-
// Production = NOT debug mode AND NODE_ENV is "production".
|
|
300
|
-
// Derived here (was previously referenced undefined → ReferenceError).
|
|
301
|
-
const isProd = !isTruthy(debug) && process.env.NODE_ENV === "production";
|
|
302
|
-
|
|
303
|
-
// Force dev mode when TINA4_DEBUG is truthy
|
|
304
|
-
if (isTruthy(debug)) {
|
|
305
|
-
return new DevMailbox();
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// No SMTP configured — must use dev mailbox
|
|
309
|
-
if (!smtpHost) {
|
|
310
|
-
return new DevMailbox();
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Non-production environment — use dev mailbox
|
|
314
|
-
if (!isProd) {
|
|
315
|
-
return new DevMailbox();
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Production with SMTP configured — use real Messenger
|
|
319
|
-
return new Messenger();
|
|
320
|
-
}
|
|
@@ -94,9 +94,9 @@ export {
|
|
|
94
94
|
handleFeedbackWidgetJs,
|
|
95
95
|
registerFeedbackRoutes,
|
|
96
96
|
} from "./feedback.js";
|
|
97
|
-
export { Messenger, MessengerConnectionError } from "./messenger.js";
|
|
97
|
+
export { Messenger, MessengerConnectionError, createMessenger } from "./messenger.js";
|
|
98
98
|
export type { SendResult, EmailMessage } from "./messenger.js";
|
|
99
|
-
export { DevMailbox
|
|
99
|
+
export { DevMailbox } from "./devMailbox.js";
|
|
100
100
|
export { WSDLService, WSDLOperation } from "./wsdl.js";
|
|
101
101
|
export type { WSDLOperationMeta } from "./wsdl.js";
|
|
102
102
|
export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
|
|
@@ -29,6 +29,7 @@ import { readFileSync } from "node:fs";
|
|
|
29
29
|
import { basename } from "node:path";
|
|
30
30
|
import { randomUUID } from "node:crypto";
|
|
31
31
|
import { isTruthy } from "./dotenv.js";
|
|
32
|
+
import { DevMailbox } from "./devMailbox.js";
|
|
32
33
|
import { Log } from "./logger.js";
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -77,6 +78,10 @@ export interface EmailMessage {
|
|
|
77
78
|
reply_to?: string;
|
|
78
79
|
subject: string;
|
|
79
80
|
body: string;
|
|
81
|
+
/** Plain-text alternative. Carried on the dev path too, so the captured message
|
|
82
|
+
* is the message: a mailbox that shows you something other than what you wrote
|
|
83
|
+
* is worse than no mailbox. */
|
|
84
|
+
text?: string;
|
|
80
85
|
html: boolean;
|
|
81
86
|
attachments: string[];
|
|
82
87
|
date: string;
|
|
@@ -328,6 +333,10 @@ export class Messenger {
|
|
|
328
333
|
private fromName: string;
|
|
329
334
|
private encryption: string;
|
|
330
335
|
private useTls: boolean;
|
|
336
|
+
/** Whether an SMTP host was actually configured (see the constructor). */
|
|
337
|
+
private smtpConfigured: boolean = false;
|
|
338
|
+
/** The local mailbox, present only when this messenger captures. */
|
|
339
|
+
public devMailbox: DevMailbox | null = null;
|
|
331
340
|
private imapHost: string;
|
|
332
341
|
private imapPort: number;
|
|
333
342
|
private imapUser: string;
|
|
@@ -337,6 +346,11 @@ export class Messenger {
|
|
|
337
346
|
constructor(options?: MessengerOptions) {
|
|
338
347
|
// Priority: constructor > TINA4_MAIL_* > sensible default.
|
|
339
348
|
// Legacy SMTP_*/IMAP_* env vars were removed in v3.12 — boot guard rejects them.
|
|
349
|
+
// Whether a host was actually CONFIGURED, which is not the same as this.host
|
|
350
|
+
// being set: it falls back to "localhost", so it is never empty and cannot
|
|
351
|
+
// answer "can this messenger send?". The capture gate needs that answer, so
|
|
352
|
+
// record it here while the real inputs are still in scope.
|
|
353
|
+
this.smtpConfigured = Boolean(options?.host ?? process.env.TINA4_MAIL_HOST);
|
|
340
354
|
this.host = options?.host
|
|
341
355
|
?? process.env.TINA4_MAIL_HOST
|
|
342
356
|
?? "localhost";
|
|
@@ -399,6 +413,31 @@ export class Messenger {
|
|
|
399
413
|
/**
|
|
400
414
|
* Send an email via SMTP.
|
|
401
415
|
*/
|
|
416
|
+
/**
|
|
417
|
+
* Should send() capture locally instead of talking to SMTP?
|
|
418
|
+
*
|
|
419
|
+
* Availability decides, not verbosity. With no SMTP host configured sending is
|
|
420
|
+
* impossible, so simulate it into a folder rather than failing -- that is what
|
|
421
|
+
* makes a laptop with no mail server usable. TINA4_MAIL_CAPTURE forces capture
|
|
422
|
+
* even when a host IS configured.
|
|
423
|
+
*
|
|
424
|
+
* TINA4_DEBUG deliberately does NOT gate this, and neither does NODE_ENV. Debug
|
|
425
|
+
* must still be able to send, and the old `NODE_ENV !== "production"` clause
|
|
426
|
+
* silently swallowed every staging email.
|
|
427
|
+
*/
|
|
428
|
+
private shouldCapture(): boolean {
|
|
429
|
+
if (isTruthy(process.env.TINA4_MAIL_CAPTURE)) return true;
|
|
430
|
+
return !this.smtpConfigured;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The local mailbox, created on first capture and reused after. */
|
|
434
|
+
private getDevMailbox(): DevMailbox {
|
|
435
|
+
if (this.devMailbox === null) {
|
|
436
|
+
this.devMailbox = new DevMailbox();
|
|
437
|
+
}
|
|
438
|
+
return this.devMailbox;
|
|
439
|
+
}
|
|
440
|
+
|
|
402
441
|
async send(
|
|
403
442
|
to: string | string[],
|
|
404
443
|
subject: string,
|
|
@@ -416,6 +455,17 @@ export class Messenger {
|
|
|
416
455
|
const ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
|
|
417
456
|
const bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
|
|
418
457
|
const allRecipients = [...toList, ...ccList, ...bccList];
|
|
458
|
+
|
|
459
|
+
// Dev capture is a BRANCH here, not a different object returned by the factory.
|
|
460
|
+
// createMessenger() used to hand back a DevMailbox, which has capture() and no
|
|
461
|
+
// send(), so the documented call threw TypeError (nodejs#41).
|
|
462
|
+
if (this.shouldCapture()) {
|
|
463
|
+
return this.getDevMailbox().capture(
|
|
464
|
+
to, subject, body, html, text, ccList, bccList, replyTo,
|
|
465
|
+
attachments, this.fromAddress || undefined,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
419
469
|
const messageId = `${randomUUID()}@${this.host}`;
|
|
420
470
|
|
|
421
471
|
if (allRecipients.length === 0) {
|
|
@@ -1069,3 +1119,25 @@ function parseFullMessage(uid: string, response: string): ImapFullMessage {
|
|
|
1069
1119
|
headers,
|
|
1070
1120
|
};
|
|
1071
1121
|
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Create a Messenger configured for the current environment.
|
|
1125
|
+
*
|
|
1126
|
+
* Returns ONE concrete type, always. It used to return `Messenger | DevMailbox`,
|
|
1127
|
+
* and those two shared NO sending method -- DevMailbox has capture(), Messenger has
|
|
1128
|
+
* send() -- so the documented call threw TypeError whenever the dev branch was
|
|
1129
|
+
* taken. That is nodejs#41. Capture is now a branch inside Messenger.send(), so the
|
|
1130
|
+
* object you get back has one send() with one signature either way.
|
|
1131
|
+
*
|
|
1132
|
+
* The gate is availability, not verbosity:
|
|
1133
|
+
* - no TINA4_MAIL_HOST -> capture (sending is impossible, so simulate it)
|
|
1134
|
+
* - TINA4_MAIL_CAPTURE truthy -> capture even with SMTP configured
|
|
1135
|
+
* - otherwise -> send, EVEN WITH TINA4_DEBUG ON
|
|
1136
|
+
*
|
|
1137
|
+
* TINA4_DEBUG no longer forces capture: debug must still be able to send real mail.
|
|
1138
|
+
* The `NODE_ENV !== "production"` clause is also gone -- it captured even with SMTP
|
|
1139
|
+
* configured and debug off, which silently ate every staging email.
|
|
1140
|
+
*/
|
|
1141
|
+
export function createMessenger(): Messenger {
|
|
1142
|
+
return new Messenger();
|
|
1143
|
+
}
|
|
@@ -497,12 +497,15 @@ export class KafkaBackend implements QueueBackend {
|
|
|
497
497
|
if (errCode === 0) {
|
|
498
498
|
finish("__PUBLISHED__", 0);
|
|
499
499
|
} else {
|
|
500
|
+
// Report the CODE, not just "it failed" — the caller decides
|
|
501
|
+
// whether it is retriable (3/5, the async topic-creation race)
|
|
502
|
+
// or fatal (e.g. 29 TOPIC_AUTHORIZATION_FAILED).
|
|
500
503
|
process.stderr.write("Produce error code " + errCode);
|
|
501
|
-
finish("
|
|
504
|
+
finish("__PRODUCEERROR__" + errCode, 0);
|
|
502
505
|
}
|
|
503
506
|
} catch (e) {
|
|
504
507
|
process.stderr.write("produce parse: " + e.message);
|
|
505
|
-
finish("
|
|
508
|
+
finish("__PARSEERROR__produce: " + e.message, 0);
|
|
506
509
|
}
|
|
507
510
|
return;
|
|
508
511
|
} else if (operation === "get") {
|
|
@@ -516,6 +519,7 @@ export class KafkaBackend implements QueueBackend {
|
|
|
516
519
|
pos += 4; // throttleTimeMs (v1+)
|
|
517
520
|
const topicCount = buffer.readInt32BE(pos); pos += 4;
|
|
518
521
|
let out = "__EMPTY__";
|
|
522
|
+
let fatalCode = 0;
|
|
519
523
|
for (let t = 0; t < topicCount; t++) {
|
|
520
524
|
const tl = buffer.readInt16BE(pos); pos += 2 + tl;
|
|
521
525
|
const pc = buffer.readInt32BE(pos); pos += 4;
|
|
@@ -527,6 +531,14 @@ export class KafkaBackend implements QueueBackend {
|
|
|
527
531
|
const abortedCount = buffer.readInt32BE(pos); pos += 4;
|
|
528
532
|
if (abortedCount > 0) pos += abortedCount * 16; // (-1 => none, skip)
|
|
529
533
|
const recSetSize = buffer.readInt32BE(pos); pos += 4;
|
|
534
|
+
// 3 = UNKNOWN_TOPIC_OR_PARTITION, 5 = LEADER_NOT_AVAILABLE:
|
|
535
|
+
// "nothing to read here yet", which a consumer that starts
|
|
536
|
+
// before its producer hits on every cold start. Any OTHER code
|
|
537
|
+
// (29 TOPIC_AUTHORIZATION_FAILED, 13 STALE_CONTROLLER_EPOCH, …)
|
|
538
|
+
// is a real failure and must NOT be reported as an empty queue.
|
|
539
|
+
if (errCode !== 0 && errCode !== 3 && errCode !== 5) {
|
|
540
|
+
fatalCode = errCode;
|
|
541
|
+
}
|
|
530
542
|
if (errCode === 0 && recSetSize > 0) {
|
|
531
543
|
const val = firstRecordValue(buffer, pos, pos + recSetSize);
|
|
532
544
|
if (val !== null) out = val;
|
|
@@ -534,21 +546,33 @@ export class KafkaBackend implements QueueBackend {
|
|
|
534
546
|
pos += recSetSize > 0 ? recSetSize : 0;
|
|
535
547
|
}
|
|
536
548
|
}
|
|
549
|
+
if (fatalCode !== 0) {
|
|
550
|
+
process.stderr.write("Fetch error code " + fatalCode);
|
|
551
|
+
finish("__FETCHERROR__" + fatalCode, 0);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
537
554
|
finish(out, 0);
|
|
538
555
|
} catch (e) {
|
|
556
|
+
// A parse failure is NOT an empty queue either — say so.
|
|
539
557
|
process.stderr.write("fetch parse: " + e.message);
|
|
540
|
-
finish("
|
|
558
|
+
finish("__PARSEERROR__fetch: " + e.message, 0);
|
|
541
559
|
}
|
|
542
560
|
return;
|
|
543
561
|
}
|
|
544
562
|
});
|
|
545
563
|
|
|
564
|
+
// Report the reason on STDOUT and exit 0. Writing it to stderr and
|
|
565
|
+
// exiting non-zero LOST it: stderr to a pipe is an async write and
|
|
566
|
+
// process.exit() truncates it, so the parent saw an empty stderr and fell
|
|
567
|
+
// back to execFileSync's message -- which embeds this entire script.
|
|
568
|
+
// stdout is flushed by finish()'s write callback, so it survives.
|
|
546
569
|
sock.on("error", (err) => {
|
|
547
|
-
|
|
548
|
-
finish("", 1);
|
|
570
|
+
finish("__TRANSPORTERROR__" + err.message, 0);
|
|
549
571
|
});
|
|
550
572
|
|
|
551
|
-
var timer = setTimeout(() => {
|
|
573
|
+
var timer = setTimeout(() => {
|
|
574
|
+
finish("__TRANSPORTERROR__timed out after 10s talking to " + host + ":" + port, 0);
|
|
575
|
+
}, 10000);
|
|
552
576
|
`;
|
|
553
577
|
|
|
554
578
|
try {
|
|
@@ -558,8 +582,59 @@ export class KafkaBackend implements QueueBackend {
|
|
|
558
582
|
stdio: ["pipe", "pipe", "pipe"],
|
|
559
583
|
});
|
|
560
584
|
return result;
|
|
561
|
-
} catch {
|
|
562
|
-
|
|
585
|
+
} catch (err) {
|
|
586
|
+
// Reached only when the child itself could not run (spawn failure, killed,
|
|
587
|
+
// the outer 15s timeout). The socket-level reasons come back through
|
|
588
|
+
// stdout as __TRANSPORTERROR__ instead. Swallowing this to "" made every
|
|
589
|
+
// failure indistinguishable from an empty queue.
|
|
590
|
+
//
|
|
591
|
+
// execFileSync's own message embeds the ENTIRE generated script, so it is
|
|
592
|
+
// truncated here -- a 20KB error that buries the cause is barely better
|
|
593
|
+
// than no error at all.
|
|
594
|
+
const e = err as { stderr?: Buffer | string; message?: string };
|
|
595
|
+
const reason = String(e.stderr ?? "").trim() || e.message || "unknown error";
|
|
596
|
+
const firstLine = reason.split("\n", 1)[0]!.slice(0, 200);
|
|
597
|
+
return "__TRANSPORTERROR__" + firstLine;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Sleep synchronously between produce retries.
|
|
603
|
+
*
|
|
604
|
+
* `push()` is synchronous (the whole backend drives its socket through a child
|
|
605
|
+
* process), so there is no event loop to await on. `Atomics.wait` on a
|
|
606
|
+
* SharedArrayBuffer is the stdlib way to block a thread for a fixed time --
|
|
607
|
+
* no dependency, no busy-wait burning CPU.
|
|
608
|
+
*/
|
|
609
|
+
private static sleepSync(ms: number): void {
|
|
610
|
+
const shared = new Int32Array(new SharedArrayBuffer(4));
|
|
611
|
+
Atomics.wait(shared, 0, 0, ms);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Turn a sentinel from the protocol child into a thrown error, or return.
|
|
616
|
+
*
|
|
617
|
+
* The wording matches the Python and PHP backends exactly -- the parity rule
|
|
618
|
+
* covers user-visible error messages, not just behaviour.
|
|
619
|
+
*/
|
|
620
|
+
private static assertNoError(result: string, operation: string, topic: string): void {
|
|
621
|
+
const fatal = /^__(PRODUCEERROR|FETCHERROR)__(\d+)/.exec(result);
|
|
622
|
+
if (fatal) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
`Kafka rejected the ${operation} for topic ${topic}: error code ${fatal[2]}`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
if (result.startsWith("__TRANSPORTERROR__")) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`Kafka ${operation} for topic ${topic} failed: ` +
|
|
630
|
+
result.slice("__TRANSPORTERROR__".length),
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
if (result.startsWith("__PARSEERROR__")) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`Kafka ${operation} for topic ${topic} returned an unreadable response: ` +
|
|
636
|
+
result.slice("__PARSEERROR__".length),
|
|
637
|
+
);
|
|
563
638
|
}
|
|
564
639
|
}
|
|
565
640
|
|
|
@@ -576,15 +651,36 @@ export class KafkaBackend implements QueueBackend {
|
|
|
576
651
|
delayUntil: null,
|
|
577
652
|
};
|
|
578
653
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
654
|
+
// Topic auto-creation is ASYNCHRONOUS, so a brand-new topic answers
|
|
655
|
+
// UNKNOWN_TOPIC_OR_PARTITION (3) or LEADER_NOT_AVAILABLE (5) on the first
|
|
656
|
+
// attempt while the controller is still electing a leader. Retry those
|
|
657
|
+
// (same 10 attempts / 200ms as the Python and PHP backends) instead of
|
|
658
|
+
// failing a cold-start push; every other code throws immediately.
|
|
659
|
+
const body = JSON.stringify(job);
|
|
660
|
+
let result = "";
|
|
661
|
+
for (let attempt = 1; attempt <= 10; attempt++) {
|
|
662
|
+
result = this.execSync("publish", queue, body);
|
|
663
|
+
if (result.includes("__PUBLISHED__")) {
|
|
664
|
+
return id;
|
|
665
|
+
}
|
|
666
|
+
const retriable = /^__PRODUCEERROR__(3|5)\b/.test(result);
|
|
667
|
+
if (!retriable || attempt === 10) {
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
KafkaBackend.sleepSync(200);
|
|
582
671
|
}
|
|
583
|
-
|
|
672
|
+
|
|
673
|
+
KafkaBackend.assertNoError(result, "produce", queue);
|
|
674
|
+
throw new Error(`Kafka publish failed for topic ${queue}: ${result || "no response"}`);
|
|
584
675
|
}
|
|
585
676
|
|
|
586
677
|
pop(queue: string): QueueJob | null {
|
|
587
678
|
const result = this.execSync("get", queue);
|
|
679
|
+
|
|
680
|
+
// A real failure must NOT read as an empty queue: a mis-permissioned
|
|
681
|
+
// consumer would otherwise poll an "idle" topic forever.
|
|
682
|
+
KafkaBackend.assertNoError(result, "fetch", queue);
|
|
683
|
+
|
|
588
684
|
if (!result || result === "__EMPTY__" || result === "__UNSUPPORTED__") return null;
|
|
589
685
|
|
|
590
686
|
try {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tina4 session handlers — turn a failed `execFileSync` child into a readable cause.
|
|
3
|
+
*
|
|
4
|
+
* The session-handler interface is synchronous but every backend client is async,
|
|
5
|
+
* so each command runs in a short-lived `node -e` child. When that child fails,
|
|
6
|
+
* `execFileSync` throws an error whose `.message` begins "Command failed:" and
|
|
7
|
+
* then embeds THE ENTIRE GENERATED SCRIPT — kilobytes of source with the real
|
|
8
|
+
* reason nowhere in it. Every handler used to throw exactly that, so an
|
|
9
|
+
* operator debugging a Redis outage got a wall of JavaScript instead of
|
|
10
|
+
* "connect ECONNREFUSED 127.0.0.1:6379".
|
|
11
|
+
*
|
|
12
|
+
* The children already write the real reason to stderr; `execFileSync` captures
|
|
13
|
+
* it on `err.stderr`. This module is the ONE place that prefers it, so the three
|
|
14
|
+
* call sites (respClient, mongoClient, redisHandler's npm path) cannot drift.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Longest fallback we will pass through when there is no usable stderr. */
|
|
18
|
+
const MAX_FALLBACK = 200;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract the most useful one-line cause from a thrown `execFileSync` error.
|
|
22
|
+
*
|
|
23
|
+
* Order of preference:
|
|
24
|
+
* 1. the child's own stderr — what it actually reported;
|
|
25
|
+
* 2. a timeout, named as such (a SIGTERM kill leaves stderr empty, so without
|
|
26
|
+
* this the caller would see the useless generic message);
|
|
27
|
+
* 3. a non-zero exit code with no output at all;
|
|
28
|
+
* 4. the error's own message, first line only and length-capped, so the
|
|
29
|
+
* generated script can never be dumped into a log.
|
|
30
|
+
*/
|
|
31
|
+
export function childFailureReason(err: unknown): string {
|
|
32
|
+
const e = (err ?? {}) as {
|
|
33
|
+
stderr?: Buffer | string;
|
|
34
|
+
message?: string;
|
|
35
|
+
signal?: string | null;
|
|
36
|
+
status?: number | null;
|
|
37
|
+
code?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const stderr = String(e.stderr ?? "").trim();
|
|
41
|
+
if (stderr !== "") {
|
|
42
|
+
return firstLine(stderr);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// execFileSync's `timeout` option kills the child with a signal, so it exits
|
|
46
|
+
// with NOTHING on stderr. Say "timed out" rather than "Command failed".
|
|
47
|
+
if (e.code === "ETIMEDOUT" || e.signal) {
|
|
48
|
+
return `timed out or was killed (${e.code ?? e.signal})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof e.status === "number" && e.status !== 0) {
|
|
52
|
+
return `child exited with code ${e.status} and no output`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return firstLine(String(e.message ?? "unknown error"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** First line of `text`, capped at MAX_FALLBACK characters. */
|
|
59
|
+
function firstLine(text: string): string {
|
|
60
|
+
const line = text.split("\n", 1)[0] ?? "";
|
|
61
|
+
return line.length > MAX_FALLBACK ? `${line.slice(0, MAX_FALLBACK)}...` : line;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the Error a session handler throws when its child command failed.
|
|
66
|
+
*
|
|
67
|
+
* `label` names the backend ("Redis", "Valkey", "MongoDB") so the message says
|
|
68
|
+
* which one broke; the wording is shared so all three read alike.
|
|
69
|
+
*/
|
|
70
|
+
export function childFailureError(label: string, err: unknown): Error {
|
|
71
|
+
return new Error(`${label} command failed: ${childFailureReason(err)}`);
|
|
72
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* writes).
|
|
23
23
|
*/
|
|
24
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { childFailureError } from "./childError.js";
|
|
25
26
|
|
|
26
27
|
export interface MongoTarget {
|
|
27
28
|
host: string;
|
|
@@ -90,8 +91,10 @@ export function mongoCommandSync(
|
|
|
90
91
|
process.stdout.write(out, () => process.exit(0));
|
|
91
92
|
} catch (err) {
|
|
92
93
|
try { if (client) await client.close(); } catch (e) {}
|
|
93
|
-
|
|
94
|
-
process.exit(
|
|
94
|
+
// Exit from the write CALLBACK: stderr to a pipe is an async write and
|
|
95
|
+
// a bare process.exit() truncates it, which left the parent with an
|
|
96
|
+
// empty stderr and nothing but execFileSync's script-dump message.
|
|
97
|
+
process.stderr.write(String((err && err.message) || err), () => process.exit(1));
|
|
95
98
|
}
|
|
96
99
|
})();
|
|
97
100
|
} else {
|
|
@@ -233,6 +236,9 @@ export function mongoCommandSync(
|
|
|
233
236
|
stdio: ["pipe", "pipe", "pipe"],
|
|
234
237
|
});
|
|
235
238
|
} catch (err) {
|
|
236
|
-
|
|
239
|
+
// The child's stderr carries the real reason (connection refused, auth
|
|
240
|
+
// failure, timeout); execFileSync's message carries the whole generated
|
|
241
|
+
// script. Prefer the former.
|
|
242
|
+
throw childFailureError(label, err);
|
|
237
243
|
}
|
|
238
244
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* TINA4_SESSION_REDIS_DB (default: 0)
|
|
18
18
|
*/
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { childFailureError } from "./childError.js";
|
|
20
21
|
import { createRequire } from "node:module";
|
|
21
22
|
import type { SessionHandler } from "../session.js";
|
|
22
23
|
import { respCommandSync } from "./respClient.js";
|
|
@@ -142,9 +143,17 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
142
143
|
(async () => {
|
|
143
144
|
try {
|
|
144
145
|
const redis = require("redis");
|
|
146
|
+
// reconnectStrategy: false — this child runs ONE command and exits, so
|
|
147
|
+
// retrying inside it is pointless: the handler is called again on the
|
|
148
|
+
// next request anyway. With the driver's default strategy a refused
|
|
149
|
+
// connection never rejects, the child hangs until execFileSync's 5s
|
|
150
|
+
// timeout kills it, and the caller is told "timed out" when the truth
|
|
151
|
+
// is "connection refused". Off, connect() rejects in ~5ms with the real
|
|
152
|
+
// reason -- a better message AND no 5s stall per request when Redis is
|
|
153
|
+
// down.
|
|
145
154
|
const clientOpts = useUrl
|
|
146
|
-
? { url }
|
|
147
|
-
: { socket: { host, port }, password: password || undefined, database: db };
|
|
155
|
+
? { url, socket: { reconnectStrategy: false } }
|
|
156
|
+
: { socket: { host, port, reconnectStrategy: false }, password: password || undefined, database: db };
|
|
148
157
|
const client = redis.createClient(clientOpts);
|
|
149
158
|
client.on("error", () => {});
|
|
150
159
|
await client.connect();
|
|
@@ -158,8 +167,10 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
158
167
|
const out = (result === null || result === undefined) ? "__NULL__" : String(result);
|
|
159
168
|
process.stdout.write(out, () => process.exit(0));
|
|
160
169
|
} catch (err) {
|
|
161
|
-
|
|
162
|
-
process.exit(
|
|
170
|
+
// Exit from the write CALLBACK: stderr to a pipe is an async write and
|
|
171
|
+
// a bare process.exit() truncates it, which left the parent with an
|
|
172
|
+
// empty stderr and nothing but execFileSync's script-dump message.
|
|
173
|
+
process.stderr.write(String((err && err.message) || err), () => process.exit(1));
|
|
163
174
|
}
|
|
164
175
|
})();
|
|
165
176
|
`;
|
|
@@ -171,7 +182,9 @@ export class RedisNpmSessionHandler implements SessionHandler {
|
|
|
171
182
|
stdio: ["pipe", "pipe", "pipe"],
|
|
172
183
|
});
|
|
173
184
|
} catch (err) {
|
|
174
|
-
|
|
185
|
+
// The child's stderr carries the driver's real reason; execFileSync's
|
|
186
|
+
// message carries the whole generated script. Prefer the former.
|
|
187
|
+
throw childFailureError("Redis", err);
|
|
175
188
|
}
|
|
176
189
|
if (result === "__NULL__") return ""; // genuine key miss
|
|
177
190
|
return result;
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* then returns the LAST (command) reply.
|
|
17
17
|
*/
|
|
18
18
|
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { childFailureError } from "./childError.js";
|
|
19
20
|
|
|
20
21
|
export interface RespTarget {
|
|
21
22
|
host: string;
|
|
@@ -161,7 +162,10 @@ export function respCommandSync(target: RespTarget, args: string[], label = "Red
|
|
|
161
162
|
// Non-zero exit = socket error / timeout / closed connection: a transport
|
|
162
163
|
// FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
|
|
163
164
|
// (or re-throws under strict mode).
|
|
164
|
-
|
|
165
|
+
//
|
|
166
|
+
// Report the CHILD's stderr, not execFileSync's message -- that message
|
|
167
|
+
// embeds the whole generated script and buries the actual reason.
|
|
168
|
+
throw childFailureError(label, err);
|
|
165
169
|
}
|
|
166
170
|
if (result === "__NULL__") return ""; // genuine key miss
|
|
167
171
|
if (result.startsWith("__ERR__")) {
|