@nano-app/plugins 0.4.3 → 0.6.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
@@ -17,9 +17,13 @@ createNanoApp({ plugins: [health()] });
17
17
 
18
18
  ## Official plugins
19
19
 
20
- | Plugin | Import | Endpoints | Options |
21
- |---|---|---|---|
22
- | `health` | `import health from '@nano-app/plugins/health'` | `GET /health` → `{ success: true, result: { status: "ok" } }` | `basePath` (default `"/health"`) |
20
+ | Plugin | Import | Endpoints | Options |
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 models listed in `models` (any other answers 404): `GET` and `QUERY` on `/api/<model>{/<id>}` → a list `{ result, count }` or one record, with `QueryParams` as the body of `QUERY`; `POST /api/<model>`, `PUT` and `DELETE` on `/api/<model>/<id>` → the record created, updated or deleted. `<id>` takes the type of the model's id (`Int`, `BigInt` or `String`). With Better Auth, each method asks for a permission on the model: `read` (`GET`, `QUERY`), `create`, `update`, `delete` | `models` (the published ones), `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)). 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` |
25
+
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.
23
27
 
24
28
  ### Layout
25
29
 
@@ -119,7 +123,7 @@ At startup nano-app generates the plugin's client in `.nano-app/prisma-clients/<
119
123
  - Migrations only add: tables, nullable columns or columns with a default, indexes. Dropping or renaming takes two releases: stop using it first, remove it later.
120
124
  - The database user of the consumer needs to create tables in the plugin's `nano_*` schema.
121
125
 
122
- Handlers and callbacks receive that client as `params.db`. Type it with `createRoute<PrismaClient>`, passing your generated `PrismaClient`.
126
+ Handlers and callbacks receive that client as `params.db`. Type it with `createRoute<{ db: PrismaClient }>`, passing your generated `PrismaClient`.
123
127
 
124
128
  ### What belongs in this repository
125
129
 
@@ -0,0 +1,62 @@
1
+ // crud/Query.ts
2
+ /** Records a list answers when the request sends no `top` */
3
+ const DEFAULT_TOP = 100;
4
+ /** Reads a model of the app's Prisma client with core's `QueryParams` */
5
+ var Query = class Query {
6
+ /** A list of the model with its total, or one record by its `where` */
7
+ static async read(oModel, oQuery, oWhere) {
8
+ const oArgs = Query.toPrisma(oQuery);
9
+ if (oWhere) {
10
+ const { select, include } = oArgs;
11
+ return { result: await oModel.findUniqueOrThrow({
12
+ where: oWhere,
13
+ select,
14
+ include
15
+ }) };
16
+ }
17
+ const [aRecords, iCount] = await Promise.all([oModel.findMany(oArgs), oModel.count({ where: oArgs.where })]);
18
+ return {
19
+ result: aRecords,
20
+ count: iCount
21
+ };
22
+ }
23
+ /** `filter` → `where`, `orderby` → `orderBy`, `top`/`skip` → `take`/`skip`, `select` and `expand` → `select` or `include` */
24
+ static toPrisma(oQuery) {
25
+ const { filter, orderby, top, skip, select, expand } = oQuery;
26
+ const oInclude = expand ? Query.include(Query.list(expand)) : void 0;
27
+ const oSelect = select ? Query.fields(Query.list(select), oInclude) : void 0;
28
+ return {
29
+ where: filter,
30
+ orderBy: orderby && Object.entries(orderby).map(([sPath, sDirection]) => Query.nest(sPath.split("."), sDirection)),
31
+ take: top ?? DEFAULT_TOP,
32
+ skip,
33
+ select: oSelect,
34
+ include: oSelect ? void 0 : oInclude
35
+ };
36
+ }
37
+ /** `['user', 'name']`, `'asc'` → `{ user: { name: 'asc' } }` */
38
+ static nest(aPath, sValue) {
39
+ const [sField, ...aRest] = aPath;
40
+ return { [sField]: aRest.length ? Query.nest(aRest, sValue) : sValue };
41
+ }
42
+ /** `['user', 'posts.comments']` → `{ user: true, posts: { include: { comments: true } } }` */
43
+ static include(aPaths) {
44
+ const aRelations = [...new Set(aPaths.map((sPath) => sPath.split(".")[0]))];
45
+ return Object.fromEntries(aRelations.map((sRelation) => {
46
+ const aNested = aPaths.filter((sPath) => sPath.startsWith(`${sRelation}.`)).map((sPath) => sPath.slice(sRelation.length + 1));
47
+ return [sRelation, aNested.length ? { include: Query.include(aNested) } : true];
48
+ }));
49
+ }
50
+ /** `['id', 'name']` → `{ id: true, name: true }`, plus the relations to load */
51
+ static fields(aFields, oInclude) {
52
+ return {
53
+ ...Object.fromEntries(aFields.map((sField) => [sField, true])),
54
+ ...oInclude
55
+ };
56
+ }
57
+ /** `"a, b"` → `['a', 'b']` */
58
+ static list(sList) {
59
+ return sList.split(",").map((sItem) => sItem.trim());
60
+ }
61
+ };
62
+ export { Query as default };
@@ -0,0 +1,9 @@
1
+ /** Automatic CRUD: the listed models of the app's database on `/api/<model>{/<id>}` */
2
+ declare const _default: (options?: {
3
+ basePath?: string;
4
+ models: string[];
5
+ } | undefined) => import("@nano-app/backend").PluginConfig<{
6
+ basePath?: string;
7
+ models: string[];
8
+ }>;
9
+ export { _default as default };
@@ -0,0 +1,13 @@
1
+ import { createPlugin } from "@nano-app/backend";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // crud/index.ts
5
+ /** Automatic CRUD: the listed models of the app's database on `/api/<model>{/<id>}` */
6
+ var crud_default = createPlugin((options) => ({
7
+ name: "crud",
8
+ basePath: options?.basePath ?? "/api",
9
+ stage: "after",
10
+ routesDir: resolve(dirname(fileURLToPath(import.meta.url)), "routes"),
11
+ options
12
+ }));
13
+ export { crud_default as default };
@@ -0,0 +1,48 @@
1
+ import Query from "../Query.js";
2
+ import { createRoute, isGranted } from "@nano-app/backend";
3
+ // crud/routes/index.route.ts
4
+ /** Permission of Better Auth that each method needs on the URL's model */
5
+ const ACTIONS = {
6
+ GET: "read",
7
+ QUERY: "read",
8
+ POST: "create",
9
+ PUT: "update",
10
+ DELETE: "delete"
11
+ };
12
+ /** Conversion of the URL's id to the type of the model's id, when it is not a string */
13
+ const ID_TYPES = {
14
+ Int: Number,
15
+ BigInt
16
+ };
17
+ /** `GET` reads without parameters, `QUERY` with `QueryParams` as its body */
18
+ var index_route_default = createRoute({
19
+ expression: "/:model{/:id}",
20
+ callbacks: { onBefore: async (oReq, sMethod, oParams) => {
21
+ const sModel = String(oReq.params.model);
22
+ const { id } = oReq.params;
23
+ if (!oParams.models.includes(sModel)) throw {
24
+ status: 404,
25
+ message: "Not found"
26
+ };
27
+ if (!await isGranted(oParams, { [sModel]: [ACTIONS[sMethod]] })) throw {
28
+ status: 403,
29
+ message: "Forbidden"
30
+ };
31
+ const oModel = oParams.db[sModel];
32
+ if (!id) return { model: oModel };
33
+ const sId = String(id);
34
+ return {
35
+ model: oModel,
36
+ where: { id: ID_TYPES[oModel.fields.id.typeName]?.(sId) ?? sId }
37
+ };
38
+ } },
39
+ GET: async (oReq, { onBeforeResult }) => Query.read(onBeforeResult?.model, oReq.body ?? {}, onBeforeResult?.where),
40
+ QUERY: async (oReq, { onBeforeResult }) => Query.read(onBeforeResult?.model, oReq.body ?? {}, onBeforeResult?.where),
41
+ POST: async (oReq, { onBeforeResult }) => ({ result: await onBeforeResult?.model.create({ data: oReq.body }) }),
42
+ PUT: async (oReq, { onBeforeResult }) => ({ result: await onBeforeResult?.model.update({
43
+ where: onBeforeResult.where,
44
+ data: oReq.body
45
+ }) }),
46
+ DELETE: async (_oReq, { onBeforeResult }) => ({ result: await onBeforeResult?.model.delete({ where: onBeforeResult.where }) })
47
+ });
48
+ export { index_route_default as default };
@@ -1,6 +1,6 @@
1
+ import { createPlugin } from "@nano-app/backend";
1
2
  import { dirname, resolve } from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
