@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/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
- /** Finds the `*.<extension>.ts` files of a folder: `{ [subPath]: defaultExport }` */
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 sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${sExtension}\\.(ts|js)$`), "");
55
- const sKey = sExtension === "store" ? sFileSubPath.slice(1).replaceAll("/", "_") : sFileSubPath;
56
- oEntries[sKey] = await FileReader.loadModule(sFilePath, { default: true });
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
- // ../core/utils/Paths.ts
102
- /** Paths of the app's pages */
103
- var Paths = class {
104
- /** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
105
- static matches(sPattern, sPath) {
106
- if (!sPattern.endsWith("/*")) return sPattern === sPath;
107
- const sBase = sPattern.slice(0, -2);
108
- return sPath === sBase || sPath.startsWith(`${sBase}/`);
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
- // ../core/utils/validations.ts
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
- /** A path of the app, starting with `/` */
149
- const PATH = z.string().startsWith("/");
150
- z.object({
151
- type: z.enum(["app", "api"]),
152
- data: z.record(z.string(), z.any()).optional(),
153
- headers: z.record(z.string(), z.string()).optional(),
154
- auth: z.object({
155
- path: z.string(),
156
- routes: z.array(z.object({
157
- match: PATH,
158
- redirect: PATH.optional(),
159
- roles: z.string().optional(),
160
- permissions: z.record(z.string(), z.string()).optional()
161
- }).refine(({ match, redirect }) => !Paths.matches(match, redirect ?? "/"), { error: "Its \"redirect\" must be outside its \"match\"" })).optional()
162
- }).optional()
163
- });
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
- constructor(sRoutesDir, sBasePath) {
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 file */
237
- async mount() {
238
- if (!this.sRoutesDir) return [];
239
- const oFiles = await FileLoader.findMany("route", this.sRoutesDir);
240
- const oRoutes = Validator.validate(oFiles, ROUTE);
241
- return Object.entries(oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
242
- const sPath = this.createPath(sFilePath, expression);
243
- Logger.info(sPath);
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
- /** The pool of `DATABASE_URL`, shared by every client */
349
- static async pool() {
350
- const sUrl = process.env.DATABASE_URL;
351
- if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
352
- const { Pool } = await import("pg");
353
- return new Pool({ connectionString: sUrl });
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/routes/Plugin.ts
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
- /** Mounts its endpoints on express app */
365
- async mount(oApp) {
366
- const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
367
- const oAuthOptions = name === "app" ? await BetterAuth.findOptions() : void 0;
368
- const oDb = database ? await Database.mount(name, database) : Database.get("app");
369
- if (oAuthOptions) {
370
- if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
371
- const { sPath, oHandler, fnSession } = await BetterAuth.create(oAuthOptions, oDb, database.schema);
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: oDb,
378
- auth: BetterAuth.get()
468
+ db
379
469
  });
380
- await callbacks?.onMount?.(oApp, oParams);
381
- const oRouter = new Router(routesDir, basePath);
382
- for (const { path, endpoint } of await oRouter.mount()) oApp.all(path, await endpoint.mount(oParams, path));
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/app/config.ts
386
- /** Initial application configuration */
387
- const INITIAL_CONFIG = {
388
- /** Application port */
389
- port: 3e3,
390
- /** Express settings: the client IP comes from `X-Forwarded-For` only through private proxies */
391
- settings: { "trust proxy": PRIVATE_NETWORKS },
392
- /** Initial middlewares */
393
- packages: {
394
- morgan: {
395
- tokens: {
396
- timestamp: () => Logger.date(),
397
- path: (oReq) => oReq.path
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
- format: ":timestamp | :method :path :status"
400
- },
401
- cors: { methods: [
402
- "GET",
403
- "HEAD",
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
- /** Whether anything thrown is an object, read as a HandlerError or a Drizzle error with its Postgres cause */
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
- /** Turns any error raised by a handler into an ApiResponse */
504
+ /** Answers anything a handler throws as an ApiResponse */
503
505
  const errorHandler = (oError, _oReq, oRes, _fnNext) => {
504
- const { status, body } = normalize(oError);
505
- if (status >= 500) Logger.error("Internal server error", oError);
506
- oRes.status(status).json(body);
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 = mergeConfig(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.oApp.listen(this.oConfig.port, () => {
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 ${this.oConfig.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(this.oConfig.settings)) this.oApp.set(sKey, oValue);
534
- this.setPackages(this.oConfig.packages);
535
- const { mainPlugin, plugins } = this.oConfig;
536
- const oPlugins = {
537
- before: [],
538
- after: []
539
- };
540
- for (const oPlugin of plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
541
- for (const oPlugin of [
542
- ...oPlugins.before,
543
- mainPlugin,
544
- ...oPlugins.after
545
- ]) await new Plugin(oPlugin).mount(this.oApp);
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/app/creators.ts
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 fnCreator;
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 };