tina4-nodejs 3.10.90 → 3.10.92

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 (49) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/ai.ts +1 -1
  3. package/packages/core/src/api.ts +6 -6
  4. package/packages/core/src/auth.ts +28 -15
  5. package/packages/core/src/cache.ts +9 -0
  6. package/packages/core/src/devAdmin.ts +85 -7
  7. package/packages/core/src/devMailbox.ts +21 -21
  8. package/packages/core/src/fakeData.ts +24 -14
  9. package/packages/core/src/graphql.ts +37 -1
  10. package/packages/core/src/i18n.ts +1 -1
  11. package/packages/core/src/index.ts +6 -6
  12. package/packages/core/src/mcp.ts +3 -0
  13. package/packages/core/src/messenger.ts +52 -4
  14. package/packages/core/src/middleware.ts +61 -0
  15. package/packages/core/src/queue.ts +103 -30
  16. package/packages/core/src/queueBackends/liteBackend.ts +43 -0
  17. package/packages/core/src/rateLimiter.ts +88 -1
  18. package/packages/core/src/request.ts +24 -1
  19. package/packages/core/src/response.ts +54 -10
  20. package/packages/core/src/router.ts +32 -14
  21. package/packages/core/src/scss.ts +44 -2
  22. package/packages/core/src/server.ts +26 -4
  23. package/packages/core/src/service.ts +7 -0
  24. package/packages/core/src/session.ts +4 -4
  25. package/packages/core/src/testClient.ts +2 -2
  26. package/packages/core/src/testing.ts +6 -6
  27. package/packages/core/src/types.ts +8 -1
  28. package/packages/core/src/watcher.ts +66 -0
  29. package/packages/core/src/websocket.ts +24 -3
  30. package/packages/core/src/websocketConnection.ts +4 -0
  31. package/packages/core/src/wsdl.ts +12 -12
  32. package/packages/frond/src/engine.ts +6 -0
  33. package/packages/orm/src/adapters/firebird.ts +2 -2
  34. package/packages/orm/src/adapters/mssql.ts +2 -2
  35. package/packages/orm/src/adapters/mysql.ts +2 -2
  36. package/packages/orm/src/adapters/postgres.ts +2 -2
  37. package/packages/orm/src/adapters/sqlite.ts +3 -3
  38. package/packages/orm/src/autoCrud.ts +117 -74
  39. package/packages/orm/src/baseModel.ts +44 -7
  40. package/packages/orm/src/database.ts +58 -15
  41. package/packages/orm/src/databaseResult.ts +5 -0
  42. package/packages/orm/src/fakeData.ts +1 -11
  43. package/packages/orm/src/index.ts +1 -1
  44. package/packages/orm/src/migration.ts +78 -5
  45. package/packages/orm/src/queryBuilder.ts +2 -2
  46. package/packages/orm/src/sqlTranslation.ts +20 -3
  47. package/packages/orm/src/types.ts +2 -2
  48. package/packages/swagger/src/generator.ts +2 -2
  49. package/packages/swagger/src/index.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.10.90",
3
+ "version": "3.10.92",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
@@ -608,7 +608,7 @@ npx tina4nodejs test # Run all tests
608
608
 
