@rvoh/psychic 3.10.1 → 3.11.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/cjs/src/bin/helpers/OpenApiSpecDiff.js +61 -22
- package/dist/cjs/src/bin/index.js +1 -1
- package/dist/cjs/src/cli/index.js +11 -1
- package/dist/cjs/src/psychic-app/index.js +18 -0
- package/dist/cjs/src/router/index.js +7 -0
- package/dist/cjs/src/server/helpers/errorBoundaryMiddleware.js +104 -0
- package/dist/cjs/src/server/helpers/startPsychicServer.js +9 -1
- package/dist/cjs/src/server/index.js +50 -3
- package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +61 -22
- package/dist/esm/src/bin/index.js +1 -1
- package/dist/esm/src/cli/index.js +11 -1
- package/dist/esm/src/psychic-app/index.js +18 -0
- package/dist/esm/src/router/index.js +7 -0
- package/dist/esm/src/server/helpers/errorBoundaryMiddleware.js +104 -0
- package/dist/esm/src/server/helpers/startPsychicServer.js +9 -1
- package/dist/esm/src/server/index.js +50 -3
- package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +28 -1
- package/dist/types/src/bin/index.d.ts +1 -1
- package/dist/types/src/psychic-app/index.d.ts +18 -0
- package/dist/types/src/server/helpers/errorBoundaryMiddleware.d.ts +30 -0
- package/dist/types/src/server/index.d.ts +16 -0
- package/package.json +1 -1
|
@@ -90,6 +90,10 @@ export class OpenApiSpecDiff {
|
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
92
|
* Validates that oasdiff is installed and builds the oasdiff config
|
|
93
|
+
*
|
|
94
|
+
* Marked `protected` so tests can supply a deterministic config (e.g. a
|
|
95
|
+
* command guaranteed to fail) instead of probing the local oasdiff install
|
|
96
|
+
* and git remote. Production code never overrides this.
|
|
93
97
|
*/
|
|
94
98
|
getOasDiffConfig() {
|
|
95
99
|
const headBranch = this.getHeadBranch();
|
|
@@ -133,8 +137,10 @@ export class OpenApiSpecDiff {
|
|
|
133
137
|
return output.trim();
|
|
134
138
|
}
|
|
135
139
|
catch (error) {
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
// a failed oasdiff invocation must never be mistaken for diff output
|
|
141
|
+
// (previously the error text was returned and string-matched, so a
|
|
142
|
+
// broken oasdiff read as "no breaking changes")
|
|
143
|
+
throw new OasDiffCommandFailedError(subcommand, error);
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
/**
|
|
@@ -159,25 +165,15 @@ export class OpenApiSpecDiff {
|
|
|
159
165
|
}
|
|
160
166
|
const breakingChanges = this.runOasDiffCommand('breaking', mainFilePath, currentFilePath);
|
|
161
167
|
const changelogChanges = this.runOasDiffCommand('changelog', mainFilePath, currentFilePath);
|
|
162
|
-
const breaking = breakingChanges &&
|
|
163
|
-
!breakingChanges.includes('Command failed') &&
|
|
164
|
-
!this.isNoChangesOutput(breakingChanges)
|
|
168
|
+
const breaking = breakingChanges && !this.isNoChangesOutput(breakingChanges)
|
|
165
169
|
? breakingChanges.split('\n').filter(line => line.trim())
|
|
166
170
|
: [];
|
|
167
|
-
const changelog = changelogChanges &&
|
|
168
|
-
!changelogChanges.includes('Command failed') &&
|
|
169
|
-
!this.isNoChangesOutput(changelogChanges)
|
|
171
|
+
const changelog = changelogChanges && !this.isNoChangesOutput(changelogChanges)
|
|
170
172
|
? changelogChanges.split('\n').filter(line => line.trim())
|
|
171
173
|
: [];
|
|
172
|
-
const failedToCompare = breakingChanges.includes('Command failed')
|
|
173
|
-
? breakingChanges
|
|
174
|
-
: changelogChanges.includes('Command failed')
|
|
175
|
-
? changelogChanges
|
|
176
|
-
: '';
|
|
177
174
|
return {
|
|
178
175
|
breaking,
|
|
179
176
|
changelog,
|
|
180
|
-
error: failedToCompare,
|
|
181
177
|
};
|
|
182
178
|
}
|
|
183
179
|
/**
|
|
@@ -235,18 +231,23 @@ export class OpenApiSpecDiff {
|
|
|
235
231
|
return result;
|
|
236
232
|
}
|
|
237
233
|
const tempMainFilePath = this.createTempFilePath(config.outputFilepath);
|
|
238
|
-
const mainContent = this.getHeadBranchContent(currentFilePath);
|
|
239
|
-
fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
|
|
240
|
-
fs.writeFileSync(tempMainFilePath, mainContent);
|
|
241
234
|
try {
|
|
242
|
-
|
|
235
|
+
// inside the try so a failed `git show` (e.g. file unreadable on the
|
|
236
|
+
// head branch) is recorded as this file's error instead of escaping
|
|
237
|
+
// and aborting the entire diff run with a raw error
|
|
238
|
+
const mainContent = this.getHeadBranchContent(currentFilePath);
|
|
239
|
+
fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
|
|
240
|
+
fs.writeFileSync(tempMainFilePath, mainContent);
|
|
241
|
+
const { breaking, changelog } = this.compareSpecs(tempMainFilePath, currentFilePath);
|
|
243
242
|
result.breaking = breaking;
|
|
244
243
|
result.changelog = changelog;
|
|
245
244
|
result.hasChanges = breaking.length > 0 || changelog.length > 0;
|
|
246
|
-
result.error = error ?? '';
|
|
247
245
|
}
|
|
248
246
|
catch (error) {
|
|
249
|
-
result.error =
|
|
247
|
+
result.error =
|
|
248
|
+
error instanceof OasDiffCommandFailedError
|
|
249
|
+
? error.message
|
|
250
|
+
: `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(error)}`;
|
|
250
251
|
}
|
|
251
252
|
finally {
|
|
252
253
|
if (fs.existsSync(tempMainFilePath)) {
|
|
@@ -261,9 +262,11 @@ export class OpenApiSpecDiff {
|
|
|
261
262
|
processResults(results) {
|
|
262
263
|
let hasAnyChanges = false;
|
|
263
264
|
let hasBreakingChanges = false;
|
|
265
|
+
const erroredFiles = [];
|
|
264
266
|
for (const result of results) {
|
|
265
267
|
if (result.error) {
|
|
266
268
|
this.logError(result);
|
|
269
|
+
erroredFiles.push(result.file);
|
|
267
270
|
}
|
|
268
271
|
else if (result.hasChanges) {
|
|
269
272
|
this.logChanges(result);
|
|
@@ -280,7 +283,7 @@ export class OpenApiSpecDiff {
|
|
|
280
283
|
this.logNoChanges(result);
|
|
281
284
|
}
|
|
282
285
|
}
|
|
283
|
-
this.logSummary(hasAnyChanges, hasBreakingChanges);
|
|
286
|
+
this.logSummary(hasAnyChanges, hasBreakingChanges, erroredFiles);
|
|
284
287
|
}
|
|
285
288
|
/**
|
|
286
289
|
* Log error for a comparison result
|
|
@@ -337,7 +340,7 @@ export class OpenApiSpecDiff {
|
|
|
337
340
|
/**
|
|
338
341
|
* Log final summary and handle exit conditions
|
|
339
342
|
*/
|
|
340
|
-
logSummary(hasAnyChanges, hasBreakingChanges) {
|
|
343
|
+
logSummary(hasAnyChanges, hasBreakingChanges, erroredFiles) {
|
|
341
344
|
DreamCLI.logger.logContinueProgress(`\n${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
|
|
342
345
|
logPrefixColor: 'gray',
|
|
343
346
|
});
|
|
@@ -351,6 +354,10 @@ export class OpenApiSpecDiff {
|
|
|
351
354
|
});
|
|
352
355
|
throw new BreakingChangesDetectedInOpenApiSpecError(this.oasdiffConfig);
|
|
353
356
|
}
|
|
357
|
+
else if (erroredFiles.length > 0) {
|
|
358
|
+
DreamCLI.logger.logContinueProgress(`${colorize(`❌ CRITICAL:`, { color: 'redBright' })} ${colorize(`The OpenAPI diff tooling failed for: ${erroredFiles.join(', ')}. This result is inconclusive — it is NOT a clean "no breaking changes" result.`, { color: 'whiteBright' })}`, { logPrefixColor: 'redBright' });
|
|
359
|
+
throw new OpenApiSpecDiffToolFailureError(erroredFiles);
|
|
360
|
+
}
|
|
354
361
|
else if (hasAnyChanges) {
|
|
355
362
|
const summary = colorize(`📊 Summary: Some OpenAPI files have non-breaking changes in current branch compared to ${this.oasdiffConfig?.headBranch}`, { color: 'yellow' });
|
|
356
363
|
DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'yellow' });
|
|
@@ -385,3 +392,35 @@ export class BreakingChangesDetectedInOpenApiSpecError extends Error {
|
|
|
385
392
|
return `Breaking changes detected in current branch compared to ${this.oasdiffConfig.headBranch}! Review before merging.`;
|
|
386
393
|
}
|
|
387
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Thrown when the diff tooling itself fails (oasdiff invocation error,
|
|
397
|
+
* unreadable head-branch spec, missing spec file), as opposed to oasdiff
|
|
398
|
+
* successfully running and detecting breaking changes
|
|
399
|
+
* ({@link BreakingChangesDetectedInOpenApiSpecError}). Under
|
|
400
|
+
* `psy diff:openapi --fail-on-breaking`, both exit nonzero, but with
|
|
401
|
+
* distinguishable messages: an inconclusive diff must never pass a CI gate
|
|
402
|
+
* as "no breaking changes".
|
|
403
|
+
*/
|
|
404
|
+
export class OpenApiSpecDiffToolFailureError extends Error {
|
|
405
|
+
erroredFiles;
|
|
406
|
+
constructor(erroredFiles) {
|
|
407
|
+
super();
|
|
408
|
+
this.erroredFiles = erroredFiles;
|
|
409
|
+
this.name = 'OpenApiSpecDiffToolFailureError';
|
|
410
|
+
}
|
|
411
|
+
get message() {
|
|
412
|
+
return `OpenAPI spec diff could not complete for: ${this.erroredFiles.join(', ')}. The diff tooling failed, so this result is inconclusive — this is a tool failure, NOT a breaking-changes result.`;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Internal marker for a failed oasdiff invocation, so
|
|
417
|
+
* {@link OpenApiSpecDiff.compareConfig} can distinguish "oasdiff itself
|
|
418
|
+
* failed" from "could not retrieve head-branch content" when recording
|
|
419
|
+
* a per-file error.
|
|
420
|
+
*/
|
|
421
|
+
export class OasDiffCommandFailedError extends Error {
|
|
422
|
+
constructor(subcommand, cause) {
|
|
423
|
+
super(`oasdiff ${subcommand} invocation failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
424
|
+
this.name = 'OasDiffCommandFailedError';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -13,7 +13,7 @@ import generateRouteTypes from './helpers/generateRouteTypes.js';
|
|
|
13
13
|
import { OpenApiSpecDiff } from './helpers/OpenApiSpecDiff.js';
|
|
14
14
|
import printControllerHierarchy, { controllerHierarchyViolations, } from './helpers/printControllerHierarchy.js';
|
|
15
15
|
import printRoutes from './helpers/printRoutes.js';
|
|
16
|
-
export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
|
|
16
|
+
export { BreakingChangesDetectedInOpenApiSpecError, OpenApiSpecDiffToolFailureError, } from './helpers/OpenApiSpecDiff.js';
|
|
17
17
|
export default class PsychicBin {
|
|
18
18
|
static async generateController(controllerName, actions) {
|
|
19
19
|
await generateController({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DreamCLI } from '@rvoh/dream/system';
|
|
2
|
-
import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError } from '../bin/index.js';
|
|
2
|
+
import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError, OpenApiSpecDiffToolFailureError, } from '../bin/index.js';
|
|
3
3
|
import generateController from '../generate/controller.js';
|
|
4
4
|
import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
|
|
5
5
|
import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
|
|
@@ -408,6 +408,16 @@ ${INDENT} pnpm psy diff:openapi --fail-on-breaking # exit 1 if breaking change
|
|
|
408
408
|
process.exit(1);
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
|
+
else if (error instanceof OpenApiSpecDiffToolFailureError) {
|
|
412
|
+
// a failed diff tool is inconclusive; under --fail-on-breaking an
|
|
413
|
+
// inconclusive result must fail the CI gate rather than pass as
|
|
414
|
+
// "no breaking changes". Without the flag (informational mode),
|
|
415
|
+
// the failure is printed loudly but the exit code is unchanged.
|
|
416
|
+
console.error(error.message);
|
|
417
|
+
if (options.failOnBreaking) {
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
411
421
|
else {
|
|
412
422
|
throw error;
|
|
413
423
|
}
|
|
@@ -487,6 +487,24 @@ Try setting it to something valid, like:
|
|
|
487
487
|
plugin(cb) {
|
|
488
488
|
this._plugins.push(cb);
|
|
489
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Registers a callback for one of psychic's lifecycle hooks.
|
|
492
|
+
*
|
|
493
|
+
* ### `server:error`
|
|
494
|
+
*
|
|
495
|
+
* Called any time the server encounters an error it isn't sure how to
|
|
496
|
+
* respond to (i.e. a 500). This covers errors thrown from controller
|
|
497
|
+
* actions as well as errors raised outside the router — e.g. in the body
|
|
498
|
+
* parser, cors callbacks, custom `psy.use` middleware, or after-routes
|
|
499
|
+
* mounts — which are captured by an error boundary mounted outermost in
|
|
500
|
+
* the middleware stack. Hooks are awaited and may shape the response via
|
|
501
|
+
* `ctx`; if they don't, psychic responds 500. Errors carrying a 4xx status
|
|
502
|
+
* (e.g. a body-parser 400) are rendered as that status and never reach
|
|
503
|
+
* `server:error` hooks.
|
|
504
|
+
*
|
|
505
|
+
* NOTE: once any `server:error` hook is registered, psychic considers the
|
|
506
|
+
* error handled and does not re-throw it to Koa.
|
|
507
|
+
*/
|
|
490
508
|
on(hookEventType, cb) {
|
|
491
509
|
switch (hookEventType) {
|
|
492
510
|
case 'server:error':
|
|
@@ -11,6 +11,7 @@ import EnvInternal from '../helpers/EnvInternal.js';
|
|
|
11
11
|
import errorIsRescuableHttpError from '../helpers/error/errorIsRescuableHttpError.js';
|
|
12
12
|
import PsychicApp from '../psychic-app/index.js';
|
|
13
13
|
import { applyResourcefulAction, convertRouteParams, lookupControllerOrFail, routePath, } from '../router/helpers.js';
|
|
14
|
+
import { psychicRouterProcessedErrorStateKey } from '../server/helpers/errorBoundaryMiddleware.js';
|
|
14
15
|
import RouteManager from './route-manager.js';
|
|
15
16
|
import { ResourceMethods, ResourcesMethods, } from './types.js';
|
|
16
17
|
const ERROR_LOGGING_DEPTH = 6;
|
|
@@ -317,6 +318,12 @@ suggested fix: "${convertRouteParams(path)}"
|
|
|
317
318
|
controllerInstance['koaSendStatus'](400);
|
|
318
319
|
}
|
|
319
320
|
else {
|
|
321
|
+
// mark the request so the error-boundary middleware passes anything
|
|
322
|
+
// this branch throws straight through to Koa: the deliberate
|
|
323
|
+
// dev/test re-throw of a failing server:error hook below must not
|
|
324
|
+
// run the hooks a second time, and with no hooks registered the
|
|
325
|
+
// re-thrown action error keeps Koa's default handling
|
|
326
|
+
ctx.state[psychicRouterProcessedErrorStateKey] = true;
|
|
320
327
|
PsychicApp.logWithLevel('error', util.inspect(err, { depth: ERROR_LOGGING_DEPTH }));
|
|
321
328
|
if (PsychicApp.getOrFail().specialHooks.serverError.length) {
|
|
322
329
|
try {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as util from 'node:util';
|
|
2
|
+
import HttpError from '../../error/http/index.js';
|
|
3
|
+
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
4
|
+
import PsychicApp from '../../psychic-app/index.js';
|
|
5
|
+
export const ERROR_LOGGING_DEPTH = 6;
|
|
6
|
+
/**
|
|
7
|
+
* @internal
|
|
8
|
+
*
|
|
9
|
+
* Set on `ctx.state` by the router when it processes an error thrown from a
|
|
10
|
+
* controller action (running `server:error` hooks when any are registered,
|
|
11
|
+
* or deliberately re-throwing to Koa when none are). The error boundary
|
|
12
|
+
* passes marked errors through untouched so a single request can never run
|
|
13
|
+
* `server:error` hooks twice, and so the router's deliberate dev/test
|
|
14
|
+
* re-throw behavior reaches Koa exactly as it did before the boundary
|
|
15
|
+
* existed.
|
|
16
|
+
*/
|
|
17
|
+
export const psychicRouterProcessedErrorStateKey = '_psychicRouterProcessedError';
|
|
18
|
+
/**
|
|
19
|
+
* @internal
|
|
20
|
+
*
|
|
21
|
+
* The outermost middleware psychic mounts. Everything mounted after it —
|
|
22
|
+
* secure default headers, etag, cors, the body parser, `psy.use` middleware,
|
|
23
|
+
* after-routes mounts, and the router itself — runs inside `await next()`,
|
|
24
|
+
* so any error those layers throw (and the router doesn't catch) lands here
|
|
25
|
+
* instead of falling through to Koa's default handler.
|
|
26
|
+
*
|
|
27
|
+
* Errors carrying a 4xx status (e.g. a body-parser 400, or an `HttpError`
|
|
28
|
+
* thrown from custom middleware) already name their response: the boundary
|
|
29
|
+
* renders that status without involving `server:error` hooks. Anything else
|
|
30
|
+
* is a genuine server error: it is logged, given a default 500 response, and
|
|
31
|
+
* escalated to `server:error` hooks, which may reshape the response.
|
|
32
|
+
*/
|
|
33
|
+
export default function errorBoundaryMiddleware() {
|
|
34
|
+
return async function psychicErrorBoundary(ctx, next) {
|
|
35
|
+
try {
|
|
36
|
+
await next();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const err = error;
|
|
40
|
+
// the router already processed this error (see the state key docs
|
|
41
|
+
// above); let it reach Koa unchanged
|
|
42
|
+
if (ctx.state[psychicRouterProcessedErrorStateKey])
|
|
43
|
+
throw err;
|
|
44
|
+
// once headers are out, the response can no longer be shaped; Koa's
|
|
45
|
+
// ctx.onerror knows how to clean up the socket, and the app-level
|
|
46
|
+
// 'error' listener registered by PsychicServer will log it
|
|
47
|
+
if (ctx.headerSent)
|
|
48
|
+
throw err;
|
|
49
|
+
const status = statusFromError(err);
|
|
50
|
+
if (status !== null && status < 500) {
|
|
51
|
+
// client-shaped errors are a handled response, not a server error;
|
|
52
|
+
// server:error hooks are never called for them
|
|
53
|
+
ctx.status = status;
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
55
|
+
ctx.body = err instanceof HttpError && err.data !== undefined ? err.data : '';
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
PsychicApp.logWithLevel('error', util.inspect(err, { depth: ERROR_LOGGING_DEPTH }));
|
|
59
|
+
// default server-error response; server:error hooks may reshape it
|
|
60
|
+
ctx.status = status ?? 500;
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
62
|
+
ctx.body = err instanceof HttpError && err.data !== undefined ? err.data : '';
|
|
63
|
+
try {
|
|
64
|
+
for (const hook of PsychicApp.getOrFail().specialHooks.serverError) {
|
|
65
|
+
await hook(err, ctx);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (hookError) {
|
|
69
|
+
if (EnvInternal.isDevelopmentOrTest) {
|
|
70
|
+
// mirror the router's deliberate dev/test behavior for throwing
|
|
71
|
+
// server:error hooks: surface the hook error so specs can see it
|
|
72
|
+
throw hookError;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
PsychicApp.logWithLevel('error', `
|
|
76
|
+
Something went wrong while attempting to call your custom server:error hooks.
|
|
77
|
+
Psychic will rescue errors thrown here to prevent the server from crashing.
|
|
78
|
+
The error thrown is:
|
|
79
|
+
`);
|
|
80
|
+
PsychicApp.logWithLevel('error', hookError);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* @internal
|
|
88
|
+
*
|
|
89
|
+
* Extracts an http response status from an error when the error names one:
|
|
90
|
+
* psychic `HttpError` subclasses, Koa `ctx.throw`/http-errors errors, and
|
|
91
|
+
* body-parser failures all carry a numeric `status`. Guarded property access
|
|
92
|
+
* because the base `HttpError#status` getter throws.
|
|
93
|
+
*/
|
|
94
|
+
function statusFromError(err) {
|
|
95
|
+
try {
|
|
96
|
+
const status = err?.status;
|
|
97
|
+
if (typeof status === 'number' && status >= 400 && status <= 599)
|
|
98
|
+
return status;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -7,9 +7,17 @@ import PsychicLogos from '../../cli/helpers/PsychicLogos.js';
|
|
|
7
7
|
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
8
8
|
import PsychicApp from '../../psychic-app/index.js';
|
|
9
9
|
export default async function startPsychicServer({ app, port, sslCredentials, }) {
|
|
10
|
-
return await new Promise(accept => {
|
|
10
|
+
return await new Promise((accept, reject) => {
|
|
11
11
|
const httpOrHttps = createPsychicHttpInstance(app, sslCredentials);
|
|
12
|
+
// typescript cannot call event-emitter methods on the http/https server
|
|
13
|
+
// union directly (incompatible overload sets), but both are EventEmitters
|
|
14
|
+
const emitter = httpOrHttps;
|
|
15
|
+
// without this listener, a failed bind (e.g. EADDRINUSE, EACCES) escapes
|
|
16
|
+
// as an uncaught 'error' event and this promise never settles
|
|
17
|
+
const onListenError = (error) => reject(error);
|
|
18
|
+
emitter.once('error', onListenError);
|
|
12
19
|
const server = httpOrHttps.listen(port, () => {
|
|
20
|
+
emitter.off('error', onListenError);
|
|
13
21
|
welcomeMessage({ port });
|
|
14
22
|
accept(server);
|
|
15
23
|
});
|
|
@@ -4,10 +4,12 @@ import etag from '@koa/etag';
|
|
|
4
4
|
import { closeAllDbConnections } from '@rvoh/dream/db';
|
|
5
5
|
import Koa from 'koa';
|
|
6
6
|
import conditional from 'koa-conditional-get';
|
|
7
|
+
import * as util from 'node:util';
|
|
7
8
|
import logIfDevelopment from '../controller/helpers/logIfDevelopment.js';
|
|
8
9
|
import EnvInternal from '../helpers/EnvInternal.js';
|
|
9
10
|
import PsychicApp from '../psychic-app/index.js';
|
|
10
11
|
import PsychicRouter from '../router/index.js';
|
|
12
|
+
import errorBoundaryMiddleware, { ERROR_LOGGING_DEPTH } from './helpers/errorBoundaryMiddleware.js';
|
|
11
13
|
import startPsychicServer, { createPsychicHttpInstance, } from './helpers/startPsychicServer.js';
|
|
12
14
|
// const debugEnabled = debuglog('psychic').enabled
|
|
13
15
|
export default class PsychicServer {
|
|
@@ -32,6 +34,21 @@ export default class PsychicServer {
|
|
|
32
34
|
if (this.booted)
|
|
33
35
|
return;
|
|
34
36
|
const psychicApp = PsychicApp.getOrFail();
|
|
37
|
+
// outermost middleware: catches errors thrown from anything mounted
|
|
38
|
+
// after it (body parser, cors, custom `psy.use` middleware, after-routes
|
|
39
|
+
// mounts) and escalates genuine server errors to server:error hooks.
|
|
40
|
+
// Errors from controller actions are still processed by the router; the
|
|
41
|
+
// boundary only sees errors the router never caught.
|
|
42
|
+
this.koaApp.use(errorBoundaryMiddleware());
|
|
43
|
+
// residual error sink: the few errors Koa surfaces outside the error
|
|
44
|
+
// boundary (errors thrown after headers were sent, response-stream
|
|
45
|
+
// failures, errors the router deliberately re-throws to Koa in
|
|
46
|
+
// dev/test). Registering a listener supersedes Koa's default stderr
|
|
47
|
+
// logger, so these flow through the configured psychic logger instead
|
|
48
|
+
// of going dark.
|
|
49
|
+
this.koaApp.on('error', (err) => {
|
|
50
|
+
PsychicApp.logWithLevel('error', util.inspect(err, { depth: ERROR_LOGGING_DEPTH }));
|
|
51
|
+
});
|
|
35
52
|
this.setSecureDefaultHeaders();
|
|
36
53
|
this.koaApp.use(async (ctx, next) => {
|
|
37
54
|
Object.keys(psychicApp.defaultResponseHeaders).forEach(key => {
|
|
@@ -57,7 +74,7 @@ export default class PsychicServer {
|
|
|
57
74
|
throw new Error(`
|
|
58
75
|
Failed to boot psychic config. the error thrown was:
|
|
59
76
|
${error.message}
|
|
60
|
-
|
|
77
|
+
`, { cause: error });
|
|
61
78
|
}
|
|
62
79
|
for (const serverInitAfterMiddlewareHook of PsychicApp.getOrFail().specialHooks
|
|
63
80
|
.serverInitAfterMiddleware) {
|
|
@@ -134,9 +151,39 @@ export default class PsychicServer {
|
|
|
134
151
|
}
|
|
135
152
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
153
|
$attached = {};
|
|
154
|
+
/**
|
|
155
|
+
* @internal
|
|
156
|
+
*
|
|
157
|
+
* the maximum number of milliseconds graceful shutdown is allowed
|
|
158
|
+
* to run before the process exits anyway, so that a hung
|
|
159
|
+
* `server:shutdown` hook or db close can never keep a dying
|
|
160
|
+
* process alive
|
|
161
|
+
*/
|
|
162
|
+
static SHUTDOWN_TIMEOUT_MS = 15000;
|
|
137
163
|
async shutdownAndExit() {
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
let exitCode = 0;
|
|
165
|
+
try {
|
|
166
|
+
await this.stopWithTimeout();
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
PsychicApp.logWithLevel('error', '[psychic] error during graceful shutdown:', error);
|
|
170
|
+
exitCode = 1;
|
|
171
|
+
}
|
|
172
|
+
process.exit(exitCode);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* @internal
|
|
176
|
+
*
|
|
177
|
+
* runs {@link PsychicServer.stop}, rejecting if it has not settled
|
|
178
|
+
* within SHUTDOWN_TIMEOUT_MS
|
|
179
|
+
*/
|
|
180
|
+
async stopWithTimeout() {
|
|
181
|
+
await Promise.race([
|
|
182
|
+
this.stop(),
|
|
183
|
+
new Promise((_, reject) => {
|
|
184
|
+
setTimeout(() => reject(new Error(`[psychic] graceful shutdown timed out after ${PsychicServer.SHUTDOWN_TIMEOUT_MS}ms`)), PsychicServer.SHUTDOWN_TIMEOUT_MS).unref();
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
140
187
|
}
|
|
141
188
|
async stop({ bypassClosingDbConnections = false, gracefulShutdownTimeoutMillis = 10_000, } = {}) {
|
|
142
189
|
for (const hook of PsychicApp.getOrFail().specialHooks.serverShutdown) {
|
|
@@ -90,6 +90,10 @@ export class OpenApiSpecDiff {
|
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
92
|
* Validates that oasdiff is installed and builds the oasdiff config
|
|
93
|
+
*
|
|
94
|
+
* Marked `protected` so tests can supply a deterministic config (e.g. a
|
|
95
|
+
* command guaranteed to fail) instead of probing the local oasdiff install
|
|
96
|
+
* and git remote. Production code never overrides this.
|
|
93
97
|
*/
|
|
94
98
|
getOasDiffConfig() {
|
|
95
99
|
const headBranch = this.getHeadBranch();
|
|
@@ -133,8 +137,10 @@ export class OpenApiSpecDiff {
|
|
|
133
137
|
return output.trim();
|
|
134
138
|
}
|
|
135
139
|
catch (error) {
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
// a failed oasdiff invocation must never be mistaken for diff output
|
|
141
|
+
// (previously the error text was returned and string-matched, so a
|
|
142
|
+
// broken oasdiff read as "no breaking changes")
|
|
143
|
+
throw new OasDiffCommandFailedError(subcommand, error);
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
/**
|
|
@@ -159,25 +165,15 @@ export class OpenApiSpecDiff {
|
|
|
159
165
|
}
|
|
160
166
|
const breakingChanges = this.runOasDiffCommand('breaking', mainFilePath, currentFilePath);
|
|
161
167
|
const changelogChanges = this.runOasDiffCommand('changelog', mainFilePath, currentFilePath);
|
|
162
|
-
const breaking = breakingChanges &&
|
|
163
|
-
!breakingChanges.includes('Command failed') &&
|
|
164
|
-
!this.isNoChangesOutput(breakingChanges)
|
|
168
|
+
const breaking = breakingChanges && !this.isNoChangesOutput(breakingChanges)
|
|
165
169
|
? breakingChanges.split('\n').filter(line => line.trim())
|
|
166
170
|
: [];
|
|
167
|
-
const changelog = changelogChanges &&
|
|
168
|
-
!changelogChanges.includes('Command failed') &&
|
|
169
|
-
!this.isNoChangesOutput(changelogChanges)
|
|
171
|
+
const changelog = changelogChanges && !this.isNoChangesOutput(changelogChanges)
|
|
170
172
|
? changelogChanges.split('\n').filter(line => line.trim())
|
|
171
173
|
: [];
|
|
172
|
-
const failedToCompare = breakingChanges.includes('Command failed')
|
|
173
|
-
? breakingChanges
|
|
174
|
-
: changelogChanges.includes('Command failed')
|
|
175
|
-
? changelogChanges
|
|
176
|
-
: '';
|
|
177
174
|
return {
|
|
178
175
|
breaking,
|
|
179
176
|
changelog,
|
|
180
|
-
error: failedToCompare,
|
|
181
177
|
};
|
|
182
178
|
}
|
|
183
179
|
/**
|
|
@@ -235,18 +231,23 @@ export class OpenApiSpecDiff {
|
|
|
235
231
|
return result;
|
|
236
232
|
}
|
|
237
233
|
const tempMainFilePath = this.createTempFilePath(config.outputFilepath);
|
|
238
|
-
const mainContent = this.getHeadBranchContent(currentFilePath);
|
|
239
|
-
fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
|
|
240
|
-
fs.writeFileSync(tempMainFilePath, mainContent);
|
|
241
234
|
try {
|
|
242
|
-
|
|
235
|
+
// inside the try so a failed `git show` (e.g. file unreadable on the
|
|
236
|
+
// head branch) is recorded as this file's error instead of escaping
|
|
237
|
+
// and aborting the entire diff run with a raw error
|
|
238
|
+
const mainContent = this.getHeadBranchContent(currentFilePath);
|
|
239
|
+
fs.mkdirSync(path.dirname(tempMainFilePath), { recursive: true });
|
|
240
|
+
fs.writeFileSync(tempMainFilePath, mainContent);
|
|
241
|
+
const { breaking, changelog } = this.compareSpecs(tempMainFilePath, currentFilePath);
|
|
243
242
|
result.breaking = breaking;
|
|
244
243
|
result.changelog = changelog;
|
|
245
244
|
result.hasChanges = breaking.length > 0 || changelog.length > 0;
|
|
246
|
-
result.error = error ?? '';
|
|
247
245
|
}
|
|
248
246
|
catch (error) {
|
|
249
|
-
result.error =
|
|
247
|
+
result.error =
|
|
248
|
+
error instanceof OasDiffCommandFailedError
|
|
249
|
+
? error.message
|
|
250
|
+
: `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(error)}`;
|
|
250
251
|
}
|
|
251
252
|
finally {
|
|
252
253
|
if (fs.existsSync(tempMainFilePath)) {
|
|
@@ -261,9 +262,11 @@ export class OpenApiSpecDiff {
|
|
|
261
262
|
processResults(results) {
|
|
262
263
|
let hasAnyChanges = false;
|
|
263
264
|
let hasBreakingChanges = false;
|
|
265
|
+
const erroredFiles = [];
|
|
264
266
|
for (const result of results) {
|
|
265
267
|
if (result.error) {
|
|
266
268
|
this.logError(result);
|
|
269
|
+
erroredFiles.push(result.file);
|
|
267
270
|
}
|
|
268
271
|
else if (result.hasChanges) {
|
|
269
272
|
this.logChanges(result);
|
|
@@ -280,7 +283,7 @@ export class OpenApiSpecDiff {
|
|
|
280
283
|
this.logNoChanges(result);
|
|
281
284
|
}
|
|
282
285
|
}
|
|
283
|
-
this.logSummary(hasAnyChanges, hasBreakingChanges);
|
|
286
|
+
this.logSummary(hasAnyChanges, hasBreakingChanges, erroredFiles);
|
|
284
287
|
}
|
|
285
288
|
/**
|
|
286
289
|
* Log error for a comparison result
|
|
@@ -337,7 +340,7 @@ export class OpenApiSpecDiff {
|
|
|
337
340
|
/**
|
|
338
341
|
* Log final summary and handle exit conditions
|
|
339
342
|
*/
|
|
340
|
-
logSummary(hasAnyChanges, hasBreakingChanges) {
|
|
343
|
+
logSummary(hasAnyChanges, hasBreakingChanges, erroredFiles) {
|
|
341
344
|
DreamCLI.logger.logContinueProgress(`\n${colorize(`${'='.repeat(60)}`, { color: 'gray' })}`, {
|
|
342
345
|
logPrefixColor: 'gray',
|
|
343
346
|
});
|
|
@@ -351,6 +354,10 @@ export class OpenApiSpecDiff {
|
|
|
351
354
|
});
|
|
352
355
|
throw new BreakingChangesDetectedInOpenApiSpecError(this.oasdiffConfig);
|
|
353
356
|
}
|
|
357
|
+
else if (erroredFiles.length > 0) {
|
|
358
|
+
DreamCLI.logger.logContinueProgress(`${colorize(`❌ CRITICAL:`, { color: 'redBright' })} ${colorize(`The OpenAPI diff tooling failed for: ${erroredFiles.join(', ')}. This result is inconclusive — it is NOT a clean "no breaking changes" result.`, { color: 'whiteBright' })}`, { logPrefixColor: 'redBright' });
|
|
359
|
+
throw new OpenApiSpecDiffToolFailureError(erroredFiles);
|
|
360
|
+
}
|
|
354
361
|
else if (hasAnyChanges) {
|
|
355
362
|
const summary = colorize(`📊 Summary: Some OpenAPI files have non-breaking changes in current branch compared to ${this.oasdiffConfig?.headBranch}`, { color: 'yellow' });
|
|
356
363
|
DreamCLI.logger.logContinueProgress(summary, { logPrefixColor: 'yellow' });
|
|
@@ -385,3 +392,35 @@ export class BreakingChangesDetectedInOpenApiSpecError extends Error {
|
|
|
385
392
|
return `Breaking changes detected in current branch compared to ${this.oasdiffConfig.headBranch}! Review before merging.`;
|
|
386
393
|
}
|
|
387
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Thrown when the diff tooling itself fails (oasdiff invocation error,
|
|
397
|
+
* unreadable head-branch spec, missing spec file), as opposed to oasdiff
|
|
398
|
+
* successfully running and detecting breaking changes
|
|
399
|
+
* ({@link BreakingChangesDetectedInOpenApiSpecError}). Under
|
|
400
|
+
* `psy diff:openapi --fail-on-breaking`, both exit nonzero, but with
|
|
401
|
+
* distinguishable messages: an inconclusive diff must never pass a CI gate
|
|
402
|
+
* as "no breaking changes".
|
|
403
|
+
*/
|
|
404
|
+
export class OpenApiSpecDiffToolFailureError extends Error {
|
|
405
|
+
erroredFiles;
|
|
406
|
+
constructor(erroredFiles) {
|
|
407
|
+
super();
|
|
408
|
+
this.erroredFiles = erroredFiles;
|
|
409
|
+
this.name = 'OpenApiSpecDiffToolFailureError';
|
|
410
|
+
}
|
|
411
|
+
get message() {
|
|
412
|
+
return `OpenAPI spec diff could not complete for: ${this.erroredFiles.join(', ')}. The diff tooling failed, so this result is inconclusive — this is a tool failure, NOT a breaking-changes result.`;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Internal marker for a failed oasdiff invocation, so
|
|
417
|
+
* {@link OpenApiSpecDiff.compareConfig} can distinguish "oasdiff itself
|
|
418
|
+
* failed" from "could not retrieve head-branch content" when recording
|
|
419
|
+
* a per-file error.
|
|
420
|
+
*/
|
|
421
|
+
export class OasDiffCommandFailedError extends Error {
|
|
422
|
+
constructor(subcommand, cause) {
|
|
423
|
+
super(`oasdiff ${subcommand} invocation failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
424
|
+
this.name = 'OasDiffCommandFailedError';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -13,7 +13,7 @@ import generateRouteTypes from './helpers/generateRouteTypes.js';
|
|
|
13
13
|
import { OpenApiSpecDiff } from './helpers/OpenApiSpecDiff.js';
|
|
14
14
|
import printControllerHierarchy, { controllerHierarchyViolations, } from './helpers/printControllerHierarchy.js';
|
|
15
15
|
import printRoutes from './helpers/printRoutes.js';
|
|
16
|
-
export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
|
|
16
|
+
export { BreakingChangesDetectedInOpenApiSpecError, OpenApiSpecDiffToolFailureError, } from './helpers/OpenApiSpecDiff.js';
|
|
17
17
|
export default class PsychicBin {
|
|
18
18
|
static async generateController(controllerName, actions) {
|
|
19
19
|
await generateController({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DreamCLI } from '@rvoh/dream/system';
|
|
2
|
-
import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError } from '../bin/index.js';
|
|
2
|
+
import PsychicBin, { BreakingChangesDetectedInOpenApiSpecError, OpenApiSpecDiffToolFailureError, } from '../bin/index.js';
|
|
3
3
|
import generateController from '../generate/controller.js';
|
|
4
4
|
import generateSyncEnumsInitializer from '../generate/initializer/syncEnums.js';
|
|
5
5
|
import generateSyncOpenapiTypescriptInitializer from '../generate/initializer/syncOpenapiTypescript.js';
|
|
@@ -408,6 +408,16 @@ ${INDENT} pnpm psy diff:openapi --fail-on-breaking # exit 1 if breaking change
|
|
|
408
408
|
process.exit(1);
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
|
+
else if (error instanceof OpenApiSpecDiffToolFailureError) {
|
|
412
|
+
// a failed diff tool is inconclusive; under --fail-on-breaking an
|
|
413
|
+
// inconclusive result must fail the CI gate rather than pass as
|
|
414
|
+
// "no breaking changes". Without the flag (informational mode),
|
|
415
|
+
// the failure is printed loudly but the exit code is unchanged.
|
|
416
|
+
console.error(error.message);
|
|
417
|
+
if (options.failOnBreaking) {
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
411
421
|
else {
|
|
412
422
|
throw error;
|
|
413
423
|
}
|
|
@@ -487,6 +487,24 @@ Try setting it to something valid, like:
|
|
|
487
487
|
plugin(cb) {
|
|
488
488
|
this._plugins.push(cb);
|
|
489
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Registers a callback for one of psychic's lifecycle hooks.
|
|
492
|
+
*
|
|
493
|
+
* ### `server:error`
|
|
494
|
+
*
|
|
495
|
+
* Called any time the server encounters an error it isn't sure how to
|
|
496
|
+
* respond to (i.e. a 500). This covers errors thrown from controller
|
|
497
|
+
* actions as well as errors raised outside the router — e.g. in the body
|
|
498
|
+
* parser, cors callbacks, custom `psy.use` middleware, or after-routes
|
|
499
|
+
* mounts — which are captured by an error boundary mounted outermost in
|
|
500
|
+
* the middleware stack. Hooks are awaited and may shape the response via
|
|
501
|
+
* `ctx`; if they don't, psychic responds 500. Errors carrying a 4xx status
|
|
502
|
+
* (e.g. a body-parser 400) are rendered as that status and never reach
|
|
503
|
+
* `server:error` hooks.
|
|
504
|
+
*
|
|
505
|
+
* NOTE: once any `server:error` hook is registered, psychic considers the
|
|
506
|
+
* error handled and does not re-throw it to Koa.
|
|
507
|
+
*/
|
|
490
508
|
on(hookEventType, cb) {
|
|
491
509
|
switch (hookEventType) {
|
|
492
510
|
case 'server:error':
|
|
@@ -11,6 +11,7 @@ import EnvInternal from '../helpers/EnvInternal.js';
|
|
|
11
11
|
import errorIsRescuableHttpError from '../helpers/error/errorIsRescuableHttpError.js';
|
|
12
12
|
import PsychicApp from '../psychic-app/index.js';
|
|
13
13
|
import { applyResourcefulAction, convertRouteParams, lookupControllerOrFail, routePath, } from '../router/helpers.js';
|
|
14
|
+
import { psychicRouterProcessedErrorStateKey } from '../server/helpers/errorBoundaryMiddleware.js';
|
|
14
15
|
import RouteManager from './route-manager.js';
|
|
15
16
|
import { ResourceMethods, ResourcesMethods, } from './types.js';
|
|
16
17
|
const ERROR_LOGGING_DEPTH = 6;
|
|
@@ -317,6 +318,12 @@ suggested fix: "${convertRouteParams(path)}"
|
|
|
317
318
|
controllerInstance['koaSendStatus'](400);
|
|
318
319
|
}
|
|
319
320
|
else {
|
|
321
|
+
// mark the request so the error-boundary middleware passes anything
|
|
322
|
+
// this branch throws straight through to Koa: the deliberate
|
|
323
|
+
// dev/test re-throw of a failing server:error hook below must not
|
|
324
|
+
// run the hooks a second time, and with no hooks registered the
|
|
325
|
+
// re-thrown action error keeps Koa's default handling
|
|
326
|
+
ctx.state[psychicRouterProcessedErrorStateKey] = true;
|
|
320
327
|
PsychicApp.logWithLevel('error', util.inspect(err, { depth: ERROR_LOGGING_DEPTH }));
|
|
321
328
|
if (PsychicApp.getOrFail().specialHooks.serverError.length) {
|
|
322
329
|
try {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as util from 'node:util';
|
|
2
|
+
import HttpError from '../../error/http/index.js';
|
|
3
|
+
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
4
|
+
import PsychicApp from '../../psychic-app/index.js';
|
|
5
|
+
export const ERROR_LOGGING_DEPTH = 6;
|
|
6
|
+
/**
|
|
7
|
+
* @internal
|
|
8
|
+
*
|
|
9
|
+
* Set on `ctx.state` by the router when it processes an error thrown from a
|
|
10
|
+
* controller action (running `server:error` hooks when any are registered,
|
|
11
|
+
* or deliberately re-throwing to Koa when none are). The error boundary
|
|
12
|
+
* passes marked errors through untouched so a single request can never run
|
|
13
|
+
* `server:error` hooks twice, and so the router's deliberate dev/test
|
|
14
|
+
* re-throw behavior reaches Koa exactly as it did before the boundary
|
|
15
|
+
* existed.
|
|
16
|
+
*/
|
|
17
|
+
export const psychicRouterProcessedErrorStateKey = '_psychicRouterProcessedError';
|
|
18
|
+
/**
|
|
19
|
+
* @internal
|
|
20
|
+
*
|
|
21
|
+
* The outermost middleware psychic mounts. Everything mounted after it —
|
|
22
|
+
* secure default headers, etag, cors, the body parser, `psy.use` middleware,
|
|
23
|
+
* after-routes mounts, and the router itself — runs inside `await next()`,
|
|
24
|
+
* so any error those layers throw (and the router doesn't catch) lands here
|
|
25
|
+
* instead of falling through to Koa's default handler.
|
|
26
|
+
*
|
|
27
|
+
* Errors carrying a 4xx status (e.g. a body-parser 400, or an `HttpError`
|
|
28
|
+
* thrown from custom middleware) already name their response: the boundary
|
|
29
|
+
* renders that status without involving `server:error` hooks. Anything else
|
|
30
|
+
* is a genuine server error: it is logged, given a default 500 response, and
|
|
31
|
+
* escalated to `server:error` hooks, which may reshape the response.
|
|
32
|
+
*/
|
|
33
|
+
export default function errorBoundaryMiddleware() {
|
|
34
|
+
return async function psychicErrorBoundary(ctx, next) {
|
|
35
|
+
try {
|
|
36
|
+
await next();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
const err = error;
|
|
40
|
+
// the router already processed this error (see the state key docs
|
|
41
|
+
// above); let it reach Koa unchanged
|
|
42
|
+
if (ctx.state[psychicRouterProcessedErrorStateKey])
|
|
43
|
+
throw err;
|
|
44
|
+
// once headers are out, the response can no longer be shaped; Koa's
|
|
45
|
+
// ctx.onerror knows how to clean up the socket, and the app-level
|
|
46
|
+
// 'error' listener registered by PsychicServer will log it
|
|
47
|
+
if (ctx.headerSent)
|
|
48
|
+
throw err;
|
|
49
|
+
const status = statusFromError(err);
|
|
50
|
+
if (status !== null && status < 500) {
|
|
51
|
+
// client-shaped errors are a handled response, not a server error;
|
|
52
|
+
// server:error hooks are never called for them
|
|
53
|
+
ctx.status = status;
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
55
|
+
ctx.body = err instanceof HttpError && err.data !== undefined ? err.data : '';
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
PsychicApp.logWithLevel('error', util.inspect(err, { depth: ERROR_LOGGING_DEPTH }));
|
|
59
|
+
// default server-error response; server:error hooks may reshape it
|
|
60
|
+
ctx.status = status ?? 500;
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
62
|
+
ctx.body = err instanceof HttpError && err.data !== undefined ? err.data : '';
|
|
63
|
+
try {
|
|
64
|
+
for (const hook of PsychicApp.getOrFail().specialHooks.serverError) {
|
|
65
|
+
await hook(err, ctx);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (hookError) {
|
|
69
|
+
if (EnvInternal.isDevelopmentOrTest) {
|
|
70
|
+
// mirror the router's deliberate dev/test behavior for throwing
|
|
71
|
+
// server:error hooks: surface the hook error so specs can see it
|
|
72
|
+
throw hookError;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
PsychicApp.logWithLevel('error', `
|
|
76
|
+
Something went wrong while attempting to call your custom server:error hooks.
|
|
77
|
+
Psychic will rescue errors thrown here to prevent the server from crashing.
|
|
78
|
+
The error thrown is:
|
|
79
|
+
`);
|
|
80
|
+
PsychicApp.logWithLevel('error', hookError);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* @internal
|
|
88
|
+
*
|
|
89
|
+
* Extracts an http response status from an error when the error names one:
|
|
90
|
+
* psychic `HttpError` subclasses, Koa `ctx.throw`/http-errors errors, and
|
|
91
|
+
* body-parser failures all carry a numeric `status`. Guarded property access
|
|
92
|
+
* because the base `HttpError#status` getter throws.
|
|
93
|
+
*/
|
|
94
|
+
function statusFromError(err) {
|
|
95
|
+
try {
|
|
96
|
+
const status = err?.status;
|
|
97
|
+
if (typeof status === 'number' && status >= 400 && status <= 599)
|
|
98
|
+
return status;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -7,9 +7,17 @@ import PsychicLogos from '../../cli/helpers/PsychicLogos.js';
|
|
|
7
7
|
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
8
8
|
import PsychicApp from '../../psychic-app/index.js';
|
|
9
9
|
export default async function startPsychicServer({ app, port, sslCredentials, }) {
|
|
10
|
-
return await new Promise(accept => {
|
|
10
|
+
return await new Promise((accept, reject) => {
|
|
11
11
|
const httpOrHttps = createPsychicHttpInstance(app, sslCredentials);
|
|
12
|
+
// typescript cannot call event-emitter methods on the http/https server
|
|
13
|
+
// union directly (incompatible overload sets), but both are EventEmitters
|
|
14
|
+
const emitter = httpOrHttps;
|
|
15
|
+
// without this listener, a failed bind (e.g. EADDRINUSE, EACCES) escapes
|
|
16
|
+
// as an uncaught 'error' event and this promise never settles
|
|
17
|
+
const onListenError = (error) => reject(error);
|
|
18
|
+
emitter.once('error', onListenError);
|
|
12
19
|
const server = httpOrHttps.listen(port, () => {
|
|
20
|
+
emitter.off('error', onListenError);
|
|
13
21
|
welcomeMessage({ port });
|
|
14
22
|
accept(server);
|
|
15
23
|
});
|
|
@@ -4,10 +4,12 @@ import etag from '@koa/etag';
|
|
|
4
4
|
import { closeAllDbConnections } from '@rvoh/dream/db';
|
|
5
5
|
import Koa from 'koa';
|
|
6
6
|
import conditional from 'koa-conditional-get';
|
|
7
|
+
import * as util from 'node:util';
|
|
7
8
|
import logIfDevelopment from '../controller/helpers/logIfDevelopment.js';
|
|
8
9
|
import EnvInternal from '../helpers/EnvInternal.js';
|
|
9
10
|
import PsychicApp from '../psychic-app/index.js';
|
|
10
11
|
import PsychicRouter from '../router/index.js';
|
|
12
|
+
import errorBoundaryMiddleware, { ERROR_LOGGING_DEPTH } from './helpers/errorBoundaryMiddleware.js';
|
|
11
13
|
import startPsychicServer, { createPsychicHttpInstance, } from './helpers/startPsychicServer.js';
|
|
12
14
|
// const debugEnabled = debuglog('psychic').enabled
|
|
13
15
|
export default class PsychicServer {
|
|
@@ -32,6 +34,21 @@ export default class PsychicServer {
|
|
|
32
34
|
if (this.booted)
|
|
33
35
|
return;
|
|
34
36
|
const psychicApp = PsychicApp.getOrFail();
|
|
37
|
+
// outermost middleware: catches errors thrown from anything mounted
|
|
38
|
+
// after it (body parser, cors, custom `psy.use` middleware, after-routes
|
|
39
|
+
// mounts) and escalates genuine server errors to server:error hooks.
|
|
40
|
+
// Errors from controller actions are still processed by the router; the
|
|
41
|
+
// boundary only sees errors the router never caught.
|
|
42
|
+
this.koaApp.use(errorBoundaryMiddleware());
|
|
43
|
+
// residual error sink: the few errors Koa surfaces outside the error
|
|
44
|
+
// boundary (errors thrown after headers were sent, response-stream
|
|
45
|
+
// failures, errors the router deliberately re-throws to Koa in
|
|
46
|
+
// dev/test). Registering a listener supersedes Koa's default stderr
|
|
47
|
+
// logger, so these flow through the configured psychic logger instead
|
|
48
|
+
// of going dark.
|
|
49
|
+
this.koaApp.on('error', (err) => {
|
|
50
|
+
PsychicApp.logWithLevel('error', util.inspect(err, { depth: ERROR_LOGGING_DEPTH }));
|
|
51
|
+
});
|
|
35
52
|
this.setSecureDefaultHeaders();
|
|
36
53
|
this.koaApp.use(async (ctx, next) => {
|
|
37
54
|
Object.keys(psychicApp.defaultResponseHeaders).forEach(key => {
|
|
@@ -57,7 +74,7 @@ export default class PsychicServer {
|
|
|
57
74
|
throw new Error(`
|
|
58
75
|
Failed to boot psychic config. the error thrown was:
|
|
59
76
|
${error.message}
|
|
60
|
-
|
|
77
|
+
`, { cause: error });
|
|
61
78
|
}
|
|
62
79
|
for (const serverInitAfterMiddlewareHook of PsychicApp.getOrFail().specialHooks
|
|
63
80
|
.serverInitAfterMiddleware) {
|
|
@@ -134,9 +151,39 @@ export default class PsychicServer {
|
|
|
134
151
|
}
|
|
135
152
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
153
|
$attached = {};
|
|
154
|
+
/**
|
|
155
|
+
* @internal
|
|
156
|
+
*
|
|
157
|
+
* the maximum number of milliseconds graceful shutdown is allowed
|
|
158
|
+
* to run before the process exits anyway, so that a hung
|
|
159
|
+
* `server:shutdown` hook or db close can never keep a dying
|
|
160
|
+
* process alive
|
|
161
|
+
*/
|
|
162
|
+
static SHUTDOWN_TIMEOUT_MS = 15000;
|
|
137
163
|
async shutdownAndExit() {
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
let exitCode = 0;
|
|
165
|
+
try {
|
|
166
|
+
await this.stopWithTimeout();
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
PsychicApp.logWithLevel('error', '[psychic] error during graceful shutdown:', error);
|
|
170
|
+
exitCode = 1;
|
|
171
|
+
}
|
|
172
|
+
process.exit(exitCode);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* @internal
|
|
176
|
+
*
|
|
177
|
+
* runs {@link PsychicServer.stop}, rejecting if it has not settled
|
|
178
|
+
* within SHUTDOWN_TIMEOUT_MS
|
|
179
|
+
*/
|
|
180
|
+
async stopWithTimeout() {
|
|
181
|
+
await Promise.race([
|
|
182
|
+
this.stop(),
|
|
183
|
+
new Promise((_, reject) => {
|
|
184
|
+
setTimeout(() => reject(new Error(`[psychic] graceful shutdown timed out after ${PsychicServer.SHUTDOWN_TIMEOUT_MS}ms`)), PsychicServer.SHUTDOWN_TIMEOUT_MS).unref();
|
|
185
|
+
}),
|
|
186
|
+
]);
|
|
140
187
|
}
|
|
141
188
|
async stop({ bypassClosingDbConnections = false, gracefulShutdownTimeoutMillis = 10_000, } = {}) {
|
|
142
189
|
for (const hook of PsychicApp.getOrFail().specialHooks.serverShutdown) {
|
|
@@ -72,8 +72,12 @@ export declare class OpenApiSpecDiff {
|
|
|
72
72
|
private getHeadBranch;
|
|
73
73
|
/**
|
|
74
74
|
* Validates that oasdiff is installed and builds the oasdiff config
|
|
75
|
+
*
|
|
76
|
+
* Marked `protected` so tests can supply a deterministic config (e.g. a
|
|
77
|
+
* command guaranteed to fail) instead of probing the local oasdiff install
|
|
78
|
+
* and git remote. Production code never overrides this.
|
|
75
79
|
*/
|
|
76
|
-
|
|
80
|
+
protected getOasDiffConfig(): OasDiffConfig;
|
|
77
81
|
/**
|
|
78
82
|
* Runs oasdiff command and returns the output
|
|
79
83
|
*/
|
|
@@ -149,3 +153,26 @@ export declare class BreakingChangesDetectedInOpenApiSpecError extends Error {
|
|
|
149
153
|
constructor(oasdiffConfig: OasDiffConfig);
|
|
150
154
|
get message(): string;
|
|
151
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Thrown when the diff tooling itself fails (oasdiff invocation error,
|
|
158
|
+
* unreadable head-branch spec, missing spec file), as opposed to oasdiff
|
|
159
|
+
* successfully running and detecting breaking changes
|
|
160
|
+
* ({@link BreakingChangesDetectedInOpenApiSpecError}). Under
|
|
161
|
+
* `psy diff:openapi --fail-on-breaking`, both exit nonzero, but with
|
|
162
|
+
* distinguishable messages: an inconclusive diff must never pass a CI gate
|
|
163
|
+
* as "no breaking changes".
|
|
164
|
+
*/
|
|
165
|
+
export declare class OpenApiSpecDiffToolFailureError extends Error {
|
|
166
|
+
private readonly erroredFiles;
|
|
167
|
+
constructor(erroredFiles: string[]);
|
|
168
|
+
get message(): string;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Internal marker for a failed oasdiff invocation, so
|
|
172
|
+
* {@link OpenApiSpecDiff.compareConfig} can distinguish "oasdiff itself
|
|
173
|
+
* failed" from "could not retrieve head-branch content" when recording
|
|
174
|
+
* a per-file error.
|
|
175
|
+
*/
|
|
176
|
+
export declare class OasDiffCommandFailedError extends Error {
|
|
177
|
+
constructor(subcommand: string, cause: unknown);
|
|
178
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { BreakingChangesDetectedInOpenApiSpecError } from './helpers/OpenApiSpecDiff.js';
|
|
1
|
+
export { BreakingChangesDetectedInOpenApiSpecError, OpenApiSpecDiffToolFailureError, } from './helpers/OpenApiSpecDiff.js';
|
|
2
2
|
export default class PsychicBin {
|
|
3
3
|
static generateController(controllerName: string, actions: string[]): Promise<void>;
|
|
4
4
|
static generateResource(route: string, fullyQualifiedModelName: string, columnsWithTypes: string[], options: {
|
|
@@ -259,6 +259,24 @@ export default class PsychicApp {
|
|
|
259
259
|
use(on: PsychicUseEventType, handler: Koa.Middleware): void;
|
|
260
260
|
use(handler: Koa.Middleware): void;
|
|
261
261
|
plugin(cb: (app: PsychicApp) => void | Promise<void>): void;
|
|
262
|
+
/**
|
|
263
|
+
* Registers a callback for one of psychic's lifecycle hooks.
|
|
264
|
+
*
|
|
265
|
+
* ### `server:error`
|
|
266
|
+
*
|
|
267
|
+
* Called any time the server encounters an error it isn't sure how to
|
|
268
|
+
* respond to (i.e. a 500). This covers errors thrown from controller
|
|
269
|
+
* actions as well as errors raised outside the router — e.g. in the body
|
|
270
|
+
* parser, cors callbacks, custom `psy.use` middleware, or after-routes
|
|
271
|
+
* mounts — which are captured by an error boundary mounted outermost in
|
|
272
|
+
* the middleware stack. Hooks are awaited and may shape the response via
|
|
273
|
+
* `ctx`; if they don't, psychic responds 500. Errors carrying a 4xx status
|
|
274
|
+
* (e.g. a body-parser 400) are rendered as that status and never reach
|
|
275
|
+
* `server:error` hooks.
|
|
276
|
+
*
|
|
277
|
+
* NOTE: once any `server:error` hook is registered, psychic considers the
|
|
278
|
+
* error handled and does not re-throw it to Koa.
|
|
279
|
+
*/
|
|
262
280
|
on<T extends PsychicHookEventType>(hookEventType: T, cb: T extends 'server:error' ? (err: Error, ctx: Koa.Context) => void | Promise<void> : T extends 'server:init:before-middleware' ? (psychicServer: PsychicServer) => void | Promise<void> : T extends 'server:init:after-middleware' ? (psychicServer: PsychicServer) => void | Promise<void> : T extends 'server:start' ? (psychicServer: PsychicServer) => void | Promise<void> : T extends 'server:shutdown' ? (psychicServer: PsychicServer) => void | Promise<void> : T extends 'server:init:after-routes' ? (psychicServer: PsychicServer) => void | Promise<void> : T extends 'cli:start' ? (program: Command) => void | Promise<void> : T extends 'cli:sync' ? () => any : (conf: PsychicApp) => void | Promise<void>): void;
|
|
263
281
|
set(option: 'openapi', name: string, value: NamedPsychicOpenapiOptions): void;
|
|
264
282
|
set<Opt extends PsychicAppOption>(option: Opt, value: Opt extends 'appName' ? string : Opt extends 'apiOnly' ? boolean : Opt extends 'defaultResponseHeaders' ? Record<string, string | null> : Opt extends 'httpServerOptions' ? http.ServerOptions | https.ServerOptions : Opt extends 'encryption' ? PsychicAppEncryptionOptions : Opt extends 'cors' ? cors.Options : Opt extends 'cookie' ? CustomCookieOptions : Opt extends 'apiRoot' ? string : Opt extends 'importExtension' ? GeneratorImportStyle : Opt extends 'sessionCookieName' ? string : Opt extends 'json' ? BodyParserOptions : Opt extends 'logger' ? PsychicLogger : Opt extends 'ssl' ? PsychicSslCredentials : Opt extends 'openapi' ? DefaultPsychicOpenapiOptions : Opt extends 'paths' ? PsychicPathOptions : Opt extends 'port' ? number : Opt extends 'saltRounds' ? number : Opt extends 'packageManager' ? DreamAppAllowedPackageManagersEnum : Opt extends 'inflections' ? () => void | Promise<void> : Opt extends 'routes' ? (r: PsychicRouter) => void | Promise<void> : Opt extends 'redirectAllowedHosts' ? readonly string[] : never): void;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Koa from 'koa';
|
|
2
|
+
export declare const ERROR_LOGGING_DEPTH = 6;
|
|
3
|
+
/**
|
|
4
|
+
* @internal
|
|
5
|
+
*
|
|
6
|
+
* Set on `ctx.state` by the router when it processes an error thrown from a
|
|
7
|
+
* controller action (running `server:error` hooks when any are registered,
|
|
8
|
+
* or deliberately re-throwing to Koa when none are). The error boundary
|
|
9
|
+
* passes marked errors through untouched so a single request can never run
|
|
10
|
+
* `server:error` hooks twice, and so the router's deliberate dev/test
|
|
11
|
+
* re-throw behavior reaches Koa exactly as it did before the boundary
|
|
12
|
+
* existed.
|
|
13
|
+
*/
|
|
14
|
+
export declare const psychicRouterProcessedErrorStateKey = "_psychicRouterProcessedError";
|
|
15
|
+
/**
|
|
16
|
+
* @internal
|
|
17
|
+
*
|
|
18
|
+
* The outermost middleware psychic mounts. Everything mounted after it —
|
|
19
|
+
* secure default headers, etag, cors, the body parser, `psy.use` middleware,
|
|
20
|
+
* after-routes mounts, and the router itself — runs inside `await next()`,
|
|
21
|
+
* so any error those layers throw (and the router doesn't catch) lands here
|
|
22
|
+
* instead of falling through to Koa's default handler.
|
|
23
|
+
*
|
|
24
|
+
* Errors carrying a 4xx status (e.g. a body-parser 400, or an `HttpError`
|
|
25
|
+
* thrown from custom middleware) already name their response: the boundary
|
|
26
|
+
* renders that status without involving `server:error` hooks. Anything else
|
|
27
|
+
* is a genuine server error: it is logged, given a default 500 response, and
|
|
28
|
+
* escalated to `server:error` hooks, which may reshape the response.
|
|
29
|
+
*/
|
|
30
|
+
export default function errorBoundaryMiddleware(): Koa.Middleware;
|
|
@@ -16,7 +16,23 @@ export default class PsychicServer {
|
|
|
16
16
|
start(port?: number): Promise<boolean>;
|
|
17
17
|
attach(id: string, obj: any): void;
|
|
18
18
|
$attached: Record<string, any>;
|
|
19
|
+
/**
|
|
20
|
+
* @internal
|
|
21
|
+
*
|
|
22
|
+
* the maximum number of milliseconds graceful shutdown is allowed
|
|
23
|
+
* to run before the process exits anyway, so that a hung
|
|
24
|
+
* `server:shutdown` hook or db close can never keep a dying
|
|
25
|
+
* process alive
|
|
26
|
+
*/
|
|
27
|
+
static readonly SHUTDOWN_TIMEOUT_MS = 15000;
|
|
19
28
|
private shutdownAndExit;
|
|
29
|
+
/**
|
|
30
|
+
* @internal
|
|
31
|
+
*
|
|
32
|
+
* runs {@link PsychicServer.stop}, rejecting if it has not settled
|
|
33
|
+
* within SHUTDOWN_TIMEOUT_MS
|
|
34
|
+
*/
|
|
35
|
+
private stopWithTimeout;
|
|
20
36
|
stop({ bypassClosingDbConnections, gracefulShutdownTimeoutMillis, }?: {
|
|
21
37
|
bypassClosingDbConnections?: boolean;
|
|
22
38
|
gracefulShutdownTimeoutMillis?: number;
|