@sjoblomtobias/relay 2.0.0 → 3.0.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/server.d.ts CHANGED
@@ -1,20 +1,30 @@
1
1
  import express from "express";
2
2
  import type { Route } from "./route.js";
3
3
  import type { RequestHandler } from "express";
4
- export type RouteConstructor = new (authMiddleware?: RequestHandler) => Route;
4
+ export type RouteConstructor = new (...args: any[]) => Route;
5
+ export type ServerOptions = {
6
+ /** Include error message and stack trace in 500 responses. Never enable in production. Defaults to false. */
7
+ exposeErrors?: boolean;
8
+ /** Maximum time in milliseconds a client has to send a complete request. Defaults to 30000. */
9
+ requestTimeoutMs?: number;
10
+ };
5
11
  export declare class Server {
6
12
  private port;
7
13
  private app;
14
+ private stack;
8
15
  private authMiddleware?;
16
+ private routesRegistered;
17
+ private readonly options;
9
18
  /**
10
19
  * Creates an instance of the Server class.
11
20
  * @param port The port number on which the server will listen.
12
21
  * @param authMiddleware Optional authentication middleware to be used for private routes.
22
+ * @param options Optional server settings.
13
23
  */
14
- constructor(port: number, authMiddleware?: RequestHandler);
24
+ constructor(port: number, authMiddleware?: RequestHandler, options?: ServerOptions);
15
25
  /**
16
- * Default error handler. Returns a generic 500 response, only including
17
- * the error message and stack trace when NODE_ENV is "development".
26
+ * Default error handler. Client errors (4xx) keep their status; everything else is a
27
+ * generic 500 and is logged. Error details are only sent when `exposeErrors` is enabled.
18
28
  */
19
29
  private readonly errorHandler;
20
30
  /**
@@ -23,7 +33,8 @@ export declare class Server {
23
33
  */
24
34
  registerRoutes(...routeClasses: Array<RouteConstructor>): void;
25
35
  /**
26
- * Adds middleware functions to the Express application.
36
+ * Adds middleware functions to the Express application. Use this instead of
37
+ * `getApp().use()` so errors from the middleware reach the error handler.
27
38
  * @param handlers - Array of Express request handlers (middleware functions).
28
39
  */
29
40
  use(...handlers: Array<express.RequestHandler>): Promise<void>;
@@ -36,7 +47,12 @@ export declare class Server {
36
47
  * Starts the Express server.
37
48
  * @param before Optional callback invoked before the server starts.
38
49
  * @param after Optional callback invoked after the server has started.
50
+ * @throws If the server cannot listen, e.g. the port is already in use.
39
51
  */
40
52
  start(before?: () => void | Promise<void>, after?: () => void | Promise<void>): Promise<void>;
53
+ /**
54
+ * Sets the authentication middleware for private routes. Must be called before
55
+ * registerRoutes(), since routes capture the middleware when they are registered.
56
+ */
41
57
  setAuthMiddleware(middleware: RequestHandler): void;
42
58
  }
package/dist/server.js CHANGED
@@ -7,29 +7,58 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
+ import { STATUS_CODES } from "node:http";
10
11
  import express from "express";
12
+ const securityHeaders = (req, res, next) => {
13
+ res.set({
14
+ "X-Content-Type-Options": "nosniff",
15
+ "X-Frame-Options": "DENY",
16
+ "Referrer-Policy": "no-referrer"
17
+ });
18
+ next();
19
+ };
20
+ /**
21
+ * Returns the 4xx status carried by an error (e.g. from body parsing), or 500 for anything else.
22
+ */
23
+ function errorStatus(err) {
24
+ const { status, statusCode } = (err !== null && err !== void 0 ? err : {});
25
+ const candidate = status !== null && status !== void 0 ? status : statusCode;
26
+ return typeof candidate === "number" && candidate >= 400 && candidate < 500 ? candidate : 500;
27
+ }
11
28
  export class Server {
12
29
  /**
13
30
  * Creates an instance of the Server class.
14
31
  * @param port The port number on which the server will listen.
15
32
  * @param authMiddleware Optional authentication middleware to be used for private routes.
33
+ * @param options Optional server settings.
16
34
  */
17
- constructor(port, authMiddleware) {
35
+ constructor(port, authMiddleware, options = {}) {
36
+ this.routesRegistered = false;
18
37
  /**
19
- * Default error handler. Returns a generic 500 response, only including
20
- * the error message and stack trace when NODE_ENV is "development".
38
+ * Default error handler. Client errors (4xx) keep their status; everything else is a
39
+ * generic 500 and is logged. Error details are only sent when `exposeErrors` is enabled.
21
40
  */
22
41
  this.errorHandler = (err, req, res, next) => {
23
42
  if (res.headersSent) {
24
43
  next(err);
25
44
  return;
26
45
  }
27
- const isDevelopment = process.env.NODE_ENV === "development";
28
- res.status(500).json(Object.assign({ error: "Internal Server Error" }, (isDevelopment && { message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined })));
46
+ const status = errorStatus(err);
47
+ if (status === 500) {
48
+ console.error(err);
49
+ }
50
+ res.status(status).json(Object.assign({ error: STATUS_CODES[status] }, (status === 500 && this.options.exposeErrors && { message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined })));
29
51
  };
30
52
  this.port = port;
53
+ this.options = Object.assign({ exposeErrors: false, requestTimeoutMs: 30000 }, options);
31
54
  this.app = express();
32
55
  this.app.disable("x-powered-by");
56
+ this.app.use(securityHeaders);
57
+ // Routes and middleware live in an inner router so the error handler, mounted
58
+ // after it, always runs last and catches errors from everything registered via this class.
59
+ this.stack = express.Router();
60
+ this.app.use(this.stack);
61
+ this.app.use(this.errorHandler);
33
62
  this.authMiddleware = authMiddleware;
34
63
  }
35
64
  /**
@@ -37,18 +66,20 @@ export class Server {
37
66
  * @param routeClasses - Route classes decorated with @Public or @Private.
38
67
  */
39
68
  registerRoutes(...routeClasses) {
69
+ this.routesRegistered = true;
40
70
  routeClasses.forEach((RouteClass) => {
41
71
  const routeInstance = new RouteClass(this.authMiddleware);
42
- this.app.use(routeInstance.router);
72
+ this.stack.use(routeInstance.router);
43
73
  });
44
74
  }
45
75
  /**
46
- * Adds middleware functions to the Express application.
76
+ * Adds middleware functions to the Express application. Use this instead of
77
+ * `getApp().use()` so errors from the middleware reach the error handler.
47
78
  * @param handlers - Array of Express request handlers (middleware functions).
48
79
  */
49
80
  use(...handlers) {
50
81
  return __awaiter(this, void 0, void 0, function* () {
51
- this.app.use(handlers);
82
+ this.stack.use(handlers);
52
83
  });
53
84
  }
54
85
  /**
@@ -62,24 +93,31 @@ export class Server {
62
93
  * Starts the Express server.
63
94
  * @param before Optional callback invoked before the server starts.
64
95
  * @param after Optional callback invoked after the server has started.
96
+ * @throws If the server cannot listen, e.g. the port is already in use.
65
97
  */
66
98
  start(before, after) {
67
99
  return __awaiter(this, void 0, void 0, function* () {
68
100
  if (before) {
69
101
  yield before();
70
102
  }
71
- this.app.use(this.errorHandler);
72
- yield new Promise((resolve) => {
73
- this.app.listen(this.port, () => {
74
- resolve();
75
- });
103
+ yield new Promise((resolve, reject) => {
104
+ // Express 5 passes listen errors (e.g. EADDRINUSE) to the callback.
105
+ const httpServer = this.app.listen(this.port, (err) => (err ? reject(err) : resolve()));
106
+ httpServer.requestTimeout = this.options.requestTimeoutMs;
76
107
  });
77
108
  if (after) {
78
109
  yield after();
79
110
  }
80
111
  });
81
112
  }
113
+ /**
114
+ * Sets the authentication middleware for private routes. Must be called before
115
+ * registerRoutes(), since routes capture the middleware when they are registered.
116
+ */
82
117
  setAuthMiddleware(middleware) {
118
+ if (this.routesRegistered) {
119
+ throw new Error("setAuthMiddleware() must be called before registerRoutes(); already registered routes would not use it.");
120
+ }
83
121
  this.authMiddleware = middleware;
84
122
  }
85
123
  }
package/package.json CHANGED
@@ -1,17 +1,19 @@
1
1
  {
2
2
  "name": "@sjoblomtobias/relay",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "build": "tsc",
8
8
  "dev": "tsx --watch test/index.ts",
9
9
  "lint": "eslint src",
10
- "format": "prettier --write src"
10
+ "format": "prettier --write src",
11
+ "typecheck:test": "tsc -p tsconfig.test.json",
12
+ "prepublishOnly": "npm run build && npm run lint && npm run typecheck:test"
11
13
  },
12
14
  "author": "Tobias Sjöblom",
13
15
  "license": "Pirate",
14
- "description": "A lightweight TypeScript framework for building structured Express applications with decorator-based routing and automatic route loading.",
16
+ "description": "A lightweight TypeScript framework for building structured Express applications with decorator-based routing.",
15
17
  "keywords": [
16
18
  "express",
17
19
  "typescript",
package/src/server.ts CHANGED
@@ -1,39 +1,80 @@
1
+ import { STATUS_CODES } from "node:http";
1
2
  import express from "express";
2
3
  import type { Route } from "./route.js";
3
4
  import type { ErrorRequestHandler, RequestHandler } from "express";
4
5
 
5
- export type RouteConstructor = new (authMiddleware?: RequestHandler) => Route;
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ export type RouteConstructor = new (...args: any[]) => Route;
8
+
9
+ export type ServerOptions = {
10
+ /** Include error message and stack trace in 500 responses. Never enable in production. Defaults to false. */
11
+ exposeErrors?: boolean;
12
+ /** Maximum time in milliseconds a client has to send a complete request. Defaults to 30000. */
13
+ requestTimeoutMs?: number;
14
+ };
15
+
16
+ const securityHeaders: RequestHandler = (req, res, next) => {
17
+ res.set({
18
+ "X-Content-Type-Options": "nosniff",
19
+ "X-Frame-Options": "DENY",
20
+ "Referrer-Policy": "no-referrer"
21
+ });
22
+ next();
23
+ };
24
+
25
+ /**
26
+ * Returns the 4xx status carried by an error (e.g. from body parsing), or 500 for anything else.
27
+ */
28
+ function errorStatus(err: unknown): number {
29
+ const { status, statusCode } = (err ?? {}) as { status?: unknown; statusCode?: unknown };
30
+ const candidate = status ?? statusCode;
31
+ return typeof candidate === "number" && candidate >= 400 && candidate < 500 ? candidate : 500;
32
+ }
6
33
 
7
34
  export class Server {
8
35
  private port: number;
9
36
  private app: express.Express;
37
+ private stack: express.Router;
10
38
  private authMiddleware?: RequestHandler;
39
+ private routesRegistered = false;
40
+ private readonly options: Required<ServerOptions>;
11
41
 
12
42
  /**
13
43
  * Creates an instance of the Server class.
14
44
  * @param port The port number on which the server will listen.
15
45
  * @param authMiddleware Optional authentication middleware to be used for private routes.
46
+ * @param options Optional server settings.
16
47
  */
17
- constructor(port: number, authMiddleware?: RequestHandler) {
48
+ constructor(port: number, authMiddleware?: RequestHandler, options: ServerOptions = {}) {
18
49
  this.port = port;
50
+ this.options = { exposeErrors: false, requestTimeoutMs: 30_000, ...options };
19
51
  this.app = express();
20
52
  this.app.disable("x-powered-by");
53
+ this.app.use(securityHeaders);
54
+ // Routes and middleware live in an inner router so the error handler, mounted
55
+ // after it, always runs last and catches errors from everything registered via this class.
56
+ this.stack = express.Router();
57
+ this.app.use(this.stack);
58
+ this.app.use(this.errorHandler);
21
59
  this.authMiddleware = authMiddleware;
22
60
  }
23
61
 
24
62
  /**
25
- * Default error handler. Returns a generic 500 response, only including
26
- * the error message and stack trace when NODE_ENV is "development".
63
+ * Default error handler. Client errors (4xx) keep their status; everything else is a
64
+ * generic 500 and is logged. Error details are only sent when `exposeErrors` is enabled.
27
65
  */
28
66
  private readonly errorHandler: ErrorRequestHandler = (err, req, res, next) => {
29
67
  if (res.headersSent) {
30
68
  next(err);
31
69
  return;
32
70
  }
33
- const isDevelopment = process.env.NODE_ENV === "development";
34
- res.status(500).json({
35
- error: "Internal Server Error",
36
- ...(isDevelopment && { message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined })
71
+ const status = errorStatus(err);
72
+ if (status === 500) {
73
+ console.error(err);
74
+ }
75
+ res.status(status).json({
76
+ error: STATUS_CODES[status],
77
+ ...(status === 500 && this.options.exposeErrors && { message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined })
37
78
  });
38
79
  };
39
80
 
@@ -42,18 +83,20 @@ export class Server {
42
83
  * @param routeClasses - Route classes decorated with @Public or @Private.
43
84
  */
44
85
  public registerRoutes(...routeClasses: Array<RouteConstructor>): void {
86
+ this.routesRegistered = true;
45
87
  routeClasses.forEach((RouteClass) => {
46
88
  const routeInstance = new RouteClass(this.authMiddleware);
47
- this.app.use(routeInstance.router);
89
+ this.stack.use(routeInstance.router);
48
90
  });
49
91
  }
50
92
 
51
93
  /**
52
- * Adds middleware functions to the Express application.
94
+ * Adds middleware functions to the Express application. Use this instead of
95
+ * `getApp().use()` so errors from the middleware reach the error handler.
53
96
  * @param handlers - Array of Express request handlers (middleware functions).
54
97
  */
55
98
  public async use(...handlers: Array<express.RequestHandler>): Promise<void> {
56
- this.app.use(handlers);
99
+ this.stack.use(handlers);
57
100
  }
58
101
 
59
102
  /**
@@ -68,23 +111,30 @@ export class Server {
68
111
  * Starts the Express server.
69
112
  * @param before Optional callback invoked before the server starts.
70
113
  * @param after Optional callback invoked after the server has started.
114
+ * @throws If the server cannot listen, e.g. the port is already in use.
71
115
  */
72
116
  public async start(before?: () => void | Promise<void>, after?: () => void | Promise<void>): Promise<void> {
73
117
  if (before) {
74
118
  await before();
75
119
  }
76
- this.app.use(this.errorHandler);
77
- await new Promise<void>((resolve) => {
78
- this.app.listen(this.port, () => {
79
- resolve();
80
- });
120
+ await new Promise<void>((resolve, reject) => {
121
+ // Express 5 passes listen errors (e.g. EADDRINUSE) to the callback.
122
+ const httpServer = this.app.listen(this.port, (err?: Error) => (err ? reject(err) : resolve()));
123
+ httpServer.requestTimeout = this.options.requestTimeoutMs;
81
124
  });
82
125
  if (after) {
83
126
  await after();
84
127
  }
85
128
  }
86
129
 
130
+ /**
131
+ * Sets the authentication middleware for private routes. Must be called before
132
+ * registerRoutes(), since routes capture the middleware when they are registered.
133
+ */
87
134
  public setAuthMiddleware(middleware: RequestHandler) {
135
+ if (this.routesRegistered) {
136
+ throw new Error("setAuthMiddleware() must be called before registerRoutes(); already registered routes would not use it.");
137
+ }
88
138
  this.authMiddleware = middleware;
89
139
  }
90
140
  }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "noEmit": true,
6
+ "declaration": false
7
+ },
8
+ "include": ["src/**/*", "test/**/*"],
9
+ "exclude": ["node_modules", "dist"]
10
+ }