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