@nano-app/backend 0.14.1 → 0.16.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 +42 -51
- package/build/index.d.ts +22 -21
- package/build/index.js +210 -318
- package/package.json +7 -12
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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
7
|
- Node.js 22.23.4, 24.22.1 or 26.12.1 and newer. ESM only.
|
|
8
|
-
- Optional PostgreSQL layer through
|
|
8
|
+
- Optional PostgreSQL layer through Drizzle. Off by default: without it no database code is loaded.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
pnpm add @nano-app/backend
|
|
@@ -58,7 +58,7 @@ export default createRoute({
|
|
|
58
58
|
});
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Each handler receives `(req, params)` and returns an `ApiResponse` without `success`: its `status` is the HTTP status (`200` by default) and the framework sets `success` to `status < 400`. `params` holds the plugin's `options`, `db` (the
|
|
61
|
+
Each handler receives `(req, params)` and returns an `ApiResponse` without `success`: its `status` is the HTTP status (`200` by default) and the framework sets `success` to `status < 400`. `params` holds the plugin's `options`, `db` (the Drizzle client when the app or the plugin has a database, see [Database](#database); a plugin without its own gets the app's) and `onBeforeResult` (see [Callbacks](#callbacks)):
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
64
|
type ApiResponse<T> = {
|
|
@@ -72,7 +72,7 @@ type ApiResponse<T> = {
|
|
|
72
72
|
|
|
73
73
|
- 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.
|
|
74
74
|
- Undeclared methods respond `405`.
|
|
75
|
-
- An invalid file
|
|
75
|
+
- An invalid file stops the startup with its path and what is wrong.
|
|
76
76
|
|
|
77
77
|
## Configuration
|
|
78
78
|
|
|
@@ -89,7 +89,9 @@ Every option is optional.
|
|
|
89
89
|
|
|
90
90
|
`packages` accepts `morgan`, `cors`, `helmet`, `compression`, `express` (`express.json` options) and `urlencoded`. The merge is **per section**: passing one key keeps the others; pass `false` to disable one.
|
|
91
91
|
|
|
92
|
-
`
|
|
92
|
+
`compression` is off by default: the proxy in front of the app (an ingress, nginx) usually compresses the answers. Turn it on with `packages: { compression: true }` when nothing does.
|
|
93
|
+
|
|
94
|
+
`morgan` writes a line per request to stdout, synchronously; with the access log of the proxy in front, `packages: { morgan: false }` saves that cost. It logs `:timestamp | :method :path :status` by default: `:path` is the path without its query string, so tokens sent there stay out of the logs. Set `format` with `:url` to log the full URL.
|
|
93
95
|
|
|
94
96
|
```ts
|
|
95
97
|
createNanoApp({
|
|
@@ -112,61 +114,50 @@ By default the backend reads that header only when the request comes from loopba
|
|
|
112
114
|
|
|
113
115
|
## Database
|
|
114
116
|
|
|
115
|
-
Off until
|
|
117
|
+
Off until the app passes its schema: nano-app creates a [Drizzle](https://orm.drizzle.team) client of it on `DATABASE_URL` and passes it to your handlers as `db`. **It never modifies your schemas**: applying them (`drizzle-kit push`, `drizzle-kit generate` and `migrate`) is up to you.
|
|
116
118
|
|
|
117
119
|
```bash
|
|
118
|
-
pnpm add
|
|
120
|
+
pnpm add drizzle-orm pg
|
|
121
|
+
pnpm add -D drizzle-kit
|
|
119
122
|
```
|
|
120
123
|
|
|
121
|
-
```
|
|
122
|
-
// src/
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
output = "./client" // any value: nano-app generates in .nano-app/prisma-clients/app
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
model User {
|
|
133
|
-
id Int @id @default(autoincrement())
|
|
134
|
-
email String @unique
|
|
135
|
-
}
|
|
124
|
+
```ts
|
|
125
|
+
// src/db/schema.ts
|
|
126
|
+
import { pgTable, serial, text } from 'drizzle-orm/pg-core';
|
|
127
|
+
|
|
128
|
+
export const users = pgTable('users', {
|
|
129
|
+
id: serial().primaryKey(),
|
|
130
|
+
email: text().notNull().unique(),
|
|
131
|
+
});
|
|
136
132
|
```
|
|
137
133
|
|
|
138
134
|
```ts
|
|
139
135
|
// src/app.ts — DATABASE_URL comes from the environment (or a .env file)
|
|
140
|
-
|
|
136
|
+
import { createNanoApp } from '@nano-app/backend';
|
|
137
|
+
import * as schema from './db/schema';
|
|
138
|
+
|
|
139
|
+
createNanoApp({ mainPlugin: { database: { schema } } });
|
|
141
140
|
```
|
|
142
141
|
|
|
143
142
|
```ts
|
|
144
143
|
// src/routes/users.route.ts
|
|
145
144
|
import { createRoute } from '@nano-app/backend';
|
|
146
|
-
import type { PrismaClient } from '../../.nano-app/prisma-clients/app/generated/client';
|
|
147
145
|
|
|
148
|
-
export default createRoute
|
|
149
|
-
GET: async (_req, { db }) => ({ result: await db
|
|
146
|
+
export default createRoute({
|
|
147
|
+
GET: async (_req, { db }) => ({ result: await db.query.users.findMany() }),
|
|
150
148
|
});
|
|
151
149
|
```
|
|
152
150
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
- The client lives in `.nano-app/prisma-clients/app/` (add `.nano-app/` to `.gitignore`). It is generated again only when the schema changes. Do not run `prisma generate`: a second client would be out of sync.
|
|
160
|
-
- The client exists after the first startup: start the app once before your editor or `tsc` can import its types. In a container, `.nano-app/` must be writable.
|
|
161
|
-
- Startup fails if `DATABASE_URL` is missing, the schema is not a valid PostgreSQL schema, or a table or column of a model cannot be read. The message says which: apply your schema with Prisma and start again.
|
|
162
|
-
- The `nano_` prefix is reserved for the official plugins (`@nano-app/plugins`): a datasource schema of yours starting with it fails the startup.
|
|
163
|
-
- Pass your generated `PrismaClient` to `createRoute<PrismaClient>` to type `db`; otherwise it is `any`, and without an enabled database `undefined`.
|
|
164
|
-
- For several PostgreSQL schemas, declare `schemas = ["public", "billing"]` in the datasource and `@@schema("billing")` on each model; one client covers them all.
|
|
165
|
-
- Prisma is loaded only when your database or a mounted plugin's is enabled.
|
|
151
|
+
- `database.schema` is the module of your tables: every export that is a table, and `relations` (from `defineRelations`) when you relate them. A `pgSchema` the tables use is exported too, so drizzle-kit creates it.
|
|
152
|
+
- One pool serves the app and every plugin with a database. It connects on its first query: a missing table fails that query, not the startup.
|
|
153
|
+
- Startup fails if `DATABASE_URL` is missing.
|
|
154
|
+
- The `nano_` prefix is reserved for plugins: a table of yours in a `nano_*` Postgres schema fails the startup.
|
|
155
|
+
- Without a database, `db` is `undefined`.
|
|
156
|
+
- Drizzle and pg are loaded only when your database or a mounted plugin's is on.
|
|
166
157
|
|
|
167
158
|
## Better Auth
|
|
168
159
|
|
|
169
|
-
Users, sign-in and sessions with [Better Auth](https://www.better-auth.com), on your database. On when your app has `src/auth.ts` (or the built `src/auth.js`), relative to `cwd`: its default export holds the options in `options` (everything but `database`: nano-app passes your
|
|
160
|
+
Users, sign-in and sessions with [Better Auth](https://www.better-auth.com), on your database. On when your app has `src/auth.ts` (or the built `src/auth.js`), relative to `cwd`: its default export holds the options in `options` (everything but `database`: nano-app passes your Drizzle client and schema). Without the file it stays off.
|
|
170
161
|
|
|
171
162
|
```bash
|
|
172
163
|
pnpm add better-auth
|
|
@@ -184,26 +175,26 @@ export default {
|
|
|
184
175
|
```ts
|
|
185
176
|
// src/app.ts
|
|
186
177
|
import { createNanoApp } from '@nano-app/backend';
|
|
178
|
+
import * as schema from './db/schema';
|
|
187
179
|
|
|
188
|
-
createNanoApp({ mainPlugin: { database: {
|
|
180
|
+
createNanoApp({ mainPlugin: { database: { schema } } });
|
|
189
181
|
```
|
|
190
182
|
|
|
191
|
-
Its tables are yours, like the rest of your schema. Add
|
|
183
|
+
Its tables are yours, like the rest of your schema. Add them with its CLI, then apply them with drizzle-kit; run both again when you change the options:
|
|
192
184
|
|
|
193
185
|
```bash
|
|
194
|
-
npx auth generate --adapter
|
|
195
|
-
|
|
186
|
+
npx auth generate --adapter drizzle --dialect pg --output src/db/auth-schema.ts # then export its tables from src/db/schema.ts
|
|
187
|
+
pnpm drizzle-kit push
|
|
196
188
|
```
|
|
197
189
|
|
|
198
|
-
- It needs the database: `src/auth.ts` without `mainPlugin.database.
|
|
199
|
-
- Startup also fails if your schema lacks a
|
|
190
|
+
- It needs the database: `src/auth.ts` without `mainPlugin.database.schema` fails at startup, and so does a file without `options`.
|
|
191
|
+
- Startup also fails if your schema lacks a table the options need (`user`, `session`, ...), naming them.
|
|
200
192
|
- Its endpoints are mounted with your routes, at its `basePath` (default `/api/auth`), and stay public.
|
|
201
193
|
- In production, Better Auth limits sign-in, sign-up and password reset to a few attempts per client IP (its `rateLimit`): see [Client IP](#client-ip).
|
|
202
194
|
- Every route of yours and of the `after` plugins needs a session: without one, 401. The `before` plugins stay public (a landing, an about page).
|
|
203
195
|
- Handlers and `onBefore` receive the session in `params.session` (`{ user, session }`).
|
|
204
196
|
- Sign-up works as Better Auth sets it: with `emailAndPassword` anyone can sign up and reach your routes. Close it with `disableSignUp: true`, or check roles in your handlers.
|
|
205
|
-
- Relate your
|
|
206
|
-
- With several PostgreSQL schemas, add `@@schema(...)` to the generated models.
|
|
197
|
+
- Relate your tables to `user` in your `relations`. Extra user fields go in its `user.additionalFields`.
|
|
207
198
|
- Set `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` in the environment, as Better Auth asks.
|
|
208
199
|
- With it on, every route of the app and of the plugins mounted after it (`stage: 'after'`) needs a session: without one it answers 401 `Not authenticated`. The handlers receive it in `session`.
|
|
209
200
|
- `createRoute({ public: true, ... })` answers without a session; its handlers still receive `session` when the request has one. An unknown path answers 404.
|
|
@@ -290,7 +281,7 @@ The endpoints of one plugin have no guaranteed order: one route's `onInit` must
|
|
|
290
281
|
|
|
291
282
|
Everything is mounted on one Express application, in this order: base middlewares → `before` plugins → your routes → `after` plugins → 404 → error handler.
|
|
292
283
|
|
|
293
|
-
A plugin with a database
|
|
284
|
+
A plugin with a database passes its own `database: { schema }`, and keeps its tables in the Postgres schema `nano_<its name>`: a table elsewhere fails the startup. Two plugins with the same name and a database fail too. Without a database of its own, a plugin receives the app's `db`. See [`@nano-app/plugins`](https://github.com/Nano-Nathan/nano-app/blob/main/plugins/README.md#database).
|
|
294
285
|
|
|
295
286
|
## Errors
|
|
296
287
|
|
|
@@ -300,8 +291,8 @@ The framework mounts the 404 and the error handler: an unmatched request becomes
|
|
|
300
291
|
throw { status: 409, message: 'Already exists' }; // → 409 { success: false, warning: false, message: "Already exists" }
|
|
301
292
|
throw 'Field name is required'; // → 400 { success: false, message: "Field name is required" }
|
|
302
293
|
throw new Error('boom'); // → 500 "Internal server error", logged to stderr
|
|
303
|
-
// A
|
|
304
|
-
//
|
|
294
|
+
// A Postgres error the request caused answers without its message, which names tables and columns:
|
|
295
|
+
// 23505 and 23503 → 409 "Conflict", 23502 and 22P02 → 400 "Invalid request"
|
|
305
296
|
GET /unknown // → 404 { success: false, warning: false, message: "/unknown not found" }
|
|
306
297
|
```
|
|
307
298
|
|
|
@@ -312,7 +303,7 @@ GET /unknown // → 404 { success: false,
|
|
|
312
303
|
| `createNanoApp(config?)` | Creates and starts the app. |
|
|
313
304
|
| `createRoute(route)` | Types a route file. |
|
|
314
305
|
| `createPlugin(creator)` | Types a plugin factory and its options. |
|
|
315
|
-
| `createPluginCallbacks(callbacks)` | Types the callbacks of a plugin (`createPluginCallbacks<
|
|
306
|
+
| `createPluginCallbacks(callbacks)` | Types the callbacks of a plugin (`createPluginCallbacks<{ db: MyDb }>` types `db`), e.g. to keep them in their own file. |
|
|
316
307
|
|
|
317
308
|
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`).
|
|
318
309
|
|
package/build/index.d.ts
CHANGED
|
@@ -17,13 +17,13 @@ type ApiResponse<T = any> = {
|
|
|
17
17
|
type ApiPromise<T = any> = Promise<ApiResponse<T>>;
|
|
18
18
|
/** Query parameters, sent as the JSON body of a `QUERY` request */
|
|
19
19
|
type QueryParams = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
/** Drizzle's relational filter: `{ title: { like: 'a%' } }` */
|
|
21
|
+
where?: NanoObject;
|
|
22
|
+
/** Columns to answer: `{ id: true, title: true }` */
|
|
23
|
+
columns?: NanoObject<boolean>;
|
|
24
|
+
orderBy?: NanoObject<'asc' | 'desc'>;
|
|
25
|
+
limit?: number;
|
|
26
|
+
offset?: number;
|
|
27
27
|
};
|
|
28
28
|
/** Extra params of handlers and callbacks: what the app injects (options, `db`) and the framework adds */
|
|
29
29
|
type Params<Deps = NanoObject<any>> = Deps & {
|
|
@@ -79,12 +79,18 @@ type RouteEntry<Deps = NanoObject<any>> = {
|
|
|
79
79
|
} & Handlers<Deps>;
|
|
80
80
|
/** Generic object */
|
|
81
81
|
type NanoObject<T = any> = Record<string, T>;
|
|
82
|
-
/**
|
|
82
|
+
/** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
|
|
83
|
+
declare class NanoError extends Error {
|
|
84
|
+
status?: number;
|
|
85
|
+
/** Error with its message, and the status a handler answers (500 without one) */
|
|
86
|
+
constructor(sMessage: string, iStatus?: number);
|
|
87
|
+
/** Prefix of the framework's messages, shown before the message */
|
|
88
|
+
get name(): string;
|
|
89
|
+
}
|
|
90
|
+
/** Database of a plugin or of the app */
|
|
83
91
|
type DatabaseConfig = {
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
/** Prisma schema file (`.prisma`), relative to `cwd` */
|
|
87
|
-
schemaPath: string;
|
|
92
|
+
/** Its Drizzle schema: the module of its tables, with its `relations` when it has them (`import * as schema from './db/schema'`) */
|
|
93
|
+
schema: NanoObject;
|
|
88
94
|
};
|
|
89
95
|
/** When a plugin is mounted, relative to the consumer routes */
|
|
90
96
|
type PluginStage = 'before' | 'after';
|
|
@@ -98,7 +104,7 @@ type PluginConfig<Options = NanoObject> = {
|
|
|
98
104
|
basePath?: string;
|
|
99
105
|
/** Mount point relative to the consumer routes. Defaults to "before" */
|
|
100
106
|
stage?: PluginStage;
|
|
101
|
-
/**
|
|
107
|
+
/** Drizzle schema of the plugin's database, mounted before its endpoints */
|
|
102
108
|
database?: DatabaseConfig;
|
|
103
109
|
/** Plugin options */
|
|
104
110
|
options?: Options;
|
|
@@ -111,13 +117,8 @@ type MainPluginConfig<Options = NanoObject> = {
|
|
|
111
117
|
routesDir?: string;
|
|
112
118
|
/** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
|
|
113
119
|
basePath?: string;
|
|
114
|
-
/**
|
|
115
|
-
database?:
|
|
116
|
-
/** Whether the database is enabled. Defaults to false */
|
|
117
|
-
enabled?: boolean;
|
|
118
|
-
/** Prisma schema file, relative to `cwd`. Defaults to "src/prisma/schema.prisma" */
|
|
119
|
-
schemaPath?: string;
|
|
120
|
-
};
|
|
120
|
+
/** Drizzle schema of the project's database; without it the app has none */
|
|
121
|
+
database?: DatabaseConfig;
|
|
121
122
|
/** Plugin options */
|
|
122
123
|
options?: Options;
|
|
123
124
|
/** Plugin callbacks */
|
|
@@ -185,4 +186,4 @@ export declare function createPlugin<Options>(fnCreator: Creator<Options>): Crea
|
|
|
185
186
|
export declare function createPluginCallbacks<Deps = NanoObject<any>>(oCallbacks: PluginCallbacks<Deps>): PluginCallbacks<Deps>;
|
|
186
187
|
/** Whether the session's user has the permissions; always without Better Auth or permissions */
|
|
187
188
|
declare function isGranted({ auth, session }: Params, oPermissions?: NanoObject<string[]>): Promise<boolean>;
|
|
188
|
-
export { type ApiPromise, type ApiResponse, type Callbacks, type DatabaseConfig, type Handler, type Handlers, type MainPluginConfig, type MethodEntry, NanoApp, type NanoAppConfig, type NanoObject, type PackagesConfig, type Params, type PluginCallbacks, type PluginConfig, type PluginStage, type QueryParams, type Request, type RouteEntry, isGranted };
|
|
189
|
+
export { type ApiPromise, type ApiResponse, type Callbacks, type DatabaseConfig, type Handler, type Handlers, type MainPluginConfig, type MethodEntry, NanoApp, type NanoAppConfig, NanoError, type NanoObject, type PackagesConfig, type Params, type PluginCallbacks, type PluginConfig, type PluginStage, type QueryParams, type Request, type RouteEntry, isGranted };
|
package/build/index.js
CHANGED
|
@@ -1,16 +1,62 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
1
|
import express from "express";
|
|
3
2
|
import morgan from "morgan";
|
|
4
3
|
import cors from "cors";
|
|
5
4
|
import helmet from "helmet";
|
|
6
5
|
import compression from "compression";
|
|
7
6
|
import "dotenv/config";
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
7
|
import { glob } from "glob";
|
|
10
|
-
import {
|
|
8
|
+
import { isAbsolute, resolve } from "node:path";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
11
10
|
import { createJiti } from "jiti";
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// ../core/utils/NanoError.ts
|
|
12
|
+
/** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
|
|
13
|
+
var NanoError = class extends Error {
|
|
14
|
+
status;
|
|
15
|
+
/** Error with its message, and the status a handler answers (500 without one) */
|
|
16
|
+
constructor(sMessage, iStatus) {
|
|
17
|
+
super(sMessage);
|
|
18
|
+
this.status = iStatus;
|
|
19
|
+
}
|
|
20
|
+
/** Prefix of the framework's messages, shown before the message */
|
|
21
|
+
get name() {
|
|
22
|
+
return "[nano-app] [ERROR]";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
// ../core/files/FileReader.ts
|
|
26
|
+
/** Reads and loads files from disk, resolved from `cwd` */
|
|
27
|
+
var FileReader = class {
|
|
28
|
+
/** Loader of the consumer's modules */
|
|
29
|
+
static oJiti = createJiti(import.meta.url);
|
|
30
|
+
/** Loads a module via jiti */
|
|
31
|
+
static async loadModule(sFilePath, oOptions = {}) {
|
|
32
|
+
return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
|
|
33
|
+
}
|
|
34
|
+
/** Resolves the absolute path of a file */
|
|
35
|
+
static getAbsolutePath(sFilePath) {
|
|
36
|
+
const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
|
|
37
|
+
if (!existsSync(sAbsolutePath)) throw new NanoError(`${sFilePath} not found.`);
|
|
38
|
+
return sAbsolutePath;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
// ../core/files/FileLoader.ts
|
|
42
|
+
/** Finds and loads the consumer's files */
|
|
43
|
+
var FileLoader = class {
|
|
44
|
+
/** Finds `*.<extension>.ts` (or built `.js`) files in a folder (`src/<extension>s` by default) and returns `{ [subPath]: defaultExport }` */
|
|
45
|
+
static async findMany(sExtension, sSourceDir) {
|
|
46
|
+
const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
47
|
+
const aFilePaths = await glob(`${sSrcDir}/**/*.${sExtension}.{ts,js}`, {
|
|
48
|
+
posix: true,
|
|
49
|
+
ignore: "**/*.d.ts"
|
|
50
|
+
});
|
|
51
|
+
const oEntries = {};
|
|
52
|
+
for (const sFilePath of aFilePaths) {
|
|
53
|
+
const sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${sExtension}\\.(ts|js)$`), "");
|
|
54
|
+
const sKey = sExtension === "store" ? sFileSubPath.slice(1).replaceAll("/", "_") : sFileSubPath;
|
|
55
|
+
oEntries[sKey] = await FileReader.loadModule(sFilePath, { default: true });
|
|
56
|
+
}
|
|
57
|
+
return oEntries;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
14
60
|
// ../core/const.ts
|
|
15
61
|
/** HTTP methods a route can handle */
|
|
16
62
|
const HTTP_METHODS = [
|
|
@@ -20,10 +66,6 @@ const HTTP_METHODS = [
|
|
|
20
66
|
"DELETE",
|
|
21
67
|
"QUERY"
|
|
22
68
|
];
|
|
23
|
-
/** Package of the official plugins: only its schemas may declare nano_* */
|
|
24
|
-
const OFFICIAL_PLUGINS = "@nano-app/plugins";
|
|
25
|
-
/** Error when the database URL is missing at startup */
|
|
26
|
-
const MISSING_DATABASE_URL = "[nano-app] [ERROR] The DATABASE_URL environment variable is not defined.";
|
|
27
69
|
/** Loopback and private networks: the proxies the backend trusts by default */
|
|
28
70
|
const PRIVATE_NETWORKS = [
|
|
29
71
|
"127.0.0.0/8",
|
|
@@ -33,142 +75,101 @@ const PRIVATE_NETWORKS = [
|
|
|
33
75
|
"192.168.0.0/16",
|
|
34
76
|
"fc00::/7"
|
|
35
77
|
];
|
|
36
|
-
// ../core/
|
|
78
|
+
// ../core/utils/Paths.ts
|
|
79
|
+
/** Paths of the app's pages */
|
|
80
|
+
var Paths = class {
|
|
81
|
+
/** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
|
|
82
|
+
static matches(sPattern, sPath) {
|
|
83
|
+
if (!sPattern.endsWith("/*")) return sPattern === sPath;
|
|
84
|
+
const sBase = sPattern.slice(0, -2);
|
|
85
|
+
return sPath === sBase || sPath.startsWith(`${sBase}/`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
// ../core/utils/Validator.ts
|
|
37
89
|
const STORE_TYPES = ["app", "api"];
|
|
38
|
-
/** Checks the consumer's files
|
|
90
|
+
/** Checks values of the framework and the consumer's files; an invalid file throws a `NanoError` */
|
|
39
91
|
var Validator = class Validator {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
92
|
+
/** Whether a value is a plain object */
|
|
93
|
+
static isObject(oValue) {
|
|
94
|
+
return Object.prototype.toString.call(oValue) === "[object Object]";
|
|
95
|
+
}
|
|
96
|
+
/** Whether a value is a plain object of strings */
|
|
97
|
+
static isStringMap(oValue) {
|
|
98
|
+
return Validator.isObject(oValue) && Object.values(oValue).every((sItem) => typeof sItem === "string");
|
|
99
|
+
}
|
|
100
|
+
/** Whether a value is a path that starts with `/` */
|
|
101
|
+
static isPath(oValue) {
|
|
102
|
+
return typeof oValue === "string" && oValue.startsWith("/");
|
|
103
|
+
}
|
|
104
|
+
/** First environment variable of a list that is not defined */
|
|
105
|
+
static missingEnv(aNames) {
|
|
106
|
+
return aNames.find((sName) => !process.env[sName]);
|
|
49
107
|
}
|
|
50
|
-
/** Checks
|
|
51
|
-
static
|
|
52
|
-
|
|
53
|
-
return Validator.warn(sFilePath, "the default export is not an object.");
|
|
108
|
+
/** Checks every entry with a guard (`Validator.isRoute`...): the first invalid one throws */
|
|
109
|
+
static validate(oEntries, fnGuard) {
|
|
110
|
+
return Object.fromEntries(Object.entries(oEntries).filter(([sKey, oEntry]) => fnGuard(oEntry, sKey)));
|
|
54
111
|
}
|
|
55
112
|
/** Type guard for a `*.route.ts` entry */
|
|
56
113
|
static isRoute(oEntry, sFilePath) {
|
|
57
|
-
if (!Validator.isObject(oEntry
|
|
58
|
-
if (oEntry.expression && typeof oEntry.expression !== "string")
|
|
59
|
-
if (oEntry.public !== void 0 && typeof oEntry.public !== "boolean")
|
|
114
|
+
if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
|
|
115
|
+
if (oEntry.expression && typeof oEntry.expression !== "string") throw new NanoError(`${sFilePath}: "expression" must be a string.`);
|
|
116
|
+
if (oEntry.public !== void 0 && typeof oEntry.public !== "boolean") throw new NanoError(`${sFilePath}: "public" must be a boolean.`);
|
|
60
117
|
for (const sMethod of HTTP_METHODS) {
|
|
61
118
|
const oMethod = oEntry[sMethod];
|
|
62
|
-
if (oMethod && typeof oMethod !== "function" && typeof oMethod.handler !== "function")
|
|
119
|
+
if (oMethod && typeof oMethod !== "function" && typeof oMethod.handler !== "function") throw new NanoError(`${sFilePath}: "${sMethod}" must be a function or { handler, permissions }.`);
|
|
63
120
|
}
|
|
64
|
-
if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod]))
|
|
121
|
+
if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) throw new NanoError(`${sFilePath}: defines no HTTP method.`);
|
|
65
122
|
return true;
|
|
66
123
|
}
|
|
67
124
|
/** Type guard for a `*.store.ts` entry */
|
|
68
125
|
static isStore(oEntry, sFilePath) {
|
|
69
|
-
if (!Validator.isObject(oEntry
|
|
70
|
-
if (!STORE_TYPES.includes(oEntry.type))
|
|
71
|
-
if (oEntry.data !== void 0 &&
|
|
72
|
-
if (oEntry.headers !== void 0 &&
|
|
73
|
-
if (oEntry.auth !== void 0 &&
|
|
126
|
+
if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
|
|
127
|
+
if (!STORE_TYPES.includes(oEntry.type)) throw new NanoError(`${sFilePath}: its type must be "app" or "api".`);
|
|
128
|
+
if (oEntry.data !== void 0 && !Validator.isObject(oEntry.data)) throw new NanoError(`${sFilePath}: "data" must be an object.`);
|
|
129
|
+
if (oEntry.headers !== void 0 && !Validator.isStringMap(oEntry.headers)) throw new NanoError(`${sFilePath}: "headers" must be an object of strings.`);
|
|
130
|
+
if (oEntry.auth !== void 0 && !Validator.isAuth(oEntry.auth)) throw new NanoError(`${sFilePath}: "auth" must be { path, routes }: each route { match, redirect, roles, permissions } with paths starting with /, roles as "admin,user", permissions as { resource: "read,update" }, and a redirect outside its match.`);
|
|
74
131
|
return true;
|
|
75
132
|
}
|
|
76
|
-
/**
|
|
77
|
-
static
|
|
78
|
-
if (typeof
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
for (const [sFilePath, oEntry] of Object.entries(oEntries)) {
|
|
87
|
-
const oValidated = this.validateUnique(oEntry, sExtension, sFilePath);
|
|
88
|
-
if (oValidated !== void 0) oValid[sFilePath] = oValidated;
|
|
89
|
-
}
|
|
90
|
-
return oValid;
|
|
91
|
-
}
|
|
92
|
-
/** Validates one entry: returns it when valid, `undefined` otherwise */
|
|
93
|
-
static validateUnique(oEntry, sExtension, sFilePath = "") {
|
|
94
|
-
return this.oValidators[sExtension]?.()(oEntry, sFilePath) ? oEntry : void 0;
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
// ../core/loader/FileReader.ts
|
|
98
|
-
/** Reads and loads files from disk, resolved from `cwd` */
|
|
99
|
-
var FileReader = class {
|
|
100
|
-
/** Loader of the consumer's modules; the Prisma runtime is loaded natively */
|
|
101
|
-
static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
|
|
102
|
-
/** Loads a module via jiti */
|
|
103
|
-
static async loadModule(sFilePath, oOptions = {}) {
|
|
104
|
-
return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
|
|
105
|
-
}
|
|
106
|
-
/** Reads the contents of a file */
|
|
107
|
-
static loadFile(sFilePath) {
|
|
108
|
-
const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
|
|
109
|
-
if (!sContent) throw new Error(`[nano-app] [ERROR] The file ${sFilePath} does not have valid content.`);
|
|
110
|
-
return sContent;
|
|
111
|
-
}
|
|
112
|
-
/** Reads a Prisma schema without its `//` comments */
|
|
113
|
-
static loadSchema(sFilePath) {
|
|
114
|
-
return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
|
|
115
|
-
}
|
|
116
|
-
/** Resolves the absolute path of a file */
|
|
117
|
-
static getAbsolutePath(sFilePath) {
|
|
118
|
-
const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
|
|
119
|
-
if (!existsSync(sAbsolutePath)) throw new Error(`[nano-app] [ERROR] ${sFilePath} not found.`);
|
|
120
|
-
return sAbsolutePath;
|
|
133
|
+
/** Whether `auth` has the base path of Better Auth and valid routes, none redirecting to itself */
|
|
134
|
+
static isAuth(oAuth) {
|
|
135
|
+
if (!Validator.isObject(oAuth) || typeof oAuth.path !== "string") return false;
|
|
136
|
+
const aRoutes = oAuth.routes ?? [];
|
|
137
|
+
if (!Array.isArray(aRoutes)) return false;
|
|
138
|
+
return aRoutes.every((oRoute) => {
|
|
139
|
+
if (!Validator.isObject(oRoute)) return false;
|
|
140
|
+
const { match, redirect = "/", roles, permissions } = oRoute;
|
|
141
|
+
return Validator.isPath(match) && Validator.isPath(redirect) && !Paths.matches(match, redirect) && (roles === void 0 || typeof roles === "string") && (permissions === void 0 || Validator.isStringMap(permissions));
|
|
142
|
+
});
|
|
121
143
|
}
|
|
122
144
|
};
|
|
123
|
-
// ../core/utils/
|
|
124
|
-
/**
|
|
125
|
-
var
|
|
126
|
-
/** Message of
|
|
127
|
-
static
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
145
|
+
// ../core/utils/Logger.ts
|
|
146
|
+
/** Messages of the framework on the console */
|
|
147
|
+
var Logger = class Logger {
|
|
148
|
+
/** Message of the framework: `[nano-app] [<level>] <text>` */
|
|
149
|
+
static message(sLevel, sText) {
|
|
150
|
+
return `[nano-app] [${sLevel}] ${sText}`;
|
|
151
|
+
}
|
|
152
|
+
/** `[nano-app] [ERROR] <text>`, with what was thrown */
|
|
153
|
+
static error(sText, oError) {
|
|
154
|
+
console.error(Logger.message("ERROR", sText), oError);
|
|
155
|
+
}
|
|
156
|
+
/** `[nano-app] [WARNING] <text>` */
|
|
157
|
+
static warn(sText) {
|
|
158
|
+
console.warn(Logger.message("WARNING", sText));
|
|
159
|
+
}
|
|
160
|
+
/** `[nano-app] [INFO] <text>` */
|
|
161
|
+
static info(sText) {
|
|
162
|
+
console.log(Logger.message("INFO", sText));
|
|
163
|
+
}
|
|
164
|
+
/** Date of the request log, in UTC: `2026-10-19 12:03:10` */
|
|
165
|
+
static date(oDate = /* @__PURE__ */ new Date()) {
|
|
133
166
|
return oDate.toISOString().slice(0, 19).replace("T", " ");
|
|
134
167
|
}
|
|
135
168
|
};
|
|
136
|
-
// ../core/loader/FileLoader.ts
|
|
137
|
-
/** Finds, loads and validates the consumer's files */
|
|
138
|
-
var FileLoader = class {
|
|
139
|
-
/**
|
|
140
|
-
* 1. Validates a `.prisma` file.
|
|
141
|
-
* 2. Loads a generated Prisma client module (`client.ts`, or `client.js`)
|
|
142
|
-
*/
|
|
143
|
-
static async findPrismaClient(sClientPath, sSchemaPath) {
|
|
144
|
-
const sContent = FileReader.loadSchema(sSchemaPath);
|
|
145
|
-
if (!Validator.validateUnique(sContent, "prisma", sSchemaPath)) throw new Error(`[nano-app] [ERROR] ${sSchemaPath} is not a valid Prisma schema.`);
|
|
146
|
-
try {
|
|
147
|
-
return (await FileReader.loadModule(sClientPath))?.PrismaClient;
|
|
148
|
-
} catch (oError) {
|
|
149
|
-
throw new Error(`[nano-app] [ERROR] The Prisma client ${sClientPath} could not be loaded: ${Formatter.error(oError)}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
/** Finds `*.route.ts` (or built `.js`) files and returns `{ [subPath]: defaultExport }` */
|
|
153
|
-
static async findMany({ extension, sourceDir }) {
|
|
154
|
-
const sSrcDir = (sourceDir ?? `src/${extension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
155
|
-
const aFilePaths = await glob(`${sSrcDir}/**/*.${extension}.{ts,js}`, {
|
|
156
|
-
posix: true,
|
|
157
|
-
ignore: "**/*.d.ts"
|
|
158
|
-
});
|
|
159
|
-
const oEntries = {};
|
|
160
|
-
for (const sFilePath of aFilePaths) {
|
|
161
|
-
const sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${extension}\\.(ts|js)$`), "");
|
|
162
|
-
const sKey = extension === "store" ? sFileSubPath.slice(1).replaceAll("/", "_") : sFileSubPath;
|
|
163
|
-
oEntries[sKey] = await FileReader.loadModule(sFilePath, { default: true });
|
|
164
|
-
}
|
|
165
|
-
return Validator.validate(oEntries, extension);
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
169
|
// src/auth/BetterAuth.ts
|
|
169
170
|
/** Files of the app's Better Auth options, relative to `cwd`: source, then built */
|
|
170
171
|
const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
|
|
171
|
-
/** Better Auth on the app's
|
|
172
|
+
/** Better Auth on the app's Drizzle client: the instance, its handler and the session lookup */
|
|
172
173
|
var BetterAuth = class {
|
|
173
174
|
static oAuth;
|
|
174
175
|
/** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
|
|
@@ -176,21 +177,21 @@ var BetterAuth = class {
|
|
|
176
177
|
const sFilePath = AUTH_FILES.find(existsSync);
|
|
177
178
|
if (!sFilePath) return void 0;
|
|
178
179
|
const oFile = await FileReader.loadModule(sFilePath, { default: true });
|
|
179
|
-
if (!oFile?.options) throw new
|
|
180
|
+
if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
|
|
180
181
|
return oFile.options;
|
|
181
182
|
}
|
|
182
183
|
/** The instance, once the app's plugin created it */
|
|
183
184
|
static get() {
|
|
184
185
|
return this.oAuth;
|
|
185
186
|
}
|
|
186
|
-
/** Creates the instance on the app's client, its handler and the session lookup */
|
|
187
|
-
static async create(oOptions, oDb) {
|
|
187
|
+
/** Creates the instance on the app's client and schema, its handler and the session lookup */
|
|
188
|
+
static async create(oOptions, oDb, oSchema) {
|
|
188
189
|
const { betterAuth } = await import("better-auth");
|
|
189
|
-
const {
|
|
190
|
+
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
|
|
190
191
|
const { getSchema } = await import("better-auth/db");
|
|
191
192
|
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
192
|
-
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !
|
|
193
|
-
if (aMissing.length) throw new
|
|
193
|
+
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
|
|
194
|
+
if (aMissing.length) throw new NanoError(`Better Auth needs the tables ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with drizzle-kit.`);
|
|
194
195
|
const oAuth = betterAuth({
|
|
195
196
|
...oOptions,
|
|
196
197
|
advanced: {
|
|
@@ -200,15 +201,16 @@ var BetterAuth = class {
|
|
|
200
201
|
...oOptions.advanced?.ipAddress
|
|
201
202
|
}
|
|
202
203
|
},
|
|
203
|
-
database:
|
|
204
|
+
database: drizzleAdapter(oDb, {
|
|
205
|
+
provider: "pg",
|
|
206
|
+
schema: oSchema
|
|
207
|
+
})
|
|
204
208
|
});
|
|
205
209
|
this.oAuth = oAuth;
|
|
206
|
-
const fnSession = (oReq, oRes, fnNext) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}).catch(fnNext);
|
|
211
|
-
};
|
|
210
|
+
const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
211
|
+
oRes.locals.session = oSession ?? void 0;
|
|
212
|
+
fnNext();
|
|
213
|
+
});
|
|
212
214
|
return {
|
|
213
215
|
oHandler: toNodeHandler(oAuth),
|
|
214
216
|
fnSession,
|
|
@@ -247,33 +249,24 @@ var Endpoint = class {
|
|
|
247
249
|
}
|
|
248
250
|
/** Handler for all methods */
|
|
249
251
|
async mount(oParams, sPath) {
|
|
250
|
-
if (!oParams.auth && Object.values(this.oMethods).some(({ permissions }) => permissions))
|
|
252
|
+
if (!oParams.auth && Object.values(this.oMethods).some(({ permissions }) => permissions)) Logger.warn(`${sPath}: its permissions are not checked without Better Auth. Add src/auth.ts.`);
|
|
251
253
|
await this.oCallbacks.onInit?.(oParams);
|
|
252
254
|
return this.createHandler(oParams);
|
|
253
255
|
}
|
|
254
256
|
/** Express handler: onBefore, then the method of the request */
|
|
255
257
|
createHandler(oParams) {
|
|
256
258
|
return (oReq, oRes, fnNext) => {
|
|
257
|
-
if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext(
|
|
258
|
-
status: 401,
|
|
259
|
-
message: "Not authenticated"
|
|
260
|
-
});
|
|
259
|
+
if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext(new NanoError("Not authenticated", 401));
|
|
261
260
|
const sMethod = oReq.method.toUpperCase();
|
|
262
261
|
const sHandled = HTTP_METHODS.find((sHttpMethod) => sHttpMethod === sMethod);
|
|
263
262
|
const oMethod = sHandled && this.oMethods[sHandled];
|
|
264
|
-
if (!oMethod) return fnNext({
|
|
265
|
-
status: 405,
|
|
266
|
-
message: `Method ${sMethod} not implemented`
|
|
267
|
-
});
|
|
263
|
+
if (!oMethod) return fnNext(new NanoError(`Method ${sMethod} not implemented`, 405));
|
|
268
264
|
const oRequestParams = {
|
|
269
265
|
...oParams,
|
|
270
266
|
session: oRes.locals.session
|
|
271
267
|
};
|
|
272
|
-
isGranted(oRequestParams, this.isPublic ? void 0 : oMethod.permissions).then((isAllowed) => {
|
|
273
|
-
if (!isAllowed) throw
|
|
274
|
-
status: 403,
|
|
275
|
-
message: "Forbidden"
|
|
276
|
-
};
|
|
268
|
+
return isGranted(oRequestParams, this.isPublic ? void 0 : oMethod.permissions).then((isAllowed) => {
|
|
269
|
+
if (!isAllowed) throw new NanoError("Forbidden", 403);
|
|
277
270
|
return this.oCallbacks.onBefore?.(oReq, sMethod, oRequestParams) ?? {};
|
|
278
271
|
}).then((onBeforeResult) => oMethod.handler(oReq, {
|
|
279
272
|
...oRequestParams,
|
|
@@ -285,14 +278,13 @@ var Endpoint = class {
|
|
|
285
278
|
...oBody,
|
|
286
279
|
success: iStatus < 400
|
|
287
280
|
});
|
|
288
|
-
})
|
|
281
|
+
});
|
|
289
282
|
};
|
|
290
283
|
}
|
|
291
284
|
};
|
|
292
285
|
// src/routes/Router.ts
|
|
293
286
|
/** Turns a folder of `*.route.ts` files into Express paths and endpoints */
|
|
294
287
|
var Router = class {
|
|
295
|
-
oRoutes = {};
|
|
296
288
|
sRoutesDir;
|
|
297
289
|
sBasePath;
|
|
298
290
|
constructor(sRoutesDir, sBasePath) {
|
|
@@ -301,13 +293,12 @@ var Router = class {
|
|
|
301
293
|
}
|
|
302
294
|
/** Loads the routes and creates one endpoint per file */
|
|
303
295
|
async mount() {
|
|
304
|
-
if (this.sRoutesDir)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
})
|
|
308
|
-
return Object.entries(this.oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
|
|
296
|
+
if (!this.sRoutesDir) return [];
|
|
297
|
+
const oFiles = await FileLoader.findMany("route", this.sRoutesDir);
|
|
298
|
+
const oRoutes = Validator.validate(oFiles, Validator.isRoute);
|
|
299
|
+
return Object.entries(oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
|
|
309
300
|
const sPath = this.createPath(sFilePath, expression);
|
|
310
|
-
|
|
301
|
+
Logger.info(sPath);
|
|
311
302
|
return {
|
|
312
303
|
path: sPath,
|
|
313
304
|
endpoint: new Endpoint(methods, {
|
|
@@ -323,138 +314,43 @@ var Router = class {
|
|
|
323
314
|
return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
|
|
324
315
|
}
|
|
325
316
|
};
|
|
326
|
-
// src/database/Prisma.ts
|
|
327
|
-
/** Runs the consumer's Prisma CLI: generates clients and applies official plugins' migrations */
|
|
328
|
-
var Prisma = class {
|
|
329
|
-
/** Folder of a client and its files, in the project */
|
|
330
|
-
static dir(sName) {
|
|
331
|
-
return join(process.cwd(), ".nano-app", "prisma-clients", sName);
|
|
332
|
-
}
|
|
333
|
-
/** Generates the client of a schema in `.nano-app/prisma-clients/<name>`, unless it is up to date */
|
|
334
|
-
static generate(sName, sSchemaPath) {
|
|
335
|
-
const sDir = this.dir(sName);
|
|
336
|
-
const sSource = FileReader.getAbsolutePath(sSchemaPath);
|
|
337
|
-
const sHash = createHash("sha256").update(readFileSync(sSource)).digest("hex");
|
|
338
|
-
const sSourceFile = join(sDir, "source.json");
|
|
339
|
-
const oCurrent = existsSync(sSourceFile) ? JSON.parse(readFileSync(sSourceFile, "utf-8")) : void 0;
|
|
340
|
-
if (oCurrent && oCurrent.schemaPath !== sSource) console.warn(`[nano-app] [WARNING] .nano-app/prisma-clients/${sName} has the client of ${oCurrent.schemaPath}: it is replaced by the one of ${sSource}.`);
|
|
341
|
-
if (oCurrent?.schemaPath === sSource && oCurrent.hash === sHash && existsSync(join(sDir, "generated", "client.ts"))) return;
|
|
342
|
-
mkdirSync(sDir, { recursive: true });
|
|
343
|
-
const sSchema = readFileSync(sSource, "utf-8");
|
|
344
|
-
writeFileSync(join(sDir, "schema.prisma"), sSchema.replace(/output\s*=\s*"[^"]*"/, "output = \"./generated\""));
|
|
345
|
-
this.run(sName, [
|
|
346
|
-
"generate",
|
|
347
|
-
"--schema",
|
|
348
|
-
join(sDir, "schema.prisma")
|
|
349
|
-
]);
|
|
350
|
-
writeFileSync(sSourceFile, JSON.stringify({
|
|
351
|
-
schemaPath: sSource,
|
|
352
|
-
hash: sHash
|
|
353
|
-
}));
|
|
354
|
-
console.log(`[nano-app] [INFO] Prisma client "${sName}" generated`);
|
|
355
|
-
}
|
|
356
|
-
/** Applies the pending migrations an official plugin ships next to its schema */
|
|
357
|
-
static migrate(sName, sSchemaPath, sDbSchema) {
|
|
358
|
-
const sMigrations = join(dirname(FileReader.getAbsolutePath(sSchemaPath)), "migrations");
|
|
359
|
-
if (!existsSync(sMigrations)) throw new Error(`[nano-app] [ERROR] The plugin "${sName}" ships no migrations: ${sMigrations}`);
|
|
360
|
-
if (!process.env.DATABASE_URL) throw new Error(MISSING_DATABASE_URL);
|
|
361
|
-
const oUrl = new URL(process.env.DATABASE_URL);
|
|
362
|
-
oUrl.searchParams.set("schema", sDbSchema);
|
|
363
|
-
const sConfig = join(this.dir(sName), "prisma.config.mjs");
|
|
364
|
-
writeFileSync(sConfig, [
|
|
365
|
-
"export default {",
|
|
366
|
-
` schema: ${JSON.stringify(join(this.dir(sName), "schema.prisma"))},`,
|
|
367
|
-
` migrations: { path: ${JSON.stringify(sMigrations)} },`,
|
|
368
|
-
" datasource: { url: process.env.NANO_APP_MIGRATION_URL },",
|
|
369
|
-
"};",
|
|
370
|
-
""
|
|
371
|
-
].join("\n"));
|
|
372
|
-
this.run(sName, [
|
|
373
|
-
"migrate",
|
|
374
|
-
"deploy",
|
|
375
|
-
"--config",
|
|
376
|
-
sConfig
|
|
377
|
-
], { NANO_APP_MIGRATION_URL: oUrl.toString() });
|
|
378
|
-
}
|
|
379
|
-
/** Runs a Prisma command of the consumer's `prisma` package */
|
|
380
|
-
static run(sName, aArgs, oEnv = {}) {
|
|
381
|
-
let sCli;
|
|
382
|
-
try {
|
|
383
|
-
sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
|
|
384
|
-
} catch {
|
|
385
|
-
throw new Error(`[nano-app] [ERROR] The database "${sName}" needs Prisma. Run \`pnpm add prisma\`.`);
|
|
386
|
-
}
|
|
387
|
-
try {
|
|
388
|
-
execFileSync(process.execPath, [sCli, ...aArgs], {
|
|
389
|
-
env: {
|
|
390
|
-
...process.env,
|
|
391
|
-
...oEnv
|
|
392
|
-
},
|
|
393
|
-
stdio: "pipe"
|
|
394
|
-
});
|
|
395
|
-
} catch (oError) {
|
|
396
|
-
const sOutput = oError instanceof Error && "stderr" in oError && "stdout" in oError ? `${oError.stderr}${oError.stdout}`.trim() : "";
|
|
397
|
-
throw new Error(`[nano-app] [ERROR] prisma ${aArgs[0]} failed for "${sName}": ${sOutput || Formatter.error(oError)}`);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
};
|
|
401
317
|
// src/database/Database.ts
|
|
402
|
-
/**
|
|
318
|
+
/** Drizzle clients of the app and its plugins, one per name, on one pool of `DATABASE_URL` */
|
|
403
319
|
var Database = class {
|
|
404
|
-
static
|
|
320
|
+
static oPool;
|
|
405
321
|
/** Mounted clients, by name */
|
|
406
322
|
static oClients = {};
|
|
407
|
-
/** Initializes the Database client */
|
|
408
|
-
static async init() {
|
|
409
|
-
const sUrl = process.env.DATABASE_URL;
|
|
410
|
-
if (!sUrl) throw new Error(MISSING_DATABASE_URL);
|
|
411
|
-
const { PrismaPg } = await import("@prisma/adapter-pg");
|
|
412
|
-
this.oAdapter = new PrismaPg({ connectionString: sUrl });
|
|
413
|
-
}
|
|
414
323
|
/** Gets a client by name */
|
|
415
324
|
static get(sName) {
|
|
416
325
|
return this.oClients[sName];
|
|
417
326
|
}
|
|
418
|
-
/**
|
|
419
|
-
static async mount(sName, {
|
|
420
|
-
if (this.get(sName)) throw new
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const sClientPath = join(Prisma.dir(sName), "generated", "client.ts");
|
|
430
|
-
const PrismaClient = await FileLoader.findPrismaClient(sClientPath, schemaPath);
|
|
431
|
-
let oClient;
|
|
432
|
-
try {
|
|
433
|
-
oClient = new PrismaClient({ adapter: this.oAdapter });
|
|
434
|
-
} catch (oError) {
|
|
435
|
-
throw new Error(`[nano-app] [ERROR] Cannot instantiate the database "${sName}" from ${sClientPath}: ${Formatter.error(oError)}`);
|
|
327
|
+
/** Mounts the Drizzle client of a schema: the app's tables outside `nano_*`, a plugin's only in `nano_<name>` */
|
|
328
|
+
static async mount(sName, { schema }) {
|
|
329
|
+
if (this.get(sName)) throw new NanoError(`The database "${sName}" is already mounted.`);
|
|
330
|
+
const { defineRelations, is } = await import("drizzle-orm");
|
|
331
|
+
const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
|
|
332
|
+
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
333
|
+
const sOwn = `nano_${sName}`;
|
|
334
|
+
const isApp = sName === "app";
|
|
335
|
+
for (const oTable of Object.values(schema).filter((oValue) => is(oValue, PgTable))) {
|
|
336
|
+
const { name, schema: sSchema = "public" } = getTableConfig(oTable);
|
|
337
|
+
if (isApp ? sSchema.startsWith("nano_") : sSchema !== sOwn) throw new NanoError(`The database "${sName}": table "${sSchema}.${name}" breaks the rule, ${isApp ? "the nano_* schemas are reserved for plugins" : `a plugin keeps its tables in "${sOwn}"`}.`);
|
|
436
338
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
339
|
+
this.oPool ??= await this.pool();
|
|
340
|
+
const oClient = drizzle({
|
|
341
|
+
client: this.oPool,
|
|
342
|
+
relations: schema.relations ?? defineRelations(schema)
|
|
441
343
|
});
|
|
442
344
|
this.oClients[sName] = oClient;
|
|
443
|
-
|
|
345
|
+
Logger.info(`Database "${sName}" mounted`);
|
|
444
346
|
return oClient;
|
|
445
347
|
}
|
|
446
|
-
/**
|
|
447
|
-
static
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
let sDir = dirname(FileReader.getAbsolutePath(sSchemaPath));
|
|
453
|
-
while (!existsSync(join(sDir, "package.json"))) {
|
|
454
|
-
if (dirname(sDir) === sDir) return false;
|
|
455
|
-
sDir = dirname(sDir);
|
|
456
|
-
}
|
|
457
|
-
return JSON.parse(readFileSync(join(sDir, "package.json"), "utf-8")).name === OFFICIAL_PLUGINS;
|
|
348
|
+
/** The pool of `DATABASE_URL`, shared by every client */
|
|
349
|
+
static async pool() {
|
|
350
|
+
const sUrl = process.env.DATABASE_URL;
|
|
351
|
+
if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
|
|
352
|
+
const { Pool } = await import("pg");
|
|
353
|
+
return new Pool({ connectionString: sUrl });
|
|
458
354
|
}
|
|
459
355
|
};
|
|
460
356
|
// src/routes/Plugin.ts
|
|
@@ -470,10 +366,10 @@ var Plugin = class {
|
|
|
470
366
|
/** Mounts its endpoints on express app */
|
|
471
367
|
async mount(oApp) {
|
|
472
368
|
const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
|
|
473
|
-
const oDb = database
|
|
369
|
+
const oDb = database ? await Database.mount(name, database) : Database.get("app");
|
|
474
370
|
if (this.oAuthOptions) {
|
|
475
|
-
if (!
|
|
476
|
-
const { sPath, oHandler, fnSession } = await BetterAuth.create(this.oAuthOptions, oDb);
|
|
371
|
+
if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
|
|
372
|
+
const { sPath, oHandler, fnSession } = await BetterAuth.create(this.oAuthOptions, oDb, database.schema);
|
|
477
373
|
oApp.all(sPath, oHandler);
|
|
478
374
|
oApp.use(fnSession);
|
|
479
375
|
}
|
|
@@ -500,7 +396,7 @@ const INITIAL_CONFIG = {
|
|
|
500
396
|
packages: {
|
|
501
397
|
morgan: {
|
|
502
398
|
tokens: {
|
|
503
|
-
timestamp: () =>
|
|
399
|
+
timestamp: () => Logger.date(),
|
|
504
400
|
path: (oReq) => oReq.path
|
|
505
401
|
},
|
|
506
402
|
format: ":timestamp | :method :path :status"
|
|
@@ -514,30 +410,22 @@ const INITIAL_CONFIG = {
|
|
|
514
410
|
"QUERY"
|
|
515
411
|
] },
|
|
516
412
|
helmet: true,
|
|
517
|
-
compression:
|
|
413
|
+
compression: false,
|
|
518
414
|
express: { limit: "10kb" },
|
|
519
415
|
urlencoded: { extended: false }
|
|
520
416
|
},
|
|
521
417
|
/** Main plugin to mount */
|
|
522
418
|
mainPlugin: {
|
|
523
419
|
routesDir: "src/routes",
|
|
524
|
-
basePath: ""
|
|
525
|
-
database: {
|
|
526
|
-
enabled: false,
|
|
527
|
-
schemaPath: "src/prisma/schema.prisma"
|
|
528
|
-
}
|
|
420
|
+
basePath: ""
|
|
529
421
|
},
|
|
530
422
|
/** Plugins around the consumer routes */
|
|
531
423
|
plugins: []
|
|
532
424
|
};
|
|
533
|
-
/** Whether a value is a plain object (`{}`), the only kind merged field by field */
|
|
534
|
-
function isPlainObject(oValue) {
|
|
535
|
-
return Object.prototype.toString.call(oValue) === "[object Object]";
|
|
536
|
-
}
|
|
537
425
|
/** Merges a value over its default: plain objects key by key at every level; anything else replaces it */
|
|
538
426
|
function deepMerge(oDefault, oValue) {
|
|
539
427
|
if (oValue === void 0) return oDefault;
|
|
540
|
-
if (!
|
|
428
|
+
if (!Validator.isObject(oDefault) || !Validator.isObject(oValue)) return oValue;
|
|
541
429
|
const aKeys = Object.keys({
|
|
542
430
|
...oDefault,
|
|
543
431
|
...oValue
|
|
@@ -546,12 +434,12 @@ function deepMerge(oDefault, oValue) {
|
|
|
546
434
|
}
|
|
547
435
|
/** Whether a merged configuration kept the plain object of the initial one */
|
|
548
436
|
function isMergedConfig(oValue) {
|
|
549
|
-
return
|
|
437
|
+
return Validator.isObject(oValue);
|
|
550
438
|
}
|
|
551
439
|
/** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
|
|
552
440
|
function mergeConfig(oConfig = {}) {
|
|
553
441
|
const oMerged = deepMerge(INITIAL_CONFIG, oConfig);
|
|
554
|
-
if (!isMergedConfig(oMerged)) throw new
|
|
442
|
+
if (!isMergedConfig(oMerged)) throw new NanoError("The configuration of createNanoApp must be an object.");
|
|
555
443
|
return {
|
|
556
444
|
...oMerged,
|
|
557
445
|
mainPlugin: {
|
|
@@ -561,26 +449,30 @@ function mergeConfig(oConfig = {}) {
|
|
|
561
449
|
};
|
|
562
450
|
}
|
|
563
451
|
// src/app/errors.ts
|
|
564
|
-
/**
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
status: 404,
|
|
568
|
-
message: "Not found"
|
|
569
|
-
},
|
|
570
|
-
P2002: {
|
|
452
|
+
/** Database errors the request caused, by their Postgres code or Drizzle's name; their own messages name tables and columns */
|
|
453
|
+
const DATABASE_ERRORS = {
|
|
454
|
+
"23505": {
|
|
571
455
|
status: 409,
|
|
572
456
|
message: "Conflict"
|
|
573
457
|
},
|
|
574
|
-
|
|
458
|
+
"23503": {
|
|
575
459
|
status: 409,
|
|
576
460
|
message: "Conflict"
|
|
577
461
|
},
|
|
578
|
-
|
|
462
|
+
"23502": {
|
|
463
|
+
status: 400,
|
|
464
|
+
message: "Invalid request"
|
|
465
|
+
},
|
|
466
|
+
"22P02": {
|
|
467
|
+
status: 400,
|
|
468
|
+
message: "Invalid request"
|
|
469
|
+
},
|
|
470
|
+
DrizzleError: {
|
|
579
471
|
status: 400,
|
|
580
472
|
message: "Invalid request"
|
|
581
473
|
}
|
|
582
474
|
};
|
|
583
|
-
/** Whether anything thrown is an object, read as a HandlerError or a
|
|
475
|
+
/** Whether anything thrown is an object, read as a HandlerError or a Drizzle error with its Postgres cause */
|
|
584
476
|
function isHandlerError(oError) {
|
|
585
477
|
return typeof oError === "object" && oError !== null;
|
|
586
478
|
}
|
|
@@ -594,7 +486,8 @@ function normalize(oError) {
|
|
|
594
486
|
}
|
|
595
487
|
};
|
|
596
488
|
const oThrown = isHandlerError(oError) ? oError : {};
|
|
597
|
-
const
|
|
489
|
+
const sKey = oThrown.cause?.code ?? oThrown.name ?? "";
|
|
490
|
+
const { status, message, warning } = DATABASE_ERRORS[sKey] ?? oThrown;
|
|
598
491
|
const iStatus = status ?? 500;
|
|
599
492
|
return {
|
|
600
493
|
status: iStatus,
|
|
@@ -607,15 +500,12 @@ function normalize(oError) {
|
|
|
607
500
|
}
|
|
608
501
|
/** Turns every unmatched request into a 404 for the error handler */
|
|
609
502
|
const notFoundHandler = (oReq, _oRes, fnNext) => {
|
|
610
|
-
fnNext({
|
|
611
|
-
status: 404,
|
|
612
|
-
message: `${oReq.path} not found`
|
|
613
|
-
});
|
|
503
|
+
fnNext(new NanoError(`${oReq.path} not found`, 404));
|
|
614
504
|
};
|
|
615
505
|
/** Turns any error raised by a handler into an ApiResponse */
|
|
616
506
|
const errorHandler = (oError, _oReq, oRes, _fnNext) => {
|
|
617
507
|
const { status, body } = normalize(oError);
|
|
618
|
-
if (status >= 500)
|
|
508
|
+
if (status >= 500) Logger.error("Internal server error", oError);
|
|
619
509
|
oRes.status(status).json(body);
|
|
620
510
|
};
|
|
621
511
|
// src/app/NanoApp.ts
|
|
@@ -634,11 +524,10 @@ var NanoApp = class {
|
|
|
634
524
|
await this.initialize();
|
|
635
525
|
this.oApp.listen(Number(this.oConfig.port), () => {
|
|
636
526
|
this.oConfig.listener?.();
|
|
637
|
-
|
|
527
|
+
Logger.info(`Nano App running on port ${this.oConfig.port}`);
|
|
638
528
|
});
|
|
639
|
-
return this;
|
|
640
529
|
} catch (oError) {
|
|
641
|
-
|
|
530
|
+
Logger.error("Error trying to start the server.", oError);
|
|
642
531
|
process.exit(1);
|
|
643
532
|
}
|
|
644
533
|
}
|
|
@@ -664,11 +553,14 @@ var NanoApp = class {
|
|
|
664
553
|
for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
|
|
665
554
|
this.oApp.use(morgan(oConfig.morgan.format));
|
|
666
555
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
556
|
+
const aPackages = [
|
|
557
|
+
oConfig.cors && cors(oConfig.cors),
|
|
558
|
+
oConfig.helmet && helmet(),
|
|
559
|
+
oConfig.compression && compression(),
|
|
560
|
+
oConfig.express && express.json(oConfig.express),
|
|
561
|
+
oConfig.urlencoded && express.urlencoded(oConfig.urlencoded)
|
|
562
|
+
];
|
|
563
|
+
for (const fnPackage of aPackages) if (fnPackage) this.oApp.use(fnPackage);
|
|
672
564
|
}
|
|
673
565
|
};
|
|
674
566
|
// src/app/creators.ts
|
|
@@ -687,4 +579,4 @@ function createPlugin(fnCreator) {
|
|
|
687
579
|
function createPluginCallbacks(oCallbacks) {
|
|
688
580
|
return oCallbacks;
|
|
689
581
|
}
|
|
690
|
-
export { NanoApp, createNanoApp, createPlugin, createPluginCallbacks, createRoute, isGranted };
|
|
582
|
+
export { NanoApp, NanoError, createNanoApp, createPlugin, createPluginCallbacks, createRoute, isGranted };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"plugins",
|
|
28
28
|
"file-based-routing",
|
|
29
29
|
"postgresql",
|
|
30
|
-
"
|
|
30
|
+
"drizzle"
|
|
31
31
|
],
|
|
32
32
|
"author": "Cristian Serrano",
|
|
33
33
|
"license": "MIT",
|
|
@@ -53,19 +53,15 @@
|
|
|
53
53
|
"morgan": "^1.12.1"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@prisma/adapter-pg": "^7.10.1",
|
|
57
|
-
"@prisma/client": "^7.10.1",
|
|
58
56
|
"better-auth": "^1.7.18",
|
|
57
|
+
"drizzle-orm": "^1.0.0-rc.5",
|
|
59
58
|
"pg": "^8.23.1"
|
|
60
59
|
},
|
|
61
60
|
"peerDependenciesMeta": {
|
|
62
|
-
"
|
|
63
|
-
"optional": true
|
|
64
|
-
},
|
|
65
|
-
"@prisma/client": {
|
|
61
|
+
"better-auth": {
|
|
66
62
|
"optional": true
|
|
67
63
|
},
|
|
68
|
-
"
|
|
64
|
+
"drizzle-orm": {
|
|
69
65
|
"optional": true
|
|
70
66
|
},
|
|
71
67
|
"pg": {
|
|
@@ -73,8 +69,6 @@
|
|
|
73
69
|
}
|
|
74
70
|
},
|
|
75
71
|
"devDependencies": {
|
|
76
|
-
"@prisma/adapter-pg": "^7.10.1",
|
|
77
|
-
"@prisma/client": "^7.10.1",
|
|
78
72
|
"@types/compression": "^1.8.1",
|
|
79
73
|
"@types/cors": "^2.8.19",
|
|
80
74
|
"@types/express": "^5.0.6",
|
|
@@ -82,8 +76,9 @@
|
|
|
82
76
|
"@types/node": "^26.6.6",
|
|
83
77
|
"@types/pg": "^8.20.0",
|
|
84
78
|
"better-auth": "^1.7.18",
|
|
79
|
+
"drizzle-kit": "^1.0.0-rc.5",
|
|
80
|
+
"drizzle-orm": "^1.0.0-rc.5",
|
|
85
81
|
"pg": "^8.23.1",
|
|
86
|
-
"prisma": "^7.10.1",
|
|
87
82
|
"tsdown": "^0.23.5",
|
|
88
83
|
"tsx": "^4.23.15",
|
|
89
84
|
"typescript": "^7.0.2"
|