@nano-app/backend 0.11.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,7 +4,7 @@
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 ≥ 22, 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
@@ -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,7 +86,7 @@ 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({
@@ -100,7 +101,7 @@ 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
107
  pnpm add prisma @prisma/client @prisma/adapter-pg pg # prisma too: the startup generates the client
@@ -260,7 +261,7 @@ 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
 
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.11.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",