@nano-app/backend 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  Preconfigured Express server with **file-convention routes** and **plugins**: it mounts the base middlewares, discovers your routes, formats every error as JSON, and groups extra endpoints in plugins.
6
6
 
7
- - Node ≥ 20, ESM.
7
+ - Node.js 22.23.4, 24.22.1 or 26.12.1 and newer. ESM only.
8
8
  - Optional PostgreSQL layer through your generated Prisma client. Disabled by default: without it no database code is loaded.
9
9
 
10
10
  ```bash
11
- npm install @nano-app/backend
11
+ pnpm add @nano-app/backend
12
12
  ```
13
13
 
14
14
  ## Quick start
@@ -24,17 +24,17 @@ src/
24
24
 
25
25
  ```ts
26
26
  // src/app.ts
27
- import { createNanoApp } from "@nano-app/backend";
27
+ import { createNanoApp } from '@nano-app/backend';
28
28
 
29
29
  createNanoApp({ port: 3000 });
30
30
  ```
31
31
 
32
32
  ```ts
33
33
  // src/routes/health.route.ts
34
- import { createRoute } from "@nano-app/backend";
34
+ import { createRoute } from '@nano-app/backend';
35
35
 
36
36
  export default createRoute({
37
- GET: async () => ({ success: true, result: { status: "ok" } }),
37
+ GET: async () => ({ success: true, result: { status: 'ok' } }),
38
38
  });
39
39
  ```
40
40
 
@@ -52,9 +52,9 @@ Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with th
52
52
 
53
53
  ```ts
54
54
  export default createRoute({
55
- expression: "/:id", // → /users/:id
55
+ expression: '/:id', // → /users/:id
56
56
  GET: async req => ({ success: true, result: { id: req.params.id } }),
57
- DELETE: async req => ({ success: true, message: "Deleted" }),
57
+ DELETE: async req => ({ success: true, message: 'Deleted' }),
58
58
  });
59
59
  ```
60
60
 
@@ -69,6 +69,7 @@ type ApiResponse<T> = {
69
69
  };
70
70
  ```
71
71
 
72
+ - A write returns the item as it is after it in `result`: `POST` the created item, `PUT` the updated item, `DELETE` the deleted item. The frontend keeps what an update returns.
72
73
  - Undeclared methods respond `405`.
73
74
  - An invalid file is discarded with a warning; the other routes keep working.
74
75
 
@@ -85,13 +86,13 @@ Every option is optional.
85
86
  | `mainPlugin` | `{ routesDir: "src/routes" }` | Your routes (`routesDir`, relative to `cwd`), `basePath` (e.g. `/api`, no trailing slash) and database: see [Database](#database). |
86
87
  | `plugins` | `[]` | Plugins mounted around your routes, in order: see [Plugins](#plugins). |
87
88
 
88
- `packages` accepts `morgan`, `cors`, `helmet`, `compression`, `rateLimit`, `express` (`express.json` options), `urlencoded` and `betterAuth` (off by default: see [Better Auth](#better-auth)). The merge is **per section**: passing one key keeps the others; pass `false`/`undefined` to disable one.
89
+ `packages` accepts `morgan`, `cors`, `helmet`, `compression`, `rateLimit`, `express` (`express.json` options), `urlencoded` and `betterAuth` (off by default: see [Better Auth](#better-auth)). The merge is **per section**: passing one key keeps the others; pass `false` to disable one.
89
90
 
90
91
  ```ts
