@nano-app/backend 0.8.3 → 0.9.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 +17 -25
- package/build/Prisma-Dq-lYuz3.js +221 -0
- package/build/index.d.ts +136 -128
- package/build/index.js +314 -531
- package/build/nano-app.js +43 -38
- package/package.json +11 -11
package/build/index.js
CHANGED
|
@@ -1,551 +1,334 @@
|
|
|
1
|
-
|
|
1
|
+
import { a as Formatter, i as FileLoader, n as MISSING_DATABASE_URL, o as FileReader, r as OFFICIAL_PLUGINS, t as Prisma } from "./Prisma-Dq-lYuz3.js";
|
|
2
2
|
import express from "express";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
92
|
-
import { createJiti } from "jiti";
|
|
93
|
-
import { isAbsolute, resolve } from "path";
|
|
94
|
-
import { readFileSync, existsSync } from "fs";
|
|
95
|
-
var FileReader = class {
|
|
96
|
-
// The Prisma runtime is loaded natively: through jiti its interop breaks the error messages
|
|
97
|
-
static jiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
|
|
98
|
-
/** Loads a module via jiti */
|
|
99
|
-
static async loadModule(filePath, options = {}) {
|
|
100
|
-
return await this.jiti.import(
|
|
101
|
-
this.getAbsolutePath(filePath),
|
|
102
|
-
options
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
/** Reads the contents of a file */
|
|
106
|
-
static loadFile(filePath) {
|
|
107
|
-
const content = readFileSync(this.getAbsolutePath(filePath), "utf-8");
|
|
108
|
-
if (!content) {
|
|
109
|
-
throw new Error(`[nano-app] [ERROR] The file ${filePath} does not have valid content.`);
|
|
110
|
-
}
|
|
111
|
-
return content;
|
|
112
|
-
}
|
|
113
|
-
/** Resolves the absolute path of a file */
|
|
114
|
-
static getAbsolutePath(filePath) {
|
|
115
|
-
const absolutePath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
|
116
|
-
if (!existsSync(absolutePath)) {
|
|
117
|
-
throw new Error(`[nano-app] [ERROR] ${filePath} not found.`);
|
|
118
|
-
}
|
|
119
|
-
return absolutePath;
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
// ../core/loader/FileLoader.ts
|
|
124
|
-
var FileLoader = class {
|
|
125
|
-
/**
|
|
126
|
-
* 1. Validates a `.prisma` file.
|
|
127
|
-
* 2. Loads a generated Prisma client module (`client.ts`, or `client.js`)
|
|
128
|
-
*/
|
|
129
|
-
static async findPrismaClient(clientPath, schemaPath) {
|
|
130
|
-
const content = FileReader.loadFile(schemaPath).replace(/\/\/.*$/gm, "");
|
|
131
|
-
if (!Validator.validateUnique(content, "prisma", schemaPath)) {
|
|
132
|
-
throw new Error(`[nano-app] [ERROR] ${schemaPath} is not a valid Prisma schema.`);
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
return (await FileReader.loadModule(clientPath))?.PrismaClient;
|
|
136
|
-
} catch (error) {
|
|
137
|
-
throw new Error(`[nano-app] [ERROR] The Prisma client ${clientPath} could not be loaded: ${error?.message ?? error}`);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
/** Finds `*.<extension>.ts` (or built `.js`) files and returns `{ [subPath]: defaultExport }` */
|
|
141
|
-
static async findMany({ extension, savePath, sourceDir, save = false }) {
|
|
142
|
-
const srcDir = (sourceDir || `src/${extension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
143
|
-
const filePaths = await glob(`${srcDir}/**/*.${extension}.{ts,js}`, { posix: true, ignore: "**/*.d.ts" });
|
|
144
|
-
const entries = {};
|
|
145
|
-
for (const filePath of filePaths) {
|
|
146
|
-
const fileSubPath = filePath.slice(srcDir.length).replace(new RegExp(`\\.${extension}\\.(ts|js)$`), "");
|
|
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
|
-
}
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
// ../core/utils/Formatter.ts
|
|
193
|
-
var Formatter = class {
|
|
194
|
-
static price(value) {
|
|
195
|
-
return `$${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }).format(value || 0)}`;
|
|
196
|
-
}
|
|
197
|
-
static logDate(date = /* @__PURE__ */ new Date()) {
|
|
198
|
-
return new Intl.DateTimeFormat("es-ES", {
|
|
199
|
-
year: "2-digit",
|
|
200
|
-
month: "2-digit",
|
|
201
|
-
day: "2-digit",
|
|
202
|
-
hour: "2-digit",
|
|
203
|
-
minute: "2-digit",
|
|
204
|
-
second: "2-digit"
|
|
205
|
-
}).format(date);
|
|
206
|
-
}
|
|
207
|
-
// public static sanitizeAndParse(result: ApiResponse): ApiResponse {
|
|
208
|
-
// const jsonString = JSON.stringify(result);
|
|
209
|
-
// // The regular expression matches the key "password": "any_value"
|
|
210
|
-
// // where:
|
|
211
|
-
// // - "password" is wrapped in quotes.
|
|
212
|
-
// // - Optional spaces may surround the colon.
|
|
213
|
-
// // - The value is assumed to be wrapped in quotes.
|
|
214
|
-
// const regex = /("password"\s*:\s*)"[^"]*"/gi;
|
|
215
|
-
// // Replace the value with the JSON literal null (unquoted)
|
|
216
|
-
// const sanitizedString = jsonString.replace(regex, '$1null');
|
|
217
|
-
// return JSON.parse(sanitizedString);
|
|
218
|
-
// }
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
// src/routes/Endpoint.ts
|
|
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";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
//#region src/routes/Endpoint.ts
|
|
12
|
+
/** Base class for all endpoints */
|
|
222
13
|
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
|
-
|
|
14
|
+
oMethods;
|
|
15
|
+
oCallbacks;
|
|
16
|
+
/** Allowed methods */
|
|
17
|
+
constructor(oMethods, oCallbacks = {}) {
|
|
18
|
+
this.oMethods = oMethods;
|
|
19
|
+
this.oCallbacks = oCallbacks;
|
|
20
|
+
}
|
|
21
|
+
/** Handler for all methods */
|
|
22
|
+
async mount(oParams) {
|
|
23
|
+
await this.oCallbacks.onInit?.(oParams);
|
|
24
|
+
return this.createHandler(oParams);
|
|
25
|
+
}
|
|
26
|
+
/** Express handler: onBefore, then the method of the request */
|
|
27
|
+
createHandler(oParams) {
|
|
28
|
+
return (oReq, oRes, fnNext) => {
|
|
29
|
+
const sMethod = oReq.method.toUpperCase();
|
|
30
|
+
const fnMethod = this.oMethods[sMethod];
|
|
31
|
+
if (!fnMethod) return fnNext({
|
|
32
|
+
status: 405,
|
|
33
|
+
message: `Method ${sMethod} not implemented`
|
|
34
|
+
});
|
|
35
|
+
(this.oCallbacks.onBefore?.(oReq, sMethod, oParams) ?? Promise.resolve({})).then((onBeforeResult) => fnMethod(oReq, {
|
|
36
|
+
...oParams,
|
|
37
|
+
onBeforeResult
|
|
38
|
+
})).then((oResponse) => {
|
|
39
|
+
oRes.status(oResponse.success ? 200 : 400).json(oResponse);
|
|
40
|
+
}).catch(fnNext);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
249
43
|
};
|
|
250
|
-
|
|
251
|
-
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/routes/Router.ts
|
|
46
|
+
/** Turns a folder of `*.route.ts` files into Express paths and endpoints */
|
|
252
47
|
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
|
-
|
|
48
|
+
oRoutes = {};
|
|
49
|
+
sRoutesDir;
|
|
50
|
+
sBasePath;
|
|
51
|
+
constructor(sRoutesDir, sBasePath) {
|
|
52
|
+
this.sRoutesDir = sRoutesDir;
|
|
53
|
+
this.sBasePath = sBasePath ?? "";
|
|
54
|
+
}
|
|
55
|
+
/** Loads the routes and creates one endpoint per file */
|
|
56
|
+
async mount() {
|
|
57
|
+
if (this.sRoutesDir) this.oRoutes = await FileLoader.findMany({
|
|
58
|
+
sourceDir: this.sRoutesDir,
|
|
59
|
+
extension: "route"
|
|
60
|
+
});
|
|
61
|
+
return Object.entries(this.oRoutes).map(([sFilePath, { expression, callbacks, ...methods }]) => {
|
|
62
|
+
const sPath = this.createPath(sFilePath, expression);
|
|
63
|
+
console.log(`[nano-app] [INFO] ${sPath}`);
|
|
64
|
+
return {
|
|
65
|
+
path: sPath,
|
|
66
|
+
endpoint: new Endpoint(methods, callbacks)
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** `basePath` + file path (`/index` is the folder itself) + `expression`, as Express reads them */
|
|
71
|
+
createPath(sEndpoint, sExpression = "") {
|
|
72
|
+
return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
|
|
73
|
+
}
|
|
277
74
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// const.ts
|
|
283
|
-
import { dirname as dirname2, resolve as resolve3 } from "path";
|
|
284
|
-
import { fileURLToPath } from "url";
|
|
285
|
-
var BASE_DIR = dirname2(fileURLToPath(import.meta.url));
|
|
286
|
-
var getDir = (...parts) => resolve3(BASE_DIR, ...parts);
|
|
287
|
-
|
|
288
|
-
// src/database/Database.ts
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/database/Database.ts
|
|
77
|
+
/** Prisma clients of the app and its plugins, one per name */
|
|
289
78
|
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
|
-
|
|
79
|
+
static oAdapter;
|
|
80
|
+
/** Mounted clients, by name */
|
|
81
|
+
static oClients = {};
|
|
82
|
+
/** Initializes the Database client */
|
|
83
|
+
static async init() {
|
|
84
|
+
const sUrl = process.env.DATABASE_URL;
|
|
85
|
+
if (!sUrl) throw new Error(MISSING_DATABASE_URL);
|
|
86
|
+
const { PrismaPg } = await import("@prisma/adapter-pg");
|
|
87
|
+
this.oAdapter = new PrismaPg({ connectionString: sUrl });
|
|
88
|
+
}
|
|
89
|
+
/** Gets a client by name */
|
|
90
|
+
static get(sName) {
|
|
91
|
+
return this.oClients[sName];
|
|
92
|
+
}
|
|
93
|
+
/** Generates and mounts the Prisma client of a schema; an official plugin's is also migrated */
|
|
94
|
+
static async mount(sName, { schemaPath }) {
|
|
95
|
+
if (this.get(sName)) throw new Error(`[nano-app] [ERROR] The database "${sName}" is already mounted.`);
|
|
96
|
+
const isPlugin = this.isOfficial(schemaPath);
|
|
97
|
+
const aSchemas = this.declaredSchemas(schemaPath);
|
|
98
|
+
const isPluginInvalid = !aSchemas.length || aSchemas.some((sItem) => !sItem.startsWith("nano_"));
|
|
99
|
+
const isConsumerInvalid = aSchemas.some((sItem) => sItem.startsWith("nano_"));
|
|
100
|
+
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"}.`);
|
|
101
|
+
Prisma.generate(sName, schemaPath);
|
|
102
|
+
if (isPlugin) Prisma.migrate(sName, schemaPath, aSchemas[0]);
|
|
103
|
+
if (!this.oAdapter) await this.init();
|
|
104
|
+
const sClientPath = join(Prisma.dir(sName), "generated", "client.ts");
|
|
105
|
+
const PrismaClient = await FileLoader.findPrismaClient(sClientPath, schemaPath);
|
|
106
|
+
let oClient;
|
|
107
|
+
try {
|
|
108
|
+
oClient = new PrismaClient({ adapter: this.oAdapter });
|
|
109
|
+
} catch (oError) {
|
|
110
|
+
throw new Error(`[nano-app] [ERROR] Cannot instantiate the database "${sName}" from ${sClientPath}: ${Formatter.error(oError)}`);
|
|
111
|
+
}
|
|
112
|
+
const sFix = isPlugin ? "Update the plugin or report it." : "Apply your schema with Prisma (db push or migrations).";
|
|
113
|
+
for (const [, sModel] of FileReader.loadSchema(schemaPath).matchAll(/^\s*model\s+(\w+)/gm)) await oClient[`${sModel[0].toLowerCase()}${sModel.slice(1)}`].findFirst().catch((oError) => {
|
|
114
|
+
const sReason = Formatter.error(oError).trim().split("\n").pop();
|
|
115
|
+
throw new Error(`[nano-app] [ERROR] The database "${sName}" is not ready: ${sReason} ${sFix}`);
|
|
116
|
+
});
|
|
117
|
+
this.oClients[sName] = oClient;
|
|
118
|
+
console.log(`[nano-app] [INFO] Database "${sName}" mounted`);
|
|
119
|
+
return oClient;
|
|
120
|
+
}
|
|
121
|
+
/** Schemas a Prisma schema declares in its datasource (`schemas = [...]`) */
|
|
122
|
+
static declaredSchemas(sSchemaPath) {
|
|
123
|
+
return [...(FileReader.loadSchema(sSchemaPath).match(/datasource\s+\w+\s*\{[^}]*schemas\s*=\s*\[([^\]]*)\]/s)?.[1] ?? "").matchAll(/["']([^"']+)["']/g)].map(([, sItem]) => sItem);
|
|
124
|
+
}
|
|
125
|
+
/** Whether a schema ships with the official plugins: its nearest package.json is theirs */
|
|
126
|
+
static isOfficial(sSchemaPath) {
|
|
127
|
+
let sDir = dirname(FileReader.getAbsolutePath(sSchemaPath));
|
|
128
|
+
while (!existsSync(join(sDir, "package.json"))) {
|
|
129
|
+
if (dirname(sDir) === sDir) return false;
|
|
130
|
+
sDir = dirname(sDir);
|
|
131
|
+
}
|
|
132
|
+
const { name } = JSON.parse(readFileSync(join(sDir, "package.json"), "utf-8"));
|
|
133
|
+
return name === OFFICIAL_PLUGINS;
|
|
134
|
+
}
|
|
340
135
|
};
|
|
341
|
-
|
|
342
|
-
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/routes/Plugin.ts
|
|
138
|
+
/** A group of routes, with its options, callbacks and database */
|
|
343
139
|
var Plugin = class {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
140
|
+
oConfig;
|
|
141
|
+
/** Creates a new Plugin instance */
|
|
142
|
+
constructor(oConfig) {
|
|
143
|
+
this.oConfig = oConfig;
|
|
144
|
+
}
|
|
145
|
+
/** Mounts its endpoints on express app */
|
|
146
|
+
async mount(oApp) {
|
|
147
|
+
const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
|
|
148
|
+
const oDb = database?.enabled ? await Database.mount(name, database) : void 0;
|
|
149
|
+
const oParams = {
|
|
150
|
+
...options,
|
|
151
|
+
db: oDb
|
|
152
|
+
};
|
|
153
|
+
await callbacks?.onMount?.(oApp, oParams);
|
|
154
|
+
const oRouter = new Router(routesDir, basePath);
|
|
155
|
+
for (const { path, endpoint } of await oRouter.mount()) oApp.all(path, await endpoint.mount(oParams));
|
|
156
|
+
}
|
|
358
157
|
};
|
|
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
|
-
database: {
|
|
404
|
-
enabled: false,
|
|
405
|
-
schemaPath: "src/prisma/schema.prisma"
|
|
406
|
-
}
|
|
407
|
-
},
|
|
408
|
-
/** Plugins around the consumer routes */
|
|
409
|
-
plugins: []
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/app/config.ts
|
|
160
|
+
/** Initial application configuration */
|
|
161
|
+
const INITIAL_CONFIG = {
|
|
162
|
+
/** Initial application configuration */
|
|
163
|
+
port: 3e3,
|
|
164
|
+
/** Application listener */
|
|
165
|
+
listener: () => {},
|
|
166
|
+
/** Express settings */
|
|
167
|
+
settings: {},
|
|
168
|
+
/** Initial middlewares */
|
|
169
|
+
packages: {
|
|
170
|
+
morgan: {
|
|
171
|
+
tokens: { timestamp: () => Formatter.logDate() },
|
|
172
|
+
format: ":timestamp | :method :url :status"
|
|
173
|
+
},
|
|
174
|
+
cors: { methods: [
|
|
175
|
+
"GET",
|
|
176
|
+
"HEAD",
|
|
177
|
+
"PUT",
|
|
178
|
+
"POST",
|
|
179
|
+
"DELETE",
|
|
180
|
+
"QUERY"
|
|
181
|
+
] },
|
|
182
|
+
helmet: true,
|
|
183
|
+
compression: true,
|
|
184
|
+
rateLimit: {
|
|
185
|
+
max: 1e3,
|
|
186
|
+
message: "Too many requests, please try again later."
|
|
187
|
+
},
|
|
188
|
+
express: { limit: "10kb" },
|
|
189
|
+
urlencoded: { extended: false }
|
|
190
|
+
},
|
|
191
|
+
/** Main plugin to mount */
|
|
192
|
+
mainPlugin: {
|
|
193
|
+
routesDir: "src/routes",
|
|
194
|
+
basePath: "",
|
|
195
|
+
database: {
|
|
196
|
+
enabled: false,
|
|
197
|
+
schemaPath: "src/prisma/schema.prisma"
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
/** Plugins around the consumer routes */
|
|
201
|
+
plugins: []
|
|
410
202
|
};
|
|
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
|
-
};
|
|
203
|
+
/** Whether a value is a plain object (`{}`), the only kind merged field by field */
|
|
204
|
+
function isPlainObject(oValue) {
|
|
205
|
+
return Object.prototype.toString.call(oValue) === "[object Object]";
|
|
206
|
+
}
|
|
207
|
+
/** Merges a value over its default: plain objects key by key at every level; anything else replaces it */
|
|
208
|
+
function deepMerge(oDefault, oValue) {
|
|
209
|
+
if (oValue === void 0) return oDefault;
|
|
210
|
+
if (!isPlainObject(oDefault) || !isPlainObject(oValue)) return oValue;
|
|
211
|
+
const aKeys = Object.keys({
|
|
212
|
+
...oDefault,
|
|
213
|
+
...oValue
|
|
214
|
+
});
|
|
215
|
+
return Object.fromEntries(aKeys.map((sKey) => [sKey, deepMerge(oDefault[sKey], oValue[sKey])]));
|
|
438
216
|
}
|
|
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
|
-
};
|
|
217
|
+
/** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
|
|
218
|
+
function mergeConfig(oConfig = {}) {
|
|
219
|
+
const oMerged = deepMerge(INITIAL_CONFIG, oConfig);
|
|
220
|
+
return {
|
|
221
|
+
...oMerged,
|
|
222
|
+
mainPlugin: {
|
|
223
|
+
...oMerged.mainPlugin,
|
|
224
|
+
name: "app"
|
|
225
|
+
}
|
|
226
|
+
};
|
|
454
227
|
}
|
|
455
|
-
|
|
456
|
-
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/app/errors.ts
|
|
230
|
+
/** Normalizes anything thrown by a handler into an ApiResponse plus its status */
|
|
231
|
+
function normalize(oError) {
|
|
232
|
+
if (typeof oError === "string") return {
|
|
233
|
+
status: 400,
|
|
234
|
+
body: {
|
|
235
|
+
success: false,
|
|
236
|
+
message: oError
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const { status, message, warning } = oError ?? {};
|
|
240
|
+
return {
|
|
241
|
+
status: status ?? 500,
|
|
242
|
+
body: {
|
|
243
|
+
success: false,
|
|
244
|
+
warning: warning ?? false,
|
|
245
|
+
message: message || "Internal server error"
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Turns every unmatched request into a 404 for the error handler */
|
|
250
|
+
const notFoundHandler = (oReq, _oRes, fnNext) => {
|
|
251
|
+
fnNext({
|
|
252
|
+
status: 404,
|
|
253
|
+
message: `${oReq.path} not found`
|
|
254
|
+
});
|
|
457
255
|
};
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
res.status(status).json(body);
|
|
256
|
+
/** Turns any error raised by a handler into an ApiResponse */
|
|
257
|
+
const errorHandler = (oError, _oReq, oRes, _fnNext) => {
|
|
258
|
+
const { status, body } = normalize(oError);
|
|
259
|
+
if (status >= 500) console.error("[nano-app] [ERROR]", oError);
|
|
260
|
+
oRes.status(status).json(body);
|
|
464
261
|
};
|
|
465
|
-
|
|
466
|
-
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/app/NanoApp.ts
|
|
264
|
+
/** Main NanoApp class */
|
|
467
265
|
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
|
-
if (config.compression) {
|
|
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
|
-
}
|
|
266
|
+
oApp;
|
|
267
|
+
oConfig;
|
|
268
|
+
constructor(oConfig = {}) {
|
|
269
|
+
this.oApp = express();
|
|
270
|
+
this.oConfig = mergeConfig(oConfig);
|
|
271
|
+
this.start();
|
|
272
|
+
}
|
|
273
|
+
/** Starts the application */
|
|
274
|
+
async start() {
|
|
275
|
+
try {
|
|
276
|
+
await this.initialize();
|
|
277
|
+
this.oApp.listen(Number(this.oConfig.port), () => {
|
|
278
|
+
this.oConfig.listener?.();
|
|
279
|
+
console.log(`\n[nano-app] [INFO] Nano App running on port ${this.oConfig.port}\n`);
|
|
280
|
+
});
|
|
281
|
+
return this;
|
|
282
|
+
} catch (oError) {
|
|
283
|
+
console.error("[nano-app] [ERROR] Error trying to start the server.", oError);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Initializes the application in the right order */
|
|
288
|
+
async initialize() {
|
|
289
|
+
for (const [sKey, oValue] of Object.entries(this.oConfig.settings)) this.oApp.set(sKey, oValue);
|
|
290
|
+
this.setPackages(this.oConfig.packages);
|
|
291
|
+
const oPlugins = {
|
|
292
|
+
before: [],
|
|
293
|
+
after: []
|
|
294
|
+
};
|
|
295
|
+
for (const oPlugin of this.oConfig.plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
|
|
296
|
+
for (const oPlugin of oPlugins.before) await new Plugin(oPlugin).mount(this.oApp);
|
|
297
|
+
await new Plugin(this.oConfig.mainPlugin).mount(this.oApp);
|
|
298
|
+
for (const oPlugin of oPlugins.after) await new Plugin(oPlugin).mount(this.oApp);
|
|
299
|
+
this.oApp.use(notFoundHandler);
|
|
300
|
+
this.oApp.use(errorHandler);
|
|
301
|
+
}
|
|
302
|
+
/** Adds the packages according to the configuration */
|
|
303
|
+
setPackages(oConfig = {}) {
|
|
304
|
+
if (oConfig.morgan) {
|
|
305
|
+
for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
|
|
306
|
+
this.oApp.use(morgan(oConfig.morgan.format));
|
|
307
|
+
}
|
|
308
|
+
if (oConfig.cors) this.oApp.use(cors(oConfig.cors));
|
|
309
|
+
if (oConfig.helmet) this.oApp.use(helmet());
|
|
310
|
+
if (oConfig.compression) this.oApp.use(compression());
|
|
311
|
+
if (oConfig.rateLimit) this.oApp.use(rateLimit(oConfig.rateLimit));
|
|
312
|
+
if (oConfig.express) this.oApp.use(express.json(oConfig.express));
|
|
313
|
+
if (oConfig.urlencoded) this.oApp.use(express.urlencoded(oConfig.urlencoded));
|
|
314
|
+
}
|
|
530
315
|
};
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/app/creators.ts
|
|
318
|
+
/** Creates a new NanoApp instance */
|
|
319
|
+
function createNanoApp(oConfig) {
|
|
320
|
+
return new NanoApp(oConfig);
|
|
535
321
|
}
|
|
536
|
-
|
|
537
|
-
|
|
322
|
+
/** Creates and types a route object. */
|
|
323
|
+
function createRoute(oRoute) {
|
|
324
|
+
return oRoute;
|
|
538
325
|
}
|
|
539
|
-
function createPlugin(
|
|
540
|
-
|
|
326
|
+
function createPlugin(fnCreator) {
|
|
327
|
+
return fnCreator;
|
|
541
328
|
}
|
|
542
|
-
|
|
543
|
-
|
|
329
|
+
/** Creates and types the callbacks of a plugin. */
|
|
330
|
+
function createPluginCallbacks(oCallbacks) {
|
|
331
|
+
return oCallbacks;
|
|
544
332
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
createNanoApp,
|
|
548
|
-
createPlugin,
|
|
549
|
-
createPluginCallbacks,
|
|
550
|
-
createRoute
|
|
551
|
-
};
|
|
333
|
+
//#endregion
|
|
334
|
+
export { NanoApp, createNanoApp, createPlugin, createPluginCallbacks, createRoute };
|