@expressots/adapter-express 4.0.0-preview.1 → 4.0.0-preview.3

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 (36) hide show
  1. package/LICENSE.md +21 -21
  2. package/README.md +61 -61
  3. package/lib/CHANGELOG.md +10 -5
  4. package/lib/README.md +61 -61
  5. package/lib/cjs/adapter-express/application-express.js +401 -45
  6. package/lib/cjs/adapter-express/express-utils/decorators.js +44 -15
  7. package/lib/cjs/adapter-express/express-utils/inversify-express-server.js +20 -4
  8. package/lib/cjs/adapter-express/express-utils/path-pattern-compat.js +129 -0
  9. package/lib/cjs/adapter-express/express-utils/route-constraints.js +12 -3
  10. package/lib/cjs/adapter-express/micro-api/application-express-micro.js +5 -9
  11. package/lib/cjs/adapter-express/micro-api/micro.js +96 -41
  12. package/lib/cjs/adapter-express/studio/index.js +2 -1
  13. package/lib/cjs/adapter-express/studio/studio-integration.js +64 -11
  14. package/lib/cjs/types/adapter-express/application-express.d.ts +51 -9
  15. package/lib/cjs/types/adapter-express/express-utils/path-pattern-compat.d.ts +66 -0
  16. package/lib/cjs/types/adapter-express/express-utils/route-constraints.d.ts +12 -3
  17. package/lib/cjs/types/adapter-express/micro-api/micro.d.ts +19 -2
  18. package/lib/cjs/types/adapter-express/studio/index.d.ts +1 -1
  19. package/lib/cjs/types/adapter-express/studio/studio-integration.d.ts +78 -0
  20. package/lib/esm/adapter-express/application-express.js +402 -46
  21. package/lib/esm/adapter-express/express-utils/decorators.js +44 -15
  22. package/lib/esm/adapter-express/express-utils/inversify-express-server.js +20 -4
  23. package/lib/esm/adapter-express/express-utils/path-pattern-compat.js +125 -0
  24. package/lib/esm/adapter-express/express-utils/route-constraints.js +12 -3
  25. package/lib/esm/adapter-express/micro-api/application-express-micro.js +6 -10
  26. package/lib/esm/adapter-express/micro-api/micro.js +97 -42
  27. package/lib/esm/adapter-express/studio/index.js +1 -1
  28. package/lib/esm/adapter-express/studio/studio-integration.js +63 -11
  29. package/lib/esm/types/adapter-express/application-express.d.ts +51 -9
  30. package/lib/esm/types/adapter-express/express-utils/path-pattern-compat.d.ts +66 -0
  31. package/lib/esm/types/adapter-express/express-utils/route-constraints.d.ts +12 -3
  32. package/lib/esm/types/adapter-express/micro-api/micro.d.ts +19 -2
  33. package/lib/esm/types/adapter-express/studio/index.d.ts +1 -1
  34. package/lib/esm/types/adapter-express/studio/studio-integration.d.ts +78 -0
  35. package/lib/package.json +24 -10
  36. package/package.json +25 -11
@@ -3,6 +3,7 @@ import { inject, injectable, decorate, getGlobalUploadConfig } from "@expressots
3
3
  import { TYPE, METADATA_KEY, PARAMETER_TYPE, HTTP_CODE_METADATA, RENDER_METADATA_KEY, } from "./constants.js";
4
4
  import { packageResolver } from "./resolver-multer.js";
5
5
  import { Report, StatusCode } from "@expressots/core";
6
+ import { splitPathConstraints, createPathConstraintMiddleware } from "./path-pattern-compat.js";
6
7
  // Explicit type annotation: without this, the inferred type pulls a
7
8
  // non-portable path from @expressots/core's internal decorator_utils,
8
9
  // which TS2742 rejects under NodeNext when emitting .d.ts files.
