@nano-app/backend 0.9.0 → 0.10.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 +50 -3
- package/build/index.d.ts +9 -4
- package/build/index.js +283 -8
- package/package.json +7 -5
- package/build/Prisma-Dq-lYuz3.js +0 -221
- package/build/nano-app.js +0 -46
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @nano-app/backend
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@nano-app/backend)
|
|
4
|
+
|
|
3
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.
|
|
4
6
|
|
|
5
7
|
- Node ≥ 20, ESM.
|
|
@@ -83,7 +85,7 @@ Every option is optional.
|
|
|
83
85
|
| `mainPlugin` | `{ routesDir: "src/routes" }` | Your routes (`routesDir`, relative to `cwd`), `basePath` (e.g. `/api`, no trailing slash) and database: see [Database](#database). |
|
|
84
86
|
| `plugins` | `[]` | Plugins mounted around your routes, in order: see [Plugins](#plugins). |
|
|
85
87
|
|
|
86
|
-
`packages` accepts `morgan`, `cors`, `helmet`, `compression`, `rateLimit`, `express` (`express.json` options) and `
|
|
88
|
+
`packages` accepts `morgan`, `cors`, `helmet`, `compression`, `rateLimit`, `express` (`express.json` options), `urlencoded` and `betterAuth` (off by default: see [Better Auth](#better-auth)). The merge is **per section**: passing one key keeps the others; pass `false`/`undefined` to disable one.
|
|
87
89
|
|
|
88
90
|
```ts
|
|
89
91
|
createNanoApp({
|
|
@@ -143,16 +145,61 @@ export default createRoute<PrismaClient>({
|
|
|
143
145
|
|
|
144
146
|
- Each key falls back to its default, so `{ enabled: true }` is enough with the default layout.
|
|
145
147
|
- 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.
|
|
146
|
-
-
|
|
148
|
+
- 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.
|
|
147
149
|
- 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.
|
|
148
150
|
- The `nano_` prefix is reserved for the official plugins (`@nano-app/plugins`): a datasource schema of yours starting with it fails the startup.
|
|
149
151
|
- Pass your generated `PrismaClient` to `createRoute<PrismaClient>` to type `db`; otherwise it is `any`, and without an enabled database `undefined`.
|
|
150
152
|
- For several PostgreSQL schemas, declare `schemas = ["public", "billing"]` in the datasource and `@@schema("billing")` on each model; one client covers them all.
|
|
151
153
|
- Prisma is loaded only when your database or a mounted plugin's is enabled.
|
|
152
154
|
|
|
155
|
+
## Better Auth
|
|
156
|
+
|
|
157
|
+
Users, sign-in and sessions with [Better Auth](https://www.better-auth.com), on your database. Off until `packages.betterAuth` gets its options (everything but `database`: nano-app passes your Prisma client).
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
npm install better-auth
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// src/auth.ts: the options, and an instance only for the Better Auth CLI
|
|
165
|
+
import { betterAuth, type BetterAuthOptions } from "better-auth";
|
|
166
|
+
|
|
167
|
+
export const authOptions = { emailAndPassword: { enabled: true } } satisfies BetterAuthOptions;
|
|
168
|
+
export const auth = betterAuth(authOptions);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// src/app.ts
|
|
173
|
+
import { createNanoApp } from "@nano-app/backend";
|
|
174
|
+
import { authOptions } from "./auth";
|
|
175
|
+
|
|
176
|
+
createNanoApp({
|
|
177
|
+
packages: { betterAuth: authOptions },
|
|
178
|
+
mainPlugin: { database: { enabled: true } },
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Its tables are yours, like the rest of your schema. Add its models with its CLI, then apply them with Prisma; run both again when you change the options:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
npx auth generate --config src/auth.ts --adapter prisma --dialect postgresql --output src/prisma/schema.prisma
|
|
186
|
+
npx prisma migrate dev
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- It needs the database: `packages.betterAuth` without `mainPlugin.database.enabled` fails at startup.
|
|
190
|
+
- Startup also fails if your schema lacks a model the options need (`user`, `session`, ...), naming them.
|
|
191
|
+
- Its endpoints are mounted with your routes, at its `basePath` (default `/api/auth`), and stay public.
|
|
192
|
+
- 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).
|
|
193
|
+
- Handlers and `onBefore` receive the session in `params.session` (`{ user, session }`).
|
|
194
|
+
- 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.
|
|
195
|
+
- Relate your models to `User` in the same schema, as any Prisma relation. Extra user fields go in its `user.additionalFields`.
|
|
196
|
+
- With several PostgreSQL schemas, add `@@schema(...)` to the generated models.
|
|
197
|
+
- Set `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` in the environment, as Better Auth asks.
|
|
198
|
+
- Better Auth is loaded only when it is on.
|
|
199
|
+
|
|
153
200
|
## Plugins
|
|
154
201
|
|
|
155
|
-
A plugin is a group of endpoints mounted under a base path. Your routes are mounted as one (named `app`). The official plugins are in [`@nano-app/plugins`](https://github.com/Nano-Nathan/nano-app/blob/main/plugins/README.md). You write one with `createPlugin` and list it in `plugins`. Nothing is mounted unless you list it. Official plugins that have tables
|
|
202
|
+
A plugin is a group of endpoints mounted under a base path. Your routes are mounted as one (named `app`). The official plugins are in [`@nano-app/plugins`](https://github.com/Nano-Nathan/nano-app/blob/main/plugins/README.md). You write one with `createPlugin` and list it in `plugins`. Nothing is mounted unless you list it. Official plugins that have tables ship their own migrations: nano-app applies them at startup, each in its `nano_*` schema. That is the only change it makes to your database.
|
|
156
203
|
|
|
157
204
|
```ts
|
|
158
205
|
// src/plugins/hello/index.ts
|
package/build/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { CorsOptions } from "cors";
|
|
|
3
3
|
import { Options } from "express-rate-limit";
|
|
4
4
|
import "dotenv/config";
|
|
5
5
|
import "jiti";
|
|
6
|
+
import { BetterAuthOptions } from "better-auth";
|
|
6
7
|
//#region ../core/types/api.d.ts
|
|
7
8
|
/** API response */
|
|
8
9
|
type ApiResponse<T = any> = {
|
|
@@ -13,12 +14,12 @@ type ApiResponse<T = any> = {
|
|
|
13
14
|
};
|
|
14
15
|
/** Promise of an API response, what a handler returns */
|
|
15
16
|
type ApiPromise<T = any> = Promise<ApiResponse<T>>;
|
|
16
|
-
/** Query parameters sent
|
|
17
|
+
/** Query parameters, sent as the JSON body of a `QUERY` request */
|
|
17
18
|
type QueryParams = {
|
|
18
19
|
skip?: number;
|
|
19
20
|
top?: number;
|
|
20
21
|
filter?: NanoObject;
|
|
21
|
-
orderby?:
|
|
22
|
+
orderby?: NanoObject<'asc' | 'desc'>;
|
|
22
23
|
select?: string;
|
|
23
24
|
};
|
|
24
25
|
//#endregion
|
|
@@ -27,6 +28,8 @@ type QueryParams = {
|
|
|
27
28
|
type Params<DB = any> = Partial<NanoObject & {
|
|
28
29
|
db?: DB;
|
|
29
30
|
onBeforeResult?: NanoObject;
|
|
31
|
+
/** `{ user, session }` of Better Auth, on the routes behind its guard */
|
|
32
|
+
session?: NanoObject;
|
|
30
33
|
}>;
|
|
31
34
|
/** Endpoint handlers */
|
|
32
35
|
type Handler<DB> = (req: Request$1, params: Params<DB>) => ApiPromise;
|
|
@@ -116,7 +119,7 @@ type PackagesConfig = {
|
|
|
116
119
|
/** Morgan configuration */
|
|
117
120
|
morgan?: false | {
|
|
118
121
|
/** Morgan tokens */
|
|
119
|
-
tokens?:
|
|
122
|
+
tokens?: NanoObject<any>;
|
|
120
123
|
/** Morgan format */
|
|
121
124
|
format: string;
|
|
122
125
|
};
|
|
@@ -132,6 +135,8 @@ type PackagesConfig = {
|
|
|
132
135
|
express?: Partial<Parameters<typeof express.json>[0]> | false;
|
|
133
136
|
/** urlencoded configuration */
|
|
134
137
|
urlencoded?: Partial<Parameters<typeof express.urlencoded>[0]> | false;
|
|
138
|
+
/** Better Auth options, without `database`: it uses the app's Prisma client. Defaults to false */
|
|
139
|
+
betterAuth?: Omit<BetterAuthOptions, 'database'> | false;
|
|
135
140
|
};
|
|
136
141
|
/** Application configuration */
|
|
137
142
|
type NanoAppConfig = {
|
|
@@ -140,7 +145,7 @@ type NanoAppConfig = {
|
|
|
140
145
|
/** Application port. Defaults to 3000 */
|
|
141
146
|
port?: number;
|
|
142
147
|
/** Values applied with Express `app.set()` (e.g. `{ "trust proxy": 1 }`) */
|
|
143
|
-
settings?:
|
|
148
|
+
settings?: NanoObject<unknown>;
|
|
144
149
|
/** Default middlewares configuration */
|
|
145
150
|
packages?: PackagesConfig;
|
|
146
151
|
/** Plugins to mount. Defaults to [] */
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
2
|
import express from "express";
|
|
3
3
|
import morgan from "morgan";
|
|
4
4
|
import cors from "cors";
|
|
@@ -6,8 +6,159 @@ import helmet from "helmet";
|
|
|
6
6
|
import compression from "compression";
|
|
7
7
|
import rateLimit from "express-rate-limit";
|
|
8
8
|
import "dotenv/config";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { glob } from "glob";
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createJiti } from "jiti";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
//#region ../core/const.ts
|
|
16
|
+
/** Package of the official plugins: only its schemas may declare nano_* */
|
|
17
|
+
const OFFICIAL_PLUGINS = "@nano-app/plugins";
|
|
18
|
+
/** Error when the database URL is missing, at startup and on `nano-app push` */
|
|
19
|
+
const MISSING_DATABASE_URL = "[nano-app] [ERROR] The DATABASE_URL environment variable is not defined.";
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region ../core/loader/Validator.ts
|
|
22
|
+
const HTTP_METHODS = [
|
|
23
|
+
"GET",
|
|
24
|
+
"POST",
|
|
25
|
+
"PUT",
|
|
26
|
+
"DELETE",
|
|
27
|
+
"QUERY"
|
|
28
|
+
];
|
|
29
|
+
const STORE_TYPES = ["app", "api"];
|
|
30
|
+
/** Checks the consumer's files: invalid ones are warned about and discarded */
|
|
31
|
+
var Validator = class Validator {
|
|
32
|
+
static oValidators = {
|
|
33
|
+
route: () => this.isRoute,
|
|
34
|
+
prisma: () => this.isPrisma,
|
|
35
|
+
store: () => this.isStore
|
|
36
|
+
};
|
|
37
|
+
/** Warns about an invalid file and returns `false`, to discard it */
|
|
38
|
+
static warn(sFilePath, sMessage) {
|
|
39
|
+
console.warn(`[nano-app] [WARNING] ${sFilePath}: ${sMessage}`);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/** Checks that the default export is an object */
|
|
43
|
+
static isObject(oEntry, sFilePath) {
|
|
44
|
+
if (oEntry && typeof oEntry === "object") return true;
|
|
45
|
+
return Validator.warn(sFilePath, "the default export is not an object.");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Type guard for a `*.route.ts` entry.
|
|
49
|
+
*/
|
|
50
|
+
static isRoute(oEntry, sFilePath) {
|
|
51
|
+
if (!Validator.isObject(oEntry, sFilePath)) return false;
|
|
52
|
+
const oRoute = oEntry;
|
|
53
|
+
if (oRoute.expression && typeof oRoute.expression !== "string") return Validator.warn(sFilePath, "\"expression\" must be a string.");
|
|
54
|
+
for (const sMethod of HTTP_METHODS) if (oRoute[sMethod] && typeof oRoute[sMethod] !== "function") return Validator.warn(sFilePath, `"${String(sMethod)}" must be a function.`);
|
|
55
|
+
if (!HTTP_METHODS.some((sMethod) => oRoute[sMethod])) return Validator.warn(sFilePath, "defines no HTTP method.");
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** Type guard for a `*.store.ts` entry */
|
|
59
|
+
static isStore(oEntry, sFilePath) {
|
|
60
|
+
if (!Validator.isObject(oEntry, sFilePath)) return false;
|
|
61
|
+
if (!STORE_TYPES.includes(oEntry.type)) return Validator.warn(sFilePath, `its type must be "app" or "api".`);
|
|
62
|
+
if (oEntry.data !== void 0 && (!oEntry.data || typeof oEntry.data !== "object")) return Validator.warn(sFilePath, "\"data\" must be an object.");
|
|
63
|
+
if (oEntry.headers !== void 0 && (!Validator.isObject(oEntry.headers, sFilePath) || Object.values(oEntry.headers).some((sValue) => typeof sValue !== "string"))) return Validator.warn(sFilePath, "\"headers\" must be an object of strings.");
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Type guard for a `.prisma` entry: the contents of the file, without comments.
|
|
68
|
+
*/
|
|
69
|
+
static isPrisma(oEntry, sFilePath) {
|
|
70
|
+
if (typeof oEntry !== "string") return Validator.warn(sFilePath, "the contents are not a string.");
|
|
71
|
+
const sProvider = oEntry.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*["']([^"']+)["']/s)?.[1];
|
|
72
|
+
if (sProvider !== "postgresql") return Validator.warn(sFilePath, `the datasource provider must be "postgresql"${sProvider ? `, not "${sProvider}"` : ""}.`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/** Validates a full map of entries and returns only the valid ones */
|
|
76
|
+
static validate(oEntries, sExtension) {
|
|
77
|
+
const oValid = {};
|
|
78
|
+
for (const [sFilePath, oEntry] of Object.entries(oEntries)) {
|
|
79
|
+
const oValidated = this.validateUnique(oEntry, sExtension, sFilePath);
|
|
80
|
+
if (oValidated !== void 0) oValid[sFilePath] = oValidated;
|
|
81
|
+
}
|
|
82
|
+
return oValid;
|
|
83
|
+
}
|
|
84
|
+
/** Validates one entry: returns it when valid, `undefined` otherwise */
|
|
85
|
+
static validateUnique(oEntry, sExtension, sFilePath = "") {
|
|
86
|
+
return this.oValidators[sExtension]?.()(oEntry, sFilePath) ? oEntry : void 0;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region ../core/loader/FileReader.ts
|
|
91
|
+
/** Reads and loads files from disk, resolved from `cwd` */
|
|
92
|
+
var FileReader = class {
|
|
93
|
+
static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
|
|
94
|
+
/** Loads a module via jiti */
|
|
95
|
+
static async loadModule(sFilePath, oOptions = {}) {
|
|
96
|
+
return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
|
|
97
|
+
}
|
|
98
|
+
/** Reads the contents of a file */
|
|
99
|
+
static loadFile(sFilePath) {
|
|
100
|
+
const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
|
|
101
|
+
if (!sContent) throw new Error(`[nano-app] [ERROR] The file ${sFilePath} does not have valid content.`);
|
|
102
|
+
return sContent;
|
|
103
|
+
}
|
|
104
|
+
/** Reads a Prisma schema without its `//` comments */
|
|
105
|
+
static loadSchema(sFilePath) {
|
|
106
|
+
return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
|
|
107
|
+
}
|
|
108
|
+
/** Resolves the absolute path of a file */
|
|
109
|
+
static getAbsolutePath(sFilePath) {
|
|
110
|
+
const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
|
|
111
|
+
if (!existsSync(sAbsolutePath)) throw new Error(`[nano-app] [ERROR] ${sFilePath} not found.`);
|
|
112
|
+
return sAbsolutePath;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region ../core/utils/Formatter.ts
|
|
117
|
+
/** Formats values for logs and messages */
|
|
118
|
+
var Formatter = class {
|
|
119
|
+
/** Message of anything thrown: its `message` when it has one, else the value itself */
|
|
120
|
+
static error(oError) {
|
|
121
|
+
return String(oError?.message ?? oError);
|
|
122
|
+
}
|
|
123
|
+
/** Formats a date for the request log, in UTC: `2026-10-19 12:03:10` */
|
|
124
|
+
static logDate(oDate = /* @__PURE__ */ new Date()) {
|
|
125
|
+
return oDate.toISOString().slice(0, 19).replace("T", " ");
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region ../core/loader/FileLoader.ts
|
|
130
|
+
/** Finds, loads and validates the consumer's files */
|
|
131
|
+
var FileLoader = class {
|
|
132
|
+
/**
|
|
133
|
+
* 1. Validates a `.prisma` file.
|
|
134
|
+
* 2. Loads a generated Prisma client module (`client.ts`, or `client.js`)
|
|
135
|
+
*/
|
|
136
|
+
static async findPrismaClient(sClientPath, sSchemaPath) {
|
|
137
|
+
const sContent = FileReader.loadSchema(sSchemaPath);
|
|
138
|
+
if (!Validator.validateUnique(sContent, "prisma", sSchemaPath)) throw new Error(`[nano-app] [ERROR] ${sSchemaPath} is not a valid Prisma schema.`);
|
|
139
|
+
try {
|
|
140
|
+
return (await FileReader.loadModule(sClientPath))?.PrismaClient;
|
|
141
|
+
} catch (oError) {
|
|
142
|
+
throw new Error(`[nano-app] [ERROR] The Prisma client ${sClientPath} could not be loaded: ${Formatter.error(oError)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Finds `*.route.ts` (or built `.js`) files and returns `{ [subPath]: defaultExport }` */
|
|
146
|
+
static async findMany({ extension, sourceDir }) {
|
|
147
|
+
const sSrcDir = (sourceDir ?? `src/${extension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
148
|
+
const aFilePaths = await glob(`${sSrcDir}/**/*.${extension}.{ts,js}`, {
|
|
149
|
+
posix: true,
|
|
150
|
+
ignore: "**/*.d.ts"
|
|
151
|
+
});
|
|
152
|
+
const oEntries = {};
|
|
153
|
+
for (const sFilePath of aFilePaths) {
|
|
154
|
+
const sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${extension}\\.(ts|js)$`), "");
|
|
155
|
+
const sKey = extension === "store" ? sFileSubPath.slice(1).replaceAll("/", "_") : sFileSubPath;
|
|
156
|
+
oEntries[sKey] = await FileReader.loadModule(sFilePath, { default: true });
|
|
157
|
+
}
|
|
158
|
+
return Validator.validate(oEntries, extension);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
//#endregion
|
|
11
162
|
//#region src/routes/Endpoint.ts
|
|
12
163
|
/** Base class for all endpoints */
|
|
13
164
|
var Endpoint = class {
|
|
@@ -32,8 +183,12 @@ var Endpoint = class {
|
|
|
32
183
|
status: 405,
|
|
33
184
|
message: `Method ${sMethod} not implemented`
|
|
34
185
|
});
|
|
35
|
-
|
|
186
|
+
const oRequestParams = {
|
|
36
187
|
...oParams,
|
|
188
|
+
session: oRes.locals.session
|
|
189
|
+
};
|
|
190
|
+
(this.oCallbacks.onBefore?.(oReq, sMethod, oRequestParams) ?? Promise.resolve({})).then((onBeforeResult) => fnMethod(oReq, {
|
|
191
|
+
...oRequestParams,
|
|
37
192
|
onBeforeResult
|
|
38
193
|
})).then((oResponse) => {
|
|
39
194
|
oRes.status(oResponse.success ? 200 : 400).json(oResponse);
|
|
@@ -73,6 +228,116 @@ var Router = class {
|
|
|
73
228
|
}
|
|
74
229
|
};
|
|
75
230
|
//#endregion
|
|
231
|
+
//#region src/auth/BetterAuth.ts
|
|
232
|
+
/** Better Auth on the app's Prisma client: the instance, its handler and a session guard */
|
|
233
|
+
var BetterAuth = class {
|
|
234
|
+
/** Creates the instance on the app's client, its handler and the guard */
|
|
235
|
+
static async create(oOptions, oDb) {
|
|
236
|
+
const { betterAuth } = await import("better-auth");
|
|
237
|
+
const { prismaAdapter } = await import("better-auth/adapters/prisma");
|
|
238
|
+
const { getSchema } = await import("better-auth/db");
|
|
239
|
+
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
240
|
+
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oDb[sModel]);
|
|
241
|
+
if (aMissing.length) throw new Error(`[nano-app] [ERROR] Better Auth needs the models ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with Prisma.`);
|
|
242
|
+
const oAuth = betterAuth({
|
|
243
|
+
...oOptions,
|
|
244
|
+
database: prismaAdapter(oDb, { provider: "postgresql" })
|
|
245
|
+
});
|
|
246
|
+
const fnGuard = (oReq, oRes, fnNext) => {
|
|
247
|
+
oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
248
|
+
if (!oSession) return fnNext({
|
|
249
|
+
status: 401,
|
|
250
|
+
message: "Not authenticated"
|
|
251
|
+
});
|
|
252
|
+
oRes.locals.session = oSession;
|
|
253
|
+
fnNext();
|
|
254
|
+
}).catch(fnNext);
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
oHandler: toNodeHandler(oAuth),
|
|
258
|
+
fnGuard,
|
|
259
|
+
sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/database/Prisma.ts
|
|
265
|
+
/** Runs the consumer's Prisma CLI: generates clients and applies official plugins' migrations */
|
|
266
|
+
var Prisma = class {
|
|
267
|
+
/** Folder of a client and its files, in the project */
|
|
268
|
+
static dir(sName) {
|
|
269
|
+
return join(process.cwd(), ".nano-app", "prisma-clients", sName);
|
|
270
|
+
}
|
|
271
|
+
/** Generates the client of a schema in `.nano-app/prisma-clients/<name>`, unless it is up to date */
|
|
272
|
+
static generate(sName, sSchemaPath) {
|
|
273
|
+
const sDir = this.dir(sName);
|
|
274
|
+
const sSource = FileReader.getAbsolutePath(sSchemaPath);
|
|
275
|
+
const sHash = createHash("sha256").update(readFileSync(sSource)).digest("hex");
|
|
276
|
+
const sSourceFile = join(sDir, "source.json");
|
|
277
|
+
const oCurrent = existsSync(sSourceFile) ? JSON.parse(readFileSync(sSourceFile, "utf-8")) : void 0;
|
|
278
|
+
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}.`);
|
|
279
|
+
if (oCurrent?.schemaPath === sSource && oCurrent.hash === sHash && existsSync(join(sDir, "generated", "client.ts"))) return;
|
|
280
|
+
mkdirSync(sDir, { recursive: true });
|
|
281
|
+
const sSchema = readFileSync(sSource, "utf-8");
|
|
282
|
+
writeFileSync(join(sDir, "schema.prisma"), sSchema.replace(/output\s*=\s*"[^"]*"/, "output = \"./generated\""));
|
|
283
|
+
this.run(sName, [
|
|
284
|
+
"generate",
|
|
285
|
+
"--schema",
|
|
286
|
+
join(sDir, "schema.prisma")
|
|
287
|
+
]);
|
|
288
|
+
writeFileSync(sSourceFile, JSON.stringify({
|
|
289
|
+
schemaPath: sSource,
|
|
290
|
+
hash: sHash
|
|
291
|
+
}));
|
|
292
|
+
console.log(`[nano-app] [INFO] Prisma client "${sName}" generated`);
|
|
293
|
+
}
|
|
294
|
+
/** Applies the pending migrations an official plugin ships next to its schema */
|
|
295
|
+
static migrate(sName, sSchemaPath, sDbSchema) {
|
|
296
|
+
const sMigrations = join(dirname(FileReader.getAbsolutePath(sSchemaPath)), "migrations");
|
|
297
|
+
if (!existsSync(sMigrations)) throw new Error(`[nano-app] [ERROR] The plugin "${sName}" ships no migrations: ${sMigrations}`);
|
|
298
|
+
if (!process.env.DATABASE_URL) throw new Error(MISSING_DATABASE_URL);
|
|
299
|
+
const oUrl = new URL(process.env.DATABASE_URL);
|
|
300
|
+
oUrl.searchParams.set("schema", sDbSchema);
|
|
301
|
+
const sConfig = join(this.dir(sName), "prisma.config.mjs");
|
|
302
|
+
writeFileSync(sConfig, [
|
|
303
|
+
"export default {",
|
|
304
|
+
` schema: ${JSON.stringify(join(this.dir(sName), "schema.prisma"))},`,
|
|
305
|
+
` migrations: { path: ${JSON.stringify(sMigrations)} },`,
|
|
306
|
+
" datasource: { url: process.env.NANO_APP_MIGRATION_URL },",
|
|
307
|
+
"};",
|
|
308
|
+
""
|
|
309
|
+
].join("\n"));
|
|
310
|
+
this.run(sName, [
|
|
311
|
+
"migrate",
|
|
312
|
+
"deploy",
|
|
313
|
+
"--config",
|
|
314
|
+
sConfig
|
|
315
|
+
], { NANO_APP_MIGRATION_URL: oUrl.toString() });
|
|
316
|
+
}
|
|
317
|
+
/** Runs a Prisma command of the consumer's `prisma` package */
|
|
318
|
+
static run(sName, aArgs, oEnv = {}) {
|
|
319
|
+
let sCli;
|
|
320
|
+
try {
|
|
321
|
+
sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
|
|
322
|
+
} catch {
|
|
323
|
+
throw new Error(`[nano-app] [ERROR] The database "${sName}" needs Prisma: install the prisma package.`);
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
execFileSync(process.execPath, [sCli, ...aArgs], {
|
|
327
|
+
env: {
|
|
328
|
+
...process.env,
|
|
329
|
+
...oEnv
|
|
330
|
+
},
|
|
331
|
+
stdio: "pipe"
|
|
332
|
+
});
|
|
333
|
+
} catch (oError) {
|
|
334
|
+
const oFailed = oError;
|
|
335
|
+
const sOutput = `${oFailed.stderr ?? ""}${oFailed.stdout ?? ""}`.trim() || Formatter.error(oError);
|
|
336
|
+
throw new Error(`[nano-app] [ERROR] prisma ${aArgs[0]} failed for "${sName}": ${sOutput}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
//#endregion
|
|
76
341
|
//#region src/database/Database.ts
|
|
77
342
|
/** Prisma clients of the app and its plugins, one per name */
|
|
78
343
|
var Database = class {
|
|
@@ -138,9 +403,11 @@ var Database = class {
|
|
|
138
403
|
/** A group of routes, with its options, callbacks and database */
|
|
139
404
|
var Plugin = class {
|
|
140
405
|
oConfig;
|
|
141
|
-
|
|
142
|
-
|
|
406
|
+
oAuthOptions;
|
|
407
|
+
/** Creates a new Plugin instance; the app's plugin also gets the Better Auth options */
|
|
408
|
+
constructor(oConfig, oAuthOptions) {
|
|
143
409
|
this.oConfig = oConfig;
|
|
410
|
+
this.oAuthOptions = oAuthOptions;
|
|
144
411
|
}
|
|
145
412
|
/** Mounts its endpoints on express app */
|
|
146
413
|
async mount(oApp) {
|
|
@@ -150,6 +417,12 @@ var Plugin = class {
|
|
|
150
417
|
...options,
|
|
151
418
|
db: oDb
|
|
152
419
|
};
|
|
420
|
+
if (this.oAuthOptions) {
|
|
421
|
+
if (!oDb) throw new Error("[nano-app] [ERROR] packages.betterAuth needs the database of the app: set mainPlugin.database.enabled to true.");
|
|
422
|
+
const { sPath, oHandler, fnGuard } = await BetterAuth.create(this.oAuthOptions, oDb);
|
|
423
|
+
oApp.all(sPath, oHandler);
|
|
424
|
+
oApp.use(fnGuard);
|
|
425
|
+
}
|
|
153
426
|
await callbacks?.onMount?.(oApp, oParams);
|
|
154
427
|
const oRouter = new Router(routesDir, basePath);
|
|
155
428
|
for (const { path, endpoint } of await oRouter.mount()) oApp.all(path, await endpoint.mount(oParams));
|
|
@@ -186,7 +459,8 @@ const INITIAL_CONFIG = {
|
|
|
186
459
|
message: "Too many requests, please try again later."
|
|
187
460
|
},
|
|
188
461
|
express: { limit: "10kb" },
|
|
189
|
-
urlencoded: { extended: false }
|
|
462
|
+
urlencoded: { extended: false },
|
|
463
|
+
betterAuth: false
|
|
190
464
|
},
|
|
191
465
|
/** Main plugin to mount */
|
|
192
466
|
mainPlugin: {
|
|
@@ -294,7 +568,8 @@ var NanoApp = class {
|
|
|
294
568
|
};
|
|
295
569
|
for (const oPlugin of this.oConfig.plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
|
|
296
570
|
for (const oPlugin of oPlugins.before) await new Plugin(oPlugin).mount(this.oApp);
|
|
297
|
-
|
|
571
|
+
const { mainPlugin, packages } = this.oConfig;
|
|
572
|
+
await new Plugin(mainPlugin, packages.betterAuth).mount(this.oApp);
|
|
298
573
|
for (const oPlugin of oPlugins.after) await new Plugin(oPlugin).mount(this.oApp);
|
|
299
574
|
this.oApp.use(notFoundHandler);
|
|
300
575
|
this.oApp.use(errorHandler);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Express server with file-convention routes, plugins and an optional PostgreSQL layer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsdown",
|
|
20
20
|
"dev": "tsdown --watch",
|
|
21
|
-
"test": "tsx --env-file-if-exists=../.env --test tests/*.test.ts",
|
|
21
|
+
"test": "tsx --env-file-if-exists=../.env --test tests/*.test.ts ../core/tests/*.test.ts",
|
|
22
22
|
"prepublishOnly": "pnpm run build"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@prisma/adapter-pg": "^7.10.1",
|
|
58
58
|
"@prisma/client": "^7.10.1",
|
|
59
|
+
"better-auth": "^1.7.12",
|
|
59
60
|
"pg": "^8.23.1"
|
|
60
61
|
},
|
|
61
62
|
"peerDependenciesMeta": {
|
|
@@ -65,6 +66,9 @@
|
|
|
65
66
|
"@prisma/client": {
|
|
66
67
|
"optional": true
|
|
67
68
|
},
|
|
69
|
+
"better-auth": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
68
72
|
"pg": {
|
|
69
73
|
"optional": true
|
|
70
74
|
}
|
|
@@ -78,13 +82,11 @@
|
|
|
78
82
|
"@types/morgan": "^1.9.10",
|
|
79
83
|
"@types/node": "^26.6.5",
|
|
80
84
|
"@types/pg": "^8.20.0",
|
|
85
|
+
"better-auth": "^1.7.12",
|
|
81
86
|
"pg": "^8.23.1",
|
|
82
87
|
"prisma": "^7.10.1",
|
|
83
88
|
"tsdown": "^0.23.1",
|
|
84
89
|
"tsx": "^4.23.15",
|
|
85
90
|
"typescript": "^7.0.2"
|
|
86
|
-
},
|
|
87
|
-
"bin": {
|
|
88
|
-
"nano-app": "build/nano-app.js"
|
|
89
91
|
}
|
|
90
92
|
}
|
package/build/Prisma-Dq-lYuz3.js
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import { glob } from "glob";
|
|
3
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { createJiti } from "jiti";
|
|
6
|
-
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
|
-
//#region ../core/loader/Validator.ts
|
|
9
|
-
const HTTP_METHODS = [
|
|
10
|
-
"GET",
|
|
11
|
-
"POST",
|
|
12
|
-
"PUT",
|
|
13
|
-
"DELETE",
|
|
14
|
-
"QUERY"
|
|
15
|
-
];
|
|
16
|
-
/** Checks the consumer's files: invalid ones are warned about and discarded */
|
|
17
|
-
var Validator = class Validator {
|
|
18
|
-
static oValidators = {
|
|
19
|
-
route: () => this.isRoute,
|
|
20
|
-
prisma: () => this.isPrisma
|
|
21
|
-
};
|
|
22
|
-
/** Warns about an invalid file and returns `false`, to discard it */
|
|
23
|
-
static warn(sFilePath, sMessage) {
|
|
24
|
-
console.warn(`[nano-app] [WARNING] ${sFilePath}: ${sMessage}`);
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
/** Checks that the default export is an object */
|
|
28
|
-
static isObject(oEntry, sFilePath) {
|
|
29
|
-
if (oEntry && typeof oEntry === "object") return true;
|
|
30
|
-
return Validator.warn(sFilePath, "the default export is not an object.");
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Type guard for a `*.route.ts` entry.
|
|
34
|
-
*/
|
|
35
|
-
static isRoute(oEntry, sFilePath) {
|
|
36
|
-
if (!Validator.isObject(oEntry, sFilePath)) return false;
|
|
37
|
-
const oRoute = oEntry;
|
|
38
|
-
if (oRoute.expression && typeof oRoute.expression !== "string") return Validator.warn(sFilePath, "\"expression\" must be a string.");
|
|
39
|
-
for (const sMethod of HTTP_METHODS) if (oRoute[sMethod] && typeof oRoute[sMethod] !== "function") return Validator.warn(sFilePath, `"${String(sMethod)}" must be a function.`);
|
|
40
|
-
if (!HTTP_METHODS.some((sMethod) => oRoute[sMethod])) return Validator.warn(sFilePath, "defines no HTTP method.");
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Type guard for a `.prisma` entry: the contents of the file, without comments.
|
|
45
|
-
*/
|
|
46
|
-
static isPrisma(oEntry, sFilePath) {
|
|
47
|
-
if (typeof oEntry !== "string") return Validator.warn(sFilePath, "the contents are not a string.");
|
|
48
|
-
const sProvider = oEntry.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*["']([^"']+)["']/s)?.[1];
|
|
49
|
-
if (sProvider !== "postgresql") return Validator.warn(sFilePath, `the datasource provider must be "postgresql"${sProvider ? `, not "${sProvider}"` : ""}.`);
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
/** Validates a full map of entries and returns only the valid ones */
|
|
53
|
-
static validate(oEntries, sExtension) {
|
|
54
|
-
const oValid = {};
|
|
55
|
-
for (const [sFilePath, oEntry] of Object.entries(oEntries)) {
|
|
56
|
-
const oValidated = this.validateUnique(oEntry, sExtension, sFilePath);
|
|
57
|
-
if (oValidated !== void 0) oValid[sFilePath] = oValidated;
|
|
58
|
-
}
|
|
59
|
-
return oValid;
|
|
60
|
-
}
|
|
61
|
-
/** Validates one entry: returns it when valid, `undefined` otherwise */
|
|
62
|
-
static validateUnique(oEntry, sExtension, sFilePath = "") {
|
|
63
|
-
return this.oValidators[sExtension]?.()(oEntry, sFilePath) ? oEntry : void 0;
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
//#endregion
|
|
67
|
-
//#region ../core/loader/FileReader.ts
|
|
68
|
-
/** Reads and loads files from disk, resolved from `cwd` */
|
|
69
|
-
var FileReader = class {
|
|
70
|
-
static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
|
|
71
|
-
/** Loads a module via jiti */
|
|
72
|
-
static async loadModule(sFilePath, oOptions = {}) {
|
|
73
|
-
return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
|
|
74
|
-
}
|
|
75
|
-
/** Reads the contents of a file */
|
|
76
|
-
static loadFile(sFilePath) {
|
|
77
|
-
const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
|
|
78
|
-
if (!sContent) throw new Error(`[nano-app] [ERROR] The file ${sFilePath} does not have valid content.`);
|
|
79
|
-
return sContent;
|
|
80
|
-
}
|
|
81
|
-
/** Reads a Prisma schema without its `//` comments */
|
|
82
|
-
static loadSchema(sFilePath) {
|
|
83
|
-
return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
|
|
84
|
-
}
|
|
85
|
-
/** Resolves the absolute path of a file */
|
|
86
|
-
static getAbsolutePath(sFilePath) {
|
|
87
|
-
const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
|
|
88
|
-
if (!existsSync(sAbsolutePath)) throw new Error(`[nano-app] [ERROR] ${sFilePath} not found.`);
|
|
89
|
-
return sAbsolutePath;
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
//#endregion
|
|
93
|
-
//#region ../core/utils/Formatter.ts
|
|
94
|
-
/** Formats values for logs and messages */
|
|
95
|
-
var Formatter = class {
|
|
96
|
-
/** Message of anything thrown: its `message` when it has one, else the value itself */
|
|
97
|
-
static error(oError) {
|
|
98
|
-
return String(oError?.message ?? oError);
|
|
99
|
-
}
|
|
100
|
-
/** Formats a date for the request log, in UTC: `2026-10-19 12:03:10` */
|
|
101
|
-
static logDate(oDate = /* @__PURE__ */ new Date()) {
|
|
102
|
-
return oDate.toISOString().slice(0, 19).replace("T", " ");
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
//#endregion
|
|
106
|
-
//#region ../core/loader/FileLoader.ts
|
|
107
|
-
/** Finds, loads and validates the consumer's files */
|
|
108
|
-
var FileLoader = class {
|
|
109
|
-
/**
|
|
110
|
-
* 1. Validates a `.prisma` file.
|
|
111
|
-
* 2. Loads a generated Prisma client module (`client.ts`, or `client.js`)
|
|
112
|
-
*/
|
|
113
|
-
static async findPrismaClient(sClientPath, sSchemaPath) {
|
|
114
|
-
const sContent = FileReader.loadSchema(sSchemaPath);
|
|
115
|
-
if (!Validator.validateUnique(sContent, "prisma", sSchemaPath)) throw new Error(`[nano-app] [ERROR] ${sSchemaPath} is not a valid Prisma schema.`);
|
|
116
|
-
try {
|
|
117
|
-
return (await FileReader.loadModule(sClientPath))?.PrismaClient;
|
|
118
|
-
} catch (oError) {
|
|
119
|
-
throw new Error(`[nano-app] [ERROR] The Prisma client ${sClientPath} could not be loaded: ${Formatter.error(oError)}`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
/** Finds `*.route.ts` (or built `.js`) files and returns `{ [subPath]: defaultExport }` */
|
|
123
|
-
static async findMany({ extension, sourceDir }) {
|
|
124
|
-
const sSrcDir = (sourceDir ?? `src/${extension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
125
|
-
const aFilePaths = await glob(`${sSrcDir}/**/*.${extension}.{ts,js}`, {
|
|
126
|
-
posix: true,
|
|
127
|
-
ignore: "**/*.d.ts"
|
|
128
|
-
});
|
|
129
|
-
const oEntries = {};
|
|
130
|
-
for (const sFilePath of aFilePaths) {
|
|
131
|
-
const sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${extension}\\.(ts|js)$`), "");
|
|
132
|
-
oEntries[sFileSubPath] = await FileReader.loadModule(sFilePath, { default: true });
|
|
133
|
-
}
|
|
134
|
-
return Validator.validate(oEntries, extension);
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
//#endregion
|
|
138
|
-
//#region const.ts
|
|
139
|
-
/** Package of the official plugins: only its schemas may declare nano_* */
|
|
140
|
-
const OFFICIAL_PLUGINS = "@nano-app/plugins";
|
|
141
|
-
/** Error when the database URL is missing, at startup and on `nano-app push` */
|
|
142
|
-
const MISSING_DATABASE_URL = "[nano-app] [ERROR] The DATABASE_URL environment variable is not defined.";
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/database/Prisma.ts
|
|
145
|
-
/** Runs the consumer's Prisma CLI: generates clients and applies official plugins' migrations */
|
|
146
|
-
var Prisma = class {
|
|
147
|
-
/** Folder of a client and its files, in the project */
|
|
148
|
-
static dir(sName) {
|
|
149
|
-
return join(process.cwd(), ".nano-app", "prisma-clients", sName);
|
|
150
|
-
}
|
|
151
|
-
/** Generates the client of a schema in `.nano-app/prisma-clients/<name>`, unless it is up to date */
|
|
152
|
-
static generate(sName, sSchemaPath) {
|
|
153
|
-
const sDir = this.dir(sName);
|
|
154
|
-
const sSource = FileReader.getAbsolutePath(sSchemaPath);
|
|
155
|
-
const sHash = createHash("sha256").update(readFileSync(sSource)).digest("hex");
|
|
156
|
-
const sSourceFile = join(sDir, "source.json");
|
|
157
|
-
const oCurrent = existsSync(sSourceFile) ? JSON.parse(readFileSync(sSourceFile, "utf-8")) : void 0;
|
|
158
|
-
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}.`);
|
|
159
|
-
if (oCurrent?.schemaPath === sSource && oCurrent.hash === sHash && existsSync(join(sDir, "generated", "client.ts"))) return;
|
|
160
|
-
mkdirSync(sDir, { recursive: true });
|
|
161
|
-
const sSchema = readFileSync(sSource, "utf-8");
|
|
162
|
-
writeFileSync(join(sDir, "schema.prisma"), sSchema.replace(/output\s*=\s*"[^"]*"/, "output = \"./generated\""));
|
|
163
|
-
this.run(sName, [
|
|
164
|
-
"generate",
|
|
165
|
-
"--schema",
|
|
166
|
-
join(sDir, "schema.prisma")
|
|
167
|
-
]);
|
|
168
|
-
writeFileSync(sSourceFile, JSON.stringify({
|
|
169
|
-
schemaPath: sSource,
|
|
170
|
-
hash: sHash
|
|
171
|
-
}));
|
|
172
|
-
console.log(`[nano-app] [INFO] Prisma client "${sName}" generated`);
|
|
173
|
-
}
|
|
174
|
-
/** Applies the pending migrations an official plugin ships next to its schema */
|
|
175
|
-
static migrate(sName, sSchemaPath, sDbSchema) {
|
|
176
|
-
const sMigrations = join(dirname(FileReader.getAbsolutePath(sSchemaPath)), "migrations");
|
|
177
|
-
if (!existsSync(sMigrations)) throw new Error(`[nano-app] [ERROR] The plugin "${sName}" ships no migrations: ${sMigrations}`);
|
|
178
|
-
if (!process.env.DATABASE_URL) throw new Error(MISSING_DATABASE_URL);
|
|
179
|
-
const oUrl = new URL(process.env.DATABASE_URL);
|
|
180
|
-
oUrl.searchParams.set("schema", sDbSchema);
|
|
181
|
-
const sConfig = join(this.dir(sName), "prisma.config.mjs");
|
|
182
|
-
writeFileSync(sConfig, [
|
|
183
|
-
"export default {",
|
|
184
|
-
` schema: ${JSON.stringify(join(this.dir(sName), "schema.prisma"))},`,
|
|
185
|
-
` migrations: { path: ${JSON.stringify(sMigrations)} },`,
|
|
186
|
-
" datasource: { url: process.env.NANO_APP_MIGRATION_URL },",
|
|
187
|
-
"};",
|
|
188
|
-
""
|
|
189
|
-
].join("\n"));
|
|
190
|
-
this.run(sName, [
|
|
191
|
-
"migrate",
|
|
192
|
-
"deploy",
|
|
193
|
-
"--config",
|
|
194
|
-
sConfig
|
|
195
|
-
], { NANO_APP_MIGRATION_URL: oUrl.toString() });
|
|
196
|
-
}
|
|
197
|
-
/** Runs a Prisma command of the consumer's `prisma` package */
|
|
198
|
-
static run(sName, aArgs, oEnv = {}) {
|
|
199
|
-
let sCli;
|
|
200
|
-
try {
|
|
201
|
-
sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
|
|
202
|
-
} catch {
|
|
203
|
-
throw new Error(`[nano-app] [ERROR] The database "${sName}" needs Prisma: install the prisma package.`);
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
execFileSync(process.execPath, [sCli, ...aArgs], {
|
|
207
|
-
env: {
|
|
208
|
-
...process.env,
|
|
209
|
-
...oEnv
|
|
210
|
-
},
|
|
211
|
-
stdio: "pipe"
|
|
212
|
-
});
|
|
213
|
-
} catch (oError) {
|
|
214
|
-
const oFailed = oError;
|
|
215
|
-
const sOutput = `${oFailed.stderr ?? ""}${oFailed.stdout ?? ""}`.trim() || Formatter.error(oError);
|
|
216
|
-
throw new Error(`[nano-app] [ERROR] prisma ${aArgs[0]} failed for "${sName}": ${sOutput}`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
//#endregion
|
|
221
|
-
export { Formatter as a, FileLoader as i, MISSING_DATABASE_URL as n, FileReader as o, OFFICIAL_PLUGINS as r, Prisma as t };
|
package/build/nano-app.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { r as OFFICIAL_PLUGINS, t as Prisma } from "./Prisma-Dq-lYuz3.js";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
-
//#region bin/nano-app.ts
|
|
6
|
-
/**
|
|
7
|
-
* Generates a Prisma client where nano-app reads it, as the startup does: for the build or the editor.
|
|
8
|
-
* nano-app generate the app's schema (src/prisma/schema.prisma)
|
|
9
|
-
* nano-app generate <name> an official plugin's, from the installed @nano-app/plugins
|
|
10
|
-
* nano-app generate <name> --schema <path>
|
|
11
|
-
*/
|
|
12
|
-
const [sCommand, ...aArgs] = process.argv.slice(2);
|
|
13
|
-
/** package.json of @nano-app/plugins: the project itself (its tests) or one in a node_modules */
|
|
14
|
-
function officialPackage() {
|
|
15
|
-
for (let sDir = process.cwd();; sDir = dirname(sDir)) {
|
|
16
|
-
const sOwn = join(sDir, "package.json");
|
|
17
|
-
if (existsSync(sOwn) && JSON.parse(readFileSync(sOwn, "utf-8")).name === "@nano-app/plugins") return sOwn;
|
|
18
|
-
const sInstalled = join(sDir, "node_modules", OFFICIAL_PLUGINS, "package.json");
|
|
19
|
-
if (existsSync(sInstalled)) return sInstalled;
|
|
20
|
-
if (dirname(sDir) === sDir) return void 0;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
/** Schema of an official plugin: next to the entry @nano-app/plugins exports for it */
|
|
24
|
-
function pluginSchema(sName) {
|
|
25
|
-
const sPackage = officialPackage();
|
|
26
|
-
if (!sPackage) return `${OFFICIAL_PLUGINS} is not installed`;
|
|
27
|
-
const oExport = JSON.parse(readFileSync(sPackage, "utf-8")).exports["./*"];
|
|
28
|
-
const sTarget = oExport.import ?? oExport.default;
|
|
29
|
-
return join(dirname(sPackage), dirname(sTarget.replaceAll("*", sName)), "schema.prisma");
|
|
30
|
-
}
|
|
31
|
-
try {
|
|
32
|
-
if (sCommand !== "generate") throw new Error("[nano-app] [ERROR] Usage: nano-app generate [name] [--schema <path>]");
|
|
33
|
-
const iSchemaFlag = aArgs.indexOf("--schema");
|
|
34
|
-
const sSchemaFlag = iSchemaFlag >= 0 ? aArgs[iSchemaFlag + 1] : void 0;
|
|
35
|
-
const fnIsFlagPart = (iIndex) => iSchemaFlag >= 0 && (iIndex === iSchemaFlag || iIndex === iSchemaFlag + 1);
|
|
36
|
-
const sName = aArgs.find((_sArg, iIndex) => !fnIsFlagPart(iIndex));
|
|
37
|
-
const sDefault = sName ? pluginSchema(sName) : "src/prisma/schema.prisma";
|
|
38
|
-
const sSchema = sSchemaFlag ?? sDefault;
|
|
39
|
-
if (!existsSync(sSchema)) throw new Error(`[nano-app] [ERROR] "${sName ?? "app"}" has no schema: ${sSchema}`);
|
|
40
|
-
Prisma.generate(sName ?? "app", sSchema);
|
|
41
|
-
} catch (oError) {
|
|
42
|
-
console.error(oError.message);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
//#endregion
|
|
46
|
-
export {};
|