91
92
  createNanoApp({
92
- settings: { "trust proxy": 1 },
93
+ settings: { 'trust proxy': 1 },
93
94
  packages: {
94
- cors: { origin: "https://my-app.com" },
95
+ cors: { origin: 'https://my-app.com' },
95
96
  helmet: false,
96
97
  rateLimit: { windowMs: 60_000, max: 120 },
97
98
  },
@@ -100,10 +101,10 @@ createNanoApp({
100
101
 
101
102
  ## Database
102
103
 
103
- Off until enabled: at startup nano-app generates the Prisma client of your schema, checks that its tables can be read and passes it to your handlers as their second parameter. **It never modifies your database**: applying the schema (`prisma db push`, `prisma migrate dev`, `prisma migrate deploy`) is up to you, with Prisma.
104
+ Off until enabled: at startup nano-app generates the Prisma client of your schema, checks that its tables can be read and passes it to your handlers as their second parameter. **It never modifies your schemas**: applying them (`prisma db push`, `prisma migrate dev`, `prisma migrate deploy`) is up to you, with Prisma.
104
105
 
105
106
  ```bash
106
- npm install prisma @prisma/client @prisma/adapter-pg pg # prisma too: the startup generates the client
107
+ pnpm add prisma @prisma/client @prisma/adapter-pg pg # prisma too: the startup generates the client
107
108
  ```
108
109
 
109
110
  ```prisma
@@ -130,8 +131,8 @@ createNanoApp({ mainPlugin: { database: { enabled: true } } });
130
131
 
131
132
  ```ts
132
133
  // src/routes/users.route.ts
133
- import { createRoute } from "@nano-app/backend";
134
- import type { PrismaClient } from "../../.nano-app/prisma-clients/app/generated/client";
134
+ import { createRoute } from '@nano-app/backend';
135
+ import type { PrismaClient } from '../../.nano-app/prisma-clients/app/generated/client';
135
136
 
136
137
  export default createRoute<PrismaClient>({
137
138
  GET: async (_req, { db }) => ({ success: true, result: await db!.user.findMany() }),
@@ -157,12 +158,12 @@ export default createRoute<PrismaClient>({
157
158
  Users, sign-in and sessions with [Better Auth](https://www.better-auth.com), on your database. Off until `packages.betterAuth` gets its options (everything but `database`: nano-app passes your Prisma client).
158
159
 
159
160
  ```bash
160
- npm install better-auth
161
+ pnpm add better-auth
161
162
  ```
162
163
 
163
164
  ```ts
164
165
  // src/auth.ts: the options, and an instance only for the Better Auth CLI
165
- import { betterAuth, type BetterAuthOptions } from "better-auth";
166
+ import { betterAuth, type BetterAuthOptions } from 'better-auth';
166
167
 
167
168
  export const authOptions = { emailAndPassword: { enabled: true } } satisfies BetterAuthOptions;
168
169
  export const auth = betterAuth(authOptions);
@@ -170,8 +171,8 @@ export const auth = betterAuth(authOptions);
170
171
 
171
172
  ```ts
172
173
  // src/app.ts
173
- import { createNanoApp } from "@nano-app/backend";
174
- import { authOptions } from "./auth";
174
+ import { createNanoApp } from '@nano-app/backend';
175
+ import { authOptions } from './auth';
175
176
 
176
177
  createNanoApp({
177
178
  packages: { betterAuth: authOptions },
@@ -203,20 +204,20 @@ A plugin is a group of endpoints mounted under a base path. Your routes are moun
203
204
 
204
205
  ```ts
205
206
  // src/plugins/hello/index.ts
206
- import { createPlugin } from "@nano-app/backend";
207
- import { dirname, resolve } from "node:path";
208
- import { fileURLToPath } from "node:url";
207
+ import { createPlugin } from '@nano-app/backend';
208
+ import { dirname, resolve } from 'node:path';
209
+ import { fileURLToPath } from 'node:url';
209
210
 
210
211
  export default createPlugin((options?: { basePath?: string }) => ({
211
- name: "hello",
212
- basePath: options?.basePath ?? "/hello",
213
- routesDir: resolve(dirname(fileURLToPath(import.meta.url)), "routes"),
212
+ name: 'hello',
213
+ basePath: options?.basePath ?? '/hello',
214
+ routesDir: resolve(dirname(fileURLToPath(import.meta.url)), 'routes'),
214
215
  }));
215
216
  ```
216
217
 
217
218
  ```ts
218
219
  // src/plugins/hello/routes/index.route.ts → /hello
219
- import { createRoute } from "@nano-app/backend";
220
+ import { createRoute } from '@nano-app/backend';
220
221
 
221
222
  export default createRoute({
222
223
  GET: async () => ({ success: true, result: { ok: true } }),
@@ -225,8 +226,8 @@ export default createRoute({
225
226
 
226
227
  ```ts
227
228
  // src/app.ts
228
- import { createNanoApp } from "@nano-app/backend";
229
- import hello from "./plugins/hello";
229
+ import { createNanoApp } from '@nano-app/backend';
230
+ import hello from './plugins/hello';
230
231
 
231
232
  createNanoApp({ plugins: [hello()] });
232
233
  ```
@@ -245,12 +246,12 @@ Two levels. Only the plugin's receives the Express app, so nothing app-wide depe
245
246
 
246
247
  ```ts
247
248
  export default createPlugin((options?: { publicPaths?: string[] }) => ({
248
- name: "guard",
249
- routesDir: resolve(dirname(fileURLToPath(import.meta.url)), "routes"),
249
+ name: 'guard',
250
+ routesDir: resolve(dirname(fileURLToPath(import.meta.url)), 'routes'),
250
251
  options: { publicPaths: options?.publicPaths ?? [] },
251
252
  callbacks: {
252
253
  onMount: async (app, { publicPaths }) => {
253
- app.use((req, _res, next) => next(publicPaths.includes(req.path) ? undefined : { status: 401, message: "Unauthorized" }));
254
+ app.use((req, _res, next) => next(publicPaths.includes(req.path) ? undefined : { status: 401, message: 'Unauthorized' }));
254
255
  },
255
256
  },
256
257
  }));
@@ -260,16 +261,16 @@ The endpoints of one plugin have no guaranteed order: one route's `onInit` must
260
261
 
261
262
  Everything is mounted on one Express application, in this order: base middlewares → `before` plugins → your routes → `after` plugins → 404 → error handler.
262
263
 
263
- A plugin with a database follows the same rules, with its client in `.nano-app/prisma-clients/<name>`: a plugin of yours points `database.schemaPath` at its schema and you apply it with Prisma. If that folder holds the client of another schema (another plugin with the same name), the startup warns and replaces it. An official plugin of `@nano-app/plugins` needs nothing from you: at startup nano-app applies its migrations to its own `nano_*` schema. See [PLUGINS.md](https://github.com/Nano-Nathan/nano-app/blob/main/PLUGINS.md).
264
+ A plugin with a database follows the same rules, with its client in `.nano-app/prisma-clients/<name>`: a plugin of yours points `database.schemaPath` at its schema and you apply it with Prisma. Two plugins with the same name and a database fail at startup. If that folder holds the client of another schema from an earlier start, the startup warns and replaces it. An official plugin of `@nano-app/plugins` needs nothing from you: at startup nano-app applies its migrations to its own `nano_*` schema. See [PLUGINS.md](https://github.com/Nano-Nathan/nano-app/blob/main/PLUGINS.md).
264
265
 
265
266
  ## Errors
266
267
 
267
268
  The framework mounts the 404 and the error handler: an unmatched request becomes a `404`, and anything a handler throws becomes an `ApiResponse`.
268
269
 
269
270
  ```ts
270
- throw { status: 409, message: "Already exists" }; // → 409 { success: false, warning: false, message: "Already exists" }
271
- throw "Field name is required"; // → 400 { success: false, message: "Field name is required" }
272
- throw new Error("boom"); // → 500, logged to stderr
271
+ throw { status: 409, message: 'Already exists' }; // → 409 { success: false, warning: false, message: "Already exists" }
272
+ throw 'Field name is required'; // → 400 { success: false, message: "Field name is required" }
273
+ throw new Error('boom'); // → 500, logged to stderr
273
274
  GET /unknown // → 404 { success: false, warning: false, message: "/unknown not found" }
274
275
  ```
275
276
 
@@ -282,7 +283,7 @@ GET /unknown // → 404 { success: false,
282
283
  | `createPlugin(creator)` | Types a plugin factory and its options. |
283
284
  | `createPluginCallbacks(callbacks)` | Types the callbacks of a plugin (`createPluginCallbacks<PrismaClient>` types `db`), e.g. to keep them in their own file. |
284
285
 
285
- Also exported: the configuration types (`NanoAppConfig`, `PackagesConfig`, `PluginConfig`, `PluginStage`, `MainPluginConfig`, `DatabaseConfig`), the Express `Request` type and the HTTP contract (`ApiResponse`, `ApiPromise`, `QueryParams`, `RouteEntry`, `Handler`, `Handlers`, `NanoObject`) and the callback types (`Params`, `Callbacks`, `PluginCallbacks`).
286
+ Also exported: the `NanoApp` class that `createNanoApp` returns, the configuration types (`NanoAppConfig`, `PackagesConfig`, `PluginConfig`, `PluginStage`, `MainPluginConfig`, `DatabaseConfig`), the Express `Request` type and the HTTP contract (`ApiResponse`, `ApiPromise`, `QueryParams`, `RouteEntry`, `Handler`, `Handlers`, `NanoObject`) and the callback types (`Params`, `Callbacks`, `PluginCallbacks`).
286
287
 
287
288
  ## Development
288
289
 
package/build/index.d.ts CHANGED
@@ -31,8 +31,9 @@ type Params<DB = any> = Partial<NanoObject & {
31
31
  /** `{ user, session }` of Better Auth, on the routes behind its guard */
32
32
  session?: NanoObject;
33
33
  }>;
34
- /** Endpoint handlers */
34
+ /** Endpoint handler */
35
35
  type Handler<DB> = (req: Request$1, params: Params<DB>) => ApiPromise;
36
+ /** Endpoint handlers, one per HTTP method */
36
37
  type Handlers<DB = any> = {
37
38
  GET?: Handler<DB>;
38
39
  POST?: Handler<DB>;
@@ -40,9 +41,11 @@ type Handlers<DB = any> = {
40
41
  DELETE?: Handler<DB>;
41
42
  QUERY?: Handler<DB>;
42
43
  };
43
- /** Callbacks */
44
+ /** Callback run with the params */
44
45
  type Callback<DB> = (params: Params<DB>) => Promise<void>;
46
+ /** Callback run before a method, with its request */
45
47
  type CallbackMethod<DB> = (req: Request$1, method: string, params: Params<DB>) => Promise<NanoObject>;
48
+ /** Callback run with the Express app */
46
49
  type CallbackMount<DB> = (app: Express, params: Params<DB>) => Promise<void>;
47
50
  /** Callbacks of an endpoint. They never receive the app: what affects the whole app goes in the plugin's onMount */
48
51
  type Callbacks<DB = any> = {
@@ -79,7 +82,7 @@ type DatabaseConfig = {
79
82
  type PluginStage = 'before' | 'after';
80
83
  /** Plugin configuration: a group of endpoints */
81
84
  type PluginConfig<Options = NanoObject> = {
82
- /** Name of the plugin, same as his database (If have one) */
85
+ /** Name of the plugin, also the name of its database when it has one */
83
86
  name: string;
84
87
  /** Directory used to discover the `*.route.ts` files */
85
88
  routesDir: string;
package/build/index.js CHANGED
@@ -13,12 +13,7 @@ import { createJiti } from "jiti";
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { createHash } from "node:crypto";
15
15
  //#region ../core/const.ts
16
- /** Package of the official plugins: only its schemas may declare nano_* */
17
- const OFFICIAL_PLUGINS = "@nano-app/plugins";
18
- /** Error when the database URL is missing, at startup and on `nano-app push` */
19
- const MISSING_DATABASE_URL = "[nano-app] [ERROR] The DATABASE_URL environment variable is not defined.";
20
- //#endregion
21
- //#region ../core/loader/Validator.ts
16
+ /** HTTP methods a route can handle */
22
17
  const HTTP_METHODS = [
23
18
  "GET",
24
19
  "POST",
@@ -26,6 +21,12 @@ const HTTP_METHODS = [
26
21
  "DELETE",
27
22
  "QUERY"
28
23
  ];
24
+ /** Package of the official plugins: only its schemas may declare nano_* */
25
+ const OFFICIAL_PLUGINS = "@nano-app/plugins";
26
+ /** Error when the database URL is missing at startup */
27
+ const MISSING_DATABASE_URL = "[nano-app] [ERROR] The DATABASE_URL environment variable is not defined.";
28
+ //#endregion
29
+ //#region ../core/loader/Validator.ts
29
30
  const STORE_TYPES = ["app", "api"];
30
31
  /** Checks the consumer's files: invalid ones are warned about and discarded */
31
32
  var Validator = class Validator {
@@ -44,15 +45,12 @@ var Validator = class Validator {
44
45
  if (oEntry && typeof oEntry === "object") return true;
45
46
  return Validator.warn(sFilePath, "the default export is not an object.");
46
47
  }
47
- /**
48
- * Type guard for a `*.route.ts` entry.
49
- */
48
+ /** Type guard for a `*.route.ts` entry */
50
49
  static isRoute(oEntry, sFilePath) {
51
50
  if (!Validator.isObject(oEntry, sFilePath)) return false;
52
- const oRoute = oEntry;
53
- if (oRoute.expression && typeof oRoute.expression !== "string") return Validator.warn(sFilePath, "\"expression\" must be a string.");
54
- for (const sMethod of HTTP_METHODS) if (oRoute[sMethod] && typeof oRoute[sMethod] !== "function") return Validator.warn(sFilePath, `"${String(sMethod)}" must be a function.`);
55
- if (!HTTP_METHODS.some((sMethod) => oRoute[sMethod])) return Validator.warn(sFilePath, "defines no HTTP method.");
51
+ if (oEntry.expression && typeof oEntry.expression !== "string") return Validator.warn(sFilePath, "\"expression\" must be a string.");
52
+ for (const sMethod of HTTP_METHODS) if (oEntry[sMethod] && typeof oEntry[sMethod] !== "function") return Validator.warn(sFilePath, `"${sMethod}" must be a function.`);
53
+ if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) return Validator.warn(sFilePath, "defines no HTTP method.");
56
54
  return true;
57
55
  }
58
56
  /** Type guard for a `*.store.ts` entry */
@@ -61,11 +59,10 @@ var Validator = class Validator {
61
59
  if (!STORE_TYPES.includes(oEntry.type)) return Validator.warn(sFilePath, `its type must be "app" or "api".`);
62
60
  if (oEntry.data !== void 0 && (!oEntry.data || typeof oEntry.data !== "object")) return Validator.warn(sFilePath, "\"data\" must be an object.");
63
61
  if (oEntry.headers !== void 0 && (!Validator.isObject(oEntry.headers, sFilePath) || Object.values(oEntry.headers).some((sValue) => typeof sValue !== "string"))) return Validator.warn(sFilePath, "\"headers\" must be an object of strings.");
62
+ if (oEntry.auth !== void 0 && typeof oEntry.auth !== "string") return Validator.warn(sFilePath, "\"auth\" must be a string: the base path of Better Auth.");
64
63
  return true;
65
64
  }
66
- /**
67
- * Type guard for a `.prisma` entry: the contents of the file, without comments.
68
- */
65
+ /** Type guard for a `.prisma` entry: the contents of the file, without comments */
69
66
  static isPrisma(oEntry, sFilePath) {
70
67
  if (typeof oEntry !== "string") return Validator.warn(sFilePath, "the contents are not a string.");
71
68
  const sProvider = oEntry.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*["']([^"']+)["']/s)?.[1];
@@ -90,6 +87,7 @@ var Validator = class Validator {
90
87
  //#region ../core/loader/FileReader.ts
91
88
  /** Reads and loads files from disk, resolved from `cwd` */
92
89
  var FileReader = class {
90
+ /** Loader of the consumer's modules; the Prisma runtime is loaded natively */
93
91
  static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
94
92
  /** Loads a module via jiti */
95
93
  static async loadModule(sFilePath, oOptions = {}) {
@@ -118,7 +116,8 @@ var FileReader = class {
118
116
  var Formatter = class {
119
117
  /** Message of anything thrown: its `message` when it has one, else the value itself */
120
118
  static error(oError) {
121
- return String(oError?.message ?? oError);
119
+ if (oError && typeof oError === "object" && "message" in oError) return String(oError.message ?? oError);
120
+ return String(oError);
122
121
  }
123
122
  /** Formats a date for the request log, in UTC: `2026-10-19 12:03:10` */
124
123
  static logDate(oDate = /* @__PURE__ */ new Date()) {
@@ -178,7 +177,8 @@ var Endpoint = class {
178
177
  createHandler(oParams) {
179
178
  return (oReq, oRes, fnNext) => {
180
179
  const sMethod = oReq.method.toUpperCase();
181
- const fnMethod = this.oMethods[sMethod];
180
+ const sHandled = HTTP_METHODS.find((sHttpMethod) => sHttpMethod === sMethod);
181
+ const fnMethod = sHandled && this.oMethods[sHandled];
182
182
  if (!fnMethod) return fnNext({
183
183
  status: 405,
184
184
  message: `Method ${sMethod} not implemented`
@@ -320,7 +320,7 @@ var Prisma = class {
320
320
  try {
321
321
  sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
322
322
  } catch {
323
- throw new Error(`[nano-app] [ERROR] The database "${sName}" needs Prisma: install the prisma package.`);
323
+ throw new Error(`[nano-app] [ERROR] The database "${sName}" needs Prisma. Run \`pnpm add prisma\`.`);
324
324
  }
325
325
  try {
326
326
  execFileSync(process.execPath, [sCli, ...aArgs], {
@@ -331,9 +331,8 @@ var Prisma = class {
331
331
  stdio: "pipe"
332
332
  });
333
333
  } catch (oError) {
334
- const oFailed = oError;
335
- const sOutput = `${oFailed.stderr ?? ""}${oFailed.stdout ?? ""}`.trim() || Formatter.error(oError);
336
- throw new Error(`[nano-app] [ERROR] prisma ${aArgs[0]} failed for "${sName}": ${sOutput}`);
334
+ const sOutput = oError instanceof Error && "stderr" in oError && "stdout" in oError ? `${oError.stderr}${oError.stdout}`.trim() : "";
335
+ throw new Error(`[nano-app] [ERROR] prisma ${aArgs[0]} failed for "${sName}": ${sOutput || Formatter.error(oError)}`);
337
336
  }
338
337
  }
339
338
  };
@@ -394,8 +393,7 @@ var Database = class {
394
393
  if (dirname(sDir) === sDir) return false;
395
394
  sDir = dirname(sDir);
396
395
  }
397
- const { name } = JSON.parse(readFileSync(join(sDir, "package.json"), "utf-8"));
398
- return name === OFFICIAL_PLUGINS;
396
+ return JSON.parse(readFileSync(join(sDir, "package.json"), "utf-8")).name === OFFICIAL_PLUGINS;
399
397
  }
400
398
  };
401
399
  //#endregion
@@ -432,7 +430,7 @@ var Plugin = class {
432
430
  //#region src/app/config.ts
433
431
  /** Initial application configuration */
434
432
  const INITIAL_CONFIG = {
435
- /** Initial application configuration */
433
+ /** Application port */
436
434
  port: 3e3,
437
435
  /** Application listener */
438
436
  listener: () => {},
@@ -488,9 +486,14 @@ function deepMerge(oDefault, oValue) {
488
486
  });
489
487
  return Object.fromEntries(aKeys.map((sKey) => [sKey, deepMerge(oDefault[sKey], oValue[sKey])]));
490
488
  }
489
+ /** Whether a merged configuration kept the plain object of the initial one */
490
+ function isMergedConfig(oValue) {
491
+ return isPlainObject(oValue);
492
+ }
491
493
  /** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
492
494
  function mergeConfig(oConfig = {}) {
493
495
  const oMerged = deepMerge(INITIAL_CONFIG, oConfig);
496
+ if (!isMergedConfig(oMerged)) throw new Error("[nano-app] [ERROR] The configuration of createNanoApp must be an object.");
494
497
  return {
495
498
  ...oMerged,
496
499
  mainPlugin: {
@@ -501,6 +504,10 @@ function mergeConfig(oConfig = {}) {
501
504
  }
502
505
  //#endregion
503
506
  //#region src/app/errors.ts
507
+ /** Whether anything thrown is an object, read as a HandlerError */
508
+ function isHandlerError(oError) {
509
+ return typeof oError === "object" && oError !== null;
510
+ }
504
511
  /** Normalizes anything thrown by a handler into an ApiResponse plus its status */
505
512
  function normalize(oError) {
506
513
  if (typeof oError === "string") return {
@@ -510,7 +517,7 @@ function normalize(oError) {
510
517
  message: oError
511
518
  }
512
519
  };
513
- const { status, message, warning } = oError ?? {};
520
+ const { status, message, warning } = isHandlerError(oError) ? oError : {};
514
521
  return {
515
522
  status: status ?? 500,
516
523
  body: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-app/backend",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Express server with file-convention routes, plugins and an optional PostgreSQL layer",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "engines": {
43
- "node": ">=22"
43
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
44
44
  },
45
45
  "dependencies": {
46
46
  "compression": "^1.8.2",