- import { createPlugin } from "@nano-app/backend";
4
4
  // health/index.ts
5
5
  /** Health check: `GET /health` answers `{ status: "ok" }` while the server is up. */
6
6
  var health_default = createPlugin((options) => ({
@@ -0,0 +1,14 @@
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 };
@@ -0,0 +1,43 @@
1
+ import { createPlugin } from "@nano-app/backend";
2
+ // rate-limit/index.ts
3
+ /**
4
+ * Rate limit per client IP: each request spends a token of the client's bucket, refilled over time.
5
+ * Covers the app's routes and the plugins listed after it.
6
+ */
7
+ var rate_limit_default = createPlugin((options) => {
8
+ const { capacity = 100, refillPerSecond = 1, message = "Too many requests, please try again later." } = options ?? {};
9
+ return {
10
+ name: "rate-limit",
11
+ stage: "before",
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 = 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-app/plugins",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "Official plugins for @nano-app/backend",
5
5
  "type": "module",
6
6
  "exports": {
@@ -37,18 +37,20 @@
37
37
  "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
38
38
  },
39
39
  "peerDependencies": {
40
- "@nano-app/backend": ">=0.9.0"
40
+ "@nano-app/backend": ">=0.12.5"
41
41
  },
42
42
  "devDependencies": {
43
+ "@happy-dom/global-registrator": "^21.0.4",
43
44
  "@nano-app/backend": "workspace:*",
44
45
  "@prisma/adapter-pg": "^7.10.1",
45
46
  "@prisma/client": "^7.10.1",
46
47
  "@types/node": "^26.6.6",
47
48
  "@types/pg": "^8.23.1",
48
49
  "glob": "^14.0.3",
50
+ "next": "^16.4.3",
49
51
  "pg": "^8.23.1",
50
52
  "prisma": "^7.10.1",
51
- "tsdown": "^0.23.4",
53
+ "tsdown": "^0.23.5",
52
54
  "tsx": "^4.23.15",
53
55
  "typescript": "^7.0.2"
54
56
  }