@nextrush/core 3.0.7 → 4.0.0-beta.1

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/dist/index.js CHANGED
@@ -1,7 +1,18 @@
1
- export { BadRequestError, ForbiddenError, HttpError, InternalServerError, NextRushError, NotFoundError, UnauthorizedError, createError as createHttpError } from '@nextrush/errors';
1
+ import { SECURITY_AUDIT } from '@nextrush/types';
2
2
  export { ContentType, HttpStatus } from '@nextrush/types';
3
+ import { getErrorStatus, NextRushError, getHttpStatusMessage } from '@nextrush/errors';
4
+ export { BadRequestError, ForbiddenError, HttpError, InternalServerError, NextRushError, NotFoundError, UnauthorizedError } from '@nextrush/errors';
5
+
6
+ // src/application.ts
3
7
 
4
8
  // src/middleware.ts
9
+ var MULTIPLE_NEXT_MESSAGE = "next() called multiple times";
10
+ var RESOLVED = Promise.resolve();
11
+ function emitDoubleResponseWarning(index) {
12
+ console.warn(
13
+ `[nextrush] Middleware at index ${String(index)} called next() after the response was already committed. Downstream middleware may attempt to write to an already-finished response. Either await next() to delegate downstream, or send a response without calling next().`
14
+ );
15
+ }
5
16
  function compose(middleware, options) {
6
17
  if (!Array.isArray(middleware)) {
7
18
  throw new TypeError("Middleware stack must be an array");
@@ -11,19 +22,46 @@ function compose(middleware, options) {
11
22
  throw new TypeError("Middleware must be a function");
12
23
  }
13
24
  }
14
- const warnDoubleResponse = options?.warnDoubleResponse ?? process.env.NODE_ENV !== "production";
25
+ const warnDoubleResponse = options?.warnDoubleResponse ?? false;
15
26
  const stack = [...middleware];
16
27
  const len = stack.length;
17
28
  if (len === 0) {
18
29
  return function composedMiddleware(_ctx, next) {
19
- return next ? next() : Promise.resolve();
30
+ return next ? next() : RESOLVED;
20
31
  };
21
32
  }
33
+ if (len === 1) {
34
+ const only = stack[0];
35
+ if (only) {
36
+ return function composedSingle(ctx, next) {
37
+ let called = false;
38
+ const nextFn = () => {
39
+ if (called) {
40
+ return Promise.reject(new Error(MULTIPLE_NEXT_MESSAGE));
41
+ }
42
+ called = true;
43
+ if (warnDoubleResponse && ctx.responded) {
44
+ emitDoubleResponseWarning(0);
45
+ }
46
+ return next ? next() : RESOLVED;
47
+ };
48
+ if (ctx.setNext) {
49
+ ctx.setNext(nextFn);
50
+ }
51
+ try {
52
+ const result = only(ctx, nextFn);
53
+ return result === void 0 ? RESOLVED : Promise.resolve(result);
54
+ } catch (err) {
55
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
56
+ }
57
+ };
58
+ }
59
+ }
22
60
  return function composedMiddleware(ctx, next) {
23
61
  let index = -1;
24
62
  function dispatch(i) {
25
63
  if (i <= index) {
26
- return Promise.reject(new Error("next() called multiple times"));
64
+ return Promise.reject(new Error(MULTIPLE_NEXT_MESSAGE));
27
65
  }
28
66
  index = i;
29
67
  let fn;
@@ -33,13 +71,11 @@ function compose(middleware, options) {
33
71
  fn = next;
34
72
  }
35
73
  if (!fn) {
36
- return Promise.resolve();
74
+ return RESOLVED;
37
75
  }
38
76
  const nextFn = () => {
39
77
  if (warnDoubleResponse && ctx.responded) {
40
- console.warn(
41
- `[nextrush] Middleware at index ${String(i)} called next() after the response was already committed. Downstream middleware may attempt to write to an already-finished response. Either await next() to delegate downstream, or send a response without calling next().`
42
- );
78
+ emitDoubleResponseWarning(i);
43
79
  }
44
80
  return dispatch(i + 1);
45
81
  };
@@ -47,7 +83,8 @@ function compose(middleware, options) {
47
83
  ctx.setNext(nextFn);
48
84
  }
49
85
  try {
50
- return Promise.resolve(fn(ctx, nextFn));
86
+ const result = fn(ctx, nextFn);
87
+ return result === void 0 ? RESOLVED : Promise.resolve(result);
51
88
  } catch (err) {
52
89
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
53
90
  }
@@ -67,10 +104,100 @@ function flattenMiddleware(arr) {
67
104
  }
68
105
  return flattened;
69
106
  }
107
+ function writeDefaultErrorResponse(error, ctx, opts) {
108
+ const status = getErrorStatus(error);
109
+ if (status >= 500 || !opts.isProduction) {
110
+ opts.logger.error("Request error:", error);
111
+ }
112
+ ctx.status = status;
113
+ if (error instanceof NextRushError) {
114
+ ctx.json(error.toJSON());
115
+ return;
116
+ }
117
+ const expose = status < 500;
118
+ ctx.json({
119
+ error: expose ? error.name : getHttpStatusMessage(status),
120
+ message: expose ? error.message : "Internal Server Error",
121
+ code: "INTERNAL_ERROR",
122
+ status
123
+ });
124
+ }
70
125
 
71
- // src/application.ts
126
+ // src/route-mount.ts
127
+ var SLASH_CHAR_CODE = 47;
72
128
  var ORIGINAL_PATH = /* @__PURE__ */ Symbol.for("nextrush.originalPath");
73
129
  var ROUTE_PREFIX = /* @__PURE__ */ Symbol.for("nextrush.routePrefix");
130
+ function resolveMountedPath(currentPath, normalizedPrefix, matchesMountPrefix) {
131
+ if (matchesMountPrefix) {
132
+ return matchesMountPrefix(currentPath, normalizedPrefix);
133
+ }
134
+ const prefixLen = normalizedPrefix.length;
135
+ if (!currentPath.startsWith(normalizedPrefix)) return void 0;
136
+ const hasCharAfterPrefix = prefixLen < currentPath.length;
137
+ if (hasCharAfterPrefix && currentPath.charCodeAt(prefixLen) !== SLASH_CHAR_CODE) return void 0;
138
+ return currentPath.slice(prefixLen) || "/";
139
+ }
140
+ function createPrefixMount(normalizedPrefix, routerMiddleware, matchesMountPrefix) {
141
+ return async (ctx, next) => {
142
+ const currentPath = ctx.path;
143
+ const adjustedPath = resolveMountedPath(currentPath, normalizedPrefix, matchesMountPrefix);
144
+ if (adjustedPath === void 0) {
145
+ return next();
146
+ }
147
+ ctx.path = adjustedPath;
148
+ ctx.state[ORIGINAL_PATH] = currentPath;
149
+ ctx.state[ROUTE_PREFIX] = normalizedPrefix;
150
+ try {
151
+ await routerMiddleware(ctx, async () => {
152
+ ctx.path = currentPath;
153
+ await next();
154
+ ctx.path = adjustedPath;
155
+ });
156
+ } finally {
157
+ ctx.path = currentPath;
158
+ ctx.state[ORIGINAL_PATH] = void 0;
159
+ ctx.state[ROUTE_PREFIX] = void 0;
160
+ }
161
+ };
162
+ }
163
+
164
+ // src/application.ts
165
+ function validateProxyTrust(proxy) {
166
+ if (proxy === true) {
167
+ throw new Error(
168
+ "createApp({ proxy: true }) is no longer valid \u2014 trusting every proxy header lets a remote client forge its own IP. Use `proxy: <hopCount>` (e.g. `proxy: 1` for one reverse proxy) or `proxy: ['<cidr>', ...]` (a list of trusted proxy IPs/ranges) instead."
169
+ );
170
+ }
171
+ if (proxy === 0) {
172
+ throw new Error(
173
+ "createApp({ proxy: 0 }) trusts zero hops, which is the same as `proxy: false` but never falls back cleanly \u2014 use `proxy: false` to disable proxy trust, or a positive hop count to trust that many proxies."
174
+ );
175
+ }
176
+ return proxy;
177
+ }
178
+ var TeardownTimeoutError = class extends Error {
179
+ constructor(unitName, timeoutMs) {
180
+ super(`Teardown unit "${unitName}" did not complete within ${String(timeoutMs)}ms`);
181
+ this.name = "TeardownTimeoutError";
182
+ }
183
+ };
184
+ async function runTeardownUnit(unit, timeoutMs) {
185
+ const settle = Promise.resolve().then(() => unit.run()).then(() => null).catch((err) => err instanceof Error ? err : new Error(String(err)));
186
+ if (timeoutMs === void 0) {
187
+ return settle;
188
+ }
189
+ let timer;
190
+ const timedOut = new Promise((resolve) => {
191
+ timer = setTimeout(() => {
192
+ resolve(new TeardownTimeoutError(unit.name, timeoutMs));
193
+ }, timeoutMs);
194
+ });
195
+ try {
196
+ return await Promise.race([settle, timedOut]);
197
+ } finally {
198
+ clearTimeout(timer);
199
+ }
200
+ }
74
201
  var NOOP_LOGGER = {
75
202
  error(..._args) {
76
203
  },
@@ -82,130 +209,126 @@ var NOOP_LOGGER = {
82
209
  }
83
210
  };
84
211
  var Application = class {
85
- /**
86
- * Middleware stack
87
- */
88
212
  middlewareStack = [];
89
213
  /**
90
- * Installed plugins
214
+ * Boot-time security audit checks contributed by tagged middleware
215
+ * (`security-boundaries` capability, task 8.1). Collected as `use()`
216
+ * registers each middleware; run once by `_boot()`, production-only.
91
217
  */
92
- plugins = /* @__PURE__ */ new Map();
218
+ securityAudits = [];
219
+ /** Registered extensions, in registration order (setup runs at ready()) */
220
+ extensions = [];
221
+ /** Registered extension names — enforces uniqueness */
222
+ extensionNames = /* @__PURE__ */ new Set();
93
223
  /**
94
- * Custom error handler
224
+ * Combined teardown-unit registration order: extension `destroy()`s and
225
+ * {@link onClose} hooks interleaved in the exact order they were registered,
226
+ * so `_shutdown()` can run the whole set in one reverse-of-registration pass
227
+ * (RFC-022 §7.3 — "teardown becomes a flat list of independently-isolated,
228
+ * budget-raced units"). Populated lazily by {@link extend} (for extensions
229
+ * declaring `destroy()`) and {@link onClose}.
95
230
  */
231
+ teardownUnits = [];
232
+ /** Whether {@link close} has started — {@link onClose} is a pre-shutdown-only registration point (RFC-022 §8.6). */
233
+ _closeStarted = false;
234
+ /** Names decorated onto the app — enforces collision detection */
235
+ decorations = /* @__PURE__ */ new Set();
96
236
  _errorHandler = null;
97
- /**
98
- * Pluggable logger
99
- */
100
237
  logger;
101
- /**
102
- * Application options
103
- */
104
238
  options;
105
- /**
106
- * Whether the app is running
107
- */
239
+ /** Whether the app is running (server listening) */
108
240
  _isRunning = false;
241
+ /** Whether the app has been booted (extensions set up, config frozen) */
242
+ _isReady = false;
243
+ /** Whether ready() mounted the app-owned router (so close() can undo it) */
244
+ _routerMounted = false;
245
+ /** In-flight boot promise — memoized so concurrent ready() calls share one boot (H-1) */
246
+ _readyPromise = null;
247
+ /** In-flight shutdown promise — memoized so concurrent close() calls share one (H-3) */
248
+ _closePromise = null;
249
+ /** The app-owned router (optional). Route methods delegate to it. */
250
+ router;
251
+ /** The app-owned DI container (optional). Exposed to extensions and registrars. */
252
+ container;
109
253
  constructor(options = {}) {
254
+ const proxy = validateProxyTrust(options.proxy ?? false);
110
255
  this.options = {
111
256
  env: options.env ?? "development",
112
- proxy: options.proxy ?? false
257
+ proxy
113
258
  };
114
259
  this.logger = options.logger ?? NOOP_LOGGER;
260
+ this.router = options.router;
261
+ this.container = options.container;
115
262
  }
116
- /**
117
- * Check if running in production
118
- */
263
+ /** Check if running in production */
119
264
  get isProduction() {
120
265
  return this.options.env === "production";
121
266
  }
122
- /**
123
- * Check if app is running
124
- */
267
+ /** Check if app is running */
125
268
  get isRunning() {
126
269
  return this._isRunning;
127
270
  }
128
271
  /**
129
- * Get middleware count
272
+ * Whether a graceful shutdown is currently in progress — `true` from the
273
+ * moment {@link close} begins tearing down until it completes (F-12:
274
+ * shutdown observability, RFC-022). A readiness check or operator tooling
275
+ * can read this to reflect an in-progress drain.
130
276
  */
277
+ get isDraining() {
278
+ return this._closeStarted;
279
+ }
280
+ /** Check if app has been booted via ready() */
281
+ get isReady() {
282
+ return this._isReady;
283
+ }
284
+ /** Get middleware count */
131
285
  get middlewareCount() {
132
286
  return this.middlewareStack.length;
133
287
  }
288
+ /** Get registered extension count */
289
+ get extensionCount() {
290
+ return this.extensions.length;
291
+ }
134
292
  /**
135
- * Throws if the app is already running.
136
- * Prevents configuration mutations after start().
293
+ * Throws if the app configuration is frozen (after ready() or start()).
294
+ * Prevents unsafe mutations once the app has booted or is serving traffic.
137
295
  */
138
- assertNotRunning(method) {
139
- if (this._isRunning) {
140
- throw new Error(`Cannot call ${method}() after the application has started`);
296
+ assertConfigurable(method) {
297
+ if (this._isReady || this._isRunning) {
298
+ throw new Error(
299
+ `Cannot call ${method}() after the app has booted (ready()) or started \u2014 configuration is frozen`
300
+ );
141
301
  }
142
302
  }
143
- // ===========================================================================
144
- // Middleware Registration
145
- // ===========================================================================
146
303
  /**
147
304
  * Register middleware function(s)
148
305
  *
149
- * @param middleware - Middleware function or array of middleware
306
+ * @param middleware - Middleware function(s)
150
307
  * @returns this for chaining
151
- *
152
- * @example
153
- * ```typescript
154
- * // Single middleware
155
- * app.use(async (ctx) => {
156
- * console.log(ctx.method, ctx.path);
157
- * await ctx.next();
158
- * });
159
- *
160
- * // Multiple middleware
161
- * app.use(cors(), helmet(), json());
162
- * ```
163
308
  */
164
309
  use(...middleware) {
165
- this.assertNotRunning("use");
310
+ this.assertConfigurable("use");
166
311
  for (const mw of middleware) {
167
312
  if (typeof mw !== "function") {
168
313
  throw new TypeError("Middleware must be a function");
169
314
  }
170
315
  this.middlewareStack.push(mw);
316
+ const audit = mw[SECURITY_AUDIT];
317
+ if (typeof audit === "function") {
318
+ this.securityAudits.push(audit);
319
+ }
171
320
  }
172
321
  return this;
173
322
  }
174
- // ===========================================================================
175
- // Router Mounting (Hono-style)
176
- // ===========================================================================
177
323
  /**
178
- * Mount a router at a path prefix
179
- *
180
- * This is the Hono-style API for mounting routers directly on the app.
181
- * The router's routes will only match requests that start with the given prefix.
324
+ * Mount a router at a path prefix.
182
325
  *
183
- * @param path - Path prefix for the router (e.g., '/api/users')
326
+ * @param path - Path prefix (e.g. '/api/users')
184
327
  * @param router - Router instance to mount
185
328
  * @returns this for chaining
186
- *
187
- * @example
188
- * ```typescript
189
- * // Create modular routers
190
- * const users = createRouter();
191
- * users.get('/', listUsers);
192
- * users.get('/:id', getUser);
193
- * users.post('/', createUser);
194
- *
195
- * const posts = createRouter();
196
- * posts.get('/', listPosts);
197
- * posts.get('/:id', getPost);
198
- *
199
- * // Mount directly on app - clean like Hono!
200
- * const app = createApp();
201
- * app.route('/api/users', users);
202
- * app.route('/api/posts', posts);
203
- *
204
- * listen(app, 3000);
205
- * ```
206
329
  */
207
330
  route(path, router) {
208
- this.assertNotRunning("route");
331
+ this.assertConfigurable("route");
209
332
  if (path === "/" || path === "") {
210
333
  this.middlewareStack.push(router.routes());
211
334
  return this;
@@ -215,229 +338,261 @@ var Application = class {
215
338
  if (!normalizedPrefix.startsWith("/")) {
216
339
  normalizedPrefix = "/" + normalizedPrefix;
217
340
  }
218
- const prefixLen = normalizedPrefix.length;
219
- const mountedMiddleware = async (ctx, next) => {
220
- const currentPath = ctx.path;
221
- if (!currentPath.startsWith(normalizedPrefix)) {
222
- return next();
223
- }
224
- const hasCharAfterPrefix = prefixLen < currentPath.length;
225
- if (hasCharAfterPrefix && currentPath.charCodeAt(prefixLen) !== 47) {
226
- return next();
227
- }
228
- const adjustedPath = currentPath.slice(prefixLen) || "/";
229
- ctx.path = adjustedPath;
230
- ctx.state[ORIGINAL_PATH] = currentPath;
231
- ctx.state[ROUTE_PREFIX] = normalizedPrefix;
232
- try {
233
- await routerMiddleware(ctx, async () => {
234
- ctx.path = currentPath;
235
- await next();
236
- ctx.path = adjustedPath;
237
- });
238
- } finally {
239
- ctx.path = currentPath;
240
- ctx.state[ORIGINAL_PATH] = void 0;
241
- ctx.state[ROUTE_PREFIX] = void 0;
242
- }
243
- };
244
- this.middlewareStack.push(mountedMiddleware);
341
+ this.middlewareStack.push(
342
+ createPrefixMount(normalizedPrefix, routerMiddleware, router.matchesMountPrefix?.bind(router))
343
+ );
344
+ return this;
345
+ }
346
+ requireRouter() {
347
+ if (!this.router) {
348
+ throw new Error(
349
+ "No router configured. Create your app with `createApp()` from `nextrush` (batteries-included), or pass one: `createApp({ router: createRouter() })`."
350
+ );
351
+ }
352
+ return this.router;
353
+ }
354
+ /** Register a GET route on the app-owned router. */
355
+ get(path, ...entries) {
356
+ this.assertConfigurable("get");
357
+ this.requireRouter().get(path, ...entries);
358
+ return this;
359
+ }
360
+ /** Register a POST route on the app-owned router. */
361
+ post(path, ...entries) {
362
+ this.assertConfigurable("post");
363
+ this.requireRouter().post(path, ...entries);
364
+ return this;
365
+ }
366
+ /** Register a PUT route on the app-owned router. */
367
+ put(path, ...entries) {
368
+ this.assertConfigurable("put");
369
+ this.requireRouter().put(path, ...entries);
370
+ return this;
371
+ }
372
+ /** Register a PATCH route on the app-owned router. */
373
+ patch(path, ...entries) {
374
+ this.assertConfigurable("patch");
375
+ this.requireRouter().patch(path, ...entries);
376
+ return this;
377
+ }
378
+ /** Register a DELETE route on the app-owned router. */
379
+ delete(path, ...entries) {
380
+ this.assertConfigurable("delete");
381
+ this.requireRouter().delete(path, ...entries);
382
+ return this;
383
+ }
384
+ /** Register a HEAD route on the app-owned router. */
385
+ head(path, ...entries) {
386
+ this.assertConfigurable("head");
387
+ this.requireRouter().head(path, ...entries);
245
388
  return this;
246
389
  }
247
- // ===========================================================================
248
- // Error Handling
249
- // ===========================================================================
250
390
  /**
251
- * Set the application error handler.
252
- *
253
- * Replaces any previously set handler. Only one error handler is active
254
- * at a time. For additive error handling, compose logic within a single
255
- * handler or use the `errorHandler()` middleware from `@nextrush/errors`.
256
- *
257
- * @param handler - Error handler function
258
- * @returns this for chaining
259
- *
260
- * @example
261
- * ```typescript
262
- * app.setErrorHandler((error, ctx) => {
263
- * console.error('Request failed:', error);
264
- *
265
- * if (error instanceof ValidationError) {
266
- * ctx.status = 400;
267
- * ctx.json({ error: error.message, details: error.details });
268
- * return;
269
- * }
391
+ * Register a route for all HTTP methods on the app-owned router.
270
392
  *
271
- * ctx.status = 500;
272
- * ctx.json({ error: 'Internal Server Error' });
273
- * });
274
- * ```
393
+ * @remarks
394
+ * There is intentionally no `app.options()` verb method it would collide
395
+ * with the `app.options` configuration property. Register OPTIONS routes via
396
+ * `app.all()`, the router directly, or let CORS middleware handle preflight.
275
397
  */
276
- setErrorHandler(handler) {
277
- this._errorHandler = handler;
398
+ all(path, ...entries) {
399
+ this.assertConfigurable("all");
400
+ this.requireRouter().all(path, ...entries);
278
401
  return this;
279
402
  }
280
403
  /**
281
- * Set custom error handler.
404
+ * Set the application error handler. Replaces any previously set handler.
282
405
  *
283
406
  * @param handler - Error handler function
284
407
  * @returns this for chaining
285
- *
286
- * @deprecated Use `setErrorHandler()` instead — `onError()` implies
287
- * event subscription (additive), but this is a setter (replaces).
288
408
  */
289
- onError(handler) {
290
- return this.setErrorHandler(handler);
409
+ setErrorHandler(handler) {
410
+ this.assertConfigurable("setErrorHandler");
411
+ this._errorHandler = handler;
412
+ return this;
291
413
  }
292
- // ===========================================================================
293
- // Plugin System
294
- // ===========================================================================
414
+ // Extension System. See docs/RFC/class-runtime/005-plugin-system.md
295
415
  /**
296
- * Install a plugin.
297
- *
298
- * Handles both sync and async `install()` methods automatically.
299
- * Returns `this` for sync plugins and `Promise<this>` for async ones.
416
+ * Register an extension. Queues it — `setup()` runs later, at `ready()`,
417
+ * in registration order. Synchronous and chainable.
300
418
  *
301
- * @param plugin - Plugin to install
302
- * @returns this (sync) or Promise<this> (async)
419
+ * @param extension - Extension to register. If it declares a decorated
420
+ * shape via `Extension<TDecorated>`, the return type carries that shape —
421
+ * `app.extend(events<MyEvents>()).events` is statically known, no
422
+ * `declare module` augmentation required.
423
+ * @returns this, intersected with the extension's declared decorated shape
303
424
  *
304
425
  * @example
305
426
  * ```typescript
306
- * // Sync plugin
307
- * app.plugin(loggerPlugin({ level: 'info' }));
308
- *
309
- * // Async plugin — just await it
310
- * await app.plugin(databasePlugin({ uri: '...' }));
427
+ * const extended = app.extend(events<MyEvents>());
428
+ * await app.ready(); // adapters call this automatically — runs setup()/decorate()
429
+ * extended.events.emit('user:created', { id: '1' }); // available only AFTER ready()
311
430
  * ```
312
431
  */
313
- plugin(plugin) {
314
- this.assertNotRunning("plugin");
315
- if (this.plugins.has(plugin.name)) {
316
- throw new Error(`Plugin "${plugin.name}" is already installed`);
432
+ extend(extension) {
433
+ this.assertConfigurable("extend");
434
+ if (!extension || typeof extension.setup !== "function") {
435
+ throw new TypeError("Extension must have a setup() method");
436
+ }
437
+ if (this.extensionNames.has(extension.name)) {
438
+ throw new Error(
439
+ `Extension "${extension.name}" is already registered. Use a different extension name, or check for a duplicate app.extend() call.`
440
+ );
317
441
  }
318
- const result = plugin.install(this);
319
- if (result instanceof Promise) {
320
- return result.then(() => {
321
- this.plugins.set(plugin.name, plugin);
322
- return this;
442
+ this.extensionNames.add(extension.name);
443
+ this.extensions.push(extension);
444
+ if (typeof extension.destroy === "function") {
445
+ const destroyableExtension = extension;
446
+ this.teardownUnits.push({
447
+ name: extension.name,
448
+ run: () => destroyableExtension.destroy()
323
449
  });
324
450
  }
325
- this.plugins.set(plugin.name, plugin);
326
451
  return this;
327
452
  }
328
453
  /**
329
- * Install a plugin asynchronously.
330
- *
331
- * @param plugin - Plugin to install
332
- * @returns Promise that resolves when plugin is installed
454
+ * Boot the application: run every registered extension's `setup()` once, in
455
+ * registration order, awaiting async setups. Idempotent — safe to call twice.
456
+ * Adapters call this automatically before `start()`.
333
457
  *
334
- * @deprecated Use `plugin()` instead it handles both sync and async
335
- * plugins automatically.
458
+ * After `ready()` resolves, the configuration is frozen (`use`/`route`/`extend`
459
+ * throw).
336
460
  *
337
- * @example
338
- * ```typescript
339
- * await app.plugin(new DatabasePlugin({ connectionString: '...' }));
340
- * ```
461
+ * @returns this
462
+ * @throws if an extension declares a `needs` dependency not yet registered
341
463
  */
342
- async pluginAsync(plugin) {
343
- const result = this.plugin(plugin);
344
- return result instanceof Promise ? result : Promise.resolve(result);
464
+ async ready() {
465
+ if (this._isReady) {
466
+ return this;
467
+ }
468
+ this._readyPromise ??= this._boot().catch((err) => {
469
+ this._readyPromise = null;
470
+ throw err;
471
+ });
472
+ return this._readyPromise;
345
473
  }
346
474
  /**
347
- * Get an installed plugin by name
348
- *
349
- * @param name - Plugin name
350
- * @returns Plugin instance or undefined
475
+ * Run the boot sequence once (extension setup + router mount). Guarded and
476
+ * memoized by {@link ready}.
351
477
  */
352
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
353
- getPlugin(name) {
354
- return this.plugins.get(name);
478
+ async _boot() {
479
+ const allNames = new Set(this.extensions.map((e) => e.name));
480
+ for (const extension of this.extensions) {
481
+ if (extension.needs) {
482
+ for (const dep of extension.needs) {
483
+ if (!allNames.has(dep)) {
484
+ throw new Error(
485
+ `Extension "${extension.name}" needs "${dep}", but no extension named "${dep}" was ever registered.`
486
+ );
487
+ }
488
+ }
489
+ }
490
+ }
491
+ const setupDone = /* @__PURE__ */ new Set();
492
+ for (const extension of this.extensions) {
493
+ if (extension.needs) {
494
+ for (const dep of extension.needs) {
495
+ if (!setupDone.has(dep)) {
496
+ throw new Error(
497
+ `Extension "${extension.name}" needs "${dep}", but "${dep}" was not registered before it. Register the "${dep}" extension before "${extension.name}".`
498
+ );
499
+ }
500
+ }
501
+ }
502
+ const ctx = {
503
+ app: this,
504
+ logger: this.logger,
505
+ container: this.container,
506
+ env: this.options.env ?? "development",
507
+ name: extension.name,
508
+ decorate: (name, value) => {
509
+ this.decorate(name, value);
510
+ }
511
+ };
512
+ await extension.setup(ctx);
513
+ setupDone.add(extension.name);
514
+ }
515
+ if (this.router) {
516
+ this.middlewareStack.push(this.router.routes());
517
+ this._routerMounted = true;
518
+ }
519
+ if (this.isProduction) {
520
+ this._runSecurityAudits();
521
+ }
522
+ this._isReady = true;
523
+ return this;
355
524
  }
356
525
  /**
357
- * Check if a plugin is installed
526
+ * Run every registered middleware's boot-time security audit check
527
+ * (`security-boundaries` capability, task 8.1/8.2). `throw`-level verdicts
528
+ * fail boot immediately; `warn`-level verdicts log once each via the app
529
+ * logger and never block boot. Production-only — `_boot()` calls this
530
+ * conditionally so a dev/test app never pays the cost or sees the noise.
358
531
  */
359
- hasPlugin(name) {
360
- return this.plugins.has(name);
532
+ _runSecurityAudits() {
533
+ for (const audit of this.securityAudits) {
534
+ const verdict = audit();
535
+ if (verdict.level === "ok") {
536
+ continue;
537
+ }
538
+ if (verdict.level === "throw") {
539
+ throw new Error(`[security-boundaries] ${verdict.message}`);
540
+ }
541
+ this.logger.warn(`[security-boundaries] ${verdict.message}`);
542
+ }
361
543
  }
362
- // ===========================================================================
363
- // Request Handler
364
- // ===========================================================================
365
544
  /**
366
- * Create the request handler callback
545
+ * Attach a value to the app under `name`. Extension-author primitive, invoked
546
+ * through {@link ExtensionContext.decorate} — there is intentionally no public
547
+ * `app.decorate()` (RFC §6.1). Throws on collision.
367
548
  *
368
- * This is the function that adapters call to handle each request.
369
- * It composes all middleware and returns a function that processes context.
549
+ * @internal
550
+ */
551
+ decorate(name, value) {
552
+ if (this.decorations.has(name)) {
553
+ throw new Error(
554
+ `Decoration "${name}" already exists on the application. Choose a different name, or check for a duplicate ctx.decorate() call across your extensions.`
555
+ );
556
+ }
557
+ if (name in this) {
558
+ throw new Error(
559
+ `Decoration "${name}" collides with an existing Application member \u2014 choose another name`
560
+ );
561
+ }
562
+ Object.defineProperty(this, name, {
563
+ value,
564
+ enumerable: true,
565
+ writable: false,
566
+ configurable: true
567
+ // allow close() to clean up
568
+ });
569
+ this.decorations.add(name);
570
+ }
571
+ /**
572
+ * Whether a decoration already occupies `name`.
573
+ */
574
+ hasDecorator(name) {
575
+ return this.decorations.has(name);
576
+ }
577
+ /**
578
+ * Create the request handler callback.
370
579
  *
371
- * **Important:** The middleware stack is snapshot at call time.
372
- * Middleware or plugins added after `callback()` will NOT take effect
373
- * on the returned handler. Call `callback()` again to get a
374
- * handler that includes subsequent registrations.
580
+ * The middleware stack is snapshot at call time — call `ready()` before
581
+ * `callback()` so extension-registered middleware is included. Adapters do
582
+ * this automatically.
375
583
  *
376
584
  * @returns Request handler function
377
585
  */
378
586
  callback() {
379
- const fn = compose(this.middlewareStack);
380
- const hookPlugins = Array.from(this.plugins.values()).filter((p) => {
381
- const hasHook = "onRequest" in p || "onResponse" in p || "onError" in p || "extendContext" in p;
382
- if (!hasHook) return false;
383
- if ("onRequest" in p && typeof p.onRequest !== "function") {
384
- throw new TypeError(
385
- `Plugin "${p.name}": onRequest must be a function, got ${typeof p.onRequest}`
386
- );
387
- }
388
- if ("onResponse" in p && typeof p.onResponse !== "function") {
389
- throw new TypeError(
390
- `Plugin "${p.name}": onResponse must be a function, got ${typeof p.onResponse}`
391
- );
392
- }
393
- if ("onError" in p && typeof p.onError !== "function") {
394
- throw new TypeError(
395
- `Plugin "${p.name}": onError must be a function, got ${typeof p.onError}`
396
- );
397
- }
398
- if ("extendContext" in p && typeof p.extendContext !== "function") {
399
- throw new TypeError(
400
- `Plugin "${p.name}": extendContext must be a function, got ${typeof p.extendContext}`
401
- );
402
- }
403
- return true;
587
+ if (!this._isReady && this.extensions.length > 0) {
588
+ this.logger.warn(
589
+ `callback() was called before ready(), but ${String(this.extensions.length)} extension(s) were registered via extend() \u2014 their setup() will NOT run, and anything they would decorate (e.g. app.events) will be missing. Call \`await app.ready()\` before \`callback()\`. Adapters (listen/serve) do this automatically.`
590
+ );
591
+ }
592
+ const fn = compose(this.middlewareStack, {
593
+ warnDoubleResponse: !this.isProduction
404
594
  });
405
- return async (ctx) => {
406
- try {
407
- for (const p of hookPlugins) {
408
- if (p.extendContext) p.extendContext(ctx);
409
- if (p.onRequest) await p.onRequest(ctx);
410
- }
411
- await fn(ctx);
412
- for (const p of hookPlugins) {
413
- if (p.onResponse) {
414
- try {
415
- await p.onResponse(ctx);
416
- } catch (hookError) {
417
- this.logger.warn("Plugin onResponse hook threw:", {
418
- plugin: p.name,
419
- error: hookError
420
- });
421
- }
422
- }
423
- }
424
- } catch (error) {
425
- const err = error instanceof Error ? error : new Error(String(error));
426
- for (const p of hookPlugins) {
427
- if (p.onError) {
428
- try {
429
- await p.onError(err, ctx);
430
- } catch (hookError) {
431
- this.logger.warn("Plugin onError hook threw:", {
432
- plugin: p.name,
433
- error: hookError
434
- });
435
- }
436
- }
437
- }
438
- await this.handleError(error, ctx);
439
- }
440
- };
595
+ return (ctx) => fn(ctx).then(void 0, (error) => this.handleError(error, ctx));
441
596
  }
442
597
  /**
443
598
  * Handle errors - uses custom handler if set, otherwise default
@@ -452,69 +607,104 @@ var Application = class {
452
607
  this.logger.error("Error handler threw:", handlerError);
453
608
  }
454
609
  }
455
- this.defaultErrorHandler(err, ctx);
610
+ try {
611
+ this.defaultErrorHandler(err, ctx);
612
+ } catch (fatal) {
613
+ this.logger.error("Default error handler threw:", fatal);
614
+ }
456
615
  }
457
616
  /**
458
- * Default error handler
459
- *
460
- * Uses the `expose` flag on errors (set by HttpError/NextRushError)
461
- * to decide whether to include the error message in the response.
462
- * 4xx errors expose messages by default; 5xx errors never do.
463
- * This prevents leaking internal details (paths, SQL, stack info)
464
- * regardless of environment.
617
+ * Default error handler — delegates to the shared error serializer so the
618
+ * default response matches `@nextrush/errors`' `errorHandler()` (audit C-1).
465
619
  */
466
620
  defaultErrorHandler(error, ctx) {
467
- if (!this.isProduction) {
468
- this.logger.error("Request error:", error);
469
- }
470
- const errorMeta = error;
471
- let status = 500;
472
- if (typeof errorMeta.status === "number") {
473
- const raw = errorMeta.status;
474
- status = raw >= 400 && raw < 600 ? raw : 500;
475
- }
476
- ctx.status = status;
477
- const expose = errorMeta.expose === true;
478
- const message = expose ? error.message : "Internal Server Error";
479
- ctx.json({ error: message });
480
- }
481
- // ===========================================================================
482
- // Lifecycle
483
- // ===========================================================================
621
+ writeDefaultErrorResponse(error, ctx, {
622
+ logger: this.logger,
623
+ isProduction: this.isProduction
624
+ });
625
+ }
484
626
  /**
485
627
  * Mark app as running and freeze configuration.
486
628
  *
487
- * Called by adapters when the server starts listening. After this call,
488
- * `use()`, `route()`, `plugin()`, and `pluginAsync()` will throw.
489
- * This prevents unsafe middleware mutations while requests are in flight.
490
- *
491
- * To re-enable registration (e.g. for testing), call `close()` first.
629
+ * Called by adapters when the server starts listening (after `ready()`).
492
630
  */
493
631
  start() {
494
632
  this._isRunning = true;
495
633
  }
496
634
  /**
497
- * Graceful shutdown
498
- * Destroys all plugins that have a destroy method.
499
- * Uses Promise.allSettled to ensure all plugins are destroyed
500
- * even if some throw errors.
635
+ * Register a teardown hook for a resource outside the extension system
636
+ * (stateful middleware, a manually-attached service, …). Run during
637
+ * {@link close} under the SAME bounded/isolated guarantee as extension
638
+ * `destroy()` one throwing/hanging hook never strands the others — in one
639
+ * combined reverse-of-registration order together with every `extend()`ed
640
+ * extension's `destroy()` (RFC-022 §8.1/§8.2).
641
+ *
642
+ * @param hook - Teardown callback. May be sync or async; a thrown/rejected
643
+ * hook is isolated and its error collected in {@link close}'s returned array.
644
+ *
645
+ * @remarks Registration is pre-shutdown only: a hook registered after
646
+ * `close()` has already started is not run in that shutdown (RFC-022 §8.6).
501
647
  *
502
- * @returns Array of errors from plugins that failed to destroy (empty on success)
648
+ * @example
649
+ * ```typescript
650
+ * const store = new MemoryStore(options);
651
+ * app.onClose(() => store.shutdown());
652
+ * ```
503
653
  */
504
- async close() {
654
+ onClose(hook) {
655
+ if (this._closeStarted) {
656
+ return;
657
+ }
658
+ this.teardownUnits.push({ name: "onClose hook", run: hook });
659
+ }
660
+ /**
661
+ * Graceful shutdown. Destroys extensions in reverse registration order,
662
+ * each isolated from the others' failures — one failing/hanging `destroy()`
663
+ * never strands the rest (RFC-022 / ADR-0012).
664
+ *
665
+ * @param options - Optional teardown budget. Omitted = unbounded, byte-identical
666
+ * to today's behavior (`Promise.allSettled`, no timeout). When `timeout` is
667
+ * given, every teardown unit races against it independently; a unit that
668
+ * does not finish in time is reported as a {@link TeardownTimeoutError} in
669
+ * the returned array instead of blocking `close()` past the budget.
670
+ * @returns Array of errors from units that failed or timed out (empty on success)
671
+ */
672
+ async close(options) {
673
+ this._closePromise ??= this._shutdown(options);
674
+ return this._closePromise;
675
+ }
676
+ /** Run the shutdown sequence once. Guarded and memoized by {@link close}. */
677
+ async _shutdown(options) {
505
678
  this._isRunning = false;
506
- const pluginArray = Array.from(this.plugins.values()).reverse();
507
- const destroyablePlugins = pluginArray.filter(
508
- (p) => typeof p.destroy === "function"
509
- );
510
- const destroyPromises = destroyablePlugins.map(
511
- (p) => Promise.resolve().then(() => p.destroy()).catch((err) => {
512
- throw err instanceof Error ? err : new Error(String(err));
513
- })
514
- );
515
- const results = await Promise.allSettled(destroyPromises);
516
- this.plugins.clear();
517
- return results.filter((r) => r.status === "rejected").map((r) => r.reason instanceof Error ? r.reason : new Error(String(r.reason)));
679
+ this._closeStarted = true;
680
+ this.logger.info("Application is draining: graceful shutdown starting");
681
+ if (!this._isReady && this.extensions.length > 0) {
682
+ this.logger.warn(
683
+ `close() was called before ready(), but ${String(this.extensions.length)} extension(s) were registered via extend() \u2014 their setup() never ran, yet destroy() will still be called on them now. If destroy() assumes setup()-established state, this may throw or behave incorrectly. Call \`await app.ready()\` before \`close()\`.`
684
+ );
685
+ }
686
+ const timeoutMs = options?.timeout;
687
+ const units = [...this.teardownUnits].reverse();
688
+ const errors = (await Promise.all(units.map((unit) => runTeardownUnit(unit, timeoutMs)))).filter((e) => e !== null);
689
+ for (const error of errors) {
690
+ this.logger.error("Teardown unit failed during shutdown:", error);
691
+ }
692
+ for (const name of this.decorations) {
693
+ Reflect.deleteProperty(this, name);
694
+ }
695
+ this.decorations.clear();
696
+ this.extensions.length = 0;
697
+ this.extensionNames.clear();
698
+ this.teardownUnits.length = 0;
699
+ this._isReady = false;
700
+ this._readyPromise = null;
701
+ this._closePromise = null;
702
+ this._closeStarted = false;
703
+ if (this._routerMounted) {
704
+ this.middlewareStack.pop();
705
+ this._routerMounted = false;
706
+ }
707
+ return errors;
518
708
  }
519
709
  };
520
710
  function createApp(options) {