tina4-nodejs 3.2.1 → 3.5.0

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 (34) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/cli/src/bin.ts +13 -1
  5. package/packages/cli/src/commands/migrate.ts +19 -5
  6. package/packages/cli/src/commands/migrateCreate.ts +29 -28
  7. package/packages/cli/src/commands/migrateRollback.ts +59 -0
  8. package/packages/cli/src/commands/migrateStatus.ts +62 -0
  9. package/packages/core/public/js/tina4-dev-admin.min.js +1 -1
  10. package/packages/core/public/js/tina4js.min.js +47 -0
  11. package/packages/core/src/auth.ts +44 -10
  12. package/packages/core/src/devAdmin.ts +14 -16
  13. package/packages/core/src/index.ts +10 -3
  14. package/packages/core/src/middleware.ts +232 -2
  15. package/packages/core/src/queue.ts +127 -25
  16. package/packages/core/src/queueBackends/mongoBackend.ts +223 -0
  17. package/packages/core/src/request.ts +3 -3
  18. package/packages/core/src/router.ts +115 -51
  19. package/packages/core/src/server.ts +47 -3
  20. package/packages/core/src/session.ts +29 -1
  21. package/packages/core/src/sessionHandlers/databaseHandler.ts +134 -0
  22. package/packages/core/src/sessionHandlers/redisHandler.ts +230 -0
  23. package/packages/core/src/types.ts +12 -6
  24. package/packages/core/src/websocket.ts +11 -2
  25. package/packages/core/src/websocketConnection.ts +4 -2
  26. package/packages/frond/src/engine.ts +66 -1
  27. package/packages/orm/src/autoCrud.ts +17 -12
  28. package/packages/orm/src/baseModel.ts +99 -21
  29. package/packages/orm/src/database.ts +197 -69
  30. package/packages/orm/src/databaseResult.ts +207 -0
  31. package/packages/orm/src/index.ts +6 -3
  32. package/packages/orm/src/migration.ts +296 -71
  33. package/packages/orm/src/model.ts +1 -0
  34. package/packages/orm/src/types.ts +1 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Tina4 MongoDB Queue Backend — uses `mongodb` npm package via dynamic import.
