@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/README.md +322 -493
- package/dist/index.d.ts +193 -182
- package/dist/index.js +445 -285
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
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/
|
|
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
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
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
|
-
*
|
|
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
|
|
136
|
-
* Prevents
|
|
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
|
-
|
|
139
|
-
if (this._isRunning) {
|
|
140
|
-
throw new Error(
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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
|
-
|
|
277
|
-
this.
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
387
|
+
setErrorHandler(handler) {
|
|
388
|
+
this.assertConfigurable("setErrorHandler");
|
|
389
|
+
this._errorHandler = handler;
|
|
390
|
+
return this;
|
|
291
391
|
}
|
|
292
392
|
// ===========================================================================
|
|
293
|
-
//
|
|
393
|
+
// Extension System (see RFC-NEXTRUSH-PLUGIN-SYSTEM)
|
|
294
394
|
// ===========================================================================
|
|
295
395
|
/**
|
|
296
|
-
*
|
|
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
|
|
302
|
-
*
|
|
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
|
-
*
|
|
307
|
-
* app.
|
|
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
|
-
|
|
314
|
-
this.
|
|
315
|
-
if (
|
|
316
|
-
throw new
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
332
|
-
*
|
|
438
|
+
* After `ready()` resolves, the configuration is frozen (`use`/`route`/`extend`
|
|
439
|
+
* throw).
|
|
333
440
|
*
|
|
334
|
-
* @
|
|
335
|
-
*
|
|
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
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
350
|
-
* @returns Plugin instance or undefined
|
|
507
|
+
* @internal
|
|
351
508
|
*/
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
*
|
|
530
|
+
* Whether a decoration already occupies `name`.
|
|
358
531
|
*/
|
|
359
|
-
|
|
360
|
-
return this.
|
|
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
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
this.logger
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
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
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
);
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
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) {
|