@nextrush/core 3.0.6 → 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 +448 -296
- 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;
|
|
@@ -35,27 +68,17 @@ function compose(middleware, options) {
|
|
|
35
68
|
if (!fn) {
|
|
36
69
|
return Promise.resolve();
|
|
37
70
|
}
|
|
38
|
-
const respondedBefore = warnDoubleResponse ? ctx.responded : false;
|
|
39
|
-
let nextCalled = false;
|
|
40
71
|
const nextFn = () => {
|
|
41
|
-
|
|
72
|
+
if (warnDoubleResponse && ctx.responded) {
|
|
73
|
+
emitDoubleResponseWarning(i);
|
|
74
|
+
}
|
|
42
75
|
return dispatch(i + 1);
|
|
43
76
|
};
|
|
44
77
|
if (ctx.setNext) {
|
|
45
78
|
ctx.setNext(nextFn);
|
|
46
79
|
}
|
|
47
80
|
try {
|
|
48
|
-
|
|
49
|
-
if (warnDoubleResponse) {
|
|
50
|
-
return result.then(() => {
|
|
51
|
-
if (!respondedBefore && ctx.responded && nextCalled) {
|
|
52
|
-
console.warn(
|
|
53
|
-
`[nextrush] Middleware at index ${String(i)} sent a response and called next(). Downstream middleware may attempt to write to an already-finished response. Either send a response OR call next(), not both.`
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
return result;
|
|
81
|
+
return Promise.resolve(fn(ctx, nextFn));
|
|
59
82
|
} catch (err) {
|
|
60
83
|
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
61
84
|
}
|
|
@@ -75,10 +98,81 @@ function flattenMiddleware(arr) {
|
|
|
75
98
|
}
|
|
76
99
|
return flattened;
|
|
77
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
|
+
}
|
|
78
119
|
|
|
79
|
-
// src/
|
|
120
|
+
// src/route-mount.ts
|
|
80
121
|
var ORIGINAL_PATH = /* @__PURE__ */ Symbol.for("nextrush.originalPath");
|
|
81
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
|
+
}
|
|
82
176
|
var NOOP_LOGGER = {
|
|
83
177
|
error(..._args) {
|
|
84
178
|
},
|
|
@@ -90,62 +184,92 @@ var NOOP_LOGGER = {
|
|
|
90
184
|
}
|
|
91
185
|
};
|
|
92
186
|
var Application = class {
|
|
93
|
-
/**
|
|
94
|
-
* Middleware stack
|
|
95
|
-
*/
|
|
187
|
+
/** Middleware stack */
|
|
96
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();
|
|
97
193
|
/**
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*
|
|
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}.
|
|
103
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 */
|
|
104
207
|
_errorHandler = null;
|
|
105
|
-
/**
|
|
106
|
-
* Pluggable logger
|
|
107
|
-
*/
|
|
208
|
+
/** Pluggable logger */
|
|
108
209
|
logger;
|
|
109
|
-
/**
|
|
110
|
-
* Application options
|
|
111
|
-
*/
|
|
210
|
+
/** Application options */
|
|
112
211
|
options;
|
|
113
|
-
/**
|
|
114
|
-
* Whether the app is running
|
|
115
|
-
*/
|
|
212
|
+
/** Whether the app is running (server listening) */
|
|
116
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;
|
|
117
226
|
constructor(options = {}) {
|
|
118
227
|
this.options = {
|
|
119
228
|
env: options.env ?? "development",
|
|
120
229
|
proxy: options.proxy ?? false
|
|
121
230
|
};
|
|
122
231
|
this.logger = options.logger ?? NOOP_LOGGER;
|
|
232
|
+
this.router = options.router;
|
|
233
|
+
this.container = options.container;
|
|
123
234
|
}
|
|
124
|
-
/**
|
|
125
|
-
* Check if running in production
|
|
126
|
-
*/
|
|
235
|
+
/** Check if running in production */
|
|
127
236
|
get isProduction() {
|
|
128
237
|
return this.options.env === "production";
|
|
129
238
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Check if app is running
|
|
132
|
-
*/
|
|
239
|
+
/** Check if app is running */
|
|
133
240
|
get isRunning() {
|
|
134
241
|
return this._isRunning;
|
|
135
242
|
}
|
|
136
243
|
/**
|
|
137
|
-
*
|
|
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.
|
|
138
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 */
|
|
139
257
|
get middlewareCount() {
|
|
140
258
|
return this.middlewareStack.length;
|
|
141
259
|
}
|
|
260
|
+
/** Get registered extension count */
|
|
261
|
+
get extensionCount() {
|
|
262
|
+
return this.extensions.length;
|
|
263
|
+
}
|
|
142
264
|
/**
|
|
143
|
-
* Throws if the app is
|
|
144
|
-
* 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.
|
|
145
267
|
*/
|
|
146
|
-
|
|
147
|
-
if (this._isRunning) {
|
|
148
|
-
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
|
+
);
|
|
149
273
|
}
|
|
150
274
|
}
|
|
151
275
|
// ===========================================================================
|
|
@@ -154,23 +278,11 @@ var Application = class {
|
|
|
154
278
|
/**
|
|
155
279
|
* Register middleware function(s)
|
|
156
280
|
*
|
|
157
|
-
* @param middleware - Middleware function
|
|
281
|
+
* @param middleware - Middleware function(s)
|
|
158
282
|
* @returns this for chaining
|
|
159
|
-
*
|
|
160
|
-
* @example
|
|
161
|
-
* ```typescript
|
|
162
|
-
* // Single middleware
|
|
163
|
-
* app.use(async (ctx) => {
|
|
164
|
-
* console.log(ctx.method, ctx.path);
|
|
165
|
-
* await ctx.next();
|
|
166
|
-
* });
|
|
167
|
-
*
|
|
168
|
-
* // Multiple middleware
|
|
169
|
-
* app.use(cors(), helmet(), json());
|
|
170
|
-
* ```
|
|
171
283
|
*/
|
|
172
284
|
use(...middleware) {
|
|
173
|
-
this.
|
|
285
|
+
this.assertConfigurable("use");
|
|
174
286
|
for (const mw of middleware) {
|
|
175
287
|
if (typeof mw !== "function") {
|
|
176
288
|
throw new TypeError("Middleware must be a function");
|
|
@@ -183,37 +295,14 @@ var Application = class {
|
|
|
183
295
|
// Router Mounting (Hono-style)
|
|
184
296
|
// ===========================================================================
|
|
185
297
|
/**
|
|
186
|
-
* Mount a router at a path prefix
|
|
298
|
+
* Mount a router at a path prefix.
|
|
187
299
|
*
|
|
188
|
-
*
|
|
189
|
-
* The router's routes will only match requests that start with the given prefix.
|
|
190
|
-
*
|
|
191
|
-
* @param path - Path prefix for the router (e.g., '/api/users')
|
|
300
|
+
* @param path - Path prefix (e.g. '/api/users')
|
|
192
301
|
* @param router - Router instance to mount
|
|
193
302
|
* @returns this for chaining
|
|
194
|
-
*
|
|
195
|
-
* @example
|
|
196
|
-
* ```typescript
|
|
197
|
-
* // Create modular routers
|
|
198
|
-
* const users = createRouter();
|
|
199
|
-
* users.get('/', listUsers);
|
|
200
|
-
* users.get('/:id', getUser);
|
|
201
|
-
* users.post('/', createUser);
|
|
202
|
-
*
|
|
203
|
-
* const posts = createRouter();
|
|
204
|
-
* posts.get('/', listPosts);
|
|
205
|
-
* posts.get('/:id', getPost);
|
|
206
|
-
*
|
|
207
|
-
* // Mount directly on app - clean like Hono!
|
|
208
|
-
* const app = createApp();
|
|
209
|
-
* app.route('/api/users', users);
|
|
210
|
-
* app.route('/api/posts', posts);
|
|
211
|
-
*
|
|
212
|
-
* listen(app, 3000);
|
|
213
|
-
* ```
|
|
214
303
|
*/
|
|
215
304
|
route(path, router) {
|
|
216
|
-
this.
|
|
305
|
+
this.assertConfigurable("route");
|
|
217
306
|
if (path === "/" || path === "") {
|
|
218
307
|
this.middlewareStack.push(router.routes());
|
|
219
308
|
return this;
|
|
@@ -223,226 +312,251 @@ var Application = class {
|
|
|
223
312
|
if (!normalizedPrefix.startsWith("/")) {
|
|
224
313
|
normalizedPrefix = "/" + normalizedPrefix;
|
|
225
314
|
}
|
|
226
|
-
|
|
227
|
-
const mountedMiddleware = async (ctx, next) => {
|
|
228
|
-
const currentPath = ctx.path;
|
|
229
|
-
if (!currentPath.startsWith(normalizedPrefix)) {
|
|
230
|
-
return next();
|
|
231
|
-
}
|
|
232
|
-
const hasCharAfterPrefix = prefixLen < currentPath.length;
|
|
233
|
-
if (hasCharAfterPrefix && currentPath.charCodeAt(prefixLen) !== 47) {
|
|
234
|
-
return next();
|
|
235
|
-
}
|
|
236
|
-
const adjustedPath = currentPath.slice(prefixLen) || "/";
|
|
237
|
-
ctx.path = adjustedPath;
|
|
238
|
-
ctx.state[ORIGINAL_PATH] = currentPath;
|
|
239
|
-
ctx.state[ROUTE_PREFIX] = normalizedPrefix;
|
|
240
|
-
try {
|
|
241
|
-
await routerMiddleware(ctx, async () => {
|
|
242
|
-
ctx.path = currentPath;
|
|
243
|
-
await next();
|
|
244
|
-
ctx.path = adjustedPath;
|
|
245
|
-
});
|
|
246
|
-
} finally {
|
|
247
|
-
ctx.path = currentPath;
|
|
248
|
-
ctx.state[ORIGINAL_PATH] = void 0;
|
|
249
|
-
ctx.state[ROUTE_PREFIX] = void 0;
|
|
250
|
-
}
|
|
251
|
-
};
|
|
252
|
-
this.middlewareStack.push(mountedMiddleware);
|
|
315
|
+
this.middlewareStack.push(createPrefixMount(normalizedPrefix, routerMiddleware));
|
|
253
316
|
return this;
|
|
254
317
|
}
|
|
255
318
|
// ===========================================================================
|
|
256
|
-
//
|
|
319
|
+
// Routing (delegates to the app-owned router)
|
|
257
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
|
+
}
|
|
258
365
|
/**
|
|
259
|
-
*
|
|
366
|
+
* Register a route for all HTTP methods on the app-owned router.
|
|
260
367
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* @param handler - Error handler function
|
|
266
|
-
* @returns this for chaining
|
|
267
|
-
*
|
|
268
|
-
* @example
|
|
269
|
-
* ```typescript
|
|
270
|
-
* app.setErrorHandler((error, ctx) => {
|
|
271
|
-
* console.error('Request failed:', error);
|
|
272
|
-
*
|
|
273
|
-
* if (error instanceof ValidationError) {
|
|
274
|
-
* ctx.status = 400;
|
|
275
|
-
* ctx.json({ error: error.message, details: error.details });
|
|
276
|
-
* return;
|
|
277
|
-
* }
|
|
278
|
-
*
|
|
279
|
-
* ctx.status = 500;
|
|
280
|
-
* ctx.json({ error: 'Internal Server Error' });
|
|
281
|
-
* });
|
|
282
|
-
* ```
|
|
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.
|
|
283
372
|
*/
|
|
284
|
-
|
|
285
|
-
this.
|
|
373
|
+
all(path, ...entries) {
|
|
374
|
+
this.assertConfigurable("all");
|
|
375
|
+
this.requireRouter().all(path, ...entries);
|
|
286
376
|
return this;
|
|
287
377
|
}
|
|
378
|
+
// ===========================================================================
|
|
379
|
+
// Error Handling
|
|
380
|
+
// ===========================================================================
|
|
288
381
|
/**
|
|
289
|
-
* Set
|
|
382
|
+
* Set the application error handler. Replaces any previously set handler.
|
|
290
383
|
*
|
|
291
384
|
* @param handler - Error handler function
|
|
292
385
|
* @returns this for chaining
|
|
293
|
-
*
|
|
294
|
-
* @deprecated Use `setErrorHandler()` instead — `onError()` implies
|
|
295
|
-
* event subscription (additive), but this is a setter (replaces).
|
|
296
386
|
*/
|
|
297
|
-
|
|
298
|
-
|
|
387
|
+
setErrorHandler(handler) {
|
|
388
|
+
this.assertConfigurable("setErrorHandler");
|
|
389
|
+
this._errorHandler = handler;
|
|
390
|
+
return this;
|
|
299
391
|
}
|
|
300
392
|
// ===========================================================================
|
|
301
|
-
//
|
|
393
|
+
// Extension System (see RFC-NEXTRUSH-PLUGIN-SYSTEM)
|
|
302
394
|
// ===========================================================================
|
|
303
395
|
/**
|
|
304
|
-
*
|
|
396
|
+
* Register an extension. Queues it — `setup()` runs later, at `ready()`,
|
|
397
|
+
* in registration order. Synchronous and chainable.
|
|
305
398
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* @returns this
|
|
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
|
|
311
404
|
*
|
|
312
405
|
* @example
|
|
313
406
|
* ```typescript
|
|
314
|
-
*
|
|
315
|
-
* app.
|
|
316
|
-
*
|
|
317
|
-
* // Async plugin — just await it
|
|
318
|
-
* 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()
|
|
319
410
|
* ```
|
|
320
411
|
*/
|
|
321
|
-
|
|
322
|
-
this.
|
|
323
|
-
if (
|
|
324
|
-
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");
|
|
325
416
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
);
|
|
421
|
+
}
|
|
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()
|
|
331
429
|
});
|
|
332
430
|
}
|
|
333
|
-
this.plugins.set(plugin.name, plugin);
|
|
334
431
|
return this;
|
|
335
432
|
}
|
|
336
433
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* @returns Promise that resolves when plugin is installed
|
|
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()`.
|
|
341
437
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
438
|
+
* After `ready()` resolves, the configuration is frozen (`use`/`route`/`extend`
|
|
439
|
+
* throw).
|
|
344
440
|
*
|
|
345
|
-
* @
|
|
346
|
-
*
|
|
347
|
-
* await app.plugin(new DatabasePlugin({ connectionString: '...' }));
|
|
348
|
-
* ```
|
|
441
|
+
* @returns this
|
|
442
|
+
* @throws if an extension declares a `needs` dependency not yet registered
|
|
349
443
|
*/
|
|
350
|
-
async
|
|
351
|
-
|
|
352
|
-
|
|
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;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
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;
|
|
353
501
|
}
|
|
354
502
|
/**
|
|
355
|
-
*
|
|
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.
|
|
356
506
|
*
|
|
357
|
-
* @
|
|
358
|
-
* @returns Plugin instance or undefined
|
|
507
|
+
* @internal
|
|
359
508
|
*/
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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);
|
|
363
528
|
}
|
|
364
529
|
/**
|
|
365
|
-
*
|
|
530
|
+
* Whether a decoration already occupies `name`.
|
|
366
531
|
*/
|
|
367
|
-
|
|
368
|
-
return this.
|
|
532
|
+
hasDecorator(name) {
|
|
533
|
+
return this.decorations.has(name);
|
|
369
534
|
}
|
|
370
535
|
// ===========================================================================
|
|
371
536
|
// Request Handler
|
|
372
537
|
// ===========================================================================
|
|
373
538
|
/**
|
|
374
|
-
* Create the request handler callback
|
|
375
|
-
*
|
|
376
|
-
* This is the function that adapters call to handle each request.
|
|
377
|
-
* It composes all middleware and returns a function that processes context.
|
|
539
|
+
* Create the request handler callback.
|
|
378
540
|
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* 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.
|
|
383
544
|
*
|
|
384
545
|
* @returns Request handler function
|
|
385
546
|
*/
|
|
386
547
|
callback() {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
if ("onResponse" in p && typeof p.onResponse !== "function") {
|
|
397
|
-
throw new TypeError(
|
|
398
|
-
`Plugin "${p.name}": onResponse must be a function, got ${typeof p.onResponse}`
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
if ("onError" in p && typeof p.onError !== "function") {
|
|
402
|
-
throw new TypeError(
|
|
403
|
-
`Plugin "${p.name}": onError must be a function, got ${typeof p.onError}`
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
if ("extendContext" in p && typeof p.extendContext !== "function") {
|
|
407
|
-
throw new TypeError(
|
|
408
|
-
`Plugin "${p.name}": extendContext must be a function, got ${typeof p.extendContext}`
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
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
|
|
412
555
|
});
|
|
413
556
|
return async (ctx) => {
|
|
414
557
|
try {
|
|
415
|
-
for (const p of hookPlugins) {
|
|
416
|
-
if (p.extendContext) p.extendContext(ctx);
|
|
417
|
-
if (p.onRequest) await p.onRequest(ctx);
|
|
418
|
-
}
|
|
419
558
|
await fn(ctx);
|
|
420
|
-
for (const p of hookPlugins) {
|
|
421
|
-
if (p.onResponse) {
|
|
422
|
-
try {
|
|
423
|
-
await p.onResponse(ctx);
|
|
424
|
-
} catch (hookError) {
|
|
425
|
-
this.logger.warn("Plugin onResponse hook threw:", {
|
|
426
|
-
plugin: p.name,
|
|
427
|
-
error: hookError
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
559
|
} catch (error) {
|
|
433
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
434
|
-
for (const p of hookPlugins) {
|
|
435
|
-
if (p.onError) {
|
|
436
|
-
try {
|
|
437
|
-
await p.onError(err, ctx);
|
|
438
|
-
} catch (hookError) {
|
|
439
|
-
this.logger.warn("Plugin onError hook threw:", {
|
|
440
|
-
plugin: p.name,
|
|
441
|
-
error: hookError
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
560
|
await this.handleError(error, ctx);
|
|
447
561
|
}
|
|
448
562
|
};
|
|
@@ -460,31 +574,21 @@ var Application = class {
|
|
|
460
574
|
this.logger.error("Error handler threw:", handlerError);
|
|
461
575
|
}
|
|
462
576
|
}
|
|
463
|
-
|
|
577
|
+
try {
|
|
578
|
+
this.defaultErrorHandler(err, ctx);
|
|
579
|
+
} catch (fatal) {
|
|
580
|
+
this.logger.error("Default error handler threw:", fatal);
|
|
581
|
+
}
|
|
464
582
|
}
|
|
465
583
|
/**
|
|
466
|
-
* Default error handler
|
|
467
|
-
*
|
|
468
|
-
* Uses the `expose` flag on errors (set by HttpError/NextRushError)
|
|
469
|
-
* to decide whether to include the error message in the response.
|
|
470
|
-
* 4xx errors expose messages by default; 5xx errors never do.
|
|
471
|
-
* This prevents leaking internal details (paths, SQL, stack info)
|
|
472
|
-
* 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).
|
|
473
586
|
*/
|
|
474
587
|
defaultErrorHandler(error, ctx) {
|
|
475
|
-
|
|
476
|
-
this.logger
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
let status = 500;
|
|
480
|
-
if (typeof errorMeta.status === "number") {
|
|
481
|
-
const raw = errorMeta.status;
|
|
482
|
-
status = raw >= 400 && raw < 600 ? raw : 500;
|
|
483
|
-
}
|
|
484
|
-
ctx.status = status;
|
|
485
|
-
const expose = errorMeta.expose === true;
|
|
486
|
-
const message = expose ? error.message : "Internal Server Error";
|
|
487
|
-
ctx.json({ error: message });
|
|
588
|
+
writeDefaultErrorResponse(error, ctx, {
|
|
589
|
+
logger: this.logger,
|
|
590
|
+
isProduction: this.isProduction
|
|
591
|
+
});
|
|
488
592
|
}
|
|
489
593
|
// ===========================================================================
|
|
490
594
|
// Lifecycle
|
|
@@ -492,37 +596,85 @@ var Application = class {
|
|
|
492
596
|
/**
|
|
493
597
|
* Mark app as running and freeze configuration.
|
|
494
598
|
*
|
|
495
|
-
* Called by adapters when the server starts listening
|
|
496
|
-
* `use()`, `route()`, `plugin()`, and `pluginAsync()` will throw.
|
|
497
|
-
* This prevents unsafe middleware mutations while requests are in flight.
|
|
498
|
-
*
|
|
499
|
-
* To re-enable registration (e.g. for testing), call `close()` first.
|
|
599
|
+
* Called by adapters when the server starts listening (after `ready()`).
|
|
500
600
|
*/
|
|
501
601
|
start() {
|
|
502
602
|
this._isRunning = true;
|
|
503
603
|
}
|
|
504
604
|
/**
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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.
|
|
509
614
|
*
|
|
510
|
-
* @
|
|
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
|
+
* ```
|
|
511
623
|
*/
|
|
512
|
-
|
|
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) {
|
|
513
648
|
this._isRunning = false;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
);
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
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;
|
|
526
678
|
}
|
|
527
679
|
};
|
|
528
680
|
function createApp(options) {
|