@nano-app/plugins 0.11.0 → 0.12.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 CHANGED
@@ -12,7 +12,7 @@ pnpm add @nano-app/backend @nano-app/plugins
12
12
  import { createNanoApp } from '@nano-app/backend';
13
13
  import health from '@nano-app/plugins/health';
14
14
 
15
- createNanoApp({ plugins: [health()] });
15
+ createNanoApp({ plugins: { before: [health()] } });
16
16
  ```
17
17
 
18
18
  ## Official plugins
@@ -20,10 +20,11 @@ createNanoApp({ plugins: [health()] });
20
20
  | Plugin | Import | Endpoints | Options |
21
21
  | -------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
22
22
  | `health` | `import health from '@nano-app/plugins/health'` | `GET /health` → `{ success: true, result: { status: "ok" } }`. Public: it answers without a session | `basePath` (default `"/health"`) |
23
- | `crud` | `import crud from '@nano-app/plugins/crud'` | On the tables listed in `models`, by their name in the app's schema (any other answers 404): `GET` and `QUERY` on `/api/<model>{/<id>}` → a list `{ result }` or one record, with `QueryParams` as the body of `QUERY`: Drizzle's relational query (`where`, `columns`, `orderBy`, `limit`, `offset`, `with`); `POST /api/<model>`, `PUT` and `DELETE` on `/api/<model>/<id>` → the record created, updated or deleted. Postgres casts `<id>` to the type of the `id` column. With Better Auth, each method asks for a permission on the table: `read` (`GET`, `QUERY`), `create`, `update`, `delete`, see [crud and related tables](#crud-and-related-tables) | `models` (the published ones), `dbSchema` (the app's Drizzle schema, as in `database.schema`), `validations` (zod schemas of the bodies, see [crud and the bodies](#crud-and-the-bodies)), `basePath` (default `"/api"`) |
24
- | `rate-limit` | `import rateLimit from '@nano-app/plugins/rate-limit'` | None: limits every request of the routes and plugins mounted after it, per client IP (see [Client IP](../backend/README.md#client-ip)); the addresses of an IPv6 `/56` network are one client. Each request spends a token of the client's bucket, which holds `capacity` and refills `refillPerSecond` tokens per second; without one it answers `429` with `message` and `Retry-After` (seconds until the next token). List it first in `plugins.before`. The buckets live in the memory of each process | `capacity` (default `100`), `refillPerSecond` (default `1`), `message` |
23
+ | `crud` | `import crud from '@nano-app/plugins/crud'` | One route per table of `models`, keyed by its name in the app's relations (any other answers 404): `GET` and `QUERY` on `/<model>{/<id>}` → a list `{ result }` or one record, with `QueryParams` as the body of `QUERY`: Drizzle's relational query (`where`, `columns`, `orderBy`, `limit`, `offset`, `with`); `POST /<model>`, `PUT` and `DELETE` on `/<model>/<id>` → the record created, updated or deleted. Postgres casts `<id>` to the type of the `id` column. With Better Auth, each method asks for a permission on the table, 403 without it: `read` (`GET`, `QUERY`), `create`, `update`, `delete`, see [crud and related tables](#crud-and-related-tables) | `models` (the published tables, `{ note, comment }`), `validations` (zod schemas of the bodies, see [crud and the bodies](#crud-and-the-bodies)), `basePath` (default none: `/<model>`) |
25
24
 
26
- What is not CRUD, such as creating a record with business logic, goes in a route of your own with its own URL: a route of yours on a `crud` URL takes every method of it.
25
+ Every plugin takes `basePath` besides its own options, over its default: `crud({ models, basePath: '/api' })` answers on `/api/<model>`.
26
+
27
+ What is not CRUD, such as creating a record with business logic, goes in a route of your own with its own URL. A `crud` mounted `before` your routes takes its URLs from them; mounted `after`, a route of yours on one of its URLs takes every method of it.
27
28
 
28
29
  ### crud and the bodies
29
30
 
@@ -35,8 +36,7 @@ import { z } from 'zod';
35
36
  import * as schema from './db/schema';
36
37
 
37
38
  crud({
38
- models: ['note'],
39
- dbSchema: schema,
39
+ models: { note: schema.note },
40
40
  validations: {
41
41
  note: {
42
42
  create: createInsertSchema(schema.note).omit({ id: true }),
@@ -49,7 +49,7 @@ crud({
49
49
 
50
50
  ### crud and related tables
51
51
 
52
- A `crud` query runs on the app's client, so it reaches the related tables Drizzle's relations give: `with` loads them and `where` filters by them, also the ones that are not in `models`. With Better Auth, the permission asked is only the one of the URL's table. What a query may load is up to the app: the `read` validation of the model decides it (a `z.strictObject` without `with` keeps a query in its table). Better Auth's tables live in `nano_auth`, outside the app's schema: `crud` does not reach them. A Postgres error of a write (a duplicate, a missing reference) answers 500.
52
+ A `crud` query runs on the app's client, so it reaches the related tables Drizzle's relations give: `with` loads them and `where` filters by them, also the ones that are not in `models`. With Better Auth, the permission asked is only the one of the URL's table. What a query may load is up to the app: the `read` validation of the model decides it (a `z.strictObject` without `with` keeps a query in its table). Better Auth's tables live in `nano_auth`, outside the app's relations: `crud` does not reach them. A Postgres error of a write (a duplicate, a missing reference) answers 500.
53
53
 
54
54
  ### Layout
55
55
 
@@ -58,7 +58,7 @@ plugins/
58
58
  └── <name>/
59
59
  ├── index.ts default-exports the plugin factory: the import path
60
60
  ├── schema.ts its tables, when it has any, in the Postgres schema nano_<name>
61
- └── routes/ *.route.ts, mounted under the plugin's basePath
61
+ └── routes/ *.route.ts, when its routes are files, under the plugin's basePath
62
62
  ```
