@nano-app/plugins 0.10.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,18 +12,19 @@ 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
19
19
 
20
20
  | Plugin | Import | Endpoints | Options |
21
21
  | -------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
22
- | `health` | `import health from '@nano-app/plugins/health'` | `GET /health` → `{ success: true, result: { status: "ok" } }` | `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 `mainPlugin.database`), `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`. The buckets live in the memory of each process | `capacity` (default `100`), `refillPerSecond` (default `1`), `message` |
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'` | 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 (`user`, `account` with its password hashes, `session` with its tokens) hold secrets: never publish `account`, `session` or `verification`, and do not relate them to a published table without a `read` validation that leaves them out.
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,10 +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',
89
- stage: 'before',
88
+ basePath: '/hello',
90
89
  routesDir: resolve(dirname(fileURLToPath(import.meta.url)), 'routes'),
91
90
  }));
92
91
  ```
@@ -105,14 +104,14 @@ export default createRoute({
105
104
  import { createNanoApp } from '@nano-app/backend';
106
105
  import hello from './plugins/hello';
107
106
 
108
- createNanoApp({ plugins: [hello()] });
107
+ createNanoApp({ plugins: { before: [hello()] } });
109
108
  ```
110
109
 
111
- `stage` is `"before"` (default) or `"after"` the app's own routes. Mount order: base middlewares, `before` plugins, your routes, `after` plugins, 404, error handler.
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`.
112
111
 
113
- 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).
114
113
 
115
- `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.
116
115
 
117
116
  ### A plugin other people can install
118
117
 
@@ -129,15 +128,15 @@ Publish a separate package. Do not add it to this repository unless a maintainer
129
128
  }
130
129
  ```
131
130
 
132
- Export the `createPlugin` factory as the package entry. The consumer installs both packages and passes the factory to `plugins`.
131
+ Export the `createPlugin` factory as the package entry. The consumer installs both packages and lists it in `plugins.before` or `plugins.after`.
133
132
 
134
133
  Use MIT or Apache-2.0 if you want the plugin listed from this repo later. Open an issue with the package name and a short description. A maintainer adds the link. The plugin stays in your repository.
135
134
 
136
135
  ### Database
137
136
 
138
- Optional. Pass `database: { schema }` on the plugin config: the module of its Drizzle tables, with its `relations` when it has them. Its tables live in the Postgres schema `nano_<plugin name>`, which drizzle-kit creates when the module exports its `pgSchema`; a table elsewhere 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.
139
138
 
140
- Its tables are applied with drizzle-kit by whoever installs it, like the app's schema. 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`.
141
140
 
142
141
  ### What belongs in this repository
143
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,12 +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
- stage: "after",
9
- routesDir: join(import.meta.dirname, "routes"),
10
- options
7
+ routes: Object.fromEntries(Object.entries(options?.models ?? {}).map(([sModel, oTable]) => [`/${sModel}`, route(sModel, oTable, options?.validations?.[sModel])]))
11
8
  }));
12
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,11 +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
- stage: "before",
9
- routesDir: join(import.meta.dirname, "routes")
6
+ basePath: "/health",
7
+ routes: { "/index": createRoute({
8
+ public: true,
9
+ GET: async () => ({ result: { status: "ok" } })
10
+ }) }
10
11
  }));
11
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.10.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,5 +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({ GET: async () => ({ result: { status: "ok" } }) });
5
- 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,44 +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
- stage: "before",
13
- routesDir: "",
14
- callbacks: { onMount: async (oApp) => {
15
- const fnRefill = (oBucket, iNow) => Math.min(capacity, oBucket.iTokens + (iNow - oBucket.iTime) / 1e3 * refillPerSecond);
16
- const oBuckets = /* @__PURE__ */ new Map();
17
- setInterval(() => {
18
- const iNow = Date.now();
19
- for (const [sIp, oBucket] of oBuckets) if (fnRefill(oBucket, iNow) >= capacity) oBuckets.delete(sIp);
20
- }, 6e4).unref();
21
- oApp.use((oReq, oRes, fnNext) => {
22
- const sIp = ipKeyGenerator(oReq.ip ?? "");
23
- const iNow = Date.now();
24
- const iTokens = fnRefill(oBuckets.get(sIp) ?? {
25
- iTokens: capacity,
26
- iTime: iNow
27
- }, iNow);
28
- if (iTokens < 1) {
29
- oRes.set("Retry-After", String(Math.ceil((1 - iTokens) / refillPerSecond)));
30
- return fnNext({
31
- status: 429,
32
- message
33
- });
34
- }
35
- oBuckets.set(sIp, {
36
- iTokens: iTokens - 1,
37
- iTime: iNow
38
- });
39
- fnNext();
40
- });
41
- } }
42
- };
43
- });
44
- export { rate_limit_default as default };