3
+ *
4
+ * Implements the same interface as the file-based queue but uses MongoDB
5
+ * for message storage and delivery. Atomic pop via findOneAndUpdate.
6
+ *
7
+ * Configure via environment variables:
8
+ * TINA4_MONGO_HOST (default: "localhost")
9
+ * TINA4_MONGO_PORT (default: 27017)
10
+ * TINA4_MONGO_URI (overrides host/port/username/password)
11
+ * TINA4_MONGO_USERNAME (optional)
12
+ * TINA4_MONGO_PASSWORD (optional)
13
+ * TINA4_MONGO_DB (default: "tina4")
14
+ * TINA4_MONGO_COLLECTION (default: "tina4_queue")
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import { execFileSync } from "node:child_process";
18
+ import type { QueueJob } from "../queue.js";
19
+
20
+ // ── Types ────────────────────────────────────────────────────
21
+
22
+ export interface MongoConfig {
23
+ host?: string;
24
+ port?: number;
25
+ uri?: string;
26
+ username?: string;
27
+ password?: string;
28
+ database?: string;
29
+ collection?: string;
30
+ }
31
+
32
+ export interface QueueBackend {
33
+ push(queue: string, payload: unknown, delay?: number): string;
34
+ pop(queue: string): QueueJob | null;
35
+ size(queue: string): number;
36
+ clear(queue: string): void;
37
+ }
38
+
39
+ // ── MongoDB Backend ──────────────────────────────────────────
40
+
41
+ /**
42
+ * MongoDB queue backend using the `mongodb` npm package.
43
+ *
44
+ * Uses synchronous-style communication by spawning a child process
45
+ * for each operation, similar to the RabbitMQ and Redis patterns.
46
+ * This keeps the interface synchronous as required by the Queue class.
47
+ */
48
+ export class MongoBackend implements QueueBackend {
49
+ private host: string;
50
+ private port: number;
51
+ private uri: string;
52
+ private username: string;
53
+ private password: string;
54
+ private database: string;
55
+ private collection: string;
56
+
57
+ constructor(config?: MongoConfig) {
58
+ this.host = config?.host ?? process.env.TINA4_MONGO_HOST ?? "localhost";
59
+ this.port = config?.port
60
+ ?? (process.env.TINA4_MONGO_PORT ? parseInt(process.env.TINA4_MONGO_PORT, 10) : 27017);
61
+ this.username = config?.username ?? process.env.TINA4_MONGO_USERNAME ?? "";
62
+ this.password = config?.password ?? process.env.TINA4_MONGO_PASSWORD ?? "";
63
+ this.database = config?.database ?? process.env.TINA4_MONGO_DB ?? "tina4";
64
+ this.collection = config?.collection ?? process.env.TINA4_MONGO_COLLECTION ?? "tina4_queue";
65
+
66
+ // URI overrides individual host/port/auth settings
67
+ if (config?.uri ?? process.env.TINA4_MONGO_URI) {
68
+ this.uri = config?.uri ?? process.env.TINA4_MONGO_URI!;
69
+ } else {
70
+ const auth = this.username
71
+ ? `${encodeURIComponent(this.username)}:${encodeURIComponent(this.password)}@`
72
+ : "";
73
+ this.uri = `mongodb://${auth}${this.host}:${this.port}`;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Execute a MongoDB operation synchronously via a child process.
79
+ */
80
+ private execSync(operation: string, queue: string, data?: string): string {
81
+ const script = `
82
+ async function main() {
83
+ let mongodb;
84
+ try {
85
+ mongodb = await import("mongodb");
86
+ } catch {
87
+ process.stderr.write("mongodb package not installed — run: npm install mongodb");
88
+ process.exit(1);
89
+ }
90
+
91
+ const { MongoClient } = mongodb;
92
+ const uri = ${JSON.stringify(this.uri)};
93
+ const dbName = ${JSON.stringify(this.database)};
94
+ const collName = ${JSON.stringify(this.collection)};
95
+ const operation = ${JSON.stringify(operation)};
96
+ const queueName = ${JSON.stringify(queue)};
97
+ const data = ${JSON.stringify(data ?? "")};
98
+
99
+ const client = new MongoClient(uri, {
100
+ connectTimeoutMS: 5000,
101
+ serverSelectionTimeoutMS: 5000,
102
+ });
103
+
104
+ try {
105
+ await client.connect();
106
+ const db = client.db(dbName);
107
+ const col = db.collection(collName);
108
+
109
+ // Ensure indexes on first use
110
+ await col.createIndex({ queue: 1, status: 1, delayUntil: 1 });
111
+ await col.createIndex({ queue: 1, createdAt: 1 });
112
+
113
+ if (operation === "push") {
114
+ const job = JSON.parse(data);
115
+ await col.insertOne({ ...job, queue: queueName });
116
+ process.stdout.write("__PUSHED__");
117
+ }
118
+ else if (operation === "pop") {
119
+ const now = new Date().toISOString();
120
+ const result = await col.findOneAndUpdate(
121
+ {
122
+ queue: queueName,
123
+ status: "pending",
124
+ $or: [
125
+ { delayUntil: null },
126
+ { delayUntil: { $lte: now } },
127
+ ],
128
+ },
129
+ { $set: { status: "reserved" } },
130
+ { sort: { createdAt: 1 }, returnDocument: "before" },
131
+ );
132
+
133
+ if (result && result.value) {
134
+ // findOneAndUpdate returns { value: doc } in older drivers
135
+ const doc = result.value;
136
+ delete doc._id;
137
+ delete doc.queue;
138
+ process.stdout.write(JSON.stringify(doc));
139
+ } else if (result && result._id) {
140
+ // Some driver versions return the doc directly
141
+ const doc = { ...result };
142
+ delete doc._id;
143
+ delete doc.queue;
144
+ process.stdout.write(JSON.stringify(doc));
145
+ } else {
146
+ process.stdout.write("__EMPTY__");
147
+ }
148
+ }
149
+ else if (operation === "size") {
150
+ const count = await col.countDocuments({
151
+ queue: queueName,
152
+ status: "pending",
153
+ });
154
+ process.stdout.write(String(count));
155
+ }
156
+ else if (operation === "clear") {
157
+ await col.deleteMany({ queue: queueName });
158
+ process.stdout.write("__CLEARED__");
159
+ }
160
+ } catch (err) {
161
+ process.stderr.write(err.message || String(err));
162
+ process.exit(1);
163
+ } finally {
164
+ await client.close();
165
+ }
166
+ }
167
+
168
+ main();
169
+ `;
170
+
171
+ try {
172
+ const result = execFileSync(process.execPath, ["-e", script], {
173
+ encoding: "utf-8",
174
+ timeout: 15000,
175
+ stdio: ["pipe", "pipe", "pipe"],
176
+ });
177
+ return result;
178
+ } catch {
179
+ return "";
180
+ }
181
+ }
182
+
183
+ push(queue: string, payload: unknown, delay?: number): string {
184
+ const id = randomUUID();
185
+ const now = new Date().toISOString();
186
+
187
+ const job: QueueJob = {
188
+ id,
189
+ payload,
190
+ status: "pending",
191
+ createdAt: now,
192
+ attempts: 0,
193
+ delayUntil: delay ? new Date(Date.now() + delay * 1000).toISOString() : null,
194
+ };
195
+
196
+ const result = this.execSync("push", queue, JSON.stringify(job));
197
+ if (!result.includes("__PUSHED__")) {
198
+ throw new Error("MongoDB push failed");
199
+ }
200
+ return id;
201
+ }
202
+
203
+ pop(queue: string): QueueJob | null {
204
+ const result = this.execSync("pop", queue);
205
+ if (!result || result === "__EMPTY__") return null;
206
+
207
+ try {
208
+ return JSON.parse(result) as QueueJob;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ size(queue: string): number {
215
+ const result = this.execSync("size", queue);
216
+ const num = parseInt(result, 10);
217
+ return isNaN(num) ? 0 : num;
218
+ }
219
+
220
+ clear(queue: string): void {
221
+ this.execSync("clear", queue);
222
+ }
223
+ }
@@ -129,12 +129,12 @@ function parseMultipart(
129
129
  const partContentType = parsePartContentType(headersStr);
130
130
 
131
131
  if (disposition.filename) {
132
- // File upload
132
+ // File upload — standardised format: filename, type, content (raw bytes), size
133
133
  files.push({
134
134
  fieldName: disposition.name,
135
135
  filename: disposition.filename,
136
- contentType: partContentType ?? "application/octet-stream",
137
- data: Buffer.from(content),
136
+ type: partContentType ?? "application/octet-stream",
137
+ content: Buffer.from(content),
138
138
  size: content.length,
139
139
  });
140
140
  } else if (disposition.name) {
@@ -7,6 +7,8 @@ interface MatchResult {
7
7
  meta?: RouteMeta;
8
8
  middlewares?: Middleware[];
9
9
  template?: string;
10
+ secure?: boolean;
11
+ cached?: boolean;
10
12
  }
11
13
 
12
14
  interface CompiledRoute {
@@ -17,28 +19,77 @@ interface CompiledRoute {
17
19
  meta?: RouteMeta;
18
20
  filePath?: string;
19
21
  middlewares?: Middleware[];
22
+ secure?: boolean;
20
23
  cached?: boolean;
21
24
  cacheStore?: Map<string, { data: unknown; expires: number }>;
22
25
  cacheTtl?: number;
23
26
  template?: string;
24
27
  }
25
28
 
29
+ /**
30
+ * Thin reference to a registered route, enabling chained modifiers.
31
+ *
32
+ * Usage:
33
+ * router.get("/api/data", handler).secure().cache();
34
+ */
35
+ export class RouteRef {
36
+ constructor(private route: CompiledRoute) {}
37
+
38
+ /** Mark this route as requiring bearer-token authentication. */
39
+ secure(): this {
40
+ this.route.secure = true;
41
+ return this;
42
+ }
43
+
44
+ /** Mark this route's response as cacheable. */
45
+ cache(): this {
46
+ this.route.cached = true;
47
+ return this;
48
+ }
49
+ }
50
+
26
51
  export interface RouteInfo {
27
52
  method: string;
28
53
  path: string;
29
54
  handler: string;
30
55
  middlewareCount: number;
31
56
  cached: boolean;
57
+ secure: boolean;
32
58
  }
33
59
 
34
60
  export class Router {
35
61
  private routes: Map<string, CompiledRoute[]> = new Map();
36
62
  private wsRoutes: WebSocketRouteDefinition[] = [];
37
63
 
64
+ /** Class-based middleware registered via `use()` / `Router.use()`. */
65
+ private static _classMiddlewares: any[] = [];
66
+
67
+ /**
68
+ * Register a class-based middleware (beforeX / afterX convention).
69
+ * Classes are stored globally and executed by MiddlewareRunner.
70
+ */
71
+ static use(middlewareClass: any): void {
72
+ Router._classMiddlewares.push(middlewareClass);
73
+ }
74
+
75
+ /**
76
+ * Get all registered class-based middleware classes.
77
+ */
78
+ static getClassMiddlewares(): any[] {
79
+ return Router._classMiddlewares;
80
+ }
81
+
82
+ /**
83
+ * Clear all registered class-based middleware (useful for testing).
84
+ */
85
+ static clearClassMiddlewares(): void {
86
+ Router._classMiddlewares = [];
87
+ }
88
+
38
89
  /**
39
90
  * Add a raw route definition (used internally and by file-based routing).
40
91
  */
41
- addRoute(definition: RouteDefinition): void {
92
+ addRoute(definition: RouteDefinition): RouteRef {
42
93
  const method = definition.method.toUpperCase();
43
94
  const { regex, paramNames } = this.compilePattern(definition.pattern);
44
95
 
@@ -54,7 +105,7 @@ export class Router {
54
105
  routes.splice(existingIndex, 1);
55
106
  }
56
107
 
57
- routes.push({
108
+ const compiled: CompiledRoute = {
58
109
  pattern: definition.pattern,
59
110
  regex,
60
111
  paramNames,
@@ -62,52 +113,58 @@ export class Router {
62
113
  meta: definition.meta,
63
114
  filePath: definition.filePath,
64
115
  middlewares: definition.middlewares,
116
+ secure: definition.secure,
117
+ cached: definition.cached,
65
118
  template: definition.template,
66
- });
119
+ };
120
+ routes.push(compiled);
121
+ return new RouteRef(compiled);
67
122
  }
68
123
 
69
124
  /**
70
125
  * Register a GET route programmatically.
71
126
  */
72
- get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
73
- this.addRoute({ method: "GET", pattern: path, handler, middlewares, meta });
127
+ get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
128
+ return this.addRoute({ method: "GET", pattern: path, handler, middlewares, meta });
74
129
  }
75
130
 
76
131
  /**
77
132
  * Register a POST route programmatically.
78
133
  */
79
- post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
80
- this.addRoute({ method: "POST", pattern: path, handler, middlewares, meta });
134
+ post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
135
+ return this.addRoute({ method: "POST", pattern: path, handler, middlewares, meta });
81
136
  }
82
137
 
83
138
  /**
84
139
  * Register a PUT route programmatically.
85
140
  */
86
- put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
87
- this.addRoute({ method: "PUT", pattern: path, handler, middlewares, meta });
141
+ put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
142
+ return this.addRoute({ method: "PUT", pattern: path, handler, middlewares, meta });
88
143
  }
89
144
 
90
145
  /**
91
146
  * Register a PATCH route programmatically.
92
147
  */
93
- patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
94
- this.addRoute({ method: "PATCH", pattern: path, handler, middlewares, meta });
148
+ patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
149
+ return this.addRoute({ method: "PATCH", pattern: path, handler, middlewares, meta });
95
150
  }
96
151
 
97
152
  /**
98
153
  * Register a DELETE route programmatically.
99
154
  */
100
- delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
101
- this.addRoute({ method: "DELETE", pattern: path, handler, middlewares, meta });
155
+ delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
156
+ return this.addRoute({ method: "DELETE", pattern: path, handler, middlewares, meta });
102
157
  }
103
158
 
104
159
  /**
105
160
  * Register a route that matches ANY HTTP method.
106
161
  */
107
- any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
162
+ any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
163
+ let lastRef!: RouteRef;
108
164
  for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) {
109
- this.addRoute({ method, pattern: path, handler, middlewares, meta });
165
+ lastRef = this.addRoute({ method, pattern: path, handler, middlewares, meta });
110
166
  }
167
+ return lastRef;
111
168
  }
112
169
 
113
170
  /**
@@ -142,6 +199,8 @@ export class Router {
142
199
  meta: route.meta,
143
200
  middlewares: route.middlewares,
144
201
  template: route.template,
202
+ secure: route.secure,
203
+ cached: route.cached,
145
204
  };
146
205
  }
147
206
  }
@@ -164,6 +223,8 @@ export class Router {
164
223
  filePath: route.filePath,
165
224
  middlewares: route.middlewares,
166
225
  template: route.template,
226
+ secure: route.secure,
227
+ cached: route.cached,
167
228
  });
168
229
  }
169
230
  }
@@ -183,6 +244,7 @@ export class Router {
183
244
  handler: route.filePath ?? (route.handler.name || "(anonymous)"),
184
245
  middlewareCount: route.middlewares?.length ?? 0,
185
246
  cached: route.cached ?? false,
247
+ secure: route.secure ?? false,
186
248
  });
187
249
  }
188
250
  }
@@ -228,43 +290,43 @@ export class Router {
228
290
  /**
229
291
  * Register a GET route on the default global router.
230
292
  */
231
- static get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
232
- defaultRouter.get(path, handler, middlewares, meta);
293
+ static get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
294
+ return defaultRouter.get(path, handler, middlewares, meta);
233
295
  }
234
296
 
235
297
  /**
236
298
  * Register a POST route on the default global router.
237
299
  */
238
- static post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
239
- defaultRouter.post(path, handler, middlewares, meta);
300
+ static post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
301
+ return defaultRouter.post(path, handler, middlewares, meta);
240
302
  }
241
303
 
242
304
  /**
243
305
  * Register a PUT route on the default global router.
244
306
  */
245
- static put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
246
- defaultRouter.put(path, handler, middlewares, meta);
307
+ static put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
308
+ return defaultRouter.put(path, handler, middlewares, meta);
247
309
  }
