@nano-app/backend 0.7.1 → 0.8.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
@@ -46,17 +46,17 @@ export default createRoute({
46
46
 
47
47
  ## Routes
48
48
 
49
- Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements. **The file path is the URL** (`index.route.ts` → `/`, `api/users.route.ts` → `/api/users`). `expression` adds parameters:
49
+ Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements. **The file path is the URL** (`index.route.ts` → `/`, `api/users.route.ts` → `/api/users`). `expression` is appended to that path as is, in Express syntax (`/:id`, `{/:id}` for optional):
50
50
 
51
51
  ```ts
52
52
  export default createRoute({
53
- expression: ":id", // → /users/:id
53
+ expression: "/:id", // → /users/:id
54
54
  GET: async req => ({ success: true, result: { id: req.params.id } }),
55
55
  DELETE: async req => ({ success: true, message: "Deleted" }),
56
56
  });
57
57
  ```
58
58
 
59
- Each handler receives `(req, db)` — `db` is the Prisma client when a database is enabled ([Database](#database)) — and returns an `ApiResponse`:
59
+ Each handler receives `(req, params)` and returns an `ApiResponse`. `params` holds the plugin's `options`, `db` (the Prisma client when a database is enabled, see [Database](#database)) and `onBeforeResult` (see [Callbacks](#callbacks)):
60
60
 
61
61
  ```ts
62
62
  type ApiResponse<T> = {
@@ -80,7 +80,7 @@ Every option is optional.
80
80
  | `listener` | `() => {}` | Callback once the server is listening. |
81
81
  | `settings` | `{}` | Values for `app.set()` (e.g. `{ "trust proxy": 1 }`). |
82
82
  | `packages` | see below | Framework base middlewares. |
83
- | `mainPlugin` | `{ routesDir: "src/routes", basePath: "/" }` | Your routes (`routesDir`, relative to `cwd`), `basePath`, `init` and database: see [Database](#database). |
83
+ | `mainPlugin` | `{ routesDir: "src/routes" }` | Your routes (`routesDir`, relative to `cwd`), `basePath` (e.g. `/api`, no trailing slash) and database: see [Database](#database). |
84
84
  | `plugins` | `[]` | Plugins mounted around your routes, in order: see [Plugins](#plugins). |
85
85
 
86
86
  `packages` accepts `morgan`, `cors`, `helmet`, `compression`, `rateLimit`, `express` (`express.json` options) and `urlencoded`. The merge is **per section**: passing one key keeps the others; pass `false`/`undefined` to disable one.
@@ -137,8 +137,8 @@ createNanoApp({ mainPlugin: { database: { enabled: true } } });
137
137
  import { createRoute } from "@nano-app/backend";
138
138
  import type { PrismaClient } from "../../.nano-app/prisma-clients/app/generated/client";
139
139
 
140
- export default createRoute({
141
- GET: async (_req, db: PrismaClient) => ({ success: true, result: await db.user.findMany() }),
140
+ export default createRoute<PrismaClient>({
141
+ GET: async (_req, { db }) => ({ success: true, result: await db!.user.findMany() }),
142
142
  });
143
143
  ```
144
144
 
@@ -150,12 +150,12 @@ export default createRoute({
150
150
  - Each key falls back to its default, so `{ enabled: true }` is enough with the default layout. The client is always read from `.nano-app/prisma-clients/app/`, where `nano-app generate` writes it.
151
151
  - Startup fails if `DATABASE_URL` is missing or the client cannot be loaded or is not a valid PostgreSQL Prisma client. The connection opens on the first query.
152
152
  - The `nano_` prefix is reserved for the framework's plugins: a datasource schema starting with it fails the startup.
153
- - Annotate `db` with your generated `PrismaClient` to type it; otherwise it is `any`, and without an enabled database `undefined`.
153
+ - Pass your generated `PrismaClient` to `createRoute<PrismaClient>` to type `db`; otherwise it is `any`, and without an enabled database `undefined`.
154
154
  - For several PostgreSQL schemas, declare `schemas = ["public", "billing"]` in the datasource and `@@schema("billing")` on each model; one client covers them all.
155
155
 
156
156
  ## Plugins
157
157
 
158
- A plugin is a group of endpoints mounted under a base path. Your routes are mounted as one (named `app`). This package ships no official plugin. You write one with `createPlugin` and list it in `plugins`. Nothing is mounted unless you list it.
158
+ A plugin is a group of endpoints mounted under a base path. Your routes are mounted as one (named `app`). The official plugins are in [`@nano-app/plugins`](https://github.com/Nano-Nathan/nano-app/blob/main/plugins/README.md). You write one with `createPlugin` and list it in `plugins`. Nothing is mounted unless you list it.
159
159
 
160
160
  ```ts
161
161
  // src/plugins/hello/index.ts
@@ -187,7 +187,32 @@ import hello from "./plugins/hello";
187
187
  createNanoApp({ plugins: [hello()] });
188
188
  ```
189
189
 
190
- `createPlugin` types the options of that plugin. Each plugin chooses its base path and whether it mounts `before` (default) or `after` your routes.
190
+ `createPlugin` types the options of that plugin. Each plugin chooses its base path and whether it mounts `before` (default) or `after` your routes. What the plugin puts in `options` reaches every callback and handler of its routes in `params`.
191
+
192
+ ### Callbacks
193
+
194
+ Two levels. Only the plugin's receives the Express app, so nothing app-wide depends on the order of the route files.
195
+
196
+ | Level | Callback | Runs | Receives | Use |
197
+ |---|---|---|---|---|
198
+ | Plugin (`callbacks` in its config) | `onMount` | Once, after its database and before its endpoints | `(app, params)` | App-level middlewares (a guard), shared setup |
199
+ | Endpoint (`callbacks` in `createRoute`) | `onInit` | Once, when the endpoint is mounted | `(params)` | Setup of that route |
200
+ | Endpoint | `onBefore` | Before every request the endpoint handles | `(req, method, params)` | Validate or load; its return value reaches the handler as `params.onBeforeResult` |
201
+
202
+ ```ts
203
+ export default createPlugin((options?: { publicPaths?: string[] }) => ({
204
+ name: "guard",
205
+ routesDir: resolve(dirname(fileURLToPath(import.meta.url)), "routes"),
206
+ options: { publicPaths: options?.publicPaths ?? [] },
207
+ callbacks: {
208
+ onMount: async (app, { publicPaths }) => {
209
+ app.use((req, _res, next) => next(publicPaths.includes(req.path) ? undefined : { status: 401, message: "Unauthorized" }));
210
+ },
211
+ },
212
+ }));
213
+ ```
214
+
215
+ The endpoints of one plugin have no guaranteed order: one route's `onInit` must not depend on another's. `mainPlugin` takes `callbacks` too.
191
216
 
192
217
  Everything is mounted on one Express application, in this order: base middlewares → `before` plugins → your routes → `after` plugins → 404 → error handler.
193
218
 
@@ -216,8 +241,9 @@ GET /unknown // → 404 { success: false,
216
241
  | `createNanoApp(config?)` | Creates and starts the app. |
217
242
  | `createRoute(route)` | Types a route file. |
218
243
  | `createPlugin(creator)` | Types a plugin factory and its options. |
244
+ | `createPluginCallbacks(callbacks)` | Types the callbacks of a plugin (`createPluginCallbacks<PrismaClient>` types `db`), e.g. to keep them in their own file. |
219
245
 
220
- 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`).
246
+ 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`).
221
247
 
222
248
  ## Development
223
249
 
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import express, { Express, Request } from 'express';
1
+ import express, { Request, Express } from 'express';
2
2
  export { Request } from 'express';
3
3
  import { Options } from 'express-rate-limit';
4
4
  import { CorsOptions } from 'cors';
@@ -11,28 +11,85 @@ type DatabaseConfig = {
11
11
  schemaPath: string;
12
12
  };
13
13
 
14
+ /** API response */
15
+ type ApiResponse<T = any> = {
16
+ success?: boolean;
17
+ result?: T;
18
+ message?: string;
19
+ warning?: boolean;
20
+ };
21
+ type ApiPromise<T = any> = Promise<{
22
+ success?: boolean;
23
+ result?: T;
24
+ message?: string;
25
+ warning?: boolean;
26
+ }>;
27
+ /** Query parameters sent in the `query` header (JSON). */
28
+ type QueryParams = {
29
+ skip?: number;
30
+ top?: number;
31
+ filter?: NanoObject;
32
+ orderby?: Record<string, "asc" | "desc">;
33
+ select?: string;
34
+ };
35
+
36
+ /** Extra params of handlers and callbacks */
37
+ type Params<DB = any> = Partial<NanoObject & {
38
+ db?: DB;
39
+ onBeforeResult?: NanoObject;
40
+ }>;
41
+ /** Endpoint handlers */
42
+ type Handler<DB> = (req: Request, params: Params<DB>) => ApiPromise;
43
+ type Handlers<DB = any> = {
44
+ GET?: Handler<DB>;
45
+ POST?: Handler<DB>;
46
+ PUT?: Handler<DB>;
47
+ DELETE?: Handler<DB>;
48
+ };
49
+ /** Callbacks */
50
+ type Callback<DB> = (params: Params<DB>) => Promise<void>;
51
+ type CallbackMethod<DB> = (req: Request, method: string, params: Params<DB>) => Promise<NanoObject>;
52
+ type CallbackMount<DB> = (app: Express, params: Params<DB>) => Promise<void>;
53
+ /** Callbacks of an endpoint. They never receive the app: what affects the whole app goes in the plugin's onMount */
54
+ type Callbacks<DB = any> = {
55
+ onInit?: Callback<DB>;
56
+ onBefore?: CallbackMethod<DB>;
57
+ };
58
+ /** Callbacks of a plugin */
59
+ type PluginCallbacks<DB = any> = {
60
+ onMount?: CallbackMount<DB>;
61
+ };
62
+ /** Entry of a route file (`*.route.ts`) */
63
+ type RouteEntry<DB = any> = {
64
+ expression?: string;
65
+ callbacks?: Callbacks<DB>;
66
+ } & Handlers<DB>;
67
+ type NanoObject<T = any> = Record<string, T>;
68
+
14
69
  /** When a plugin is mounted, relative to the consumer routes */
15
70
  type PluginStage = "before" | "after";
16
71
  /** Plugin configuration: a group of endpoints */
17
- type PluginConfig = {
72
+ type PluginConfig<Options = NanoObject> = {
18
73
  /** Name of the plugin, same as his database (If have one) */
19
74
  name: string;
20
75
  /** Directory used to discover the `*.route.ts` files */
21
76
  routesDir: string;
22
- /** Base path prepended to every endpoint of the plugin. Defaults to "/" */
77
+ /** Base path prepended to every endpoint, e.g. "/auth" (no trailing slash). Defaults to none */
23
78
  basePath?: string;
24
79
  /** Mount point relative to the consumer routes. Defaults to "before" */
25
80
  stage?: PluginStage;
26
81
  /** Prisma client the plugin needs, mounted before its endpoints */
27
82
  database?: DatabaseConfig;
28
- /** Callback run first, before the endpoints are mounted */
29
- init?: (app: Express, db: any) => void | Promise<void>;
83
+ /** Plugin options */
84
+ options?: Options;
85
+ /** Plugin callbacks */
86
+ callbacks?: PluginCallbacks;
30
87
  };
31
88
  /** The project's plugin: nano-app names it "app" and mounts it between the "before" and "after" plugins */
32
- type MainPluginConfig = {
89
+ type MainPluginConfig<Options = NanoObject> = {
33
90
  /** Directory of the project's `*.route.ts` files. Defaults to "src/routes" */
34
91
  routesDir?: string;
35
- /** Base path prepended to every route. Defaults to "/" */
92
+ /** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
36
93
  basePath?: string;
37
94
  /** The project's database */
38
95
  database?: {
@@ -41,8 +98,10 @@ type MainPluginConfig = {
41
98
  /** Prisma schema file, relative to `cwd`. Defaults to "src/prisma/schema.prisma" */
42
99
  schemaPath?: string;
43
100
  };
44
- /** Callback run first, before the routes are mounted */
45
- init?: (app: Express, db: any) => void | Promise<void>;
101
+ /** Plugin options */
102
+ options?: Options;
103
+ /** Plugin callbacks */
104
+ callbacks?: PluginCallbacks;
46
105
  };
47
106
 
48
107
  /** Configuration of the framework's built-in packages */
@@ -83,42 +142,6 @@ type NanoAppConfig = {
83
142
  mainPlugin?: MainPluginConfig;
84
143
  };
85
144
 
86
- /** API response */
87
- type ApiResponse<T = any> = {
88
- success?: boolean;
89
- result?: T;
90
- message?: string;
91
- warning?: boolean;
92
- };
93
- type ApiPromise<T = any> = Promise<{
94
- success?: boolean;
95
- result?: T;
96
- message?: string;
97
- warning?: boolean;
98
- }>;
99
- /** Query parameters sent in the `query` header (JSON). */
100
- type QueryParams = {
101
- skip?: number;
102
- top?: number;
103
- filter?: NanoObject;
104
- orderby?: Record<string, "asc" | "desc">;
105
- select?: string;
106
- };
107
-
108
- /** Endpoint method: receives the request and, when the plugin has one, its Prisma client */
109
- type Handler = (req: Request, db?: any) => ApiPromise;
110
- type Handlers = {
111
- GET?: Handler;
112
- POST?: Handler;
113
- PUT?: Handler;
114
- DELETE?: Handler;
115
- };
116
- /** Entry of a route file (`*.route.ts`) */
117
- type RouteEntry = {
118
- expression?: string;
119
- } & Handlers;
120
- type NanoObject<T = any> = Record<string, T>;
121
-
122
145
  /**
123
146
  * Merges the consumer configuration with the initial one.
124
147
  */
@@ -141,10 +164,12 @@ declare class NanoApp {
141
164
  /** Creates a new NanoApp instance */
142
165
  declare function createNanoApp(config?: NanoAppConfig): NanoApp;
143
166
  /** Creates and types a route object. */
144
- declare function createRoute(route: RouteEntry): RouteEntry;
167
+ declare function createRoute<DB>(route: RouteEntry<DB>): RouteEntry<DB>;
145
168
  /** Creates a plugin: a function of the plugin's own options that returns its configuration. */
146
- type Creator<Options> = (options?: Options) => PluginConfig;
169
+ type Creator<Options> = (options?: Options) => PluginConfig<Options>;
147
170
  declare function createPlugin<Options>(creator: Creator<Options>): Creator<Options>;
171
+ /** Creates and types the callbacks of a plugin. */
172
+ declare function createPluginCallbacks<DB>(callbacks: PluginCallbacks<DB>): PluginCallbacks<DB>;
148
173
 
149
- export { NanoApp, createNanoApp, createPlugin, createRoute };
150
- export type { ApiPromise, ApiResponse, DatabaseConfig, Handler, Handlers, MainPluginConfig, NanoAppConfig, NanoObject, PackagesConfig, PluginConfig, PluginStage, QueryParams, RouteEntry };
174
+ export { NanoApp, createNanoApp, createPlugin, createPluginCallbacks, createRoute };
175
+ export type { ApiPromise, ApiResponse, Callbacks, DatabaseConfig, Handler, Handlers, MainPluginConfig, NanoAppConfig, NanoObject, PackagesConfig, Params, PluginCallbacks, PluginConfig, PluginStage, QueryParams, RouteEntry };
package/build/index.js CHANGED
@@ -221,17 +221,27 @@ var Formatter = class {
221
221
  // src/routes/Endpoint.ts
222
222
  var Endpoint = class {
223
223
  methods;
224
+ callbacks;
224
225
  /** Allowed methods */
225
- constructor(methods) {
226
+ constructor(methods, callbacks = {}) {
226
227
  this.methods = methods;
228
+ this.callbacks = callbacks;
227
229
  }
228
230
  /** Handler for all methods */
229
- getHandler = (db) => (req, res, next) => {
231
+ async mount(params) {
232
+ await this.callbacks.onInit?.(params);
233
+ return this.createHandler(params);
234
+ }
235
+ createHandler = (params) => (req, res, next) => {
230
236
  const method = req.method.toUpperCase();
231
- if (this.methods[method]) {
232
- this.methods[method](req, db).then((response) => {
233
- res.status(response.success ? 200 : 400).json(response);
234
- }).catch(next);
237
+ const fnMethod = this.methods[method];
238
+ if (fnMethod) {
239
+ (this.callbacks.onBefore?.(req, method, params) ?? Promise.resolve({})).then((onBeforeResult) => (
240
+ // Runs the method
241
+ fnMethod(req, { ...params, onBeforeResult }).then((response) => {
242
+ res.status(response.success ? 200 : 400).json(response);
243
+ })
244
+ )).catch(next);
235
245
  } else {
236
246
  res.status(405).json({ success: false, message: `Method ${method} not implemented` });
237
247
  }
@@ -245,7 +255,7 @@ var Router = class {
245
255
  basePath;
246
256
  constructor(routesDir, basePath) {
247
257
  this.routesDir = routesDir;
248
- this.basePath = basePath ?? "/";
258
+ this.basePath = basePath ?? "";
249
259
  }
250
260
  async mount() {
251
261
  if (!!this.routesDir) {
@@ -254,23 +264,15 @@ var Router = class {
254
264
  extension: "route"
255
265
  });
256
266
  }
257
- return Object.entries(this.routes).map(([filePath, { expression, ...methods }]) => {
267
+ return Object.entries(this.routes).map(([filePath, { expression, callbacks, ...methods }]) => {
258
268
  const path = this.createPath(filePath, expression);
259
269
  console.log(`[nano-app] [INFO] ${path}`);
260
- return { path, endpoint: new Endpoint(methods) };
270
+ return { path, endpoint: new Endpoint(methods, callbacks) };
261
271
  });
262
272
  }
263
- createPath(endpoint, expression) {
264
- const isIndex = endpoint === "/index";
265
- let path = isIndex ? "/" : endpoint;
266
- if (!!expression) {
267
- path = `${isIndex ? "" : endpoint.replace(/\/+$/, "")}/${expression.replace(/^\/+/, "")}`;
268
- }
269
- const basePath = this.basePath.replace(/\/+$/, "");
270
- if (basePath) {
271
- path = path === "/" ? basePath : `${basePath}${path}`;
272
- }
273
- return path;
273
+ /** `basePath` + file path (`/index` is the folder itself) + `expression`, as Express reads them */
274
+ createPath(endpoint, expression = "") {
275
+ return `${this.basePath}${endpoint.replace(/\/index$/, "")}${expression}` || "/";
274
276
  }
275
277
  };
276
278
 
@@ -346,11 +348,12 @@ var Plugin = class {
346
348
  }
347
349
  /** Mounts its endpoints on express app */
348
350
  async mount(app) {
349
- const { name, routesDir, basePath, database, init } = this.config;
351
+ const { name, routesDir, basePath, database, options, callbacks } = this.config;
350
352
  const router = new Router(routesDir, basePath);
351
353
  const db = database?.enabled ? await Database.mount(name, database) : void 0;
352
- await init?.(app, db);
353
- for (const { path, endpoint } of await router.mount()) app.all(path, endpoint.getHandler(db));
354
+ const params = { ...options, db };
355
+ await callbacks?.onMount?.(app, params);
356
+ for (const { path, endpoint } of await router.mount()) app.all(path, await endpoint.mount(params));
354
357
  }
355
358
  };
356
359
 
@@ -396,7 +399,7 @@ var INITIAL_CONFIG = {
396
399
  /** Main plugin to mount */
397
400
  mainPlugin: {
398
401
  routesDir: "src/routes",
399
- basePath: "/",
402
+ basePath: "",
400
403
  database: {
401
404
  enabled: false,
402
405
  schemaPath: "src/prisma/schema.prisma"
@@ -536,9 +539,13 @@ function createRoute(route) {
536
539
  function createPlugin(creator) {
537
540
  return creator;
538
541
  }
542
+ function createPluginCallbacks(callbacks) {
543
+ return callbacks;
544
+ }
539
545
  export {
540
546
  NanoApp,
541
547
  createNanoApp,
542
548
  createPlugin,
549
+ createPluginCallbacks,
543
550
  createRoute
544
551
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-app/backend",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "tsup",
20
20
  "dev": "tsup --watch",
21
- "test": "tsx --test tests/*.test.ts",
21
+ "test": "tsx --env-file-if-exists=../.env --test tests/*.test.ts",
22
22
  "prepublishOnly": "pnpm run build"
23
23
  },
24
24
  "keywords": [