609
609
  Add test files in \`test/\` directory. Use built-in inline testing:
610
610
  \`\`\`typescript
611
- import { tests, assertEqual, runAllTests } from "@tina4/core";
611
+ import { tests, assertEqual, runAll } from "@tina4/core";
612
612
  \`\`\`
613
613
 
614
614
  ## Important
@@ -36,7 +36,7 @@ export class Api {
36
36
  /**
37
37
  * Add custom headers to all subsequent requests.
38
38
  */
39
- addCustomHeaders(headers: Record<string, string>): void {
39
+ addHeaders(headers: Record<string, string>): void {
40
40
  Object.assign(this.headers, headers);
41
41
  }
42
42
 
@@ -78,36 +78,36 @@ export class Api {
78
78
  * HTTP POST request.
79
79
  */
80
80
  async post(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
81
- return this.sendRequest(path, "POST", body, contentType);
81
+ return this.sendRequest("POST", path, body, contentType);
82
82
  }
83
83
 
84
84
  /**
85
85
  * HTTP PUT request.
86
86
  */
87
87
  async put(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
88
- return this.sendRequest(path, "PUT", body, contentType);
88
+ return this.sendRequest("PUT", path, body, contentType);
89
89
  }
90
90
 
91
91
  /**
92
92
  * HTTP PATCH request.
93
93
  */
94
94
  async patch(path: string, body?: unknown, contentType: string = "application/json"): Promise<ApiResult> {
95
- return this.sendRequest(path, "PATCH", body, contentType);
95
+ return this.sendRequest("PATCH", path, body, contentType);
96
96
  }
97
97
 
98
98
  /**
99
99
  * HTTP DELETE request.
100
100
  */
101
101
  async delete(path: string, body?: unknown): Promise<ApiResult> {
102
- return this.sendRequest(path, "DELETE", body);
102
+ return this.sendRequest("DELETE", path, body);
103
103
  }
104
104
 
105
105
  /**
106
106
  * Generic request method — public entry point for any HTTP method.
107
107
  */
108
108
  async sendRequest(
109
- path: string,
110
109
  method: string,
110
+ path: string,
111
111
  body?: unknown,
112
112
  contentType: string = "application/json",
113
113
  ): Promise<ApiResult> {
@@ -35,32 +35,45 @@ function base64urlDecode(str: string): Buffer {
35
35
  * Secret is always read from `process.env.SECRET`.
36
36
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
37
37
  *
38
- * @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
39
- * @param expiresIn - Lifetime in seconds (default 3600)
38
+ * @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
39
+ * @param secretOrExpiresIn - Signing secret string, OR expiresIn number (back-compat with old 2-arg form)
40
+ * @param expiresIn - Lifetime in seconds (default 3600). Only used when secret is a string.
40
41
  * @returns Signed JWT string: header.payload.signature
41
42
  */
42
43
  export function getToken(
43
44
  payload: Record<string, unknown>,
45
+ secretOrExpiresIn?: string | number,
44
46
  expiresIn: number = 3600,
47
+ algorithm?: string,
45
48
  ): string {
46
- const secret = process.env.SECRET ?? "";
47
- if (!secret) {
49
+ // Back-compat: if second arg is a number, treat it as expiresIn (old 2-arg form)
50
+ let resolvedSecret: string;
51
+ let resolvedExpiresIn: number;
52
+ if (typeof secretOrExpiresIn === "number") {
53
+ resolvedSecret = process.env.SECRET ?? "";
54
+ resolvedExpiresIn = secretOrExpiresIn;
55
+ } else {
56
+ resolvedSecret = secretOrExpiresIn ?? process.env.SECRET ?? "";
57
+ resolvedExpiresIn = expiresIn;
58
+ }
59
+
60
+ if (!resolvedSecret) {
48
61
  console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
49
62
  }
50
- const algorithm = process.env.TINA4_JWT_ALGORITHM ?? "HS256";
63
+ const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
51
64
 
52
- const header = { alg: algorithm, typ: "JWT" };
65
+ const header = { alg: resolvedAlgorithm, typ: "JWT" };
53
66
  const now = Math.floor(Date.now() / 1000);
54
67
 
55
68
  const claims: Record<string, unknown> = { ...payload, iat: now };
56
- if (expiresIn !== 0) {
57
- claims.exp = now + expiresIn;
69
+ if (resolvedExpiresIn !== 0) {
70
+ claims.exp = now + resolvedExpiresIn;
58
71
  }
59
72
 
60
73
  const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
61
74
  const p = base64urlEncode(Buffer.from(JSON.stringify(claims)));
62
75
  const signingInput = `${h}.${p}`;
63
- const signature = sign(signingInput, secret, algorithm);
76
+ const signature = sign(signingInput, resolvedSecret, resolvedAlgorithm);
64
77
 
65
78
  return `${h}.${p}.${signature}`;
66
79
  }
@@ -71,12 +84,12 @@ export function getToken(
71
84
  * Secret is always read from `process.env.SECRET`.
72
85
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
73
86
  */
74
- export function validToken(token: string): boolean {
75
- const secret = process.env.SECRET ?? "";
76
- if (!secret) {
87
+ export function validToken(token: string, secret?: string, algorithm?: string): boolean {
88
+ const resolvedSecret = secret ?? process.env.SECRET ?? "";
89
+ if (!resolvedSecret) {
77
90
  console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
78
91
  }
79
- const algorithm = process.env.TINA4_JWT_ALGORITHM ?? "HS256";
92
+ const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
80
93
  try {
81
94
  const parts = token.split(".");
82
95
  if (parts.length !== 3) return false;
@@ -84,7 +97,7 @@ export function validToken(token: string): boolean {
84
97
  const [h, p, sig] = parts;
85
98
  const signingInput = `${h}.${p}`;
86
99
 
87
- if (!verifySignature(signingInput, sig, secret as string, algorithm)) {
100
+ if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
88
101
  return false;
89
102
  }
90
103
 
@@ -211,7 +224,7 @@ export function authMiddleware(secret?: string, algorithm: string = "HS256"): Mi
211
224
  }
212
225
 
213
226
  const token = authHeader.slice(7);
214
- if (!validToken(token)) {
227
+ if (!validToken(token, secret, algorithm)) {
215
228
  res({ error: "Unauthorized" }, 401);
216
229
  return;
217
230
  }
@@ -501,6 +501,15 @@ export function cacheClear(): void {
501
501
  _getBackend().clear();
502
502
  }
503
503
 
504
+ /** Remove expired entries from the cache. Returns count removed. */
505
+ export function sweep(): number {
506
+ const backend = _getBackend();
507
+ if (typeof (backend as any).sweep === "function") {
508
+ return (backend as any).sweep();
509
+ }
510
+ return 0;
511
+ }
512
+
504
513
  /** Return cache statistics from the active backend. */
505
514
  export function cacheBackendStats(): { hits: number; misses: number; size: number; backend: string } {
506
515
  return _getBackend().stats();
@@ -191,15 +191,40 @@ export class RequestInspector {
191
191
 
192
192
  export class ErrorTracker {
193
193
  private static errors: ErrorEntry[] = [];
194
+ private static maxErrors = 200;
195
+ private static registered = false;
194
196
 
197
+ /**
198
+ * Capture an error with dedup (matches PHP/Ruby/Python signature).
199
+ * Duplicate errors (same message) increment count and update last_seen.
200
+ */
201
+ static capture(errorType: string, message: string, traceback = "", file = "", line = 0): void {
202
+ const fingerprint = `${errorType}|${message}|${file}|${line}`;
203
+ const existing = this.errors.find((e) => (e as any).fingerprint === fingerprint);
204
+ const now = new Date().toISOString();
205
+
206
+ if (existing) {
207
+ (existing as any).count = ((existing as any).count || 1) + 1;
208
+ (existing as any).last_seen = now;
209
+ existing.resolved = false; // re-open resolved duplicates
210
+ } else {
211
+ this.errors.push({
212
+ id: `err_${Date.now()}_${this.errors.length}`,
213
+ timestamp: now,
214
+ message,
215
+ stack: traceback || undefined,
216
+ resolved: false,
217
+ ...({ fingerprint, error_type: errorType, file, line, count: 1, first_seen: now, last_seen: now } as any),
218
+ });
219
+ if (this.errors.length > this.maxErrors) {
220
+ this.errors = this.errors.slice(-this.maxErrors);
221
+ }
222
+ }
223
+ }
224
+
225
+ /** Legacy alias for capture (backward compatibility). */
195
226
  static track(message: string, stack?: string): void {
196
- this.errors.push({
197
- id: `err_${Date.now()}_${this.errors.length}`,
198
- timestamp: new Date().toISOString(),
199
- message,
200
- stack,
201
- resolved: false,
202
- });
227
+ this.capture("Error", message, stack || "");
203
228
  }
204
229
 
205
230
  static get(): ErrorEntry[] {
@@ -218,6 +243,56 @@ export class ErrorTracker {
218
243
  static clearResolved(): void {
219
244
  this.errors = this.errors.filter((e) => !e.resolved);
220
245
  }
246
+
247
+ /** Remove ALL tracked errors. */
248
+ static clearAll(): void {
249
+ this.errors = [];
250
+ }
251
+
252
+ /** Health summary — are there unresolved errors? */
253
+ static health(): { healthy: boolean; total: number; unresolved: number; resolved: number } {
254
+ const total = this.errors.length;
255
+ const resolved = this.errors.filter((e) => e.resolved).length;
256
+ const unresolved = total - resolved;
257
+ return { healthy: unresolved === 0, total, unresolved, resolved };
258
+ }
259
+
260
+ /** Count of unresolved errors. */
261
+ static unresolvedCount(): number {
262
+ return this.errors.filter((e) => !e.resolved).length;
263
+ }
264
+
265
+ /** Reset all state (for testing). */
266
+ static reset(): void {
267
+ this.errors = [];
268
+ this.registered = false;
269
+ }
270
+
271
+ /**
272
+ * Register global error handlers to feed the tracker.
273
+ * Safe to call multiple times — only registers once.
274
+ */
275
+ static register(): void {
276
+ if (this.registered) return;
277
+ this.registered = true;
278
+
279
+ process.on("uncaughtException", (err: Error) => {
280
+ this.capture(
281
+ err.constructor.name,
282
+ err.message,
283
+ err.stack || "",
284
+ );
285
+ });
286
+
287
+ process.on("unhandledRejection", (reason: unknown) => {
288
+ const err = reason instanceof Error ? reason : new Error(String(reason));
289
+ this.capture(
290
+ err.constructor.name,
291
+ err.message,
292
+ err.stack || "",
293
+ );
294
+ });
295
+ }
221
296
  }
222
297
 
223
298
  // ---------------------------------------------------------------------------
@@ -355,6 +430,9 @@ export class DevAdmin {
355
430
  * Register all /__dev routes on the given router.
356
431
  */
357
432
  static register(router: Router): void {
433
+ // Register error handlers to feed the ErrorTracker
434
+ ErrorTracker.register();
435
+
358
436
  const routes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
359
437
  // Dashboard
360
438
  { method: "GET", pattern: "/__dev", handler: handleDashboard },
@@ -7,7 +7,7 @@
7
7
  * import { DevMailbox, createMessenger } from "@tina4/core";
8
8
  *
9
9
  * const mailbox = new DevMailbox();
10
- * mailbox.capture({ to: "alice@test.com", subject: "Hello", body: "Hi!" });
10
+ * mailbox.capture("alice@test.com", "Hello", "Hi!");
11
11
  * const messages = mailbox.inbox();
12
12
  */
13
13
  import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
@@ -39,33 +39,33 @@ export class DevMailbox {
39
39
  /**
40
40
  * Capture an email to the dev mailbox instead of sending it.
41
41
  */
42
- capture(options: {
43
- to: string | string[];
44
- subject: string;
45
- body: string;
46
- html?: boolean;
47
- cc?: string[];
48
- bcc?: string[];
49
- replyTo?: string;
50
- attachments?: string[];
51
- from?: string;
52
- }): SendResult {
42
+ capture(
43
+ to: string | string[],
44
+ subject: string,
45
+ body: string,
46
+ html: boolean = false,
47
+ cc: string[] = [],
48
+ bcc: string[] = [],
49
+ replyTo?: string,
50
+ attachments: string[] = [],
51
+ from?: string,
52
+ ): SendResult {
53
53
  const id = randomUUID();
54
- const toList = Array.isArray(options.to) ? options.to : [options.to];
54
+ const toList = Array.isArray(to) ? to : [to];
55
55
  const now = new Date().toISOString();
56
56
 
57
57
  const message: EmailMessage = {
58
58
  id,
59
59
  type: "outbox",
60
- from: options.from ?? process.env.SMTP_FROM ?? "dev@localhost",
60
+ from: from ?? process.env.SMTP_FROM ?? "dev@localhost",
61
61
  to: toList,
62
- cc: options.cc ?? [],
63
- bcc: options.bcc ?? [],
64
- reply_to: options.replyTo,
65
- subject: options.subject,
66
- body: options.body,
67
- html: options.html ?? false,
68
- attachments: options.attachments ?? [],
62
+ cc,
63
+ bcc,
64
+ reply_to: replyTo,
65
+ subject,
66
+ body,
67
+ html,
68
+ attachments,
69
69
  date: now,
70
70
  read: false,
71
71
  };
@@ -68,12 +68,6 @@ const JOB_TITLES = [
68
68
  "Systems Administrator",
69
69
  ];
70
70
 
71
- const COLORS = [
72
- "red", "blue", "green", "yellow", "purple", "orange", "pink",
73
- "cyan", "magenta", "teal", "indigo", "violet", "coral", "salmon",
74
- "turquoise", "maroon", "navy", "olive", "silver", "gold",
75
- ];
76
-
77
71
  const CURRENCIES = [
78
72
  "USD", "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "CNY",
79
73
  "SEK", "NZD", "MXN", "SGD", "HKD", "NOK", "ZAR", "INR",
@@ -137,7 +131,7 @@ export class FakeData {
137
131
  return this.pick(LAST_NAMES);
138
132
  }
139
133
 
140
- fullName(): string {
134
+ name(): string {
141
135
  return `${this.firstName()} ${this.lastName()}`;
142
136
  }
143
137
 
@@ -209,7 +203,7 @@ export class FakeData {
209
203
  return this.randInt(min, max + 1);
210
204
  }
211
205
 
212
- float(min = 0, max = 1000, decimals = 2): number {
206
+ numeric(min = 0, max = 1000, decimals = 2): number {
213
207
  const raw = min + this.rng() * (max - min);
214
208
  return Number(raw.toFixed(decimals));
215
209
  }
@@ -251,11 +245,7 @@ export class FakeData {
251
245
  return `${this.randInt(1, 256)}.${this.randInt(0, 256)}.${this.randInt(0, 256)}.${this.randInt(1, 256)}`;
252
246
  }
253
247
 
254
- color(): string {
255
- return this.pick(COLORS);
256
- }
257
-
258
- hexColor(): string {
248
+ colorHex(): string {
259
249
  const hex = this.randInt(0, 0x1000000).toString(16).padStart(6, "0");
260
250
  return `#${hex}`;