248
310
 
249
311
  /**
250
312
  * Register a PATCH route on the default global router.
251
313
  */
252
- static patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
253
- defaultRouter.patch(path, handler, middlewares, meta);
314
+ static patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
315
+ return defaultRouter.patch(path, handler, middlewares, meta);
254
316
  }
255
317
 
256
318
  /**
257
319
  * Register a DELETE route on the default global router.
258
320
  */
259
- static delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
260
- defaultRouter.delete(path, handler, middlewares, meta);
321
+ static delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
322
+ return defaultRouter.delete(path, handler, middlewares, meta);
261
323
  }
262
324
 
263
325
  /**
264
326
  * Register a route that matches ANY HTTP method on the default global router.
265
327
  */
266
- static any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
267
- defaultRouter.any(path, handler, middlewares, meta);
328
+ static any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
329
+ return defaultRouter.any(path, handler, middlewares, meta);
268
330
  }
269
331
 
270
332
  /**
@@ -343,8 +405,8 @@ export class RouteGroup {
343
405
  return merged.length > 0 ? merged : undefined;
344
406
  }
345
407
 
346
- get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
347
- this.router.addRoute({
408
+ get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
409
+ return this.router.addRoute({
348
410
  method: "GET",
349
411
  pattern: this.prefix + path,
350
412
  handler,
@@ -353,8 +415,8 @@ export class RouteGroup {
353
415
  });
354
416
  }
355
417
 
356
- post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
357
- this.router.addRoute({
418
+ post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
419
+ return this.router.addRoute({
358
420
  method: "POST",
359
421
  pattern: this.prefix + path,
360
422
  handler,
@@ -363,8 +425,8 @@ export class RouteGroup {
363
425
  });
364
426
  }
365
427
 
366
- put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
367
- this.router.addRoute({
428
+ put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
429
+ return this.router.addRoute({
368
430
  method: "PUT",
369
431
  pattern: this.prefix + path,
370
432
  handler,
@@ -373,8 +435,8 @@ export class RouteGroup {
373
435
  });
374
436
  }
375
437
 
376
- patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
377
- this.router.addRoute({
438
+ patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
439
+ return this.router.addRoute({
378
440
  method: "PATCH",
379
441
  pattern: this.prefix + path,
380
442
  handler,
@@ -383,8 +445,8 @@ export class RouteGroup {
383
445
  });
384
446
  }
385
447
 
386
- delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
387
- this.router.addRoute({
448
+ delete(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
449
+ return this.router.addRoute({
388
450
  method: "DELETE",
389
451
  pattern: this.prefix + path,
390
452
  handler,
@@ -393,9 +455,10 @@ export class RouteGroup {
393
455
  });
394
456
  }
395
457
 
396
- any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
458
+ any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
459
+ let lastRef!: RouteRef;
397
460
  for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) {
398
- this.router.addRoute({
461
+ lastRef = this.router.addRoute({
399
462
  method,
400
463
  pattern: this.prefix + path,
401
464
  handler,
@@ -403,6 +466,7 @@ export class RouteGroup {
403
466
  meta,
404
467
  });
405
468
  }
469
+ return lastRef;
406
470
  }
407
471
 
408
472
  /**
@@ -458,29 +522,29 @@ export const defaultRouter = new Router();
458
522
  * res.json({ id: req.params.id }, 201);
459
523
  * });
460
524
  */