@@ -17,9 +18,19 @@ export function controller(path, ...middleware) {
17
18
  return (target) => {
18
19
  // Check for version metadata on the controller class
19
20
  const controllerVersion = Reflect.getOwnMetadata(METADATA_KEY.version, target);
21
+ // Translate any inline regex constraints in the controller-level
22
+ // prefix (`@controller("/users/:tenant(\\d+)")`) into a
23
+ // request-time validator. Keeps `@controller("/")` and the common
24
+ // case allocation-free.
25
+ const split = splitPathConstraints(path);
26
+ const constraintMiddleware = createPathConstraintMiddleware(split.constraints);
27
+ const effectivePath = split.path;
28
+ const effectiveMiddleware = constraintMiddleware
29
+ ? [constraintMiddleware, ...middleware]
30
+ : middleware;
20
31
  const currentMetadata = {
21
- middleware,
22
- path,
32
+ middleware: effectiveMiddleware,
33
+ path: effectivePath,
23
34
  target: target,
24
35
  version: controllerVersion,
25
36
  };
@@ -29,17 +40,18 @@ export function controller(path, ...middleware) {
29
40
  for (const key in pathMetadata) {
30
41
  if (statusCodeMetadata && statusCodeMetadata[key]) {
31
42
  const methodPath = pathMetadata[key]["path"];
32
- // Properly join controller and method paths
43
+ // Properly join controller and method paths. The controller
44
+ // path is the v8-cleaned `effectivePath` so the mapping key is
45
+ // consistent with what gets registered on Express.
33
46
  let realPath;
34
47
  if (methodPath === "/" || methodPath === "") {
35
- realPath = path;
48
+ realPath = effectivePath;
36
49
  }
37
- else if (path === "/" || path === "") {
50
+ else if (effectivePath === "/" || effectivePath === "") {
38
51
  realPath = methodPath.startsWith("/") ? methodPath : `/${methodPath}`;
39
52
  }
40
53
  else {
41
- // Normalize: remove trailing slash from controller, ensure method has leading slash
42
- const basePath = path.endsWith("/") ? path.slice(0, -1) : path;
54
+ const basePath = effectivePath.endsWith("/") ? effectivePath.slice(0, -1) : effectivePath;
43
55
  const subPath = methodPath.startsWith("/") ? methodPath : `/${methodPath}`;
44
56
  realPath = `${basePath}${subPath}`;
45
57
  }
@@ -204,11 +216,21 @@ function enhancedHttpMethod(method, path, ...middleware) {
204
216
  return (target, key) => {
205
217
  // Check for version metadata on the method
206
218
  const methodVersion = Reflect.getOwnMetadata(METADATA_KEY.version, target, key);
219
+ // Express 5 / path-to-regexp v8 dropped inline regex constraints
220
+ // (`:id(\\d+)`). Split them out into a v8-compatible path + a
221
+ // request-time validator middleware so existing routes — and our
222
+ // {@link Patterns} / {@link pattern} public API — keep working.
223
+ const split = splitPathConstraints(path);
224
+ const constraintMiddleware = createPathConstraintMiddleware(split.constraints);
225
+ const effectivePath = split.path;
226
+ const effectiveMiddleware = constraintMiddleware
227
+ ? [constraintMiddleware, ...middleware]
228
+ : middleware;
207
229
  const metadata = {
208
230
  key: String(key),
209
231
  method,
210
- middleware,
211
- path,
232
+ middleware: effectiveMiddleware,
233
+ path: effectivePath,
212
234
  target: target,
213
235
  version: methodVersion,
214
236
  };
@@ -216,14 +238,14 @@ function enhancedHttpMethod(method, path, ...middleware) {
216
238
  let pathMetadata = Reflect.getOwnMetadata(HTTP_CODE_METADATA.path, Reflect);
217
239
  if (pathMetadata) {
218
240
  pathMetadata[key] = {
219
- path,
241
+ path: effectivePath,
220
242
  method,
221
243
  };
222
244
  }
223
245
  else {
224
246
  pathMetadata = {};
225
247
  pathMetadata[key] = {
226
- path,
248
+ path: effectivePath,
227
249
  method,
228
250
  };
229
251
  }
@@ -259,11 +281,18 @@ export function Method(method, path, ...middleware) {
259
281
  return (target, key) => {
260
282
  // Check for version metadata on the method
261
283
  const methodVersion = Reflect.getOwnMetadata(METADATA_KEY.version, target, key);
284
+ // Same path-to-regexp v8 compatibility shim as `enhancedHttpMethod`.
285
+ const split = splitPathConstraints(path);
286
+ const constraintMiddleware = createPathConstraintMiddleware(split.constraints);
287
+ const effectivePath = split.path;
288
+ const effectiveMiddleware = constraintMiddleware
289
+ ? [constraintMiddleware, ...middleware]
290
+ : middleware;
262
291
  const metadata = {
263
292
  key: String(key),
264
293
  method,
265
- middleware,
266
- path,
294
+ middleware: effectiveMiddleware,
295
+ path: effectivePath,
267
296
  target: target,
268
297
  version: methodVersion,
269
298
  };
@@ -271,14 +300,14 @@ export function Method(method, path, ...middleware) {
271
300
  let pathMetadata = Reflect.getOwnMetadata(HTTP_CODE_METADATA.path, Reflect);
272
301
  if (pathMetadata) {
273
302
  pathMetadata[key] = {
274
- path,
303
+ path: effectivePath,
275
304
  method,
276
305
  };
277
306
  }
278
307
  else {
279
308
  pathMetadata = {};
280
309
  pathMetadata[key] = {
281
- path,
310
+ path: effectivePath,
282
311
  method,
283
312
  };
284
313
  }
@@ -130,7 +130,12 @@ export class InversifyExpressServer {
130
130
  // request and attach it via a WeakMap (see ./http-context-store).
131
131
  // This is cheaper than the previous `Reflect.defineMetadata` call
132
132
  // because it bypasses reflect-metadata's string-keyed map.
133
- this._app.all("*", (req, res, next) => {
133
+ //
134
+ // We use `app.use(handler)` (no path arg) which runs for every request
135
+ // regardless of method or path — the same behavior as the previous
136
+ // `app.all("*", ...)` registration but without a path-to-regexp pattern,
137
+ // so it is forward-compatible with Express 5 / path-to-regexp v8.
138
+ this._app.use((req, res, next) => {
134
139
  this._createHttpContext(req, res, next)
135
140
  .then((httpContext) => {
136
141
  setHttpContext(req, httpContext);
@@ -769,13 +774,20 @@ export class InversifyExpressServer {
769
774
  }
770
775
  // Execute guard middleware if guards exist
771
776
  if (guardMiddleware && allGuards.length > 0) {
772
- return guardMiddleware(req, res, async (err) => {
777
+ // Express 5 typings expect the handler to return `void`. We cannot
778
+ // `return` the call because the express handler signature is
779
+ // `(req, res, next) => void | Promise<void>` in v5; chaining the
780
+ // expression off `return` makes TS infer `unknown`. Invoke it
781
+ // for-effect and return.
782
+ guardMiddleware(req, res, async (err) => {
773
783
  if (err) {
774
- return next(err);
784
+ next(err);
785
+ return;
775
786
  }
776
787
  // Guards passed, continue to route handler
777
788
  await this.executeRouteHandler(req, res, next, controllerName, key, parameterMetadata, controllerConstructor);
778
789
  });
790
+ return;
779
791
  }
780
792
  // No guards, execute route handler directly
781
793
  await this.executeRouteHandler(req, res, next, controllerName, key, parameterMetadata, controllerConstructor);
@@ -876,7 +888,11 @@ export class InversifyExpressServer {
876
888
  // Extract resource info from path for helpful error message
877
889
  const pathParts = req.path.split("/").filter(Boolean);
878
890
  const resource = pathParts[pathParts.length - 2] || "Resource";
879
- const id = req.params?.id || pathParts[pathParts.length - 1];
891
+ // Express 5's path-to-regexp v8 widens param values to
892
+ // `string | string[]` because array-style params are now first
893
+ // class. Coerce to string for the NotFoundError signature.
894
+ const rawId = req.params?.id ?? pathParts[pathParts.length - 1];
895
+ const id = Array.isArray(rawId) ? rawId.join("/") : rawId;
880
896
  throw new NotFoundError(resource, id);
881
897
  }
882
898
  // For other methods (DELETE, PUT, PATCH), undefined is valid (204 No Content)
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Split `:name(regex)` segments out of an Express-style route path.
3
+ *
4
+ * The walker honours balanced parens inside the regex (e.g.
5
+ * `(\\d{4})` or `((a|b)+)`), which is more forgiving than a naive
6
+ * single-pass regex match would be. Returns the original path and an
7
+ * empty constraints list when no inline patterns are found, so this is
8
+ * a no-op for the common case.
9
+ */
10
+ export function splitPathConstraints(path) {
11
+ // Defensive: hand a non-string straight back. The decorators occasionally
12
+ // see `undefined` or `null` paths in older test fixtures and we don't
13
+ // want to crash decorator-time evaluation just because of input shape.
14
+ if (typeof path !== "string") {
15
+ return { path: path, constraints: [] };
16
+ }
17
+ const constraints = [];
18
+ let out = "";
19
+ let i = 0;
20
+ while (i < path.length) {
21
+ const ch = path[i];
22
+ if (ch !== ":") {
23
+ out += ch;
24
+ i++;
25
+ continue;
26
+ }
27
+ // Found a `:` — scan the following identifier (matches `[A-Za-z0-9_]+`,
28
+ // same character class path-to-regexp v8 accepts).
29
+ const start = i;
30
+ i++;
31
+ let nameEnd = i;
32
+ while (nameEnd < path.length && /[A-Za-z0-9_]/.test(path[nameEnd])) {
33
+ nameEnd++;
34
+ }
35
+ if (nameEnd === i) {
36
+ // `:` not followed by an identifier — leave it to path-to-regexp.
37
+ out += ":";
38
+ continue;
39
+ }
40
+ const paramName = path.slice(i, nameEnd);
41
+ out += `:${paramName}`;
42
+ i = nameEnd;
43
+ // Optional inline `(...regex...)` immediately after the name.
44
+ if (path[i] !== "(") {
45
+ continue;
46
+ }
47
+ let depth = 1;
48
+ let j = i + 1;
49
+ while (j < path.length && depth > 0) {
50
+ const c = path[j];
51
+ if (c === "\\" && j + 1 < path.length) {
52
+ j += 2;
53
+ continue;
54
+ }
55
+ if (c === "(")
56
+ depth++;
57
+ else if (c === ")")
58
+ depth--;
59
+ j++;
60
+ }
61
+ if (depth !== 0) {
62
+ // Unbalanced — leave the original path intact and let path-to-regexp
63
+ // raise its own (better-located) error.
64
+ out += path.slice(i);
65
+ i = path.length;
66
+ break;
67
+ }
68
+ const rawPattern = path.slice(i + 1, j - 1);
69
+ try {
70
+ constraints.push({
71
+ paramName,
72
+ regex: new RegExp(`^(?:${rawPattern})$`),
73
+ rawPattern,
74
+ });
75
+ }
76
+ catch {
77
+ // The regex inside the parens is malformed. We don't want a
78
+ // decorator-time crash — fall back to dropping the constraint so
79
+ // the route at least registers; runtime validation simply won't
80
+ // run for this param.
81
+ }
82
+ // Consume the `(...)` segment without re-emitting it into `out`,
83
+ // since path-to-regexp v8 doesn't accept the syntax.
84
+ i = j;
85
+ // Quietly consume a trailing redundant `?` (older code wrote
86
+ // `:foo(\\d+)?`); v8 spells optional segments with `{...}` braces,
87
+ // and "drop the constraint, keep the param" is the sane fallback.
88
+ if (path[i] === "?") {
89
+ i++;
90
+ }
91
+ // Path-to-regexp v6 supported quantifier suffixes (`+`, `*`); v8
92
+ // dropped them. We strip them silently for the same reason.
93
+ while (path[i] === "+" || path[i] === "*") {
94
+ i++;
95
+ }
96
+ void start;
97
+ }
98
+ return { path: out, constraints };
99
+ }
100
+ /**
101
+ * Build a middleware that enforces the given param-level regex
102
+ * constraints on `req.params`. Returns `null` when the list is empty
103
+ * (so callers can avoid wiring an unnecessary middleware).
104
+ *
105
+ * When a constraint fails, the middleware delegates to `next()` without
106
+ * a value; the framework's NotFound handler then converts that into a
107
+ * 404 — same observable behaviour as Express 4's "no route matched".
108
+ */
109
+ export function createPathConstraintMiddleware(constraints) {
110
+ if (constraints.length === 0)
111
+ return null;
112
+ return (req, res, next) => {
113
+ for (const c of constraints) {
114
+ const value = req.params?.[c.paramName];
115
+ // Express 5 sometimes hands back `string[]` for splat params, but
116
+ // inline-regex params are always scalar strings; coerce defensively.
117
+ const scalar = Array.isArray(value) ? value.join("/") : value;
118
+ if (typeof scalar !== "string" || !c.regex.test(scalar)) {
119
+ // Skip to the next handler so the framework's 404 path runs.
120
+ return next("route");
121
+ }
122
+ }
123
+ next();
124
+ };
125
+ }
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Route parameter patterns for common use cases.
3
- * These are Express regex patterns that can be used in route paths.
3
+ *
4
+ * Express 5 / path-to-regexp v8 dropped the inline-regex form
5
+ * (`:id(\\d+)`), so the framework no longer hands these patterns to
6
+ * the underlying matcher verbatim. Instead, the HTTP-method decorators
7
+ * (`@Get`, `@Post`, …) parse the constraint out of the path at decorator
8
+ * time, register the route under a plain `:id` placeholder, and inject
9
+ * a small validator middleware that 404s when the captured value
10
+ * doesn't match. The user-facing semantics are unchanged: a path that
11
+ * uses `Patterns.NUMERIC_ID` still rejects `/users/abc` and only
12
+ * dispatches the handler for matches like `/users/123`.
4
13
  *
5
14
  * @example
6
15
  * ```typescript
@@ -8,12 +17,12 @@
8
17
  *
9
18
  * @Get(`/users/${pattern("id", Patterns.NUMERIC_ID)}`)
10
19
  * getUserById(@param("id") id: number) {
11
- * // Only matches numeric IDs like /users/123
20
+ * // Only dispatches for numeric IDs like /users/123
12
21
  * }
13
22
  *
14
23
  * @Get(`/documents/${pattern("uuid", Patterns.UUID)}`)
15
24
  * getDocument(@param("uuid") uuid: string) {
16
- * // Only matches valid UUIDs
25
+ * // Only dispatches for valid UUIDs
17
26
  * }
18
27
  * ```
19
28
  *
@@ -1,4 +1,4 @@
1
- import { Console, Logger, Middleware } from "@expressots/core";
1
+ import { Logger, Middleware } from "@expressots/core";
2
2
  import express from "express";
3
3
  import { IOC } from "./application-express-micro-container.js";
4
4
  import { Route } from "./application-express-micro-route.js";
@@ -17,7 +17,6 @@ class AppExpressMicro {
17
17
  * @private
18
18
  */
19
19
  handleExit() {
20
- this.logger.info("Server shutting down.", "MicroAPI");
21
20
  process.exit(0);
22
21
  }
23
22
  /**
@@ -115,7 +114,6 @@ class AppExpressMicro {
115
114
  */
116
115
  async listen(port, appInfo) {
117
116
  const logger = new Logger();
118
- const console = new Console();
119
117
  const normalizedPort = typeof port === "string" ? parseInt(port, 10) : port;
120
118
  this.configureMiddleware();
121
119
  this.routeManager.applyRoutes();
@@ -123,7 +121,7 @@ class AppExpressMicro {
123
121
  this.app.use(this.Middleware.getErrorHandler());
124
122
  }
125
123
  return new Promise((resolve, reject) => {
126
- this.httpServer = this.app.listen(normalizedPort, async () => {
124
+ this.httpServer = this.app.listen(normalizedPort, () => {
127
125
  const address = this.httpServer.address();
128
126
  if (typeof address === "object" && address?.port) {
129
127
  this.port = address.port;
@@ -131,18 +129,16 @@ class AppExpressMicro {
131
129
  else {
132
130
  this.port = normalizedPort;
133
131
  }
134
- // Display startup message using Console class
135
- await console.messageServer(this.port, this.environment, {
136
- appName: appInfo?.appName || "ExpressoTS Micro",
137
- appVersion: appInfo?.appVersion || "1.0.0",
138
- });
132
+ const name = appInfo?.appName || "ExpressoTS Micro";
133
+ const version = appInfo?.appVersion || "1.0.0";
134
+ logger.info(`${name} version ${version} is running on port ${this.port} - Environment: ${this.environment}`, "micro");
139
135
  ["SIGTERM", "SIGHUP", "SIGBREAK", "SIGQUIT", "SIGINT"].forEach((signal) => {
140
136
  process.on(signal, this.handleExit.bind(this));
141
137
  });
142
138
  resolve();
143
139
  });
144
140
  this.httpServer.on("error", (error) => {
145
- logger.error(`Error starting server: ${error.message}`, "MicroAPI");
141
+ logger.error(`Error starting server: ${error.message}`, "micro");
146
142
  reject(error);
147
143
  });
148
144
  });
@@ -1,6 +1,7 @@
1
- import { Console, Logger, getRouteRegistry, getErrorHints, getDefaultSuggestionsConfig, formatSuggestions, } from "@expressots/core";
1
+ import { Logger, getRouteRegistry, getErrorHints, getDefaultSuggestionsConfig, formatSuggestions, } from "@expressots/core";
2
2
  import express from "express";
3
3
  import { AppExpress } from "../application-express.js";
4
+ import { initializeStudio, stopStudio, isStudioEnabled as checkStudioEnabled, getStudioAgent, reportStudioRuntimeInfo, rescanStudioRoutes, } from "../studio/index.js";
4
5
  /**
5
6
  * Create a new micro API instance
6
7
  *
@@ -21,10 +22,21 @@ export function micro(config) {
21
22
  AppExpress.disableBuffering();
22
23
  const app = express();
23
24
  const logger = new Logger();
24
- const console = new Console();
25
25
  const globalPrefix = config?.globalPrefix?.replace(/\/$/, "") || "";
26
26
  let httpServer;
27
27
  let errorHandler = null;
28
+ let studioConfig = config?.studio ?? {};
29
+ // Lazy proxy for the Studio Agent middleware. Installed at position 0 so
30
+ // it always runs before route handlers — even though initializeStudio()
31
+ // is only called in listen(). Once the agent starts, it sets the real
32
+ // handler; until then the proxy is a no-op pass-through.
33
+ let studioMiddlewareDelegate = null;
34
+ app.use((req, res, next) => {
35
+ if (studioMiddlewareDelegate) {
36
+ return studioMiddlewareDelegate(req, res, next);
37
+ }
38
+ next();
39
+ });
28
40
  // Auto-enable JSON parsing by default
29
41
  if (config?.autoParseJson !== false) {
30
42
  app.use(express.json());
@@ -87,30 +99,8 @@ export function micro(config) {
87
99
  if (res.headersSent) {
88
100
  return next();
89
101
  }
90
- const suggestionsConfig = getDefaultSuggestionsConfig();
91
- if (!suggestionsConfig.enabled) {
92
- return next();
93
- }
94
102
  const requestedPath = req.originalUrl || req.url;
95
103
  const requestedMethod = req.method;
96
- const hints = getErrorHints(new Error(`Route '${requestedMethod} ${requestedPath}' not found`), {
97
- path: requestedPath,
98
- method: requestedMethod,
99
- statusCode: 404,
100
- }, suggestionsConfig);
101
- if (hints.length > 0) {
102
- try {
103
- const formatted = formatSuggestions(hints);
104
- if (formatted) {
105
- logger.warn(`Route not found: ${requestedMethod} ${requestedPath}${formatted}`, "router-404");
106
- }
107
- }
108
- catch {
109
- // best-effort logging
110
- }
111
- }
112
- const routeSuggestion = hints.find((hint) => hint.type === "route");
113
- const actionHint = hints.find((hint) => hint.type === "hint");
114
104
  const body = {
115
105
  type: "https://expressots.dev/errors/not-found",
116
106
  title: "Route Not Found",
@@ -119,26 +109,58 @@ export function micro(config) {
119
109
  instance: requestedPath,
120
110
  timestamp: new Date().toISOString(),
121
111
  };
122
- if (routeSuggestion?.routes && routeSuggestion.routes.length > 0) {
123
- body.suggestions = routeSuggestion.routes.map((suggestion) => ({
124
- method: suggestion.route.method,
125
- path: suggestion.route.fullPath || suggestion.route.path,
126
- similarity: Math.round(suggestion.similarity * 100),
127
- reason: suggestion.reason,
128
- }));
129
- }
130
- else if (actionHint?.actions && actionHint.actions.length > 0) {
131
- body.actions = actionHint.actions;
112
+ const suggestionsConfig = getDefaultSuggestionsConfig();
113
+ if (suggestionsConfig.enabled) {
114
+ const hints = getErrorHints(new Error(`Route '${requestedMethod} ${requestedPath}' not found`), {
115
+ path: requestedPath,
116
+ method: requestedMethod,
117
+ statusCode: 404,
118
+ }, suggestionsConfig);
119
+ if (hints.length > 0) {
120
+ try {
121
+ const formatted = formatSuggestions(hints);
122
+ if (formatted) {
123
+ logger.warn(`Route not found: ${requestedMethod} ${requestedPath}${formatted}`, "router-404");
124
+ }
125
+ }
126
+ catch {
127
+ // best-effort logging
128
+ }
129
+ const routeSuggestion = hints.find((hint) => hint.type === "route");
130
+ const actionHint = hints.find((hint) => hint.type === "hint");
131
+ if (routeSuggestion?.routes && routeSuggestion.routes.length > 0) {
132
+ body.suggestions = routeSuggestion.routes.map((suggestion) => ({
133
+ method: suggestion.route.method,
134
+ path: suggestion.route.fullPath || suggestion.route.path,
135
+ similarity: Math.round(suggestion.similarity * 100),
136
+ reason: suggestion.reason,
137
+ }));
138
+ }
139
+ else if (actionHint?.actions && actionHint.actions.length > 0) {
140
+ body.actions = actionHint.actions;
141
+ }
142
+ }
132
143
  }
133
144
  res.status(404).type("application/json").send(JSON.stringify(body));
134
145
  });
135
146
  };
136
147
  /**
137
- * Handle server shutdown gracefully
148
+ * Handle server shutdown. In development, exit immediately for fast
149
+ * hot-reload. In production, drain connections before exiting.
138
150
  */
139
151
  const handleExit = () => {
140
- logger.info("Server shutting down", "micro");
141
- process.exit(0);
152
+ const environment = config?.environment || process.env.NODE_ENV || "development";
153
+ void stopStudio();
154
+ if (environment === "development") {
155
+ process.exit(0);
156
+ }
157
+ if (httpServer) {
158
+ httpServer.close(() => process.exit(0));
159
+ setTimeout(() => process.exit(0), 5000).unref();
160
+ }
161
+ else {
162
+ process.exit(0);
163
+ }
142
164
  };
143
165
  const microApp = {
144
166
  get: (path, ...handlers) => route("get", path, ...handlers),
@@ -161,6 +183,24 @@ export function micro(config) {
161
183
  },
162
184
  async listen(port, appInfo) {
163
185
  const normalizedPort = typeof port === "string" ? parseInt(port, 10) : port;
186
+ const listenStartedAt = Date.now();
187
+ // Initialize Studio Agent. The agent's middleware is registered via
188
+ // app.use() inside initializeStudio, but that lands AFTER the user's
189
+ // routes in the Express stack. Our lazy proxy (installed at position 0
190
+ // during micro() creation) ensures CORS headers are injected before
191
+ // any route handler sends a response.
192
+ const studioStarted = await initializeStudio(app, {
193
+ ...studioConfig,
194
+ serviceName: studioConfig.serviceName ?? "expressots-micro",
195
+ appPort: normalizedPort,
196
+ globalPrefix: globalPrefix || undefined,
197
+ });
198
+ if (studioStarted) {
199
+ const agent = getStudioAgent();
200
+ if (agent) {
201
+ studioMiddlewareDelegate = agent.createMiddleware();
202
+ }
203
+ }
164
204
  // Install the 404 fallback before the user error handler so unmatched
165
205
  // routes get suggestions instead of falling through to the default
166
206
  // Express HTML or - worse - to a regular middleware that arity-confused
@@ -183,11 +223,19 @@ export function micro(config) {
183
223
  const address = httpServer.address();
184
224
  const actualPort = typeof address === "object" && address?.port ? address.port : normalizedPort;
185
225
  if (config?.showBanner !== false) {
186
- await console.messageServer(actualPort, "development", {
187
- appName: appInfo?.appName || "ExpressoTS Micro",
188
- appVersion: appInfo?.appVersion || "1.0.0",
189
- });
226
+ const name = appInfo?.appName || "ExpressoTS Micro";
227
+ const version = appInfo?.appVersion || "1.0.0";
228
+ const environment = config?.environment || process.env.NODE_ENV || "development";
229
+ logger.info(`${name} version ${version} is running on port ${actualPort} - Environment: ${environment}`, "micro");
190
230
  }
231
+ // Push runtime info to Studio Agent now that we know the actual port
232
+ reportStudioRuntimeInfo({
233
+ appPort: actualPort,
234
+ globalPrefix: globalPrefix || undefined,
235
+ startupMs: Date.now() - listenStartedAt,
236
+ });
237
+ // Re-scan routes so Studio sees the fully-populated Express router
238
+ void rescanStudioRoutes();
191
239
  // Handle graceful shutdown
192
240
  ["SIGTERM", "SIGHUP", "SIGBREAK", "SIGQUIT", "SIGINT"].forEach((signal) => {
193
241
  process.on(signal, handleExit);
@@ -201,11 +249,18 @@ export function micro(config) {
201
249
  });
202
250
  },
203
251
  getHttpServer() {
204
- return httpServer;
252
+ return httpServer ?? null;
205
253
  },
206
254
  getApp() {
207
255
  return app;
208
256
  },
257
+ setStudio(cfg) {
258
+ studioConfig = cfg;
259
+ return microApp;
260
+ },
261
+ isStudioEnabled() {
262
+ return checkStudioEnabled();
263
+ },
209
264
  };
210
265
  return microApp;
211
266
  }
@@ -1 +1 @@
1
- export { initializeStudio, stopStudio, isStudioEnabled, getStudioAgent, reportStudioRuntimeInfo, } from "./studio-integration.js";
1
+ export { initializeStudio, stopStudio, isStudioEnabled, getStudioAgent, reportStudioRuntimeInfo, rescanStudioRoutes, } from "./studio-integration.js";