261
251
  }
@@ -270,11 +260,31 @@ export class FakeData {
270
260
  return this.pick(CURRENCIES);
271
261
  }
272
262
 
263
+ /**
264
+ * Returns multi-paragraph text.
265
+ * Matches Python's text() method.
266
+ */
267
+ text(paragraphs = 3): string {
268
+ const parts: string[] = [];
269
+ for (let i = 0; i < paragraphs; i++) {
270
+ parts.push(this.paragraph(4));
271
+ }
272
+ return parts.join("\n\n");
273
+ }
274
+
275
+ /**
276
+ * Returns a random element from the given array.
277
+ * Matches Python's choice() method.
278
+ */
279
+ choice<T>(items: T[]): T {
280
+ return items[this.randInt(0, items.length)];
281
+ }
282
+
273
283
  /**
274
284
  * Run seed files from a directory. Each file should export a default async function.
275
285
  * Returns an array of executed file paths.
276
286
  */
277
- async runSeeds(seedDir?: string): Promise<string[]> {
287
+ async seedDir(seedDir?: string): Promise<string[]> {
278
288
  const dir = resolve(seedDir ?? "src/seeds");
279
289
  if (!existsSync(dir)) return [];
280
290
  const files = readdirSync(dir)
@@ -69,7 +69,7 @@ const TOKEN_PATTERNS: Array<[string, RegExp]> = [
69
69
  ["COMMENT", /#[^\n]*/y],
70
70
  ];
71
71
 
72
- function tokenize(source: string): Token[] {
72
+ export function tokenize(source: string): Token[] {
73
73
  const tokens: Token[] = [];
74
74
  let pos = 0;
75
75
 
@@ -932,3 +932,39 @@ export class GraphQL {
932
932
  return resolved;
933
933
  }
934
934
  }
935
+
936
+ /**
937
+ * Lightweight GraphQL type wrapper matching Ruby's GraphQLType.
938
+ */
939
+ export class GraphQLType {
940
+ static readonly SCALARS = ["String", "Int", "Float", "Boolean", "ID"];
941
+
942
+ name: string;
943
+ kind: string;
944
+ ofType: GraphQLType | null;
945
+
946
+ constructor(name: string, kind: string = "object", ofType: GraphQLType | null = null) {
947
+ this.name = name;
948
+ this.kind = kind;
949
+ this.ofType = ofType;
950
+ }
951
+
952
+ /**
953
+ * Parse a GraphQL type string like "String", "String!", "[Int!]!".
954
+ */
955
+ static parse(typeStr: string): GraphQLType {
956
+ const s = String(typeStr).trim();
957
+ if (s.endsWith("!")) {
958
+ const inner = GraphQLType.parse(s.slice(0, -1));
959
+ return new GraphQLType(s, "non_null", inner);
960
+ }
961
+ if (s.startsWith("[") && s.endsWith("]")) {
962
+ const inner = GraphQLType.parse(s.slice(1, -1));
963
+ return new GraphQLType(s, "list", inner);
964
+ }
965
+ if (GraphQLType.SCALARS.includes(s)) {
966
+ return new GraphQLType(s, "scalar");
967
+ }
968
+ return new GraphQLType(s, "object");
969
+ }
970
+ }
@@ -85,7 +85,7 @@ export class I18n {
85
85
  }
86
86
 
87
87
  /** List available locale codes based on JSON files in the locale directory. */
88
- getAvailableLocales(): string[] {
88
+ availableLocales(): string[] {
89
89
  if (!existsSync(this._localeDir)) {
90
90
  return [this._defaultLocale];
91
91
  }
@@ -12,15 +12,15 @@ export type {
12
12
  WebSocketRouteDefinition,
13
13
  } from "./types.js";
14
14
 
15
- export { startServer, resolvePortAndHost, handle } from "./server.js";
15
+ export { startServer, resolvePortAndHost, handle, start, stop } from "./server.js";
16
16
  export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
17
17
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
18
18
  export type { RouteInfo } from "./router.js";
19
19
  export { discoverRoutes } from "./routeDiscovery.js";
20
20
  export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware } from "./middleware.js";
21
21
  export type { CorsConfig } from "./middleware.js";
22
- export { createRequest, parseBody } from "./request.js";
23
- export { createResponse, errorResponse, setDefaultTemplatesDir } from "./response.js";
22
+ export { createRequest } from "./request.js";
23
+ export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFrond, getFrameworkFrond } from "./response.js";
24
24
  export { tryServeStatic } from "./static.js";
25
25
  export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
26
26
  export { Log } from "./logger.js";
@@ -74,8 +74,8 @@ export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore,
74
74
  export { Messenger } from "./messenger.js";
75
75
  export type { SendResult, EmailMessage } from "./messenger.js";
76
76
  export { DevMailbox, createMessenger } from "./devMailbox.js";
77
- export { WSDLService, WSDLOp } from "./wsdl.js";
78
- export type { WSDLOperation } from "./wsdl.js";
77
+ export { WSDLService, WSDLOperation } from "./wsdl.js";
78
+ export type { WSDLOperationMeta } from "./wsdl.js";
79
79
  export { HtmlElement, htmlElement, addHtmlHelpers } from "./htmlElement.js";
80
80
  export { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
81
81
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
@@ -96,7 +96,7 @@ export { ValkeySessionHandler } from "./sessionHandlers/valkeyHandler.js";
96
96
  export type { ValkeySessionConfig } from "./sessionHandlers/valkeyHandler.js";
97
97
  export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
98
98
  export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
99
- export { tests, assertEqual, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "./testing.js";
99
+ export { tests, assertEqual, assertRaises, assertTrue, assertFalse, runAll, reset } from "./testing.js";
100
100
  export { TestClient, TestResponse } from "./testClient.js";
101
101
  export { Container, container } from "./container.js";
102
102
  export { Validator } from "./validator.js";
@@ -990,3 +990,6 @@ export function registerDevTools(server: McpServer): void {
990
990
  schemaFromParams([]),
991
991
  );
992
992
  }
993
+
994
+ /** Alias for registerDevTools — parity with PHP/Ruby/Python. */
995
+ export const register = registerDevTools;
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * const mail = new Messenger(); // reads from .env
23
23
  * const mail = new Messenger({ host: "smtp.office365.com", port: 587 }); // override
24
- * await mail.send({ to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!" });
24
+ * await mail.send("user@test.com", "Welcome", "<h1>Hello!</h1>", true, "Hello!");
25
25
  */
26
26
  import net from "node:net";
27
27
  import tls from "node:tls";
@@ -355,7 +355,19 @@ export class Messenger {
355
355
  /**
356
356
  * Send an email via SMTP.
357
357
  */
358
- async send(options: SendOptions): Promise<SendResult> {
358
+ async send(
359
+ to: string | string[],
360
+ subject: string,
361
+ body: string,
362
+ html: boolean = false,
363
+ text?: string,
364
+ cc?: string | string[],
365
+ bcc?: string | string[],
366
+ replyTo?: string,
367
+ attachments?: string[],
368
+ headers?: Record<string, string>,
369
+ ): Promise<SendResult> {
370
+ const options: SendOptions = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
359
371
  const toList = Array.isArray(options.to) ? options.to : [options.to];
360
372
  const ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
361
373
  const bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
@@ -655,7 +667,24 @@ export class Messenger {
655
667
  /**
656
668
  * Search messages using IMAP search criteria.
657
669
  */
658
- async search(query: string, folder: string = "INBOX"): Promise<ImapMessage[]> {
670
+ async search(
671
+ folder: string = "INBOX",
672
+ subject?: string,
673
+ sender?: string,
674
+ since?: string,
675
+ before?: string,
676
+ unseenOnly: boolean = false,
677
+ limit: number = 50,
678
+ ): Promise<ImapMessage[]> {
679
+ // Build IMAP SEARCH criteria from structured params
680
+ const criteria: string[] = ["ALL"];
681
+ if (subject) criteria.push(`SUBJECT "${subject}"`);
682
+ if (sender) criteria.push(`FROM "${sender}"`);
683
+ if (since) criteria.push(`SINCE ${since}`);
684
+ if (before) criteria.push(`BEFORE ${before}`);
685
+ if (unseenOnly) criteria.push("UNSEEN");
686
+
687
+ const query = criteria.join(" ");
659
688
  const socket = await this.imapConnect();
660
689
  try {
661
690
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
@@ -665,7 +694,7 @@ export class Messenger {
665
694
 
666
695
  uids.reverse();
667
696
  const messages: ImapMessage[] = [];
668
- for (const uid of uids.slice(0, 50)) {
697
+ for (const uid of uids.slice(0, limit)) {
669
698
  const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
670
699
  messages.push(parseHeaderResponse(uid, fetchResp));
671
700
  }
@@ -716,6 +745,25 @@ export class Messenger {
716
745
  }
717
746
  }
718
747
 
748
+ /**
749
+ * List available IMAP folders/mailboxes.
750
+ */
751
+ async folders(): Promise<string[]> {
752
+ const socket = await this.imapConnect();
753
+ try {
754
+ const resp = await imapCommand(socket, 'LIST "" "*"');
755
+ const result: string[] = [];
756
+ for (const line of resp.split("\r\n")) {
757
+ // Parse LIST response: * LIST (\flags) "/" "FolderName"
758
+ const m = line.match(/\* LIST \([^)]*\) "[^"]*" "?([^"\r\n]+)"?/i);
759
+ if (m) result.push(m[1]);
760
+ }
761
+ return result;
762
+ } finally {
763
+ await this.imapDisconnect(socket);
764
+ }
765
+ }
766
+
719
767
  /**
720
768
  * Test IMAP connectivity without reading.
721
769
  */