@nano-app/backend 0.8.3 → 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 +65 -26
- package/build/index.d.ts +142 -129
- package/build/index.js +583 -525
- package/package.json +17 -15
- package/build/nano-app.js +0 -41
package/build/index.js
CHANGED
|
@@ -1,551 +1,609 @@
|
|
|
1
|
-
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
2
|
import express from "express";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import
|
|
3
|
+
import morgan from "morgan";
|
|
4
|
+
import cors from "cors";
|
|
5
|
+
import helmet from "helmet";
|
|
6
|
+
import compression from "compression";
|
|
7
|
+
import rateLimit from "express-rate-limit";
|
|
8
|
+
import "dotenv/config";
|
|
7
9
|
import { glob } from "glob";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
var HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"];
|
|
11
|
-
var Validator = class {
|
|
12
|
-
static validators = {
|
|
13
|
-
"route": () => this.isRoute,
|
|
14
|
-
"store": () => this.isStore,
|
|
15
|
-
"prisma": () => this.isPrisma
|
|
16
|
-
};
|
|
17
|
-
/**
|
|
18
|
-
* Type guard for a `*.route.ts` entry.
|
|
19
|
-
*/
|
|
20
|
-
static isRoute(entry, filePath) {
|
|
21
|
-
if (!entry || typeof entry !== "object") {
|
|
22
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: the default export is not an object.`);
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
const route = entry;
|
|
26
|
-
if (route.expression && typeof route.expression !== "string") {
|
|
27
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: "expression" must be a string.`);
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
for (const method of HTTP_METHODS) {
|
|
31
|
-
if (route[method] && typeof route[method] !== "function") {
|
|
32
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: "${String(method)}" must be a function.`);
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
if (!HTTP_METHODS.some((method) => route[method])) {
|
|
37
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: no defines any HTTP method.`);
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Type guard for a `*.store.ts` entry.
|
|
44
|
-
*/
|
|
45
|
-
static isStore(entry, filePath) {
|
|
46
|
-
if (!entry || typeof entry !== "object") {
|
|
47
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: the default export is not an object.`);
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
const store = entry;
|
|
51
|
-
if (store.type !== "app" && store.type !== "api") {
|
|
52
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: "type" must be "app" or "api".`);
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
if (store.type === "api" && !store.path) {
|
|
56
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: "path" is required for type "api".`);
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
return true;
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Type guard for a `.prisma` entry: the contents of the file, without comments.
|
|
63
|
-
*/
|
|
64
|
-
static isPrisma(entry, filePath) {
|
|
65
|
-
if (typeof entry !== "string") {
|
|
66
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: the contents are not a string.`);
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
const provider = entry.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*["']([^"']+)["']/s)?.[1];
|
|
70
|
-
if (provider !== "postgresql") {
|
|
71
|
-
console.warn(`[nano-app] [WARNING] ${filePath}: the datasource provider must be "postgresql"${provider ? `, not "${provider}"` : ""}.`);
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
/** Validates a full map of entries and returns only the valid ones */
|
|
77
|
-
static validate(entries, extension) {
|
|
78
|
-
const valid = {};
|
|
79
|
-
for (const [filePath, entry] of Object.entries(entries)) {
|
|
80
|
-
const validated = this.validateUnique(entry, extension, filePath);
|
|
81
|
-
if (validated !== void 0) valid[filePath] = validated;
|
|
82
|
-
}
|
|
83
|
-
return valid;
|
|
84
|
-
}
|
|
85
|
-
/** Validates one entry: returns it when valid, `undefined` otherwise */
|
|
86
|
-
static validateUnique(entry, extension, filePath = "") {
|
|
87
|
-
return this.validators[extension]?.()(entry, filePath) ? entry : void 0;
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
// ../core/loader/FileReader.ts
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
92
12
|
import { createJiti } from "jiti";
|
|
93
|
-
import {
|
|
94
|
-
import {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
}
|
|
121
88
|
};
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
entries[fileSubPath] = await FileReader.loadModule(filePath, { default: true });
|
|
148
|
-
}
|
|
149
|
-
const valid = Validator.validate(entries, extension);
|
|
150
|
-
if (save) {
|
|
151
|
-
this.save(valid, savePath || resolve2(process.cwd(), "node_modules", "@nano-app", "data", `${extension}s.cjs`));
|
|
152
|
-
}
|
|
153
|
-
return valid;
|
|
154
|
-
}
|
|
155
|
-
/** Writes a `{ [filePath]: fileDefaultExport }` map to the given path. */
|
|
156
|
-
static save(data, outPath) {
|
|
157
|
-
const SERIALIZE = (value, indent = 0) => {
|
|
158
|
-
const pad = " ".repeat(indent);
|
|
159
|
-
const padInner = " ".repeat(indent + 1);
|
|
160
|
-
if (value === null) return "null";
|
|
161
|
-
if (value === void 0) return "undefined";
|
|
162
|
-
if (typeof value === "function") return value.toString();
|
|
163
|
-
if (typeof value === "string") return JSON.stringify(value);
|
|
164
|
-
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
165
|
-
if (Array.isArray(value)) {
|
|
166
|
-
if (value.length === 0) return "[]";
|
|
167
|
-
const items = value.map((v) => `${padInner}${SERIALIZE(v, indent + 1)}`).join(",\n");
|
|
168
|
-
return `[
|
|
169
|
-
${items}
|
|
170
|
-
${pad}]`;
|
|
171
|
-
}
|
|
172
|
-
if (typeof value === "object") {
|
|
173
|
-
const entries = Object.entries(value);
|
|
174
|
-
if (entries.length === 0) return "{}";
|
|
175
|
-
const lines = entries.map(([k, v]) => `${padInner}${JSON.stringify(k)}: ${SERIALIZE(v, indent + 1)}`).join(",\n");
|
|
176
|
-
return `{
|
|
177
|
-
${lines}
|
|
178
|
-
${pad}}`;
|
|
179
|
-
}
|
|
180
|
-
return String(value);
|
|
181
|
-
};
|
|
182
|
-
const GENERATE_EXPORT = (content) => `${'// Auto-generated by @nano-app.\n// Do not edit manually.\n"use strict";\nmodule.exports = {\n' + content + "\n};"}`;
|
|
183
|
-
const output = GENERATE_EXPORT(
|
|
184
|
-
Object.entries(data).map(([filePath, value]) => ` ${JSON.stringify(filePath)}: ${SERIALIZE(value, 1)}`).join(",\n")
|
|
185
|
-
);
|
|
186
|
-
mkdirSync(dirname(outPath), { recursive: true });
|
|
187
|
-
writeFileSync(outPath, output, "utf-8");
|
|
188
|
-
console.log(`[nano-app] File generated: ${outPath}`);
|
|
189
|
-
}
|
|
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
|
+
}
|
|
190
114
|
};
|
|
191
|
-
|
|
192
|
-
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region ../core/utils/Formatter.ts
|
|
117
|
+
/** Formats values for logs and messages */
|
|
193
118
|
var Formatter = class {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}
|
|
219
160
|
};
|
|
220
|
-
|
|
221
|
-
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/routes/Endpoint.ts
|
|
163
|
+
/** Base class for all endpoints */
|
|
222
164
|
var Endpoint = class {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
165
|
+
oMethods;
|
|
166
|
+
oCallbacks;
|
|
167
|
+
/** Allowed methods */
|
|
168
|
+
constructor(oMethods, oCallbacks = {}) {
|
|
169
|
+
this.oMethods = oMethods;
|
|
170
|
+
this.oCallbacks = oCallbacks;
|
|
171
|
+
}
|
|
172
|
+
/** Handler for all methods */
|
|
173
|
+
async mount(oParams) {
|
|
174
|
+
await this.oCallbacks.onInit?.(oParams);
|
|
175
|
+
return this.createHandler(oParams);
|
|
176
|
+
}
|
|
177
|
+
/** Express handler: onBefore, then the method of the request */
|
|
178
|
+
createHandler(oParams) {
|
|
179
|
+
return (oReq, oRes, fnNext) => {
|
|
180
|
+
const sMethod = oReq.method.toUpperCase();
|
|
181
|
+
const fnMethod = this.oMethods[sMethod];
|
|
182
|
+
if (!fnMethod) return fnNext({
|
|
183
|
+
status: 405,
|
|
184
|
+
message: `Method ${sMethod} not implemented`
|
|
185
|
+
});
|
|
186
|
+
const oRequestParams = {
|
|
187
|
+
...oParams,
|
|
188
|
+
session: oRes.locals.session
|
|
189
|
+
};
|
|
190
|
+
(this.oCallbacks.onBefore?.(oReq, sMethod, oRequestParams) ?? Promise.resolve({})).then((onBeforeResult) => fnMethod(oReq, {
|
|
191
|
+
...oRequestParams,
|
|
192
|
+
onBeforeResult
|
|
193
|
+
})).then((oResponse) => {
|
|
194
|
+
oRes.status(oResponse.success ? 200 : 400).json(oResponse);
|
|
195
|
+
}).catch(fnNext);
|
|
196
|
+
};
|
|
197
|
+
}
|
|
249
198
|
};
|
|
250
|
-
|
|
251
|
-
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/routes/Router.ts
|
|
201
|
+
/** Turns a folder of `*.route.ts` files into Express paths and endpoints */
|
|
252
202
|
var Router = class {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
203
|
+
oRoutes = {};
|
|
204
|
+
sRoutesDir;
|
|
205
|
+
sBasePath;
|
|
206
|
+
constructor(sRoutesDir, sBasePath) {
|
|
207
|
+
this.sRoutesDir = sRoutesDir;
|
|
208
|
+
this.sBasePath = sBasePath ?? "";
|
|
209
|
+
}
|
|
210
|
+
/** Loads the routes and creates one endpoint per file */
|
|
211
|
+
async mount() {
|
|
212
|
+
if (this.sRoutesDir) this.oRoutes = await FileLoader.findMany({
|
|
213
|
+
sourceDir: this.sRoutesDir,
|
|
214
|
+
extension: "route"
|
|
215
|
+
});
|
|
216
|
+
return Object.entries(this.oRoutes).map(([sFilePath, { expression, callbacks, ...methods }]) => {
|
|
217
|
+
const sPath = this.createPath(sFilePath, expression);
|
|
218
|
+
console.log(`[nano-app] [INFO] ${sPath}`);
|
|
219
|
+
return {
|
|
220
|
+
path: sPath,
|
|
221
|
+
endpoint: new Endpoint(methods, callbacks)
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** `basePath` + file path (`/index` is the folder itself) + `expression`, as Express reads them */
|
|
226
|
+
createPath(sEndpoint, sExpression = "") {
|
|
227
|
+
return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
|
|
228
|
+
}
|
|
277
229
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
341
|
+
//#region src/database/Database.ts
|
|
342
|
+
/** Prisma clients of the app and its plugins, one per name */
|
|
289
343
|
var Database = class {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
344
|
+
static oAdapter;
|
|
345
|
+
/** Mounted clients, by name */
|
|
346
|
+
static oClients = {};
|
|
347
|
+
/** Initializes the Database client */
|
|
348
|
+
static async init() {
|
|
349
|
+
const sUrl = process.env.DATABASE_URL;
|
|
350
|
+
if (!sUrl) throw new Error(MISSING_DATABASE_URL);
|
|
351
|
+
const { PrismaPg } = await import("@prisma/adapter-pg");
|
|
352
|
+
this.oAdapter = new PrismaPg({ connectionString: sUrl });
|
|
353
|
+
}
|
|
354
|
+
/** Gets a client by name */
|
|
355
|
+
static get(sName) {
|
|
356
|
+
return this.oClients[sName];
|
|
357
|
+
}
|
|
358
|
+
/** Generates and mounts the Prisma client of a schema; an official plugin's is also migrated */
|
|
359
|
+
static async mount(sName, { schemaPath }) {
|
|
360
|
+
if (this.get(sName)) throw new Error(`[nano-app] [ERROR] The database "${sName}" is already mounted.`);
|
|
361
|
+
const isPlugin = this.isOfficial(schemaPath);
|
|
362
|
+
const aSchemas = this.declaredSchemas(schemaPath);
|
|
363
|
+
const isPluginInvalid = !aSchemas.length || aSchemas.some((sItem) => !sItem.startsWith("nano_"));
|
|
364
|
+
const isConsumerInvalid = aSchemas.some((sItem) => sItem.startsWith("nano_"));
|
|
365
|
+
if (isPlugin ? isPluginInvalid : isConsumerInvalid) throw new Error(`[nano-app] [ERROR] ${schemaPath}: ${isPlugin ? "a plugin declares only nano_* schemas" : "the nano_* schemas are reserved for plugins"}.`);
|
|
366
|
+
Prisma.generate(sName, schemaPath);
|
|
367
|
+
if (isPlugin) Prisma.migrate(sName, schemaPath, aSchemas[0]);
|
|
368
|
+
if (!this.oAdapter) await this.init();
|
|
369
|
+
const sClientPath = join(Prisma.dir(sName), "generated", "client.ts");
|
|
370
|
+
const PrismaClient = await FileLoader.findPrismaClient(sClientPath, schemaPath);
|
|
371
|
+
let oClient;
|
|
372
|
+
try {
|
|
373
|
+
oClient = new PrismaClient({ adapter: this.oAdapter });
|
|
374
|
+
} catch (oError) {
|
|
375
|
+
throw new Error(`[nano-app] [ERROR] Cannot instantiate the database "${sName}" from ${sClientPath}: ${Formatter.error(oError)}`);
|
|
376
|
+
}
|
|
377
|
+
const sFix = isPlugin ? "Update the plugin or report it." : "Apply your schema with Prisma (db push or migrations).";
|
|
378
|
+
for (const [, sModel] of FileReader.loadSchema(schemaPath).matchAll(/^\s*model\s+(\w+)/gm)) await oClient[`${sModel[0].toLowerCase()}${sModel.slice(1)}`].findFirst().catch((oError) => {
|
|
379
|
+
const sReason = Formatter.error(oError).trim().split("\n").pop();
|
|
380
|
+
throw new Error(`[nano-app] [ERROR] The database "${sName}" is not ready: ${sReason} ${sFix}`);
|
|
381
|
+
});
|
|
382
|
+
this.oClients[sName] = oClient;
|
|
383
|
+
console.log(`[nano-app] [INFO] Database "${sName}" mounted`);
|
|
384
|
+
return oClient;
|
|
385
|
+
}
|
|
386
|
+
/** Schemas a Prisma schema declares in its datasource (`schemas = [...]`) */
|
|
387
|
+
static declaredSchemas(sSchemaPath) {
|
|
388
|
+
return [...(FileReader.loadSchema(sSchemaPath).match(/datasource\s+\w+\s*\{[^}]*schemas\s*=\s*\[([^\]]*)\]/s)?.[1] ?? "").matchAll(/["']([^"']+)["']/g)].map(([, sItem]) => sItem);
|
|
389
|
+
}
|
|
390
|
+
/** Whether a schema ships with the official plugins: its nearest package.json is theirs */
|
|
391
|
+
static isOfficial(sSchemaPath) {
|
|
392
|
+
let sDir = dirname(FileReader.getAbsolutePath(sSchemaPath));
|
|
393
|
+
while (!existsSync(join(sDir, "package.json"))) {
|
|
394
|
+
if (dirname(sDir) === sDir) return false;
|
|
395
|
+
sDir = dirname(sDir);
|
|
396
|
+
}
|
|
397
|
+
const { name } = JSON.parse(readFileSync(join(sDir, "package.json"), "utf-8"));
|
|
398
|
+
return name === OFFICIAL_PLUGINS;
|
|
399
|
+
}
|
|
340
400
|
};
|
|
341
|
-
|
|
342
|
-
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/routes/Plugin.ts
|
|
403
|
+
/** A group of routes, with its options, callbacks and database */
|
|
343
404
|
var Plugin = class {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
405
|
+
oConfig;
|
|
406
|
+
oAuthOptions;
|
|
407
|
+
/** Creates a new Plugin instance; the app's plugin also gets the Better Auth options */
|
|
408
|
+
constructor(oConfig, oAuthOptions) {
|
|
409
|
+
this.oConfig = oConfig;
|
|
410
|
+
this.oAuthOptions = oAuthOptions;
|
|
411
|
+
}
|
|
412
|
+
/** Mounts its endpoints on express app */
|
|
413
|
+
async mount(oApp) {
|
|
414
|
+
const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
|
|
415
|
+
const oDb = database?.enabled ? await Database.mount(name, database) : void 0;
|
|
416
|
+
const oParams = {
|
|
417
|
+
...options,
|
|
418
|
+
db: oDb
|
|
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
|
+
}
|
|
426
|
+
await callbacks?.onMount?.(oApp, oParams);
|
|
427
|
+
const oRouter = new Router(routesDir, basePath);
|
|
428
|
+
for (const { path, endpoint } of await oRouter.mount()) oApp.all(path, await endpoint.mount(oParams));
|
|
429
|
+
}
|
|
358
430
|
};
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
enabled: false,
|
|
405
|
-
schemaPath: "src/prisma/schema.prisma"
|
|
406
|
-
}
|
|
407
|
-
},
|
|
408
|
-
/** Plugins around the consumer routes */
|
|
409
|
-
plugins: []
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/app/config.ts
|
|
433
|
+
/** Initial application configuration */
|
|
434
|
+
const INITIAL_CONFIG = {
|
|
435
|
+
/** Initial application configuration */
|
|
436
|
+
port: 3e3,
|
|
437
|
+
/** Application listener */
|
|
438
|
+
listener: () => {},
|
|
439
|
+
/** Express settings */
|
|
440
|
+
settings: {},
|
|
441
|
+
/** Initial middlewares */
|
|
442
|
+
packages: {
|
|
443
|
+
morgan: {
|
|
444
|
+
tokens: { timestamp: () => Formatter.logDate() },
|
|
445
|
+
format: ":timestamp | :method :url :status"
|
|
446
|
+
},
|
|
447
|
+
cors: { methods: [
|
|
448
|
+
"GET",
|
|
449
|
+
"HEAD",
|
|
450
|
+
"PUT",
|
|
451
|
+
"POST",
|
|
452
|
+
"DELETE",
|
|
453
|
+
"QUERY"
|
|
454
|
+
] },
|
|
455
|
+
helmet: true,
|
|
456
|
+
compression: true,
|
|
457
|
+
rateLimit: {
|
|
458
|
+
max: 1e3,
|
|
459
|
+
message: "Too many requests, please try again later."
|
|
460
|
+
},
|
|
461
|
+
express: { limit: "10kb" },
|
|
462
|
+
urlencoded: { extended: false },
|
|
463
|
+
betterAuth: false
|
|
464
|
+
},
|
|
465
|
+
/** Main plugin to mount */
|
|
466
|
+
mainPlugin: {
|
|
467
|
+
routesDir: "src/routes",
|
|
468
|
+
basePath: "",
|
|
469
|
+
database: {
|
|
470
|
+
enabled: false,
|
|
471
|
+
schemaPath: "src/prisma/schema.prisma"
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
/** Plugins around the consumer routes */
|
|
475
|
+
plugins: []
|
|
410
476
|
};
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
express: config.packages?.express ?? INITIAL_CONFIG.packages.express,
|
|
425
|
-
urlencoded: config.packages?.urlencoded ?? INITIAL_CONFIG.packages.urlencoded
|
|
426
|
-
},
|
|
427
|
-
mainPlugin: {
|
|
428
|
-
...INITIAL_CONFIG.mainPlugin,
|
|
429
|
-
...config.mainPlugin,
|
|
430
|
-
name: "app",
|
|
431
|
-
database: {
|
|
432
|
-
...INITIAL_CONFIG.mainPlugin.database,
|
|
433
|
-
...config.mainPlugin?.database
|
|
434
|
-
}
|
|
435
|
-
},
|
|
436
|
-
plugins: config.plugins ?? INITIAL_CONFIG.plugins
|
|
437
|
-
};
|
|
477
|
+
/** Whether a value is a plain object (`{}`), the only kind merged field by field */
|
|
478
|
+
function isPlainObject(oValue) {
|
|
479
|
+
return Object.prototype.toString.call(oValue) === "[object Object]";
|
|
480
|
+
}
|
|
481
|
+
/** Merges a value over its default: plain objects key by key at every level; anything else replaces it */
|
|
482
|
+
function deepMerge(oDefault, oValue) {
|
|
483
|
+
if (oValue === void 0) return oDefault;
|
|
484
|
+
if (!isPlainObject(oDefault) || !isPlainObject(oValue)) return oValue;
|
|
485
|
+
const aKeys = Object.keys({
|
|
486
|
+
...oDefault,
|
|
487
|
+
...oValue
|
|
488
|
+
});
|
|
489
|
+
return Object.fromEntries(aKeys.map((sKey) => [sKey, deepMerge(oDefault[sKey], oValue[sKey])]));
|
|
438
490
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
success: false,
|
|
450
|
-
warning: warning ?? false,
|
|
451
|
-
message: message || "Internal server error"
|
|
452
|
-
}
|
|
453
|
-
};
|
|
491
|
+
/** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
|
|
492
|
+
function mergeConfig(oConfig = {}) {
|
|
493
|
+
const oMerged = deepMerge(INITIAL_CONFIG, oConfig);
|
|
494
|
+
return {
|
|
495
|
+
...oMerged,
|
|
496
|
+
mainPlugin: {
|
|
497
|
+
...oMerged.mainPlugin,
|
|
498
|
+
name: "app"
|
|
499
|
+
}
|
|
500
|
+
};
|
|
454
501
|
}
|
|
455
|
-
|
|
456
|
-
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region src/app/errors.ts
|
|
504
|
+
/** Normalizes anything thrown by a handler into an ApiResponse plus its status */
|
|
505
|
+
function normalize(oError) {
|
|
506
|
+
if (typeof oError === "string") return {
|
|
507
|
+
status: 400,
|
|
508
|
+
body: {
|
|
509
|
+
success: false,
|
|
510
|
+
message: oError
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const { status, message, warning } = oError ?? {};
|
|
514
|
+
return {
|
|
515
|
+
status: status ?? 500,
|
|
516
|
+
body: {
|
|
517
|
+
success: false,
|
|
518
|
+
warning: warning ?? false,
|
|
519
|
+
message: message || "Internal server error"
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/** Turns every unmatched request into a 404 for the error handler */
|
|
524
|
+
const notFoundHandler = (oReq, _oRes, fnNext) => {
|
|
525
|
+
fnNext({
|
|
526
|
+
status: 404,
|
|
527
|
+
message: `${oReq.path} not found`
|
|
528
|
+
});
|
|
457
529
|
};
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
res.status(status).json(body);
|
|
530
|
+
/** Turns any error raised by a handler into an ApiResponse */
|
|
531
|
+
const errorHandler = (oError, _oReq, oRes, _fnNext) => {
|
|
532
|
+
const { status, body } = normalize(oError);
|
|
533
|
+
if (status >= 500) console.error("[nano-app] [ERROR]", oError);
|
|
534
|
+
oRes.status(status).json(body);
|
|
464
535
|
};
|
|
465
|
-
|
|
466
|
-
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/app/NanoApp.ts
|
|
538
|
+
/** Main NanoApp class */
|
|
467
539
|
var NanoApp = class {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
this.app.use(compression());
|
|
519
|
-
}
|
|
520
|
-
if (config.rateLimit) {
|
|
521
|
-
this.app.use(rateLimit(config.rateLimit));
|
|
522
|
-
}
|
|
523
|
-
if (config.express) {
|
|
524
|
-
this.app.use(express.json(config.express));
|
|
525
|
-
}
|
|
526
|
-
if (config.urlencoded) {
|
|
527
|
-
this.app.use(express.urlencoded(config.urlencoded));
|
|
528
|
-
}
|
|
529
|
-
}
|
|
540
|
+
oApp;
|
|
541
|
+
oConfig;
|
|
542
|
+
constructor(oConfig = {}) {
|
|
543
|
+
this.oApp = express();
|
|
544
|
+
this.oConfig = mergeConfig(oConfig);
|
|
545
|
+
this.start();
|
|
546
|
+
}
|
|
547
|
+
/** Starts the application */
|
|
548
|
+
async start() {
|
|
549
|
+
try {
|
|
550
|
+
await this.initialize();
|
|
551
|
+
this.oApp.listen(Number(this.oConfig.port), () => {
|
|
552
|
+
this.oConfig.listener?.();
|
|
553
|
+
console.log(`\n[nano-app] [INFO] Nano App running on port ${this.oConfig.port}\n`);
|
|
554
|
+
});
|
|
555
|
+
return this;
|
|
556
|
+
} catch (oError) {
|
|
557
|
+
console.error("[nano-app] [ERROR] Error trying to start the server.", oError);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/** Initializes the application in the right order */
|
|
562
|
+
async initialize() {
|
|
563
|
+
for (const [sKey, oValue] of Object.entries(this.oConfig.settings)) this.oApp.set(sKey, oValue);
|
|
564
|
+
this.setPackages(this.oConfig.packages);
|
|
565
|
+
const oPlugins = {
|
|
566
|
+
before: [],
|
|
567
|
+
after: []
|
|
568
|
+
};
|
|
569
|
+
for (const oPlugin of this.oConfig.plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
|
|
570
|
+
for (const oPlugin of oPlugins.before) await new Plugin(oPlugin).mount(this.oApp);
|
|
571
|
+
const { mainPlugin, packages } = this.oConfig;
|
|
572
|
+
await new Plugin(mainPlugin, packages.betterAuth).mount(this.oApp);
|
|
573
|
+
for (const oPlugin of oPlugins.after) await new Plugin(oPlugin).mount(this.oApp);
|
|
574
|
+
this.oApp.use(notFoundHandler);
|
|
575
|
+
this.oApp.use(errorHandler);
|
|
576
|
+
}
|
|
577
|
+
/** Adds the packages according to the configuration */
|
|
578
|
+
setPackages(oConfig = {}) {
|
|
579
|
+
if (oConfig.morgan) {
|
|
580
|
+
for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
|
|
581
|
+
this.oApp.use(morgan(oConfig.morgan.format));
|
|
582
|
+
}
|
|
583
|
+
if (oConfig.cors) this.oApp.use(cors(oConfig.cors));
|
|
584
|
+
if (oConfig.helmet) this.oApp.use(helmet());
|
|
585
|
+
if (oConfig.compression) this.oApp.use(compression());
|
|
586
|
+
if (oConfig.rateLimit) this.oApp.use(rateLimit(oConfig.rateLimit));
|
|
587
|
+
if (oConfig.express) this.oApp.use(express.json(oConfig.express));
|
|
588
|
+
if (oConfig.urlencoded) this.oApp.use(express.urlencoded(oConfig.urlencoded));
|
|
589
|
+
}
|
|
530
590
|
};
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/app/creators.ts
|
|
593
|
+
/** Creates a new NanoApp instance */
|
|
594
|
+
function createNanoApp(oConfig) {
|
|
595
|
+
return new NanoApp(oConfig);
|
|
535
596
|
}
|
|
536
|
-
|
|
537
|
-
|
|
597
|
+
/** Creates and types a route object. */
|
|
598
|
+
function createRoute(oRoute) {
|
|
599
|
+
return oRoute;
|
|
538
600
|
}
|
|
539
|
-
function createPlugin(
|
|
540
|
-
|
|
601
|
+
function createPlugin(fnCreator) {
|
|
602
|
+
return fnCreator;
|
|
541
603
|
}
|
|
542
|
-
|
|
543
|
-
|
|
604
|
+
/** Creates and types the callbacks of a plugin. */
|
|
605
|
+
function createPluginCallbacks(oCallbacks) {
|
|
606
|
+
return oCallbacks;
|
|
544
607
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
createNanoApp,
|
|
548
|
-
createPlugin,
|
|
549
|
-
createPluginCallbacks,
|
|
550
|
-
createRoute
|
|
551
|
-
};
|
|
608
|
+
//#endregion
|
|
609
|
+
export { NanoApp, createNanoApp, createPlugin, createPluginCallbacks, createRoute };
|