@nano-app/backend 0.18.0 → 0.20.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 +54 -71
- package/build/index.d.ts +110 -53
- package/build/index.js +451 -403
- package/package.json +11 -1
package/build/index.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import express from "express";
|
|
2
|
-
import morgan from "morgan";
|
|
3
|
-
import cors from "cors";
|
|
4
|
-
import helmet from "helmet";
|
|
5
|
-
import compression from "compression";
|
|
6
|
-
import "dotenv/config";
|
|
7
1
|
import { glob } from "glob";
|
|
8
2
|
import { isAbsolute, resolve } from "node:path";
|
|
9
3
|
import { existsSync } from "node:fs";
|
|
10
4
|
import { createJiti } from "jiti";
|
|
11
5
|
import { z } from "zod";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import "dotenv/config";
|
|
8
|
+
import morgan from "morgan";
|
|
9
|
+
import cors from "cors";
|
|
10
|
+
import helmet from "helmet";
|
|
11
|
+
import compression from "compression";
|
|
12
|
+
import { ipKeyGenerator } from "express-rate-limit";
|
|
12
13
|
// ../core/utils/NanoError.ts
|
|
13
14
|
/** Error of the framework, with the status a handler answers */
|
|
14
15
|
var NanoError = class extends Error {
|
|
@@ -39,11 +40,21 @@ var FileReader = class {
|
|
|
39
40
|
return sAbsolutePath;
|
|
40
41
|
}
|
|
41
42
|
};
|
|
43
|
+
// ../core/utils/Validator.ts
|
|
44
|
+
/** Checks the consumer's files; an invalid file throws a `NanoError` */
|
|
45
|
+
var Validator = class {
|
|
46
|
+
/** Checks a file with a schema: an invalid one throws with its key */
|
|
47
|
+
static validate(oSchema, oFile, sKey) {
|
|
48
|
+
const oResult = oSchema.safeParse(oFile);
|
|
49
|
+
if (!oResult.success) throw new NanoError(`${sKey}:\n${z.prettifyError(oResult.error)}`);
|
|
50
|
+
return oResult.data;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
42
53
|
// ../core/files/FileLoader.ts
|
|
43
54
|
/** Finds and loads the consumer's files */
|
|
44
55
|
var FileLoader = class {
|
|
45
|
-
/**
|
|
46
|
-
static async findMany(sExtension, sSourceDir) {
|
|
56
|
+
/** The `*.<extension>.ts` files of a folder by subpath, checked with a schema */
|
|
57
|
+
static async findMany(sExtension, oSchema, sSourceDir) {
|
|
47
58
|
const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
48
59
|
const aFilePaths = await glob(`${sSrcDir}/**/*.${sExtension}.{ts,js}`, {
|
|
49
60
|
posix: true,
|
|
@@ -51,29 +62,13 @@ var FileLoader = class {
|
|
|
51
62
|
});
|
|
52
63
|
const oEntries = {};
|
|
53
64
|
for (const sFilePath of aFilePaths) {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
oEntries[sKey] =
|
|
65
|
+
const sKey = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${sExtension}\\.(ts|js)$`), "");
|
|
66
|
+
const oFile = await FileReader.loadModule(sFilePath, { default: true });
|
|
67
|
+
oEntries[sKey] = Validator.validate(oSchema, oFile, sKey);
|
|
57
68
|
}
|
|
58
69
|
return oEntries;
|
|
59
70
|
}
|
|
60
71
|
};
|
|
61
|
-
// ../core/utils/Validator.ts
|
|
62
|
-
/** Checks values of the framework and the consumer's files; an invalid file throws a `NanoError` */
|
|
63
|
-
var Validator = class {
|
|
64
|
-
/** Whether a value is a plain object */
|
|
65
|
-
static isObject(oValue) {
|
|
66
|
-
return Object.prototype.toString.call(oValue) === "[object Object]";
|
|
67
|
-
}
|
|
68
|
-
/** Checks every entry with a zod schema (`ROUTE`, `STORE`): the first invalid one throws with its key */
|
|
69
|
-
static validate(oEntries, oSchema) {
|
|
70
|
-
return Object.fromEntries(Object.entries(oEntries).map(([sKey, oEntry]) => {
|
|
71
|
-
const oResult = oSchema.safeParse(oEntry);
|
|
72
|
-
if (!oResult.success) throw new NanoError(`${sKey}:\n${z.prettifyError(oResult.error)}`);
|
|
73
|
-
return [sKey, oResult.data];
|
|
74
|
-
}));
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
72
|
// ../core/utils/Logger.ts
|
|
78
73
|
/** Messages of the framework on the console */
|
|
79
74
|
var Logger = class Logger {
|
|
@@ -98,17 +93,7 @@ var Logger = class Logger {
|
|
|
98
93
|
return oDate.toISOString().slice(0, 19).replace("T", " ");
|
|
99
94
|
}
|
|
100
95
|
};
|
|
101
|
-
//
|
|
102
|
-
/** Paths of the app's pages */
|
|
103
|
-
var Paths = class {
|
|
104
|
-
/** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
|
|
105
|
-
static matches(sPattern, sPath) {
|
|
106
|
-
if (!sPattern.endsWith("/*")) return sPattern === sPath;
|
|
107
|
-
const sBase = sPattern.slice(0, -2);
|
|
108
|
-
return sPath === sBase || sPath.startsWith(`${sBase}/`);
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
// ../core/const.ts
|
|
96
|
+
// src/const.ts
|
|
112
97
|
/** Loopback and private networks: the proxies the backend trusts by default */
|
|
113
98
|
const PRIVATE_NETWORKS = [
|
|
114
99
|
"127.0.0.0/8",
|
|
@@ -118,7 +103,303 @@ const PRIVATE_NETWORKS = [
|
|
|
118
103
|
"192.168.0.0/16",
|
|
119
104
|
"fc00::/7"
|
|
120
105
|
];
|
|
121
|
-
|
|
106
|
+
/** Express settings: the client IP comes from `X-Forwarded-For` only through private proxies */
|
|
107
|
+
const SETTINGS = { "trust proxy": PRIVATE_NETWORKS };
|
|
108
|
+
/** Base middlewares */
|
|
109
|
+
const PACKAGES = {
|
|
110
|
+
morgan: {
|
|
111
|
+
tokens: {
|
|
112
|
+
timestamp: () => Logger.date(),
|
|
113
|
+
path: (oReq) => oReq.path
|
|
114
|
+
},
|
|
115
|
+
format: ":timestamp | :method :path :status"
|
|
116
|
+
},
|
|
117
|
+
cors: { methods: [
|
|
118
|
+
"GET",
|
|
119
|
+
"HEAD",
|
|
120
|
+
"PUT",
|
|
121
|
+
"POST",
|
|
122
|
+
"DELETE",
|
|
123
|
+
"QUERY"
|
|
124
|
+
] },
|
|
125
|
+
helmet: true,
|
|
126
|
+
compression: false,
|
|
127
|
+
express: { limit: "10kb" },
|
|
128
|
+
urlencoded: { extended: false }
|
|
129
|
+
};
|
|
130
|
+
/** Key of the Postgres lock that lets one startup change the database at a time */
|
|
131
|
+
const LOCK = 7310002;
|
|
132
|
+
/** Name of Better Auth's database: its tables live in `nano_auth` */
|
|
133
|
+
const AUTH_NAME = "auth";
|
|
134
|
+
/** Marks of the dynamic segments of a path, from the least to the most generic */
|
|
135
|
+
const MARKS = [
|
|
136
|
+
":",
|
|
137
|
+
"{",
|
|
138
|
+
"*"
|
|
139
|
+
];
|
|
140
|
+
// src/database/DatabaseManager.ts
|
|
141
|
+
/** The databases of the app and its plugins: the pool of `DATABASE_URL`, its lock and one client per config */
|
|
142
|
+
var DatabaseManager = class {
|
|
143
|
+
static oPool;
|
|
144
|
+
static oClients = /* @__PURE__ */ new Map();
|
|
145
|
+
/** Mounts a database once, whatever its client, and gives its client; undefined without one */
|
|
146
|
+
static async mount(oDB) {
|
|
147
|
+
if (!oDB) return void 0;
|
|
148
|
+
return this.getClient(oDB);
|
|
149
|
+
}
|
|
150
|
+
/** The pool of `DATABASE_URL`, shared by every client, created on the first call */
|
|
151
|
+
static async pool() {
|
|
152
|
+
const sUrl = process.env.DATABASE_URL;
|
|
153
|
+
if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
|
|
154
|
+
const { Pool } = await import("pg");
|
|
155
|
+
this.oPool ??= new Pool({ connectionString: sUrl });
|
|
156
|
+
return this.oPool;
|
|
157
|
+
}
|
|
158
|
+
/** Creates the client of a database on the shared pool, with the lock for its changes */
|
|
159
|
+
static async getClient(oDB) {
|
|
160
|
+
const { sName, oConfig } = oDB;
|
|
161
|
+
const oClient = this.oClients.get(oConfig);
|
|
162
|
+
if (oClient !== void 0) return oClient;
|
|
163
|
+
await this.pool();
|
|
164
|
+
await oDB.checkTables();
|
|
165
|
+
const oDBClient = await oDB.connect(this.oPool);
|
|
166
|
+
const oConnection = await this.oPool.connect();
|
|
167
|
+
await oConnection.query("SELECT pg_advisory_lock($1)", [LOCK]);
|
|
168
|
+
const { migrate, push } = oConfig;
|
|
169
|
+
if (migrate) await oDB.migrate(migrate);
|
|
170
|
+
if (push) await oDB.push();
|
|
171
|
+
oConnection.release(true);
|
|
172
|
+
Logger.info(`Database "${sName}" mounted`);
|
|
173
|
+
this.oClients.set(oConfig, oDBClient);
|
|
174
|
+
return oDBClient;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
// src/database/clients/DatabaseClient.ts
|
|
178
|
+
/** A database the manager mounts, whatever its client: its tables, its client and its changes */
|
|
179
|
+
var DatabaseClient = class {
|
|
180
|
+
sName;
|
|
181
|
+
oConfig;
|
|
182
|
+
/** Its nano_* schema, reserved for its plugin; the app has none */
|
|
183
|
+
sSchema;
|
|
184
|
+
/** Creates a new client of a database, named as its plugin */
|
|
185
|
+
constructor(sName, oConfig) {
|
|
186
|
+
this.sName = sName;
|
|
187
|
+
this.oConfig = oConfig;
|
|
188
|
+
this.sSchema = sName === "app" ? void 0 : `nano_${sName}`;
|
|
189
|
+
}
|
|
190
|
+
/** Checks its tables: a nano_* schema only for the plugin of its name */
|
|
191
|
+
async checkTables() {
|
|
192
|
+
for (const { schema, name } of await this.tables()) if (schema.startsWith("nano_") && schema !== this.sSchema) throw new NanoError(`The database "${this.sName}": table "${schema}.${name}" breaks the rule, each nano_* schema is reserved for the plugin of its name.`);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
// src/database/clients/BetterAuth.ts
|
|
196
|
+
/** Better Auth's tables, as its options declare them, in its nano_* schema on its Kysely adapter */
|
|
197
|
+
var BetterAuth = class extends DatabaseClient {
|
|
198
|
+
oOptions;
|
|
199
|
+
oPool;
|
|
200
|
+
oDatabase;
|
|
201
|
+
/** Creates the database of Better Auth from its options */
|
|
202
|
+
constructor(sName, oConfig, oOptions) {
|
|
203
|
+
super(sName, oConfig);
|
|
204
|
+
this.oOptions = oOptions;
|
|
205
|
+
}
|
|
206
|
+
/** Its tables, from the options: the base ones, its plugins' and the additional fields */
|
|
207
|
+
async tables() {
|
|
208
|
+
const { getSchema } = await import("better-auth/db");
|
|
209
|
+
const sSchema = this.sSchema ?? "public";
|
|
210
|
+
return Object.keys(getSchema(this.oOptions)).map((name) => ({
|
|
211
|
+
schema: sSchema,
|
|
212
|
+
name
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
/** Better Auth's `database` option on the shared pool, its tables in its nano_* schema */
|
|
216
|
+
async connect(oPool) {
|
|
217
|
+
const { PostgresDialect } = await import("kysely");
|
|
218
|
+
this.oPool = oPool;
|
|
219
|
+
this.oDatabase = {
|
|
220
|
+
dialect: new PostgresDialect({ pool: oPool }),
|
|
221
|
+
type: "postgres",
|
|
222
|
+
schemaName: this.sSchema
|
|
223
|
+
};
|
|
224
|
+
return this.oDatabase;
|
|
225
|
+
}
|
|
226
|
+
/** Applies the pending migrations of a folder, generated from `npx auth generate --adapter drizzle` */
|
|
227
|
+
async migrate(sFolder) {
|
|
228
|
+
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
229
|
+
const { migrate } = await import("drizzle-orm/node-postgres/migrator");
|
|
230
|
+
await migrate(drizzle({ client: this.oPool }), {
|
|
231
|
+
migrationsFolder: sFolder,
|
|
232
|
+
migrationsSchema: this.sSchema
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/** Creates the tables and columns its options need; it only adds */
|
|
236
|
+
async push() {
|
|
237
|
+
const { getMigrations } = await import("better-auth/db/migration");
|
|
238
|
+
const { runMigrations } = await getMigrations({
|
|
239
|
+
...this.oOptions,
|
|
240
|
+
database: this.oDatabase
|
|
241
|
+
});
|
|
242
|
+
await runMigrations();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
// src/auth/Auth.ts
|
|
246
|
+
/** Better Auth of the app: its tables in `nano_auth`, its endpoints and the access of each request */
|
|
247
|
+
var Auth = class {
|
|
248
|
+
static oAuth;
|
|
249
|
+
static fnHeaders;
|
|
250
|
+
/** Mounts its database and creates the instance; its endpoints, none without options */
|
|
251
|
+
static async init(oOptions) {
|
|
252
|
+
if (!oOptions) return [];
|
|
253
|
+
const { betterAuth } = await import("better-auth");
|
|
254
|
+
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
255
|
+
const { database = { push: true }, advanced, basePath = "/api/auth", ...oRest } = oOptions;
|
|
256
|
+
const oFullOptions = {
|
|
257
|
+
...oRest,
|
|
258
|
+
basePath,
|
|
259
|
+
advanced: {
|
|
260
|
+
...advanced,
|
|
261
|
+
ipAddress: {
|
|
262
|
+
trustedProxies: PRIVATE_NETWORKS,
|
|
263
|
+
...advanced?.ipAddress
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const oDatabase = await DatabaseManager.mount(new BetterAuth(AUTH_NAME, database, oFullOptions));
|
|
268
|
+
const oAuth = betterAuth({
|
|
269
|
+
...oFullOptions,
|
|
270
|
+
database: oDatabase
|
|
271
|
+
});
|
|
272
|
+
this.oAuth = oAuth;
|
|
273
|
+
this.fnHeaders = fromNodeHeaders;
|
|
274
|
+
return [{
|
|
275
|
+
path: `${basePath}/{*path}`,
|
|
276
|
+
handler: toNodeHandler(oAuth)
|
|
277
|
+
}];
|
|
278
|
+
}
|
|
279
|
+
/** The session of a request; 401 without one unless public, 403 without the permissions */
|
|
280
|
+
static async guard(oReq, isPublic, oPermissions) {
|
|
281
|
+
const oSession = await this.session(oReq);
|
|
282
|
+
if (this.oAuth && !oSession && !isPublic) throw new NanoError("Not authenticated", 401);
|
|
283
|
+
if (!await this.isGranted(oSession, isPublic ? void 0 : oPermissions)) throw new NanoError("Forbidden", 403);
|
|
284
|
+
return oSession;
|
|
285
|
+
}
|
|
286
|
+
/** The session of a request, when it has one */
|
|
287
|
+
static async session(oReq) {
|
|
288
|
+
if (!this.oAuth || !this.fnHeaders) return void 0;
|
|
289
|
+
const headers = this.fnHeaders(oReq.headers);
|
|
290
|
+
return await this.oAuth.api.getSession({ headers }) ?? void 0;
|
|
291
|
+
}
|
|
292
|
+
/** Whether the user of a session has the permissions; always without Better Auth or permissions */
|
|
293
|
+
static async isGranted(oSession, oPermissions) {
|
|
294
|
+
if (!this.oAuth || !oPermissions) return true;
|
|
295
|
+
const { success } = await this.oAuth.api.userHasPermission({ body: {
|
|
296
|
+
userId: oSession?.user.id,
|
|
297
|
+
role: oSession?.user.role,
|
|
298
|
+
permissions: oPermissions
|
|
299
|
+
} });
|
|
300
|
+
return success;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
// src/database/clients/Drizzle.ts
|
|
304
|
+
/** Drizzle client of the app or of a plugin: its tables, its client and its changes */
|
|
305
|
+
var Drizzle = class extends DatabaseClient {
|
|
306
|
+
oClient;
|
|
307
|
+
/** The Postgres schema and name of each of its tables, from Drizzle's metadata */
|
|
308
|
+
async tables() {
|
|
309
|
+
const { is } = await import("drizzle-orm");
|
|
310
|
+
const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
|
|
311
|
+
return Object.values(this.oConfig.relations).map(({ table }) => table).filter((oTable) => is(oTable, PgTable)).map((oTable) => {
|
|
312
|
+
const { name, schema = "public" } = getTableConfig(oTable);
|
|
313
|
+
return {
|
|
314
|
+
schema,
|
|
315
|
+
name
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** Creates its client on a pool */
|
|
320
|
+
async connect(oPool) {
|
|
321
|
+
const { migrate, push, ...oDrizzleConfig } = this.oConfig;
|
|
322
|
+
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
323
|
+
this.oClient = drizzle({
|
|
324
|
+
...oDrizzleConfig,
|
|
325
|
+
client: oPool
|
|
326
|
+
});
|
|
327
|
+
return this.oClient;
|
|
328
|
+
}
|
|
329
|
+
/** Applies the pending migrations of a folder, recorded in its own nano_* schema */
|
|
330
|
+
async migrate(sFolder) {
|
|
331
|
+
const { migrate } = await import("drizzle-orm/node-postgres/migrator");
|
|
332
|
+
await migrate(this.oClient, {
|
|
333
|
+
migrationsFolder: sFolder,
|
|
334
|
+
migrationsSchema: this.sSchema
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** Pushes its tables to their Postgres schemas, failing when it would lose data */
|
|
338
|
+
async push() {
|
|
339
|
+
const { pushSchema } = await import("drizzle-kit/api-postgres");
|
|
340
|
+
const oTables = Object.fromEntries(Object.values(this.oConfig.relations).map(({ name, table }) => [name, table]));
|
|
341
|
+
const schemas = (await this.tables()).map(({ schema }) => schema);
|
|
342
|
+
const { hints, apply } = await pushSchema(oTables, this.oClient, {
|
|
343
|
+
schemas,
|
|
344
|
+
tables: void 0,
|
|
345
|
+
entities: void 0,
|
|
346
|
+
extensions: void 0
|
|
347
|
+
});
|
|
348
|
+
if (hints.length) {
|
|
349
|
+
const sHints = hints.map((oHint) => oHint.hint).join("; ");
|
|
350
|
+
throw new NanoError(`The database "${this.sName}" cannot be pushed without losing data: ${sHints}. Run \`pnpm drizzle-kit push\` by hand.`);
|
|
351
|
+
}
|
|
352
|
+
await apply();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
// src/router/Endpoint.ts
|
|
356
|
+
/** A route: its methods, its access and its callbacks */
|
|
357
|
+
var Endpoint = class {
|
|
358
|
+
oMethods;
|
|
359
|
+
oCallbacks;
|
|
360
|
+
isPublic;
|
|
361
|
+
/** Allowed methods and the options of the route */
|
|
362
|
+
constructor({ callbacks = {}, public: isPublic = false, permissions, ...oMethods }) {
|
|
363
|
+
this.oMethods = Object.fromEntries(Object.entries(oMethods).map(([sMethod, oMethod]) => [sMethod, typeof oMethod === "function" ? {
|
|
364
|
+
handler: oMethod,
|
|
365
|
+
permissions
|
|
366
|
+
} : {
|
|
367
|
+
permissions,
|
|
368
|
+
...oMethod
|
|
369
|
+
}]));
|
|
370
|
+
this.oCallbacks = callbacks;
|
|
371
|
+
this.isPublic = isPublic;
|
|
372
|
+
}
|
|
373
|
+
/** Runs its onInit and gives its handler for all methods */
|
|
374
|
+
async init(oParams) {
|
|
375
|
+
await this.oCallbacks.onInit?.(oParams);
|
|
376
|
+
return (oReq, oRes) => this.run(oReq, oParams).then((oResponse) => {
|
|
377
|
+
const { status, ...oBody } = oResponse;
|
|
378
|
+
const iStatus = status ?? 200;
|
|
379
|
+
oRes.status(iStatus).json({
|
|
380
|
+
...oBody,
|
|
381
|
+
success: iStatus < 400
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
/** Checks the session and the permissions of the method, then runs onBefore and it */
|
|
386
|
+
async run(oReq, oParams) {
|
|
387
|
+
const sMethod = oReq.method;
|
|
388
|
+
const oMethod = this.oMethods[sMethod];
|
|
389
|
+
const session = await Auth.guard(oReq, this.isPublic, oMethod?.permissions);
|
|
390
|
+
if (!oMethod) throw new NanoError(`Method ${sMethod} not implemented`, 405);
|
|
391
|
+
const oRequestParams = {
|
|
392
|
+
...oParams,
|
|
393
|
+
session
|
|
394
|
+
};
|
|
395
|
+
const onBeforeResult = await this.oCallbacks.onBefore?.(oReq, sMethod, oRequestParams) ?? {};
|
|
396
|
+
return oMethod.handler(oReq, {
|
|
397
|
+
...oRequestParams,
|
|
398
|
+
onBeforeResult
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
// src/router/validations.ts
|
|
122
403
|
/** A function of the consumer, typed as what the framework calls */
|
|
123
404
|
function callable() {
|
|
124
405
|
return z.custom((oValue) => typeof oValue === "function");
|
|
@@ -145,215 +426,32 @@ const ROUTE = z.strictObject({
|
|
|
145
426
|
DELETE: METHOD,
|
|
146
427
|
QUERY: METHOD
|
|
147
428
|
}).refine(({ GET, POST, PUT, DELETE, QUERY }) => GET ?? POST ?? PUT ?? DELETE ?? QUERY, { error: "It defines no HTTP method" });
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
type: z.enum(["app", "api"]),
|
|
152
|
-
data: z.record(z.string(), z.any()).optional(),
|
|
153
|
-
headers: z.record(z.string(), z.string()).optional(),
|
|
154
|
-
auth: z.object({
|
|
155
|
-
path: z.string(),
|
|
156
|
-
routes: z.array(z.object({
|
|
157
|
-
match: PATH,
|
|
158
|
-
redirect: PATH.optional(),
|
|
159
|
-
roles: z.string().optional(),
|
|
160
|
-
permissions: z.record(z.string(), z.string()).optional()
|
|
161
|
-
}).refine(({ match, redirect }) => !Paths.matches(match, redirect ?? "/"), { error: "Its \"redirect\" must be outside its \"match\"" })).optional()
|
|
162
|
-
}).optional()
|
|
163
|
-
});
|
|
164
|
-
// src/auth/permissions.ts
|
|
165
|
-
/** Whether the session's user has the permissions; always without Better Auth or permissions */
|
|
166
|
-
async function isGranted({ auth, session }, oPermissions) {
|
|
167
|
-
if (!auth || !oPermissions) return true;
|
|
168
|
-
const { success } = await auth.api.userHasPermission({ body: {
|
|
169
|
-
userId: session?.user.id,
|
|
170
|
-
role: session?.user.role,
|
|
171
|
-
permissions: oPermissions
|
|
172
|
-
} });
|
|
173
|
-
return success;
|
|
174
|
-
}
|
|
175
|
-
// src/routes/Endpoint.ts
|
|
176
|
-
/** Base class for all endpoints */
|
|
177
|
-
var Endpoint = class {
|
|
178
|
-
oMethods;
|
|
179
|
-
oCallbacks;
|
|
180
|
-
isPublic;
|
|
181
|
-
/** Allowed methods and the options of the route */
|
|
182
|
-
constructor(oMethods, { callbacks = {}, public: isPublic = false, permissions }) {
|
|
183
|
-
this.oMethods = Object.fromEntries(Object.entries(oMethods).map(([sMethod, oMethod]) => [sMethod, typeof oMethod === "function" ? {
|
|
184
|
-
handler: oMethod,
|
|
185
|
-
permissions
|
|
186
|
-
} : {
|
|
187
|
-
permissions,
|
|
188
|
-
...oMethod
|
|
189
|
-
}]));
|
|
190
|
-
this.oCallbacks = callbacks;
|
|
191
|
-
this.isPublic = isPublic;
|
|
192
|
-
}
|
|
193
|
-
/** Handler for all methods */
|
|
194
|
-
async mount(oParams, sPath) {
|
|
195
|
-
if (!oParams.auth && Object.values(this.oMethods).some(({ permissions }) => permissions)) Logger.warn(`${sPath}: its permissions are not checked without Better Auth. Add src/auth.ts.`);
|
|
196
|
-
await this.oCallbacks.onInit?.(oParams);
|
|
197
|
-
return this.createHandler(oParams);
|
|
198
|
-
}
|
|
199
|
-
/** Express handler: onBefore, then the method of the request */
|
|
200
|
-
createHandler(oParams) {
|
|
201
|
-
return (oReq, oRes, fnNext) => {
|
|
202
|
-
if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext(new NanoError("Not authenticated", 401));
|
|
203
|
-
const sMethod = oReq.method;
|
|
204
|
-
const oMethod = this.oMethods[sMethod];
|
|
205
|
-
if (!oMethod) return fnNext(new NanoError(`Method ${sMethod} not implemented`, 405));
|
|
206
|
-
const oRequestParams = {
|
|
207
|
-
...oParams,
|
|
208
|
-
session: oRes.locals.session
|
|
209
|
-
};
|
|
210
|
-
return isGranted(oRequestParams, this.isPublic ? void 0 : oMethod.permissions).then((isAllowed) => {
|
|
211
|
-
if (!isAllowed) throw new NanoError("Forbidden", 403);
|
|
212
|
-
return this.oCallbacks.onBefore?.(oReq, sMethod, oRequestParams) ?? {};
|
|
213
|
-
}).then((onBeforeResult) => oMethod.handler(oReq, {
|
|
214
|
-
...oRequestParams,
|
|
215
|
-
onBeforeResult
|
|
216
|
-
})).then((oResponse) => {
|
|
217
|
-
const { status, ...oBody } = oResponse;
|
|
218
|
-
const iStatus = status ?? 200;
|
|
219
|
-
oRes.status(iStatus).json({
|
|
220
|
-
...oBody,
|
|
221
|
-
success: iStatus < 400
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
// src/routes/Router.ts
|
|
228
|
-
/** Turns a folder of `*.route.ts` files into Express paths and endpoints */
|
|
229
|
-
var Router = class {
|
|
429
|
+
// src/router/Router.ts
|
|
430
|
+
/** Turns a folder of `*.route.ts` files and routes made in code into Express paths and endpoints */
|
|
431
|
+
var Router = class Router {
|
|
230
432
|
sRoutesDir;
|
|
231
433
|
sBasePath;
|
|
232
|
-
|
|
434
|
+
oRoutes;
|
|
435
|
+
constructor(sRoutesDir, sBasePath, oRoutes) {
|
|
233
436
|
this.sRoutesDir = sRoutesDir;
|
|
234
437
|
this.sBasePath = sBasePath ?? "";
|
|
438
|
+
this.oRoutes = oRoutes ?? {};
|
|
235
439
|
}
|
|
236
|
-
/** Loads the routes and creates one endpoint per
|
|
237
|
-
async
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
path: sPath,
|
|
246
|
-
endpoint: new Endpoint(methods, {
|
|
247
|
-
callbacks,
|
|
248
|
-
public: isPublic,
|
|
249
|
-
permissions
|
|
250
|
-
})
|
|
251
|
-
};
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
/** `basePath` + file path (`/index` is the folder itself) + `expression`, as Express reads them */
|
|
255
|
-
createPath(sEndpoint, sExpression = "") {
|
|
256
|
-
return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
// src/auth/BetterAuth.ts
|
|
260
|
-
/** Files of the app's Better Auth options, relative to `cwd`: source, then built */
|
|
261
|
-
const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
|
|
262
|
-
/** Better Auth on the app's Drizzle client: the instance, its handler and the session lookup */
|
|
263
|
-
var BetterAuth = class {
|
|
264
|
-
static oAuth;
|
|
265
|
-
/** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
|
|
266
|
-
static async findOptions() {
|
|
267
|
-
const sFilePath = AUTH_FILES.find(existsSync);
|
|
268
|
-
if (!sFilePath) return void 0;
|
|
269
|
-
const oFile = await FileReader.loadModule(sFilePath, { default: true });
|
|
270
|
-
if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
|
|
271
|
-
return oFile.options;
|
|
272
|
-
}
|
|
273
|
-
/** The instance, once the app's plugin created it */
|
|
274
|
-
static get() {
|
|
275
|
-
return this.oAuth;
|
|
276
|
-
}
|
|
277
|
-
/** Creates the instance on the app's client and schema, its handler and the session lookup */
|
|
278
|
-
static async create(oOptions, oDb, oSchema) {
|
|
279
|
-
const { betterAuth } = await import("better-auth");
|
|
280
|
-
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
|
|
281
|
-
const { getSchema } = await import("better-auth/db");
|
|
282
|
-
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
283
|
-
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
|
|
284
|
-
if (aMissing.length) throw new NanoError(`Better Auth needs the tables ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with drizzle-kit.`);
|
|
285
|
-
const oAuth = betterAuth({
|
|
286
|
-
...oOptions,
|
|
287
|
-
advanced: {
|
|
288
|
-
...oOptions.advanced,
|
|
289
|
-
ipAddress: {
|
|
290
|
-
trustedProxies: PRIVATE_NETWORKS,
|
|
291
|
-
...oOptions.advanced?.ipAddress
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
database: drizzleAdapter(oDb, {
|
|
295
|
-
provider: "pg",
|
|
296
|
-
schema: oSchema
|
|
297
|
-
})
|
|
298
|
-
});
|
|
299
|
-
this.oAuth = oAuth;
|
|
300
|
-
const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
301
|
-
oRes.locals.session = oSession ?? void 0;
|
|
302
|
-
fnNext();
|
|
303
|
-
});
|
|
304
|
-
return {
|
|
305
|
-
oHandler: toNodeHandler(oAuth),
|
|
306
|
-
fnSession,
|
|
307
|
-
sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
};
|
|
311
|
-
// src/database/Database.ts
|
|
312
|
-
/** Drizzle clients of the app and its plugins, one per name, on one pool of `DATABASE_URL` */
|
|
313
|
-
var Database = class {
|
|
314
|
-
static oPool;
|
|
315
|
-
/** Mounted clients, by name */
|
|
316
|
-
static oClients = {};
|
|
317
|
-
/** Gets a client by name */
|
|
318
|
-
static get(sName) {
|
|
319
|
-
return this.oClients[sName];
|
|
320
|
-
}
|
|
321
|
-
/** Mounts the Drizzle client of a schema, checking the `nano_*` rule of its tables */
|
|
322
|
-
static async mount(sName, { schema }) {
|
|
323
|
-
if (this.get(sName)) throw new NanoError(`The database "${sName}" is already mounted.`);
|
|
324
|
-
const { defineRelations, is } = await import("drizzle-orm");
|
|
325
|
-
const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
|
|
326
|
-
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
327
|
-
const sOwn = `nano_${sName}`;
|
|
328
|
-
const { breaks, rule } = sName === "app" ? {
|
|
329
|
-
breaks: (sSchema) => sSchema.startsWith("nano_"),
|
|
330
|
-
rule: "the nano_* schemas are reserved for plugins"
|
|
331
|
-
} : {
|
|
332
|
-
breaks: (sSchema) => sSchema !== sOwn,
|
|
333
|
-
rule: `a plugin keeps its tables in "${sOwn}"`
|
|
334
|
-
};
|
|
335
|
-
for (const oTable of Object.values(schema).filter((oValue) => is(oValue, PgTable))) {
|
|
336
|
-
const { name, schema: sSchema = "public" } = getTableConfig(oTable);
|
|
337
|
-
if (breaks(sSchema)) throw new NanoError(`The database "${sName}": table "${sSchema}.${name}" breaks the rule, ${rule}.`);
|
|
338
|
-
}
|
|
339
|
-
this.oPool ??= await this.pool();
|
|
340
|
-
const oClient = drizzle({
|
|
341
|
-
client: this.oPool,
|
|
342
|
-
relations: schema.relations ?? defineRelations(schema)
|
|
343
|
-
});
|
|
344
|
-
this.oClients[sName] = oClient;
|
|
345
|
-
Logger.info(`Database "${sName}" mounted`);
|
|
346
|
-
return oClient;
|
|
440
|
+
/** Loads the routes and creates one endpoint per route, the most specific path first */
|
|
441
|
+
async load() {
|
|
442
|
+
const oRoutes = this.sRoutesDir ? await FileLoader.findMany("route", ROUTE, this.sRoutesDir) : {};
|
|
443
|
+
for (const [sPath, oRoute] of Object.entries(this.oRoutes)) oRoutes[sPath] = Validator.validate(ROUTE, oRoute, sPath);
|
|
444
|
+
return Object.entries(oRoutes).map(([sFilePath, { expression = "", ...oEndpointParams }]) => ({
|
|
445
|
+
path: `${this.sBasePath}${sFilePath.replace(/\/index$/, "")}${expression}` || "/",
|
|
446
|
+
endpoint: new Endpoint(oEndpointParams)
|
|
447
|
+
})).sort((oA, oB) => Router.specificity(oA.path).localeCompare(Router.specificity(oB.path)));
|
|
347
448
|
}
|
|
348
|
-
/**
|
|
349
|
-
static
|
|
350
|
-
|
|
351
|
-
if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
|
|
352
|
-
const { Pool } = await import("pg");
|
|
353
|
-
return new Pool({ connectionString: sUrl });
|
|
449
|
+
/** One digit per segment: 0 static, 1 `:param`, 2 `{optional}`, 3 `*wildcard` */
|
|
450
|
+
static specificity(sPath) {
|
|
451
|
+
return sPath.split("/").map((sSegment) => MARKS.reduce((iRank, sMark, iIndex) => sSegment.includes(sMark) ? iIndex + 1 : iRank, 0)).join("");
|
|
354
452
|
}
|
|
355
453
|
};
|
|
356
|
-
// src/
|
|
454
|
+
// src/plugins/Plugin.ts
|
|
357
455
|
/** A group of routes, with its options, callbacks and database */
|
|
358
456
|
var Plugin = class {
|
|
359
457
|
oConfig;
|
|
@@ -361,150 +459,104 @@ var Plugin = class {
|
|
|
361
459
|
constructor(oConfig) {
|
|
362
460
|
this.oConfig = oConfig;
|
|
363
461
|
}
|
|
364
|
-
/**
|
|
365
|
-
async
|
|
366
|
-
const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
|
|
367
|
-
const
|
|
368
|
-
const oDb = database ? await Database.mount(name, database) : Database.get("app");
|
|
369
|
-
if (oAuthOptions) {
|
|
370
|
-
if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
|
|
371
|
-
const { sPath, oHandler, fnSession } = await BetterAuth.create(oAuthOptions, oDb, database.schema);
|
|
372
|
-
oApp.all(sPath, oHandler);
|
|
373
|
-
oApp.use(fnSession);
|
|
374
|
-
}
|
|
462
|
+
/** Its endpoints, the most specific path first, for NanoApp to register */
|
|
463
|
+
async build(oAppDB) {
|
|
464
|
+
const { name, routesDir, routes, basePath, database, options, callbacks } = this.oConfig;
|
|
465
|
+
const db = await DatabaseManager.mount(database ? new Drizzle(name, database) : oAppDB);
|
|
375
466
|
const oParams = Object.freeze({
|
|
376
467
|
...options,
|
|
377
|
-
db
|
|
378
|
-
auth: BetterAuth.get()
|
|
468
|
+
db
|
|
379
469
|
});
|
|
380
|
-
await callbacks?.onMount?.(
|
|
381
|
-
const
|
|
382
|
-
|
|
470
|
+
await callbacks?.onMount?.(oParams);
|
|
471
|
+
const aRoutes = await new Router(routesDir, basePath, routes).load();
|
|
472
|
+
return Promise.all(aRoutes.map(async ({ path, endpoint }) => ({
|
|
473
|
+
path,
|
|
474
|
+
handler: await endpoint.init(oParams)
|
|
475
|
+
})));
|
|
383
476
|
}
|
|
384
477
|
};
|
|
385
|
-
// src/
|
|
386
|
-
/**
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
478
|
+
// src/plugins/PluginManager.ts
|
|
479
|
+
/** The plugins of the app: its own, the ones before it and the ones after it */
|
|
480
|
+
var PluginManager = class {
|
|
481
|
+
/** The endpoints of every plugin, in order: before, the app's, after */
|
|
482
|
+
static async build(oConfig) {
|
|
483
|
+
const { routesDir = "src/routes", database, plugins } = oConfig;
|
|
484
|
+
const oAppDB = database && new Drizzle("app", database);
|
|
485
|
+
const { before = [], after = [] } = plugins ?? {};
|
|
486
|
+
const aPlugins = [
|
|
487
|
+
...before,
|
|
488
|
+
{
|
|
489
|
+
name: "app",
|
|
490
|
+
routesDir
|
|
398
491
|
},
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
"PUT",
|
|
405
|
-
"POST",
|
|
406
|
-
"DELETE",
|
|
407
|
-
"QUERY"
|
|
408
|
-
] },
|
|
409
|
-
helmet: true,
|
|
410
|
-
compression: false,
|
|
411
|
-
express: { limit: "10kb" },
|
|
412
|
-
urlencoded: { extended: false }
|
|
413
|
-
},
|
|
414
|
-
/** Main plugin to mount */
|
|
415
|
-
mainPlugin: {
|
|
416
|
-
routesDir: "src/routes",
|
|
417
|
-
basePath: ""
|
|
418
|
-
},
|
|
419
|
-
/** Plugins around the consumer routes */
|
|
420
|
-
plugins: []
|
|
421
|
-
};
|
|
422
|
-
/** Merges a value over its default: plain objects key by key at every level; anything else replaces it */
|
|
423
|
-
function deepMerge(oDefault, oValue) {
|
|
424
|
-
if (oValue === void 0) return oDefault;
|
|
425
|
-
if (!Validator.isObject(oDefault) || !Validator.isObject(oValue)) return oValue;
|
|
426
|
-
const aKeys = Object.keys({
|
|
427
|
-
...oDefault,
|
|
428
|
-
...oValue
|
|
429
|
-
});
|
|
430
|
-
return Object.fromEntries(aKeys.map((sKey) => [sKey, deepMerge(oDefault[sKey], oValue[sKey])]));
|
|
431
|
-
}
|
|
432
|
-
/** Whether a merged configuration kept the plain object of the initial one */
|
|
433
|
-
function isMergedConfig(oValue) {
|
|
434
|
-
return Validator.isObject(oValue);
|
|
435
|
-
}
|
|
436
|
-
/** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
|
|
437
|
-
function mergeConfig(oConfig = {}) {
|
|
438
|
-
const oMerged = deepMerge(INITIAL_CONFIG, oConfig);
|
|
439
|
-
if (!isMergedConfig(oMerged)) throw new NanoError("The configuration of createNanoApp must be an object.");
|
|
440
|
-
return {
|
|
441
|
-
...oMerged,
|
|
442
|
-
mainPlugin: {
|
|
443
|
-
...oMerged.mainPlugin,
|
|
444
|
-
name: "app"
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
// src/app/errors.ts
|
|
449
|
-
/** Database errors the request caused, by their Postgres code or Drizzle's name */
|
|
450
|
-
const DATABASE_ERRORS = {
|
|
451
|
-
"23505": {
|
|
452
|
-
status: 409,
|
|
453
|
-
message: "Conflict"
|
|
454
|
-
},
|
|
455
|
-
"23503": {
|
|
456
|
-
status: 409,
|
|
457
|
-
message: "Conflict"
|
|
458
|
-
},
|
|
459
|
-
"23502": {
|
|
460
|
-
status: 400,
|
|
461
|
-
message: "Invalid request"
|
|
462
|
-
},
|
|
463
|
-
"22P02": {
|
|
464
|
-
status: 400,
|
|
465
|
-
message: "Invalid request"
|
|
466
|
-
},
|
|
467
|
-
DrizzleError: {
|
|
468
|
-
status: 400,
|
|
469
|
-
message: "Invalid request"
|
|
492
|
+
...after
|
|
493
|
+
];
|
|
494
|
+
const aMounts = [];
|
|
495
|
+
for (const oPlugin of aPlugins) aMounts.push(...await new Plugin(oPlugin).build(oAppDB));
|
|
496
|
+
return aMounts;
|
|
470
497
|
}
|
|
471
498
|
};
|
|
472
|
-
|
|
473
|
-
function isHandlerError(oError) {
|
|
474
|
-
return typeof oError === "object" && oError !== null;
|
|
475
|
-
}
|
|
476
|
-
/** Normalizes anything thrown by a handler into an ApiResponse plus its status */
|
|
477
|
-
function normalize(oError) {
|
|
478
|
-
if (typeof oError === "string") return {
|
|
479
|
-
status: 400,
|
|
480
|
-
body: {
|
|
481
|
-
success: false,
|
|
482
|
-
message: oError
|
|
483
|
-
}
|
|
484
|
-
};
|
|
485
|
-
const oThrown = isHandlerError(oError) ? oError : {};
|
|
486
|
-
const sKey = oThrown.cause?.code ?? oThrown.name ?? "";
|
|
487
|
-
const { status, message, warning } = DATABASE_ERRORS[sKey] ?? oThrown;
|
|
488
|
-
const iStatus = status ?? 500;
|
|
489
|
-
return {
|
|
490
|
-
status: iStatus,
|
|
491
|
-
body: {
|
|
492
|
-
success: false,
|
|
493
|
-
warning: warning ?? false,
|
|
494
|
-
message: iStatus < 500 && message || "Internal server error"
|
|
495
|
-
}
|
|
496
|
-
};
|
|
497
|
-
}
|
|
499
|
+
// src/app/handlers/errors.ts
|
|
498
500
|
/** Turns every unmatched request into a 404 for the error handler */
|
|
499
501
|
const notFoundHandler = (oReq, _oRes, fnNext) => {
|
|
500
502
|
fnNext(new NanoError(`${oReq.path} not found`, 404));
|
|
501
503
|
};
|
|
502
|
-
/**
|
|
504
|
+
/** Answers anything a handler throws as an ApiResponse */
|
|
503
505
|
const errorHandler = (oError, _oReq, oRes, _fnNext) => {
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
506
|
+
const oThrown = typeof oError === "string" ? new NanoError(oError, 400) : oError;
|
|
507
|
+
const iStatus = oThrown?.status ?? 500;
|
|
508
|
+
const sMessage = iStatus < 500 ? oThrown.message : "Internal server error";
|
|
509
|
+
if (iStatus >= 500) Logger.error(sMessage, oError);
|
|
510
|
+
oRes.status(iStatus).json({
|
|
511
|
+
success: false,
|
|
512
|
+
warning: oThrown.warning ?? false,
|
|
513
|
+
message: sMessage
|
|
514
|
+
});
|
|
507
515
|
};
|
|
516
|
+
// src/app/handlers/rateLimit.ts
|
|
517
|
+
/** Rate limit per client IP: each request spends a token of the client's bucket, refilled over time */
|
|
518
|
+
function rateLimit({ capacity = 100, refillPerSecond = 1, message = "Too many requests, please try again later." }) {
|
|
519
|
+
const fnRefill = (oBucket, iNow) => Math.min(capacity, oBucket.iTokens + (iNow - oBucket.iTime) / 1e3 * refillPerSecond);
|
|
520
|
+
const oBuckets = /* @__PURE__ */ new Map();
|
|
521
|
+
setInterval(() => {
|
|
522
|
+
const iNow = Date.now();
|
|
523
|
+
for (const [sIp, oBucket] of oBuckets) if (fnRefill(oBucket, iNow) >= capacity) oBuckets.delete(sIp);
|
|
524
|
+
}, 6e4).unref();
|
|
525
|
+
return (oReq, oRes, fnNext) => {
|
|
526
|
+
const sIp = ipKeyGenerator(oReq.ip ?? "");
|
|
527
|
+
const iNow = Date.now();
|
|
528
|
+
const iTokens = fnRefill(oBuckets.get(sIp) ?? {
|
|
529
|
+
iTokens: capacity,
|
|
530
|
+
iTime: iNow
|
|
531
|
+
}, iNow);
|
|
532
|
+
if (iTokens < 1) {
|
|
533
|
+
oRes.set("Retry-After", String(Math.ceil((1 - iTokens) / refillPerSecond)));
|
|
534
|
+
return fnNext({
|
|
535
|
+
status: 429,
|
|
536
|
+
message
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
oBuckets.set(sIp, {
|
|
540
|
+
iTokens: iTokens - 1,
|
|
541
|
+
iTime: iNow
|
|
542
|
+
});
|
|
543
|
+
fnNext();
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
// src/app/packages.ts
|
|
547
|
+
/** The base middlewares that are on, in the order the app uses them */
|
|
548
|
+
function packages(oConfig) {
|
|
549
|
+
if (oConfig.morgan) for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
|
|
550
|
+
return [
|
|
551
|
+
oConfig.morgan && morgan(oConfig.morgan.format),
|
|
552
|
+
oConfig.cors && cors(oConfig.cors),
|
|
553
|
+
oConfig.helmet && helmet(),
|
|
554
|
+
oConfig.rateLimit && rateLimit(oConfig.rateLimit),
|
|
555
|
+
oConfig.compression && compression(),
|
|
556
|
+
oConfig.express && express.json(oConfig.express),
|
|
557
|
+
oConfig.urlencoded && express.urlencoded(oConfig.urlencoded)
|
|
558
|
+
].filter((fnPackage) => typeof fnPackage === "function");
|
|
559
|
+
}
|
|
508
560
|
// src/app/NanoApp.ts
|
|
509
561
|
/** Main NanoApp class */
|
|
510
562
|
var NanoApp = class {
|
|
@@ -512,16 +564,17 @@ var NanoApp = class {
|
|
|
512
564
|
oConfig;
|
|
513
565
|
constructor(oConfig = {}) {
|
|
514
566
|
this.oApp = express();
|
|
515
|
-
this.oConfig =
|
|
567
|
+
this.oConfig = oConfig;
|
|
516
568
|
this.start();
|
|
517
569
|
}
|
|
518
570
|
/** Starts the application */
|
|
519
571
|
async start() {
|
|
520
572
|
try {
|
|
521
573
|
await this.initialize();
|
|
522
|
-
this.
|
|
574
|
+
const iPort = this.oConfig.port ?? 3e3;
|
|
575
|
+
this.oApp.listen(iPort, () => {
|
|
523
576
|
this.oConfig.listener?.();
|
|
524
|
-
Logger.info(`Nano App running on port ${
|
|
577
|
+
Logger.info(`Nano App running on port ${iPort}`);
|
|
525
578
|
});
|
|
526
579
|
} catch (oError) {
|
|
527
580
|
Logger.error("Error trying to start the server.", oError);
|
|
@@ -530,39 +583,25 @@ var NanoApp = class {
|
|
|
530
583
|
}
|
|
531
584
|
/** Initializes the application in the right order */
|
|
532
585
|
async initialize() {
|
|
533
|
-
for (const [sKey, oValue] of Object.entries(
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
586
|
+
for (const [sKey, oValue] of Object.entries({
|
|
587
|
+
...SETTINGS,
|
|
588
|
+
...this.oConfig.settings
|
|
589
|
+
})) this.oApp.set(sKey, oValue);
|
|
590
|
+
for (const fnPackage of packages({
|
|
591
|
+
...PACKAGES,
|
|
592
|
+
...this.oConfig.packages
|
|
593
|
+
})) this.oApp.use(fnPackage);
|
|
594
|
+
const aAuth = await Auth.init(this.oConfig.auth);
|
|
595
|
+
const aPlugins = await PluginManager.build(this.oConfig);
|
|
596
|
+
for (const { path, handler } of [...aAuth, ...aPlugins]) {
|
|
597
|
+
Logger.info(path);
|
|
598
|
+
this.oApp.all(path, handler);
|
|
599
|
+
}
|
|
546
600
|
this.oApp.use(notFoundHandler);
|
|
547
601
|
this.oApp.use(errorHandler);
|
|
548
602
|
}
|
|
549
|
-
/** Adds the packages according to the configuration */
|
|
550
|
-
setPackages(oConfig) {
|
|
551
|
-
if (oConfig.morgan) {
|
|
552
|
-
for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
|
|
553
|
-
this.oApp.use(morgan(oConfig.morgan.format));
|
|
554
|
-
}
|
|
555
|
-
const aPackages = [
|
|
556
|
-
oConfig.cors && cors(oConfig.cors),
|
|
557
|
-
oConfig.helmet && helmet(),
|
|
558
|
-
oConfig.compression && compression(),
|
|
559
|
-
oConfig.express && express.json(oConfig.express),
|
|
560
|
-
oConfig.urlencoded && express.urlencoded(oConfig.urlencoded)
|
|
561
|
-
];
|
|
562
|
-
for (const fnPackage of aPackages) if (fnPackage) this.oApp.use(fnPackage);
|
|
563
|
-
}
|
|
564
603
|
};
|
|
565
|
-
// src/
|
|
604
|
+
// src/lib/creators.ts
|
|
566
605
|
/** Creates a new NanoApp instance */
|
|
567
606
|
function createNanoApp(oConfig) {
|
|
568
607
|
return new NanoApp(oConfig);
|
|
@@ -572,10 +611,19 @@ function createRoute(oRoute) {
|
|
|
572
611
|
return oRoute;
|
|
573
612
|
}
|
|
574
613
|
function createPlugin(fnCreator) {
|
|
575
|
-
return
|
|
614
|
+
return (options) => {
|
|
615
|
+
const oConfig = fnCreator(options);
|
|
616
|
+
return {
|
|
617
|
+
...oConfig,
|
|
618
|
+
basePath: options?.basePath ?? oConfig.basePath
|
|
619
|
+
};
|
|
620
|
+
};
|
|
576
621
|
}
|
|
577
622
|
/** Creates the callbacks of a plugin, typed with what it injects in its params. */
|
|
578
623
|
function createPluginCallbacks(oCallbacks) {
|
|
579
624
|
return oCallbacks;
|
|
580
625
|
}
|
|
626
|
+
// src/index.ts
|
|
627
|
+
/** Whether the user of a session has the permissions, asked to Better Auth */
|
|
628
|
+
const isGranted = Auth.isGranted.bind(Auth);
|
|
581
629
|
export { NanoApp, NanoError, createNanoApp, createPlugin, createPluginCallbacks, createRoute, isGranted };
|