@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.
@@ -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
- const errorOutput = error instanceof Error ? error.message : String(error);
137
- return errorOutput;
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
- const { breaking, changelog, error } = this.compareSpecs(tempMainFilePath, currentFilePath);
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 = `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(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
- await this.stop();
139
- process.exit();
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
- const errorOutput = error instanceof Error ? error.message : String(error);
137
- return errorOutput;
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
- const { breaking, changelog, error } = this.compareSpecs(tempMainFilePath, currentFilePath);
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 = `Could not retrieve ${config.outputFilepath} from ${this.oasdiffConfig?.headBranch} branch: ${String(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
- await this.stop();
139
- process.exit();
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
- private getOasDiffConfig;
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;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.10.1",
5
+ "version": "3.11.0",
6
6
  "author": "RVOHealth",
7
7
  "publishConfig": {
8
8
  "access": "public"