461
- export function get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
462
- defaultRouter.get(path, handler, middlewares, meta);
525
+ export function get(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
526
+ return defaultRouter.get(path, handler, middlewares, meta);
463
527
  }
464
528
 
465
- export function post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
466
- defaultRouter.post(path, handler, middlewares, meta);
529
+ export function post(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
530
+ return defaultRouter.post(path, handler, middlewares, meta);
467
531
  }
468
532
 
469
- export function put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
470
- defaultRouter.put(path, handler, middlewares, meta);
533
+ export function put(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
534
+ return defaultRouter.put(path, handler, middlewares, meta);
471
535
  }
472
536
 
473
- export function patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
474
- defaultRouter.patch(path, handler, middlewares, meta);
537
+ export function patch(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
538
+ return defaultRouter.patch(path, handler, middlewares, meta);
475
539
  }
476
540
 
477
541
  // Named "del" to avoid conflict with the "delete" keyword; also exported as "delete" alias below.
478
- export function del(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
479
- defaultRouter.delete(path, handler, middlewares, meta);
542
+ export function del(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
543
+ return defaultRouter.delete(path, handler, middlewares, meta);
480
544
  }
481
545
 
482
- export function any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): void {
483
- defaultRouter.any(path, handler, middlewares, meta);
546
+ export function any(path: string, handler: RouteHandler, middlewares?: Middleware[], meta?: RouteMeta): RouteRef {
547
+ return defaultRouter.any(path, handler, middlewares, meta);
484
548
  }
485
549
 
486
550
  export function websocket(path: string, handler: WebSocketRouteHandler): void {
@@ -29,6 +29,38 @@ const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
29
29
 
30
30
  const TINA4_VERSION = "3.0.0";
31
31
 
32
+ /**
33
+ * Test-bind each port in a subprocess to find one that is available.
34
+ * Falls back to `start` if none of the candidates work.
35
+ */
36
+ function findAvailablePort(start: number, maxTries = 10): number {
37
+ const { execFileSync } = require("node:child_process");
38
+ for (let offset = 0; offset < maxTries; offset++) {
39
+ const port = start + offset;
40
+ try {
41
+ execFileSync(process.execPath, ["-e", `
42
+ const s = require("net").createServer();
43
+ s.listen(${port}, "127.0.0.1", () => { s.close(); process.exit(0); });
44
+ s.on("error", () => process.exit(1));
45
+ `], { timeout: 1000 });
46
+ return port;
47
+ } catch { continue; }
48
+ }
49
+ return start;
50
+ }
51
+
52
+ /**
53
+ * Open the user's default browser after a short delay so the server is ready.
54
+ */
55
+ function openBrowser(url: string) {
56
+ const { exec } = require("node:child_process");
57
+ setTimeout(() => {
58
+ if (process.platform === "darwin") exec(`open ${url}`);
59
+ else if (process.platform === "win32") exec(`start "" "${url}"`);
60
+ else exec(`xdg-open ${url}`);
61
+ }, 2000);
62
+ }
63
+
32
64
  /**
33
65
  * Resolve port and host with priority: explicit config > ENV var > default.
34
66
  * Exported for testability.
@@ -266,8 +298,10 @@ function deployGallery(name) {
266
298
  if (d.error) {
267
299
  alert('Deploy failed: ' + d.error);
268
300
  } else {
269
- alert('Deployed "' + d.deployed + '" (' + d.files.length + ' files). Reloading...');
270
- window.location.reload();
301
+ // Brief delay to allow newly deployed routes to register before reloading
302
+ setTimeout(function() {
303
+ window.location.reload();
304
+ }, 500);
271
305
  }
272
306
  })
273
307
  .catch(function(e) { alert('Deploy error: ' + e.message); });
@@ -285,7 +319,16 @@ export async function startServer(config?: Tina4Config): Promise<{
285
319
  // Load .env early so TINA4_DEBUG is available for cluster decision
286
320
  loadEnv();
287
321
 
288
- const { port, host } = resolvePortAndHost(config);
322
+ const resolved = resolvePortAndHost(config);
323
+ const host = resolved.host;
324
+ let port = resolved.port;
325
+
326
+ // Auto-increment port if the requested one is already in use
327
+ const availablePort = findAvailablePort(port);
328
+ if (availablePort !== port) {
329
+ console.log(` Port ${port} in use, using ${availablePort} instead`);
330
+ port = availablePort;
331
+ }
289
332
 
290
333
  // Cluster mode for production: fork workers based on CPU count
291
334
  // Only when not in dev mode and running as primary process
@@ -669,6 +712,7 @@ ${reset}
669
712
  Dashboard: http://localhost:${port}/__dev
670
713
  Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})
671
714
  `);
715
+ openBrowser(`http://${displayHost}:${port}`);
672
716
  resolvePromise({
673
717
  close: () => {
674
718
  server.close();