63
63
 
64
64
  ## Write a plugin
@@ -83,9 +83,9 @@ import { createPlugin } from '@nano-app/backend';
83
83
  import { dirname, resolve } from 'node:path';
84
84
  import { fileURLToPath } from 'node:url';
85
85
 
86
- export default createPlugin((options?: { basePath?: string }) => ({
86
+ export default createPlugin(() => ({
87
87
  name: 'hello',
88
- basePath: options?.basePath ?? '/hello',
88
+ basePath: '/hello',
89
89
  routesDir: resolve(dirname(fileURLToPath(import.meta.url)), 'routes'),
90
90
  }));
91
91
  ```
@@ -107,11 +107,11 @@ import hello from './plugins/hello';
107
107
  createNanoApp({ plugins: { before: [hello()] } });
108
108
  ```
109
109
 
110
- The app lists it in `plugins.before` or `plugins.after` its own routes. Mount order: base middlewares, Better Auth, `before` plugins, your routes, `after` plugins, 404, error handler. With Better Auth on, its routes need a session unless they are `public`.
110
+ The app lists it in `plugins.before` or `plugins.after` its own routes, and moves it with `hello({ basePath: '/hi' })`: `createPlugin` adds `basePath` to the options of every plugin. Mount order: base middlewares, Better Auth, `before` plugins, your routes, `after` plugins, 404, error handler. With Better Auth on, its routes need a session unless they are `public`.
111
111
 
112
- Set `options` in the returned config to pass values to the plugin's routes: every handler and callback receives them in `params`, next to `db`. Callbacks: `callbacks.onMount(app, params)` on the plugin, once, before its endpoints (app-level middlewares go here); `onInit(params)` and `onBefore(req, method, params)` on each route. See the [backend README](../backend/README.md#callbacks).
112
+ Set `options` in the returned config to pass values to the plugin's routes: every handler and callback receives them in `params`, next to `db`. Callbacks: `callbacks.onMount(params)` on the plugin, once, before its endpoints, for its setup; `onInit(params)` and `onBefore(req, method, params)` on each route. See the [backend README](../backend/README.md#callbacks).
113
113
 
114
- `routesDir` must be a real directory of `*.route.ts` files when you run the app from source (`tsx`). `jiti` loads them. There is no compile step for your routes.
114
+ `routesDir` must be a real directory of `*.route.ts` files when you run the app from source (`tsx`). `jiti` loads them. There is no compile step for your routes. A plugin can also pass its routes made in code in `routes`, by path (`{ '/index': createRoute({...}) }`), beside or instead of `routesDir`: they are checked as the files.
115
115
 
116
116
  ### A plugin other people can install
117
117
 
@@ -134,9 +134,9 @@ Use MIT or Apache-2.0 if you want the plugin listed from this repo later. Open a
134
134
 
135
135
  ### Database
136
136
 
137
- Optional. Pass `database: { schema }` on the plugin config: the module of its Drizzle tables, with its `relations` when it has them. Keep its tables in the Postgres schema `nano_<plugin name>`, reserved for it, which drizzle-kit creates when the module exports its `pgSchema`; a table in the `nano_*` schema of another fails the startup.
137
+ Optional. Pass `database: { relations }` on the plugin config: what `defineRelations` gives of its Drizzle tables. Keep its tables in the Postgres schema `nano_<plugin name>`, reserved for it, which drizzle-kit creates when the module exports its `pgSchema`; a table in the `nano_*` schema of another fails the startup.
138
138
 
139
- `push: true` applies them at startup, and `migrations` the folder the plugin ships; without them whoever installs it applies them with drizzle-kit. Handlers and callbacks receive its client as `params.db`.
139
+ `push: true` applies them at startup, and `migrate` the folder the plugin ships, with its record in `nano_<plugin name>`; without them whoever installs it applies them with drizzle-kit. Handlers and callbacks receive its client as `params.db`.
140
140
 
141
141
  ### What belongs in this repository
142
142
 
package/build/const.js ADDED
@@ -0,0 +1,10 @@
1
+ // const.ts
2
+ /** Action of each method: its permission of Better Auth and the validation of its body */
3
+ const ACTIONS = {
4
+ GET: "read",
5
+ QUERY: "read",
6
+ POST: "create",
7
+ PUT: "update",
8
+ DELETE: "delete"
9
+ };
10
+ export { ACTIONS };
@@ -0,0 +1 @@
1
+ import "zod";
@@ -0,0 +1,4 @@
1
+ import { NanoObject } from "./types/objects.js";
2
+ import "./files/FileLoader.js";
3
+ import "./utils/Validator.js";
4
+ export { NanoObject };
@@ -0,0 +1,2 @@
1
+ /** Generic object */
2
+ export type NanoObject<T = any> = Record<string, T>;
@@ -0,0 +1 @@
1
+ import "zod";
@@ -1,15 +1,11 @@
1
1
  import { CrudValidations } from "../types/crud.js";
2
2
  import { NanoObject } from "@nano-app/backend";
3
- /** Automatic CRUD: the listed tables of the app's database on `/api/<model>{/<id>}` */
4
- declare const _default: (options?: {
5
- basePath?: string;
6
- models: string[];
7
- dbSchema: NanoObject;
3
+ /** Automatic CRUD: the tables of `models` of the app's database on `/<model>{/<id>}` */
4
+ declare const _default: (options?: ({
5
+ models: NanoObject;
8
6
  validations?: NanoObject<CrudValidations>;
9
- } | undefined) => import("@nano-app/backend").PluginConfig<{
10
- basePath?: string;
11
- models: string[];
12
- dbSchema: NanoObject;
7
+ } & import("@nano-app/backend").PluginOptions) | undefined) => import("@nano-app/backend").PluginConfig<{
8
+ models: NanoObject;
13
9
  validations?: NanoObject<CrudValidations>;
14
10
  }>;
15
11
  export { _default as default };
@@ -1,11 +1,9 @@
1
+ import route from "./route.js";
1
2
  import { createPlugin } from "@nano-app/backend";
2
- import { join } from "node:path";
3
3
  // crud/index.ts
4
- /** Automatic CRUD: the listed tables of the app's database on `/api/<model>{/<id>}` */
4
+ /** Automatic CRUD: the tables of `models` of the app's database on `/<model>{/<id>}` */
5
5
  var crud_default = createPlugin((options) => ({
6
6
  name: "crud",
7
- basePath: options?.basePath ?? "/api",
8
- routesDir: join(import.meta.dirname, "routes"),
9
- options
7
+ routes: Object.fromEntries(Object.entries(options?.models ?? {}).map(([sModel, oTable]) => [`/${sModel}`, route(sModel, oTable, options?.validations?.[sModel])]))
10
8
  }));
11
9
  export { crud_default as default };
@@ -0,0 +1,81 @@
1
+ import { ACTIONS } from "../const.js";
2
+ import { NanoError, createRoute } from "@nano-app/backend";
3
+ import { eq } from "drizzle-orm";
4
+ // crud/route.ts
5
+ /** The first record of a query or a write: 404 without it */
6
+ function first(aRecords) {
7
+ if (!aRecords[0]) throw new NanoError("Not found", 404);
8
+ return { result: aRecords[0] };
9
+ }
10
+ /** Every record of a query */
11
+ function list(aRecords) {
12
+ return { result: aRecords };
13
+ }
14
+ /** A list of the table, or one record by its id, with Drizzle's relational query of the body */
15
+ async function read(_oReq, { db, onBeforeResult }) {
16
+ const { model, page, answer, body } = onBeforeResult;
17
+ return answer(await db.query[model].findMany({
18
+ limit: 100,
19
+ ...body,
20
+ ...page
21
+ }));
22
+ }
23
+ /** Inserts the body as a record */
24
+ async function create(_oReq, { db, onBeforeResult }) {
25
+ const { table, body } = onBeforeResult;
26
+ return first(await db.insert(table).values(body).returning());
27
+ }
28
+ /** Updates the record of the id with the body */
29
+ async function update(_oReq, { db, onBeforeResult }) {
30
+ const { table, id, body } = onBeforeResult;
31
+ return first(await db.update(table).set(body).where(eq(table.id, id)).returning());
32
+ }
33
+ /** Deletes the record of the id */
34
+ async function remove(_oReq, { db, onBeforeResult }) {
35
+ const { table, id } = onBeforeResult;
36
+ return first(await db.delete(table).where(eq(table.id, id)).returning());
37
+ }
38
+ /** The route of a table: `GET` and `QUERY` read, each method asks its permission on the table */
39
+ function route(sModel, oTable, oValidations = {}) {
40
+ const oMethods = Object.fromEntries(Object.entries({
41
+ GET: read,
42
+ QUERY: read,
43
+ POST: create,
44
+ PUT: update,
45
+ DELETE: remove
46
+ }).map(([sMethod, handler]) => [sMethod, {
47
+ handler,
48
+ permissions: { [sModel]: [ACTIONS[sMethod]] }
49
+ }]));
50
+ return createRoute({
51
+ expression: "{/:id}",
52
+ callbacks: { onBefore: async (oReq, sMethod) => {
53
+ const { id } = oReq.params;
54
+ const oBody = oReq.body ?? {};
55
+ const oResult = oValidations[ACTIONS[sMethod]]?.safeParse(oBody) ?? {
56
+ success: true,
57
+ data: oBody
58
+ };
59
+ if (!oResult.success) {
60
+ const { path, message } = oResult.error.issues[0];
61
+ throw new NanoError([...path, message].join(": "), 400);
62
+ }
63
+ const oRead = id ? {
64
+ page: {
65
+ where: { id },
66
+ limit: 1
67
+ },
68
+ answer: first
69
+ } : { answer: list };
70
+ return {
71
+ model: sModel,
72
+ table: oTable,
73
+ id,
74
+ body: oResult.data,
75
+ ...oRead
76
+ };
77
+ } },
78
+ ...oMethods
79
+ });
80
+ }
81
+ export { route as default };
@@ -1,7 +1,5 @@
1
+ import { NanoObject } from "../core/types/objects.js";
2
+ import "../core/index.js";
1
3
  /** Health check: `GET /health` answers `{ status: "ok" }` while the server is up. */
2
- declare const _default: (options?: {
3
- basePath?: string;
4
- } | undefined) => import("@nano-app/backend").PluginConfig<{
5
- basePath?: string;
6
- }>;
4
+ declare const _default: (options?: (NanoObject & import("@nano-app/backend").PluginOptions) | undefined) => import("@nano-app/backend").PluginConfig<NanoObject>;
7
5
  export { _default as default };
@@ -1,10 +1,12 @@
1
- import { createPlugin } from "@nano-app/backend";
2
- import { join } from "node:path";
1
+ import { createPlugin, createRoute } from "@nano-app/backend";
3
2
  // health/index.ts
4
3
  /** Health check: `GET /health` answers `{ status: "ok" }` while the server is up. */
5
- var health_default = createPlugin((options) => ({
4
+ var health_default = createPlugin(() => ({
6
5
  name: "health",
7
- basePath: options?.basePath ?? "/health",
8
- routesDir: join(import.meta.dirname, "routes")
6
+ basePath: "/health",
7
+ routes: { "/index": createRoute({
8
+ public: true,
9
+ GET: async () => ({ result: { status: "ok" } })
10
+ }) }
9
11
  }));
10
12
  export { health_default as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-app/plugins",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Official plugins for @nano-app/backend",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,8 +64,5 @@
64
64
  "zod": {
65
65
  "optional": true
66
66
  }
67
- },
68
- "dependencies": {
69
- "express-rate-limit": "^8.7.3"
70
67
  }
71
68
  }
@@ -1,78 +0,0 @@
1
- import { NanoError, createRoute, isGranted } from "@nano-app/backend";
2
- import { eq } from "drizzle-orm";
3
- // crud/routes/index.route.ts
4
- /** Action of each method: its permission of Better Auth and the validation of its body */
5
- const ACTIONS = {
6
- GET: "read",
7
- QUERY: "read",
8
- POST: "create",
9
- PUT: "update",
10
- DELETE: "delete"
11
- };
12
- /** Records a list answers when the request sends no `limit` */
13
- const DEFAULT_LIMIT = 100;
14
- /** The first record of a query or a write: 404 without it */
15
- function first(aRecords) {
16
- if (!aRecords[0]) throw new NanoError("Not found", 404);
17
- return { result: aRecords[0] };
18
- }
19
- /** Every record of a query */
20
- function list(aRecords) {
21
- return { result: aRecords };
22
- }
23
- /** A list of the table, or one record by its id, with Drizzle's relational query of the body */
24
- async function read(_oReq, { db, onBeforeResult }) {
25
- const { model, page, answer, body } = onBeforeResult;
26
- return answer(await db.query[model].findMany({
27
- limit: DEFAULT_LIMIT,
28
- ...body,
29
- ...page
30
- }));
31
- }
32
- /** `GET` reads without parameters, `QUERY` with `QueryParams` as its body */
33
- var index_route_default = createRoute({
34
- expression: "/:model{/:id}",
35
- callbacks: { onBefore: async (oReq, sMethod, oParams) => {
36
- const sModel = String(oReq.params.model);
37
- const sAction = ACTIONS[sMethod];
38
- const { id } = oReq.params;
39
- const oTable = oParams.dbSchema[sModel];
40
- if (!oParams.models.includes(sModel) || !oTable) throw new NanoError("Not found", 404);
41
- if (!await isGranted(oParams, { [sModel]: [sAction] })) throw new NanoError("Forbidden", 403);
42
- const oBody = oReq.body ?? {};
43
- const oResult = (oParams.validations?.[sModel]?.[sAction])?.safeParse(oBody) ?? { data: oBody };
44
- if (oResult.error) {
45
- const { path, message } = oResult.error.issues[0];
46
- throw new NanoError([...path, message].join(": "), 400);
47
- }
48
- const oRead = id ? {
49
- page: {
50
- where: { id },
51
- limit: 1
52
- },
53
- answer: first
54
- } : { answer: list };
55
- return {
56
- model: sModel,
57
- table: oTable,
58
- id,
59
- body: oResult.data,
60
- ...oRead
61
- };
62
- } },
63
- GET: read,
64
- QUERY: read,
65
- POST: async (_oReq, { db, onBeforeResult }) => {
66
- const { table, body } = onBeforeResult;
67
- return first(await db.insert(table).values(body).returning());
68
- },
69
- PUT: async (_oReq, { db, onBeforeResult }) => {
70
- const { table, id, body } = onBeforeResult;
71
- return first(await db.update(table).set(body).where(eq(table.id, id)).returning());
72
- },
73
- DELETE: async (_oReq, { db, onBeforeResult }) => {
74
- const { table, id } = onBeforeResult;
75
- return first(await db.delete(table).where(eq(table.id, id)).returning());
76
- }
77
- });
78
- export { index_route_default as default };
@@ -1,8 +0,0 @@
1
- import { createRoute } from "@nano-app/backend";
2
- // health/routes/index.route.ts
3
- /** Answers that the server is up */
4
- var index_route_default = createRoute({
5
- public: true,
6
- GET: async () => ({ result: { status: "ok" } })
7
- });
8
- export { index_route_default as default };
@@ -1,14 +0,0 @@
1
- /**
2
- * Rate limit per client IP: each request spends a token of the client's bucket, refilled over time.
3
- * Covers the app's routes and the plugins listed after it.
4
- */
5
- declare const _default: (options?: {
6
- capacity?: number;
7
- refillPerSecond?: number;
8
- message?: string;
9
- } | undefined) => import("@nano-app/backend").PluginConfig<{
10
- capacity?: number;
11
- refillPerSecond?: number;
12
- message?: string;
13
- }>;
14
- export { _default as default };
@@ -1,43 +0,0 @@
1
- import { ipKeyGenerator } from "express-rate-limit";
2
- import { createPlugin } from "@nano-app/backend";
3
- // rate-limit/index.ts
4
- /**
5
- * Rate limit per client IP: each request spends a token of the client's bucket, refilled over time.
6
- * Covers the app's routes and the plugins listed after it.
7
- */
8
- var rate_limit_default = createPlugin((options) => {
9
- const { capacity = 100, refillPerSecond = 1, message = "Too many requests, please try again later." } = options ?? {};
10
- return {
11
- name: "rate-limit",
12
- routesDir: "",
13
- callbacks: { onMount: async (oApp) => {
14
- const fnRefill = (oBucket, iNow) => Math.min(capacity, oBucket.iTokens + (iNow - oBucket.iTime) / 1e3 * refillPerSecond);
15
- const oBuckets = /* @__PURE__ */ new Map();
16
- setInterval(() => {
17
- const iNow = Date.now();
18
- for (const [sIp, oBucket] of oBuckets) if (fnRefill(oBucket, iNow) >= capacity) oBuckets.delete(sIp);
19
- }, 6e4).unref();
20
- oApp.use((oReq, oRes, fnNext) => {
21
- const sIp = ipKeyGenerator(oReq.ip ?? "");
22
- const iNow = Date.now();
23
- const iTokens = fnRefill(oBuckets.get(sIp) ?? {
24
- iTokens: capacity,
25
- iTime: iNow
26
- }, iNow);
27
- if (iTokens < 1) {
28
- oRes.set("Retry-After", String(Math.ceil((1 - iTokens) / refillPerSecond)));
29
- return fnNext({
30
- status: 429,
31
- message
32
- });
33
- }
34
- oBuckets.set(sIp, {
35
- iTokens: iTokens - 1,
36
- iTime: iNow
37
- });
38
- fnNext();
39
- });
40
- } }
41
- };
42
- });
43
- export { rate_limit_default as default };