@nano-app/backend 0.15.0 → 0.17.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 +39 -50
- package/build/index.d.ts +17 -34
- package/build/index.js +191 -293
- package/package.json +9 -13
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
|
|
@@ -48,7 +48,7 @@ export default createRoute({
|
|
|
48
48
|
|
|
49
49
|
## Routes
|
|
50
50
|
|
|
51
|
-
Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements (`GET`, `POST`, `PUT`, `DELETE`, `QUERY`). **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):
|
|
51
|
+
Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements (`GET`, `POST`, `PUT`, `DELETE`, `QUERY`). **The file path is the URL** (`index.route.ts` → `/`, `api/users.route.ts` → `/api/users`). A route file holds only those methods and its options (`expression`, `public`, `permissions`, `callbacks`): any other key, or an invalid value, stops the startup with the file and what is wrong. `expression` is appended to that path as is, in Express syntax (`/:id`, `{/:id}` for optional):
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
54
|
export default createRoute({
|
|
@@ -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> = {
|
|
@@ -114,61 +114,50 @@ By default the backend reads that header only when the request comes from loopba
|
|
|
114
114
|
|
|
115
115
|
## Database
|
|
116
116
|
|
|
117
|
-
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.
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
|
-
pnpm add
|
|
120
|
+
pnpm add drizzle-orm pg
|
|
121
|
+
pnpm add -D drizzle-kit
|
|
121
122
|
```
|
|
122
123
|
|
|
123
|
-
```
|
|
124
|
-
// src/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
output = "./client" // any value: nano-app generates in .nano-app/prisma-clients/app
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
model User {
|
|
135
|
-
id Int @id @default(autoincrement())
|
|
136
|
-
email String @unique
|
|
137
|
-
}
|
|
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
|
+
});
|
|
138
132
|
```
|
|
139
133
|
|
|
140
134
|
```ts
|
|
141
135
|
// src/app.ts — DATABASE_URL comes from the environment (or a .env file)
|
|
142
|
-
|
|
136
|
+
import { createNanoApp } from '@nano-app/backend';
|
|
137
|
+
import * as schema from './db/schema';
|
|
138
|
+
|
|
139
|
+
createNanoApp({ mainPlugin: { database: { schema } } });
|
|
143
140
|
```
|
|
144
141
|
|
|
145
142
|
```ts
|
|
146
143
|
// src/routes/users.route.ts
|
|
147
144
|
import { createRoute } from '@nano-app/backend';
|
|
148
|
-
import type { PrismaClient } from '../../.nano-app/prisma-clients/app/generated/client';
|
|
149
145
|
|
|
150
|
-
export default createRoute
|
|
151
|
-
GET: async (_req, { db }) => ({ result: await db
|
|
146
|
+
export default createRoute({
|
|
147
|
+
GET: async (_req, { db }) => ({ result: await db.query.users.findMany() }),
|
|
152
148
|
});
|
|
153
149
|
```
|
|
154
150
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
-
|
|
161
|
-
- 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.
|
|
162
|
-
- 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.
|
|
163
|
-
- 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.
|
|
164
|
-
- The `nano_` prefix is reserved for the official plugins (`@nano-app/plugins`): a datasource schema of yours starting with it fails the startup.
|
|
165
|
-
- Pass your generated `PrismaClient` to `createRoute<PrismaClient>` to type `db`; otherwise it is `any`, and without an enabled database `undefined`.
|
|
166
|
-
- For several PostgreSQL schemas, declare `schemas = ["public", "billing"]` in the datasource and `@@schema("billing")` on each model; one client covers them all.
|
|
167
|
-
- 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.
|
|
168
157
|
|
|
169
158
|
## Better Auth
|
|
170
159
|
|
|
171
|
-
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.
|
|
172
161
|
|
|
173
162
|
```bash
|
|
174
163
|
pnpm add better-auth
|
|
@@ -186,26 +175,26 @@ export default {
|
|
|
186
175
|
```ts
|
|
187
176
|
// src/app.ts
|
|
188
177
|
import { createNanoApp } from '@nano-app/backend';
|
|
178
|
+
import * as schema from './db/schema';
|
|
189
179
|
|
|
190
|
-
createNanoApp({ mainPlugin: { database: {
|
|
180
|
+
createNanoApp({ mainPlugin: { database: { schema } } });
|
|
191
181
|
```
|
|
192
182
|
|
|
193
|
-
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:
|
|
194
184
|
|
|
195
185
|
```bash
|
|
196
|
-
npx auth generate --adapter
|
|
197
|
-
|
|
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
|
|
198
188
|
```
|
|
199
189
|
|
|
200
|
-
- It needs the database: `src/auth.ts` without `mainPlugin.database.
|
|
201
|
-
- 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.
|
|
202
192
|
- Its endpoints are mounted with your routes, at its `basePath` (default `/api/auth`), and stay public.
|
|
203
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).
|
|
204
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).
|
|
205
195
|
- Handlers and `onBefore` receive the session in `params.session` (`{ user, session }`).
|
|
206
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.
|
|
207
|
-
- Relate your
|
|
208
|
-
- 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`.
|
|
209
198
|
- Set `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` in the environment, as Better Auth asks.
|
|
210
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`.
|
|
211
200
|
- `createRoute({ public: true, ... })` answers without a session; its handlers still receive `session` when the request has one. An unknown path answers 404.
|
|
@@ -292,7 +281,7 @@ The endpoints of one plugin have no guaranteed order: one route's `onInit` must
|
|
|
292
281
|
|
|
293
282
|
Everything is mounted on one Express application, in this order: base middlewares → `before` plugins → your routes → `after` plugins → 404 → error handler.
|
|
294
283
|
|
|
295
|
-
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).
|
|
296
285
|
|
|
297
286
|
## Errors
|
|
298
287
|
|
|
@@ -302,8 +291,8 @@ The framework mounts the 404 and the error handler: an unmatched request becomes
|
|
|
302
291
|
throw { status: 409, message: 'Already exists' }; // → 409 { success: false, warning: false, message: "Already exists" }
|
|
303
292
|
throw 'Field name is required'; // → 400 { success: false, message: "Field name is required" }
|
|
304
293
|
throw new Error('boom'); // → 500 "Internal server error", logged to stderr
|
|
305
|
-
// A
|
|
306
|
-
//
|
|
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"
|
|
307
296
|
GET /unknown // → 404 { success: false, warning: false, message: "/unknown not found" }
|
|
308
297
|
```
|
|
309
298
|
|
|
@@ -314,7 +303,7 @@ GET /unknown // → 404 { success: false,
|
|
|
314
303
|
| `createNanoApp(config?)` | Creates and starts the app. |
|
|
315
304
|
| `createRoute(route)` | Types a route file. |
|
|
316
305
|
| `createPlugin(creator)` | Types a plugin factory and its options. |
|
|
317
|
-
| `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. |
|
|
318
307
|
|
|
319
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`).
|
|
320
309
|
|
package/build/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import express, { Express, Request, Request as Request$1 } from "express";
|
|
|
2
2
|
import { CorsOptions } from "cors";
|
|
3
3
|
import "dotenv/config";
|
|
4
4
|
import "jiti";
|
|
5
|
+
import { z } from "zod";
|
|
5
6
|
import "better-auth";
|
|
6
7
|
/** API response */
|
|
7
8
|
type ApiResponse<T = any> = {
|
|
@@ -17,13 +18,13 @@ type ApiResponse<T = any> = {
|
|
|
17
18
|
type ApiPromise<T = any> = Promise<ApiResponse<T>>;
|
|
18
19
|
/** Query parameters, sent as the JSON body of a `QUERY` request */
|
|
19
20
|
type QueryParams = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
/** Drizzle's relational filter: `{ title: { like: 'a%' } }` */
|
|
22
|
+
where?: NanoObject;
|
|
23
|
+
/** Columns to answer: `{ id: true, title: true }` */
|
|
24
|
+
columns?: NanoObject<boolean>;
|
|
25
|
+
orderBy?: NanoObject<'asc' | 'desc'>;
|
|
26
|
+
limit?: number;
|
|
27
|
+
offset?: number;
|
|
27
28
|
};
|
|
28
29
|
/** Extra params of handlers and callbacks: what the app injects (options, `db`) and the framework adds */
|
|
29
30
|
type Params<Deps = NanoObject<any>> = Deps & {
|
|
@@ -55,7 +56,7 @@ type Callback<Deps> = (params: Params<Deps>) => Promise<void>;
|
|
|
55
56
|
type CallbackMethod<Deps> = (req: Request$1, method: string, params: Params<Deps>) => Promise<NanoObject>;
|
|
56
57
|
/** Callback run with the Express app */
|
|
57
58
|
type CallbackMount<Deps> = (app: Express, params: Params<Deps>) => Promise<void>;
|
|
58
|
-
/** Callbacks of an endpoint
|
|
59
|
+
/** Callbacks of an endpoint; what affects the whole app goes in the plugin's onMount */
|
|
59
60
|
type Callbacks<Deps = NanoObject<any>> = {
|
|
60
61
|
/** Run once, when the endpoint is mounted */
|
|
61
62
|
onInit?: Callback<Deps>;
|
|
@@ -79,7 +80,7 @@ type RouteEntry<Deps = NanoObject<any>> = {
|
|
|
79
80
|
} & Handlers<Deps>;
|
|
80
81
|
/** Generic object */
|
|
81
82
|
type NanoObject<T = any> = Record<string, T>;
|
|
82
|
-
/** Error of the framework
|
|
83
|
+
/** Error of the framework, with the status a handler answers */
|
|
83
84
|
declare class NanoError extends Error {
|
|
84
85
|
status?: number;
|
|
85
86
|
/** Error with its message, and the status a handler answers (500 without one) */
|
|
@@ -87,12 +88,10 @@ declare class NanoError extends Error {
|
|
|
87
88
|
/** Prefix of the framework's messages, shown before the message */
|
|
88
89
|
get name(): string;
|
|
89
90
|
}
|
|
90
|
-
/** Database
|
|
91
|
+
/** Database of a plugin or of the app */
|
|
91
92
|
type DatabaseConfig = {
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
/** Prisma schema file (`.prisma`), relative to `cwd` */
|
|
95
|
-
schemaPath: string;
|
|
93
|
+
/** Its Drizzle schema: the module of its tables and `relations` */
|
|
94
|
+
schema: NanoObject;
|
|
96
95
|
};
|
|
97
96
|
/** When a plugin is mounted, relative to the consumer routes */
|
|
98
97
|
type PluginStage = 'before' | 'after';
|
|
@@ -106,31 +105,15 @@ type PluginConfig<Options = NanoObject> = {
|
|
|
106
105
|
basePath?: string;
|
|
107
106
|
/** Mount point relative to the consumer routes. Defaults to "before" */
|
|
108
107
|
stage?: PluginStage;
|
|
109
|
-
/**
|
|
108
|
+
/** Drizzle schema of the plugin's database, mounted before its endpoints */
|
|
110
109
|
database?: DatabaseConfig;
|
|
111
110
|
/** Plugin options */
|
|
112
111
|
options?: Options;
|
|
113
112
|
/** Plugin callbacks */
|
|
114
113
|
callbacks?: PluginCallbacks;
|
|
115
114
|
};
|
|
116
|
-
/** The project's plugin
|
|
117
|
-
type MainPluginConfig<Options = NanoObject> =
|
|
118
|
-
/** Directory of the project's `*.route.ts` files. Defaults to "src/routes" */
|
|
119
|
-
routesDir?: string;
|
|
120
|
-
/** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
|
|
121
|
-
basePath?: string;
|
|
122
|
-
/** The project's database */
|
|
123
|
-
database?: {
|
|
124
|
-
/** Whether the database is enabled. Defaults to false */
|
|
125
|
-
enabled?: boolean;
|
|
126
|
-
/** Prisma schema file, relative to `cwd`. Defaults to "src/prisma/schema.prisma" */
|
|
127
|
-
schemaPath?: string;
|
|
128
|
-
};
|
|
129
|
-
/** Plugin options */
|
|
130
|
-
options?: Options;
|
|
131
|
-
/** Plugin callbacks */
|
|
132
|
-
callbacks?: PluginCallbacks;
|
|
133
|
-
};
|
|
115
|
+
/** The project's plugin, named "app": `routesDir` defaults to "src/routes" */
|
|
116
|
+
type MainPluginConfig<Options = NanoObject> = Partial<Omit<PluginConfig<Options>, 'name' | 'stage'>>;
|
|
134
117
|
/** Configuration of the framework's built-in packages */
|
|
135
118
|
type PackagesConfig = {
|
|
136
119
|
/** Morgan configuration */
|
|
@@ -167,7 +150,7 @@ type NanoAppConfig = {
|
|
|
167
150
|
mainPlugin?: MainPluginConfig;
|
|
168
151
|
};
|
|
169
152
|
/** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
|
|
170
|
-
declare function mergeConfig(oConfig?: NanoAppConfig): NanoAppConfig & Required<Pick<NanoAppConfig, 'settings' | 'plugins' | 'packages'>> & {
|
|
153
|
+
declare function mergeConfig(oConfig?: NanoAppConfig): NanoAppConfig & Required<Pick<NanoAppConfig, 'port' | 'settings' | 'plugins' | 'packages'>> & {
|
|
171
154
|
mainPlugin: PluginConfig;
|
|
172
155
|
};
|
|
173
156
|
/** Main NanoApp class */
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
1
|
import express from "express";
|
|
3
2
|
import morgan from "morgan";
|
|
4
3
|
import cors from "cors";
|
|
@@ -6,13 +5,12 @@ import helmet from "helmet";
|
|
|
6
5
|
import compression from "compression";
|
|
7
6
|
import "dotenv/config";
|
|
8
7
|
import { glob } from "glob";
|
|
9
|
-
import {
|
|
10
|
-
import { existsSync
|
|
8
|
+
import { isAbsolute, resolve } from "node:path";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
11
10
|
import { createJiti } from "jiti";
|
|
12
|
-
import {
|
|
13
|
-
import { createHash } from "node:crypto";
|
|
11
|
+
import { z } from "zod";
|
|
14
12
|
// ../core/utils/NanoError.ts
|
|
15
|
-
/** Error of the framework
|
|
13
|
+
/** Error of the framework, with the status a handler answers */
|
|
16
14
|
var NanoError = class extends Error {
|
|
17
15
|
status;
|
|
18
16
|
/** Error with its message, and the status a handler answers (500 without one) */
|
|
@@ -27,31 +25,13 @@ var NanoError = class extends Error {
|
|
|
27
25
|
};
|
|
28
26
|
// ../core/files/FileReader.ts
|
|
29
27
|
/** Reads and loads files from disk, resolved from `cwd` */
|
|
30
|
-
var FileReader = class
|
|
31
|
-
/** Loader of the consumer's modules
|
|
32
|
-
static oJiti = createJiti(import.meta.url
|
|
28
|
+
var FileReader = class {
|
|
29
|
+
/** Loader of the consumer's modules */
|
|
30
|
+
static oJiti = createJiti(import.meta.url);
|
|
33
31
|
/** Loads a module via jiti */
|
|
34
32
|
static async loadModule(sFilePath, oOptions = {}) {
|
|
35
33
|
return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
|
|
36
34
|
}
|
|
37
|
-
/** Reads the contents of a file */
|
|
38
|
-
static loadFile(sFilePath) {
|
|
39
|
-
const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
|
|
40
|
-
if (!sContent) throw new NanoError(`The file ${sFilePath} does not have valid content.`);
|
|
41
|
-
return sContent;
|
|
42
|
-
}
|
|
43
|
-
/** Reads a Prisma schema without its `//` comments */
|
|
44
|
-
static loadSchema(sFilePath) {
|
|
45
|
-
return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
|
|
46
|
-
}
|
|
47
|
-
/** Body of the `datasource` block of a Prisma schema, without its braces */
|
|
48
|
-
static datasource(sContent) {
|
|
49
|
-
return sContent.match(/datasource\s+\w+\s*\{([^}]*)\}/)?.[1] ?? "";
|
|
50
|
-
}
|
|
51
|
-
/** Schemas a Prisma schema declares in its datasource (`schemas = [...]`) */
|
|
52
|
-
static schemas(sContent) {
|
|
53
|
-
return [...(FileReader.datasource(sContent).match(/\bschemas\s*=\s*\[([^\]]*)\]/)?.[1] ?? "").matchAll(/["']([^"']+)["']/g)].map(([, sItem]) => sItem);
|
|
54
|
-
}
|
|
55
35
|
/** Resolves the absolute path of a file */
|
|
56
36
|
static getAbsolutePath(sFilePath) {
|
|
57
37
|
const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
|
|
@@ -62,11 +42,7 @@ var FileReader = class FileReader {
|
|
|
62
42
|
// ../core/files/FileLoader.ts
|
|
63
43
|
/** Finds and loads the consumer's files */
|
|
64
44
|
var FileLoader = class {
|
|
65
|
-
/**
|
|
66
|
-
static async findPrismaClient(sClientPath) {
|
|
67
|
-
return (await FileReader.loadModule(sClientPath))?.PrismaClient;
|
|
68
|
-
}
|
|
69
|
-
/** Finds `*.<extension>.ts` (or built `.js`) files in a folder (`src/<extension>s` by default) and returns `{ [subPath]: defaultExport }` */
|
|
45
|
+
/** Finds the `*.<extension>.ts` files of a folder: `{ [subPath]: defaultExport }` */
|
|
70
46
|
static async findMany(sExtension, sSourceDir) {
|
|
71
47
|
const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
72
48
|
const aFilePaths = await glob(`${sSrcDir}/**/*.${sExtension}.{ts,js}`, {
|
|
@@ -82,101 +58,20 @@ var FileLoader = class {
|
|
|
82
58
|
return oEntries;
|
|
83
59
|
}
|
|
84
60
|
};
|
|
85
|
-
// ../core/const.ts
|
|
86
|
-
/** HTTP methods a route can handle */
|
|
87
|
-
const HTTP_METHODS = [
|
|
88
|
-
"GET",
|
|
89
|
-
"POST",
|
|
90
|
-
"PUT",
|
|
91
|
-
"DELETE",
|
|
92
|
-
"QUERY"
|
|
93
|
-
];
|
|
94
|
-
/** Package of the official plugins: only its schemas may declare nano_* */
|
|
95
|
-
const OFFICIAL_PLUGINS = "@nano-app/plugins";
|
|
96
|
-
/** Loopback and private networks: the proxies the backend trusts by default */
|
|
97
|
-
const PRIVATE_NETWORKS = [
|
|
98
|
-
"127.0.0.0/8",
|
|
99
|
-
"::1/128",
|
|
100
|
-
"10.0.0.0/8",
|
|
101
|
-
"172.16.0.0/12",
|
|
102
|
-
"192.168.0.0/16",
|
|
103
|
-
"fc00::/7"
|
|
104
|
-
];
|
|
105
|
-
// ../core/utils/Paths.ts
|
|
106
|
-
/** Paths of the app's pages */
|
|
107
|
-
var Paths = class {
|
|
108
|
-
/** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
|
|
109
|
-
static matches(sPattern, sPath) {
|
|
110
|
-
if (!sPattern.endsWith("/*")) return sPattern === sPath;
|
|
111
|
-
const sBase = sPattern.slice(0, -2);
|
|
112
|
-
return sPath === sBase || sPath.startsWith(`${sBase}/`);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
61
|
// ../core/utils/Validator.ts
|
|
116
|
-
const STORE_TYPES = ["app", "api"];
|
|
117
62
|
/** Checks values of the framework and the consumer's files; an invalid file throws a `NanoError` */
|
|
118
|
-
var Validator = class
|
|
63
|
+
var Validator = class {
|
|
119
64
|
/** Whether a value is a plain object */
|
|
120
65
|
static isObject(oValue) {
|
|
121
66
|
return Object.prototype.toString.call(oValue) === "[object Object]";
|
|
122
67
|
}
|
|
123
|
-
/**
|
|
124
|
-
static
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
/** First environment variable of a list that is not defined */
|
|
132
|
-
static missingEnv(aNames) {
|
|
133
|
-
return aNames.find((sName) => !process.env[sName]);
|
|
134
|
-
}
|
|
135
|
-
/** Checks every entry with a guard (`Validator.isRoute`...): the first invalid one throws */
|
|
136
|
-
static validate(oEntries, fnGuard) {
|
|
137
|
-
return Object.fromEntries(Object.entries(oEntries).filter(([sKey, oEntry]) => fnGuard(oEntry, sKey)));
|
|
138
|
-
}
|
|
139
|
-
/** Type guard for a `*.route.ts` entry */
|
|
140
|
-
static isRoute(oEntry, sFilePath) {
|
|
141
|
-
if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
|
|
142
|
-
if (oEntry.expression && typeof oEntry.expression !== "string") throw new NanoError(`${sFilePath}: "expression" must be a string.`);
|
|
143
|
-
if (oEntry.public !== void 0 && typeof oEntry.public !== "boolean") throw new NanoError(`${sFilePath}: "public" must be a boolean.`);
|
|
144
|
-
for (const sMethod of HTTP_METHODS) {
|
|
145
|
-
const oMethod = oEntry[sMethod];
|
|
146
|
-
if (oMethod && typeof oMethod !== "function" && typeof oMethod.handler !== "function") throw new NanoError(`${sFilePath}: "${sMethod}" must be a function or { handler, permissions }.`);
|
|
147
|
-
}
|
|
148
|
-
if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) throw new NanoError(`${sFilePath}: defines no HTTP method.`);
|
|
149
|
-
return true;
|
|
150
|
-
}
|
|
151
|
-
/** Type guard for a `*.store.ts` entry */
|
|
152
|
-
static isStore(oEntry, sFilePath) {
|
|
153
|
-
if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
|
|
154
|
-
if (!STORE_TYPES.includes(oEntry.type)) throw new NanoError(`${sFilePath}: its type must be "app" or "api".`);
|
|
155
|
-
if (oEntry.data !== void 0 && !Validator.isObject(oEntry.data)) throw new NanoError(`${sFilePath}: "data" must be an object.`);
|
|
156
|
-
if (oEntry.headers !== void 0 && !Validator.isStringMap(oEntry.headers)) throw new NanoError(`${sFilePath}: "headers" must be an object of strings.`);
|
|
157
|
-
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.`);
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
/** Whether `auth` has the base path of Better Auth and valid routes, none redirecting to itself */
|
|
161
|
-
static isAuth(oAuth) {
|
|
162
|
-
if (!Validator.isObject(oAuth) || typeof oAuth.path !== "string") return false;
|
|
163
|
-
const aRoutes = oAuth.routes ?? [];
|
|
164
|
-
if (!Array.isArray(aRoutes)) return false;
|
|
165
|
-
return aRoutes.every((oRoute) => {
|
|
166
|
-
if (!Validator.isObject(oRoute)) return false;
|
|
167
|
-
const { match, redirect = "/", roles, permissions } = oRoute;
|
|
168
|
-
return Validator.isPath(match) && Validator.isPath(redirect) && !Paths.matches(match, redirect) && (roles === void 0 || typeof roles === "string") && (permissions === void 0 || Validator.isStringMap(permissions));
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
/** Whether a Prisma schema, without comments, is PostgreSQL and declares the schemas it may */
|
|
172
|
-
static isPrisma(sContent, sFilePath, isPlugin) {
|
|
173
|
-
const sProvider = FileReader.datasource(sContent).match(/\bprovider\s*=\s*["']([^"']+)["']/)?.[1];
|
|
174
|
-
if (sProvider !== "postgresql") throw new NanoError(`${sFilePath}: the datasource provider must be "postgresql"${sProvider ? `, not "${sProvider}"` : ""}.`);
|
|
175
|
-
const aSchemas = FileReader.schemas(sContent);
|
|
176
|
-
const isPluginInvalid = !aSchemas.length || aSchemas.some((sItem) => !sItem.startsWith("nano_"));
|
|
177
|
-
const isConsumerInvalid = aSchemas.some((sItem) => sItem.startsWith("nano_"));
|
|
178
|
-
if (isPlugin ? isPluginInvalid : isConsumerInvalid) throw new NanoError(`${sFilePath}: ${isPlugin ? "a plugin declares only nano_* schemas" : "the nano_* schemas are reserved for plugins"}.`);
|
|
179
|
-
return true;
|
|
68
|
+
/** Checks every entry with a zod schema (`ROUTE`, `STORE`): the first invalid one throws with its key */
|
|
69
|
+
static validate(oEntries, oSchema) {
|
|
70
|
+
return Object.fromEntries(Object.entries(oEntries).map(([sKey, oEntry]) => {
|
|
71
|
+
const oResult = oSchema.safeParse(oEntry);
|
|
72
|
+
if (!oResult.success) throw new NanoError(`${sKey}:\n${z.prettifyError(oResult.error)}`);
|
|
73
|
+
return [sKey, oResult.data];
|
|
74
|
+
}));
|
|
180
75
|
}
|
|
181
76
|
};
|
|
182
77
|
// ../core/utils/Logger.ts
|
|
@@ -203,55 +98,69 @@ var Logger = class Logger {
|
|
|
203
98
|
return oDate.toISOString().slice(0, 19).replace("T", " ");
|
|
204
99
|
}
|
|
205
100
|
};
|
|
206
|
-
//
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const sFilePath = AUTH_FILES.find(existsSync);
|
|
215
|
-
if (!sFilePath) return void 0;
|
|
216
|
-
const oFile = await FileReader.loadModule(sFilePath, { default: true });
|
|
217
|
-
if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
|
|
218
|
-
return oFile.options;
|
|
219
|
-
}
|
|
220
|
-
/** The instance, once the app's plugin created it */
|
|
221
|
-
static get() {
|
|
222
|
-
return this.oAuth;
|
|
223
|
-
}
|
|
224
|
-
/** Creates the instance on the app's client, its handler and the session lookup */
|
|
225
|
-
static async create(oOptions, oDb) {
|
|
226
|
-
const { betterAuth } = await import("better-auth");
|
|
227
|
-
const { prismaAdapter } = await import("better-auth/adapters/prisma");
|
|
228
|
-
const { getSchema } = await import("better-auth/db");
|
|
229
|
-
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
230
|
-
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oDb[sModel]);
|
|
231
|
-
if (aMissing.length) throw new NanoError(`Better Auth needs the models ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with Prisma.`);
|
|
232
|
-
const oAuth = betterAuth({
|
|
233
|
-
...oOptions,
|
|
234
|
-
advanced: {
|
|
235
|
-
...oOptions.advanced,
|
|
236
|
-
ipAddress: {
|
|
237
|
-
trustedProxies: PRIVATE_NETWORKS,
|
|
238
|
-
...oOptions.advanced?.ipAddress
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
database: prismaAdapter(oDb, { provider: "postgresql" })
|
|
242
|
-
});
|
|
243
|
-
this.oAuth = oAuth;
|
|
244
|
-
const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
245
|
-
oRes.locals.session = oSession ?? void 0;
|
|
246
|
-
fnNext();
|
|
247
|
-
});
|
|
248
|
-
return {
|
|
249
|
-
oHandler: toNodeHandler(oAuth),
|
|
250
|
-
fnSession,
|
|
251
|
-
sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
|
|
252
|
-
};
|
|
101
|
+
// ../core/utils/Paths.ts
|
|
102
|
+
/** Paths of the app's pages */
|
|
103
|
+
var Paths = class {
|
|
104
|
+
/** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
|
|
105
|
+
static matches(sPattern, sPath) {
|
|
106
|
+
if (!sPattern.endsWith("/*")) return sPattern === sPath;
|
|
107
|
+
const sBase = sPattern.slice(0, -2);
|
|
108
|
+
return sPath === sBase || sPath.startsWith(`${sBase}/`);
|
|
253
109
|
}
|
|
254
110
|
};
|
|
111
|
+
// ../core/const.ts
|
|
112
|
+
/** Loopback and private networks: the proxies the backend trusts by default */
|
|
113
|
+
const PRIVATE_NETWORKS = [
|
|
114
|
+
"127.0.0.0/8",
|
|
115
|
+
"::1/128",
|
|
116
|
+
"10.0.0.0/8",
|
|
117
|
+
"172.16.0.0/12",
|
|
118
|
+
"192.168.0.0/16",
|
|
119
|
+
"fc00::/7"
|
|
120
|
+
];
|
|
121
|
+
// ../core/utils/validations.ts
|
|
122
|
+
/** A function of the consumer, typed as what the framework calls */
|
|
123
|
+
function callable() {
|
|
124
|
+
return z.custom((oValue) => typeof oValue === "function");
|
|
125
|
+
}
|
|
126
|
+
/** Permissions of Better Auth's admin plugin: the actions per resource */
|
|
127
|
+
const PERMISSIONS = z.record(z.string(), z.array(z.string())).optional();
|
|
128
|
+
/** A method of a route: its handler, or it with its own permissions */
|
|
129
|
+
const METHOD = z.union([callable(), z.object({
|
|
130
|
+
handler: callable(),
|
|
131
|
+
permissions: PERMISSIONS
|
|
132
|
+
})]).optional();
|
|
133
|
+
/** A `*.route.ts` file: its options, its callbacks and at least one HTTP method, nothing else */
|
|
134
|
+
const ROUTE = z.strictObject({
|
|
135
|
+
expression: z.string().optional(),
|
|
136
|
+
public: z.boolean().optional(),
|
|
137
|
+
permissions: PERMISSIONS,
|
|
138
|
+
callbacks: z.object({
|
|
139
|
+
onInit: callable().optional(),
|
|
140
|
+
onBefore: callable().optional()
|
|
141
|
+
}).optional(),
|
|
142
|
+
GET: METHOD,
|
|
143
|
+
POST: METHOD,
|
|
144
|
+
PUT: METHOD,
|
|
145
|
+
DELETE: METHOD,
|
|
146
|
+
QUERY: METHOD
|
|
147
|
+
}).refine(({ GET, POST, PUT, DELETE, QUERY }) => GET ?? POST ?? PUT ?? DELETE ?? QUERY, { error: "It defines no HTTP method" });
|
|
148
|
+
/** A path of the app, starting with `/` */
|
|
149
|
+
const PATH = z.string().startsWith("/");
|
|
150
|
+
z.object({
|
|
151
|
+
type: z.enum(["app", "api"]),
|
|
152
|
+
data: z.record(z.string(), z.any()).optional(),
|
|
153
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
154
|
+
auth: z.object({
|
|
155
|
+
path: z.string(),
|
|
156
|
+
routes: z.array(z.object({
|
|
157
|
+
match: PATH,
|
|
158
|
+
redirect: PATH.optional(),
|
|
159
|
+
roles: z.string().optional(),
|
|
160
|
+
permissions: z.record(z.string(), z.string()).optional()
|
|
161
|
+
}).refine(({ match, redirect }) => !Paths.matches(match, redirect ?? "/"), { error: "Its \"redirect\" must be outside its \"match\"" })).optional()
|
|
162
|
+
}).optional()
|
|
163
|
+
});
|
|
255
164
|
// src/auth/permissions.ts
|
|
256
165
|
/** Whether the session's user has the permissions; always without Better Auth or permissions */
|
|
257
166
|
async function isGranted({ auth, session }, oPermissions) {
|
|
@@ -291,9 +200,8 @@ var Endpoint = class {
|
|
|
291
200
|
createHandler(oParams) {
|
|
292
201
|
return (oReq, oRes, fnNext) => {
|
|
293
202
|
if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext(new NanoError("Not authenticated", 401));
|
|
294
|
-
const sMethod = oReq.method
|
|
295
|
-
const
|
|
296
|
-
const oMethod = sHandled && this.oMethods[sHandled];
|
|
203
|
+
const sMethod = oReq.method;
|
|
204
|
+
const oMethod = this.oMethods[sMethod];
|
|
297
205
|
if (!oMethod) return fnNext(new NanoError(`Method ${sMethod} not implemented`, 405));
|
|
298
206
|
const oRequestParams = {
|
|
299
207
|
...oParams,
|
|
@@ -329,7 +237,7 @@ var Router = class {
|
|
|
329
237
|
async mount() {
|
|
330
238
|
if (!this.sRoutesDir) return [];
|
|
331
239
|
const oFiles = await FileLoader.findMany("route", this.sRoutesDir);
|
|
332
|
-
const oRoutes = Validator.validate(oFiles,
|
|
240
|
+
const oRoutes = Validator.validate(oFiles, ROUTE);
|
|
333
241
|
return Object.entries(oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
|
|
334
242
|
const sPath = this.createPath(sFilePath, expression);
|
|
335
243
|
Logger.info(sPath);
|
|
@@ -348,130 +256,119 @@ var Router = class {
|
|
|
348
256
|
return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
|
|
349
257
|
}
|
|
350
258
|
};
|
|
351
|
-
// src/
|
|
352
|
-
/**
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const oUrl = new URL(process.env.DATABASE_URL ?? "");
|
|
386
|
-
oUrl.searchParams.set("schema", sDbSchema);
|
|
387
|
-
const sConfig = join(this.dir(sName), "prisma.config.mjs");
|
|
388
|
-
writeFileSync(sConfig, [
|
|
389
|
-
"export default {",
|
|
390
|
-
` schema: ${JSON.stringify(join(this.dir(sName), "schema.prisma"))},`,
|
|
391
|
-
` migrations: { path: ${JSON.stringify(sMigrations)} },`,
|
|
392
|
-
" datasource: { url: process.env.NANO_APP_MIGRATION_URL },",
|
|
393
|
-
"};",
|
|
394
|
-
""
|
|
395
|
-
].join("\n"));
|
|
396
|
-
this.run([
|
|
397
|
-
"migrate",
|
|
398
|
-
"deploy",
|
|
399
|
-
"--config",
|
|
400
|
-
sConfig
|
|
401
|
-
], { NANO_APP_MIGRATION_URL: oUrl.toString() });
|
|
402
|
-
}
|
|
403
|
-
/** Runs a Prisma command of the consumer's `prisma` package */
|
|
404
|
-
static run(aArgs, oEnv = {}) {
|
|
405
|
-
const sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
|
|
406
|
-
execFileSync(process.execPath, [sCli, ...aArgs], {
|
|
407
|
-
env: {
|
|
408
|
-
...process.env,
|
|
409
|
-
...oEnv
|
|
259
|
+
// src/auth/BetterAuth.ts
|
|
260
|
+
/** Files of the app's Better Auth options, relative to `cwd`: source, then built */
|
|
261
|
+
const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
|
|
262
|
+
/** Better Auth on the app's Drizzle client: the instance, its handler and the session lookup */
|
|
263
|
+
var BetterAuth = class {
|
|
264
|
+
static oAuth;
|
|
265
|
+
/** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
|
|
266
|
+
static async findOptions() {
|
|
267
|
+
const sFilePath = AUTH_FILES.find(existsSync);
|
|
268
|
+
if (!sFilePath) return void 0;
|
|
269
|
+
const oFile = await FileReader.loadModule(sFilePath, { default: true });
|
|
270
|
+
if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
|
|
271
|
+
return oFile.options;
|
|
272
|
+
}
|
|
273
|
+
/** The instance, once the app's plugin created it */
|
|
274
|
+
static get() {
|
|
275
|
+
return this.oAuth;
|
|
276
|
+
}
|
|
277
|
+
/** Creates the instance on the app's client and schema, its handler and the session lookup */
|
|
278
|
+
static async create(oOptions, oDb, oSchema) {
|
|
279
|
+
const { betterAuth } = await import("better-auth");
|
|
280
|
+
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
|
|
281
|
+
const { getSchema } = await import("better-auth/db");
|
|
282
|
+
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
283
|
+
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
|
|
284
|
+
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.`);
|
|
285
|
+
const oAuth = betterAuth({
|
|
286
|
+
...oOptions,
|
|
287
|
+
advanced: {
|
|
288
|
+
...oOptions.advanced,
|
|
289
|
+
ipAddress: {
|
|
290
|
+
trustedProxies: PRIVATE_NETWORKS,
|
|
291
|
+
...oOptions.advanced?.ipAddress
|
|
292
|
+
}
|
|
410
293
|
},
|
|
411
|
-
|
|
294
|
+
database: drizzleAdapter(oDb, {
|
|
295
|
+
provider: "pg",
|
|
296
|
+
schema: oSchema
|
|
297
|
+
})
|
|
298
|
+
});
|
|
299
|
+
this.oAuth = oAuth;
|
|
300
|
+
const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
301
|
+
oRes.locals.session = oSession ?? void 0;
|
|
302
|
+
fnNext();
|
|
412
303
|
});
|
|
304
|
+
return {
|
|
305
|
+
oHandler: toNodeHandler(oAuth),
|
|
306
|
+
fnSession,
|
|
307
|
+
sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
|
|
308
|
+
};
|
|
413
309
|
}
|
|
414
310
|
};
|
|
415
311
|
// src/database/Database.ts
|
|
416
|
-
/**
|
|
312
|
+
/** Drizzle clients of the app and its plugins, one per name, on one pool of `DATABASE_URL` */
|
|
417
313
|
var Database = class {
|
|
418
|
-
static
|
|
314
|
+
static oPool;
|
|
419
315
|
/** Mounted clients, by name */
|
|
420
316
|
static oClients = {};
|
|
421
|
-
/** Initializes the Database client */
|
|
422
|
-
static async init() {
|
|
423
|
-
const sUrl = process.env.DATABASE_URL;
|
|
424
|
-
if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
|
|
425
|
-
const { PrismaPg } = await import("@prisma/adapter-pg");
|
|
426
|
-
this.oAdapter = new PrismaPg({ connectionString: sUrl });
|
|
427
|
-
}
|
|
428
317
|
/** Gets a client by name */
|
|
429
318
|
static get(sName) {
|
|
430
319
|
return this.oClients[sName];
|
|
431
320
|
}
|
|
432
|
-
/**
|
|
433
|
-
static async mount(sName, {
|
|
321
|
+
/** Mounts the Drizzle client of a schema, checking the `nano_*` rule of its tables */
|
|
322
|
+
static async mount(sName, { schema }) {
|
|
434
323
|
if (this.get(sName)) throw new NanoError(`The database "${sName}" is already mounted.`);
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
324
|
+
const { defineRelations, is } = await import("drizzle-orm");
|
|
325
|
+
const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
|
|
326
|
+
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
327
|
+
const sOwn = `nano_${sName}`;
|
|
328
|
+
const { breaks, rule } = sName === "app" ? {
|
|
329
|
+
breaks: (sSchema) => sSchema.startsWith("nano_"),
|
|
330
|
+
rule: "the nano_* schemas are reserved for plugins"
|
|
331
|
+
} : {
|
|
332
|
+
breaks: (sSchema) => sSchema !== sOwn,
|
|
333
|
+
rule: `a plugin keeps its tables in "${sOwn}"`
|
|
334
|
+
};
|
|
335
|
+
for (const oTable of Object.values(schema).filter((oValue) => is(oValue, PgTable))) {
|
|
336
|
+
const { name, schema: sSchema = "public" } = getTableConfig(oTable);
|
|
337
|
+
if (breaks(sSchema)) throw new NanoError(`The database "${sName}": table "${sSchema}.${name}" breaks the rule, ${rule}.`);
|
|
338
|
+
}
|
|
339
|
+
this.oPool ??= await this.pool();
|
|
340
|
+
const oClient = drizzle({
|
|
341
|
+
client: this.oPool,
|
|
342
|
+
relations: schema.relations ?? defineRelations(schema)
|
|
343
|
+
});
|
|
444
344
|
this.oClients[sName] = oClient;
|
|
445
345
|
Logger.info(`Database "${sName}" mounted`);
|
|
446
346
|
return oClient;
|
|
447
347
|
}
|
|
448
|
-
/**
|
|
449
|
-
static
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
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 });
|
|
456
354
|
}
|
|
457
355
|
};
|
|
458
356
|
// src/routes/Plugin.ts
|
|
459
357
|
/** A group of routes, with its options, callbacks and database */
|
|
460
358
|
var Plugin = class {
|
|
461
359
|
oConfig;
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
constructor(oConfig, oAuthOptions) {
|
|
360
|
+
/** Creates a new Plugin instance */
|
|
361
|
+
constructor(oConfig) {
|
|
465
362
|
this.oConfig = oConfig;
|
|
466
|
-
this.oAuthOptions = oAuthOptions;
|
|
467
363
|
}
|
|
468
364
|
/** Mounts its endpoints on express app */
|
|
469
365
|
async mount(oApp) {
|
|
470
366
|
const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
367
|
+
const oAuthOptions = name === "app" ? await BetterAuth.findOptions() : void 0;
|
|
368
|
+
const oDb = database ? await Database.mount(name, database) : Database.get("app");
|
|
369
|
+
if (oAuthOptions) {
|
|
370
|
+
if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
|
|
371
|
+
const { sPath, oHandler, fnSession } = await BetterAuth.create(oAuthOptions, oDb, database.schema);
|
|
475
372
|
oApp.all(sPath, oHandler);
|
|
476
373
|
oApp.use(fnSession);
|
|
477
374
|
}
|
|
@@ -490,8 +387,6 @@ var Plugin = class {
|
|
|
490
387
|
const INITIAL_CONFIG = {
|
|
491
388
|
/** Application port */
|
|
492
389
|
port: 3e3,
|
|
493
|
-
/** Application listener */
|
|
494
|
-
listener: () => {},
|
|
495
390
|
/** Express settings: the client IP comes from `X-Forwarded-For` only through private proxies */
|
|
496
391
|
settings: { "trust proxy": PRIVATE_NETWORKS },
|
|
497
392
|
/** Initial middlewares */
|
|
@@ -519,11 +414,7 @@ const INITIAL_CONFIG = {
|
|
|
519
414
|
/** Main plugin to mount */
|
|
520
415
|
mainPlugin: {
|
|
521
416
|
routesDir: "src/routes",
|
|
522
|
-
basePath: ""
|
|
523
|
-
database: {
|
|
524
|
-
enabled: false,
|
|
525
|
-
schemaPath: "src/prisma/schema.prisma"
|
|
526
|
-
}
|
|
417
|
+
basePath: ""
|
|
527
418
|
},
|
|
528
419
|
/** Plugins around the consumer routes */
|
|
529
420
|
plugins: []
|
|
@@ -555,26 +446,30 @@ function mergeConfig(oConfig = {}) {
|
|
|
555
446
|
};
|
|
556
447
|
}
|
|
557
448
|
// src/app/errors.ts
|
|
558
|
-
/**
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
status: 404,
|
|
562
|
-
message: "Not found"
|
|
563
|
-
},
|
|
564
|
-
P2002: {
|
|
449
|
+
/** Database errors the request caused, by their Postgres code or Drizzle's name */
|
|
450
|
+
const DATABASE_ERRORS = {
|
|
451
|
+
"23505": {
|
|
565
452
|
status: 409,
|
|
566
453
|
message: "Conflict"
|
|
567
454
|
},
|
|
568
|
-
|
|
455
|
+
"23503": {
|
|
569
456
|
status: 409,
|
|
570
457
|
message: "Conflict"
|
|
571
458
|
},
|
|
572
|
-
|
|
459
|
+
"23502": {
|
|
460
|
+
status: 400,
|
|
461
|
+
message: "Invalid request"
|
|
462
|
+
},
|
|
463
|
+
"22P02": {
|
|
464
|
+
status: 400,
|
|
465
|
+
message: "Invalid request"
|
|
466
|
+
},
|
|
467
|
+
DrizzleError: {
|
|
573
468
|
status: 400,
|
|
574
469
|
message: "Invalid request"
|
|
575
470
|
}
|
|
576
471
|
};
|
|
577
|
-
/** Whether anything thrown is an object, read as a HandlerError or a
|
|
472
|
+
/** Whether anything thrown is an object, read as a HandlerError or a Drizzle error with its Postgres cause */
|
|
578
473
|
function isHandlerError(oError) {
|
|
579
474
|
return typeof oError === "object" && oError !== null;
|
|
580
475
|
}
|
|
@@ -588,7 +483,8 @@ function normalize(oError) {
|
|
|
588
483
|
}
|
|
589
484
|
};
|
|
590
485
|
const oThrown = isHandlerError(oError) ? oError : {};
|
|
591
|
-
const
|
|
486
|
+
const sKey = oThrown.cause?.code ?? oThrown.name ?? "";
|
|
487
|
+
const { status, message, warning } = DATABASE_ERRORS[sKey] ?? oThrown;
|
|
592
488
|
const iStatus = status ?? 500;
|
|
593
489
|
return {
|
|
594
490
|
status: iStatus,
|
|
@@ -623,7 +519,7 @@ var NanoApp = class {
|
|
|
623
519
|
async start() {
|
|
624
520
|
try {
|
|
625
521
|
await this.initialize();
|
|
626
|
-
this.oApp.listen(
|
|
522
|
+
this.oApp.listen(this.oConfig.port, () => {
|
|
627
523
|
this.oConfig.listener?.();
|
|
628
524
|
Logger.info(`Nano App running on port ${this.oConfig.port}`);
|
|
629
525
|
});
|
|
@@ -636,20 +532,22 @@ var NanoApp = class {
|
|
|
636
532
|
async initialize() {
|
|
637
533
|
for (const [sKey, oValue] of Object.entries(this.oConfig.settings)) this.oApp.set(sKey, oValue);
|
|
638
534
|
this.setPackages(this.oConfig.packages);
|
|
535
|
+
const { mainPlugin, plugins } = this.oConfig;
|
|
639
536
|
const oPlugins = {
|
|
640
537
|
before: [],
|
|
641
538
|
after: []
|
|
642
539
|
};
|
|
643
|
-
for (const oPlugin of
|
|
644
|
-
for (const oPlugin of
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
540
|
+
for (const oPlugin of plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
|
|
541
|
+
for (const oPlugin of [
|
|
542
|
+
...oPlugins.before,
|
|
543
|
+
mainPlugin,
|
|
544
|
+
...oPlugins.after
|
|
545
|
+
]) await new Plugin(oPlugin).mount(this.oApp);
|
|
648
546
|
this.oApp.use(notFoundHandler);
|
|
649
547
|
this.oApp.use(errorHandler);
|
|
650
548
|
}
|
|
651
549
|
/** Adds the packages according to the configuration */
|
|
652
|
-
setPackages(oConfig
|
|
550
|
+
setPackages(oConfig) {
|
|
653
551
|
if (oConfig.morgan) {
|
|
654
552
|
for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
|
|
655
553
|
this.oApp.use(morgan(oConfig.morgan.format));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|
|
@@ -50,22 +50,19 @@
|
|
|
50
50
|
"glob": "^14.0.4",
|
|
51
51
|
"helmet": "^8.3.0",
|
|
52
52
|
"jiti": "^2.7.0",
|
|
53
|
-
"morgan": "^1.12.1"
|
|
53
|
+
"morgan": "^1.12.1",
|
|
54
|
+
"zod": "^4.6.7"
|
|
54
55
|
},
|
|
55
56
|
"peerDependencies": {
|
|
56
|
-
"@prisma/adapter-pg": "^7.10.1",
|
|
57
|
-
"@prisma/client": "^7.10.1",
|
|
58
57
|
"better-auth": "^1.7.18",
|
|
58
|
+
"drizzle-orm": "^1.0.0-rc.5",
|
|
59
59
|
"pg": "^8.23.1"
|
|
60
60
|
},
|
|
61
61
|
"peerDependenciesMeta": {
|
|
62
|
-
"
|
|
63
|
-
"optional": true
|
|
64
|
-
},
|
|
65
|
-
"@prisma/client": {
|
|
62
|
+
"better-auth": {
|
|
66
63
|
"optional": true
|
|
67
64
|
},
|
|
68
|
-
"
|
|
65
|
+
"drizzle-orm": {
|
|
69
66
|
"optional": true
|
|
70
67
|
},
|
|
71
68
|
"pg": {
|
|
@@ -73,8 +70,6 @@
|
|
|
73
70
|
}
|
|
74
71
|
},
|
|
75
72
|
"devDependencies": {
|
|
76
|
-
"@prisma/adapter-pg": "^7.10.1",
|
|
77
|
-
"@prisma/client": "^7.10.1",
|
|
78
73
|
"@types/compression": "^1.8.1",
|
|
79
74
|
"@types/cors": "^2.8.19",
|
|
80
75
|
"@types/express": "^5.0.6",
|
|
@@ -82,8 +77,9 @@
|
|
|
82
77
|
"@types/node": "^26.6.6",
|
|
83
78
|
"@types/pg": "^8.20.0",
|
|
84
79
|
"better-auth": "^1.7.18",
|
|
80
|
+
"drizzle-kit": "^1.0.0-rc.5",
|
|
81
|
+
"drizzle-orm": "^1.0.0-rc.5",
|
|
85
82
|
"pg": "^8.23.1",
|
|
86
|
-
"prisma": "^7.10.1",
|
|
87
83
|
"tsdown": "^0.23.5",
|
|
88
84
|
"tsx": "^4.23.15",
|
|
89
85
|
"typescript": "^7.0.2"
|