@nano-app/plugins 0.4.2 → 0.4.4
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 +8 -4
- package/build/crud/Query.js +45 -0
- package/build/crud/index.d.ts +9 -0
- package/build/crud/index.js +13 -0
- package/build/crud/routes/index.route.js +80 -0
- package/build/health/index.js +1 -1
- package/build/rate-limit/index.d.ts +14 -0
- package/build/rate-limit/index.js +43 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -17,9 +17,13 @@ createNanoApp({ plugins: [health()] });
|
|
|
17
17
|
|
|
18
18
|
## Official plugins
|
|
19
19
|
|
|
20
|
-
| Plugin
|
|
21
|
-
|
|
22
|
-
| `health` | `import health from '@nano-app/plugins/health'` | `GET /health` → `{ success: true, result: { status: "ok" } }`
|
|
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,45 @@
|
|
|
1
|
+
// crud/Query.ts
|
|
2
|
+
/** Records a list answers when the request sends no `top` */
|
|
3
|
+
const DEFAULT_TOP = 100;
|
|
4
|
+
/** Translates core's `QueryParams` into the arguments of a Prisma `findMany` */
|
|
5
|
+
var Query = class Query {
|
|
6
|
+
/** `filter` → `where`, `orderby` → `orderBy`, `top`/`skip` → `take`/`skip`, `select` and `expand` → `select` or `include` */
|
|
7
|
+
static toPrisma(oQuery) {
|
|
8
|
+
const { filter, orderby, top, skip, select, expand } = oQuery;
|
|
9
|
+
const oInclude = expand ? Query.include(Query.list(expand)) : void 0;
|
|
10
|
+
const oSelect = select ? Query.fields(Query.list(select), oInclude) : void 0;
|
|
11
|
+
return {
|
|
12
|
+
where: filter,
|
|
13
|
+
orderBy: orderby && Object.entries(orderby).map(([sPath, sDirection]) => Query.nest(sPath.split("."), sDirection)),
|
|
14
|
+
take: top ?? DEFAULT_TOP,
|
|
15
|
+
skip,
|
|
16
|
+
select: oSelect,
|
|
17
|
+
include: oSelect ? void 0 : oInclude
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** `['user', 'name']`, `'asc'` → `{ user: { name: 'asc' } }` */
|
|
21
|
+
static nest(aPath, sValue) {
|
|
22
|
+
const [sField, ...aRest] = aPath;
|
|
23
|
+
return { [sField]: aRest.length ? Query.nest(aRest, sValue) : sValue };
|
|
24
|
+
}
|
|
25
|
+
/** `['user', 'posts.comments']` → `{ user: true, posts: { include: { comments: true } } }` */
|
|
26
|
+
static include(aPaths) {
|
|
27
|
+
const aRelations = [...new Set(aPaths.map((sPath) => sPath.split(".")[0]))];
|
|
28
|
+
return Object.fromEntries(aRelations.map((sRelation) => {
|
|
29
|
+
const aNested = aPaths.filter((sPath) => sPath.startsWith(`${sRelation}.`)).map((sPath) => sPath.slice(sRelation.length + 1));
|
|
30
|
+
return [sRelation, aNested.length ? { include: Query.include(aNested) } : true];
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/** `['id', 'name']` → `{ id: true, name: true }`, plus the relations to load */
|
|
34
|
+
static fields(aFields, oInclude) {
|
|
35
|
+
return {
|
|
36
|
+
...Object.fromEntries(aFields.map((sField) => [sField, true])),
|
|
37
|
+
...oInclude
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** `"a, b"` → `['a', 'b']` */
|
|
41
|
+
static list(sList) {
|
|
42
|
+
return sList.split(",").map((sItem) => sItem.trim());
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
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
|
+
options,
|
|
11
|
+
routesDir: resolve(dirname(fileURLToPath(import.meta.url)), "routes")
|
|
12
|
+
}));
|
|
13
|
+
export { crud_default as default };
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
/** The Prisma delegate of the URL's model: 404 when the app does not publish it, 403 without its permission */
|
|
18
|
+
async function delegate(oReq, oParams) {
|
|
19
|
+
const sModel = String(oReq.params.model);
|
|
20
|
+
if (!oParams.models.includes(sModel)) throw {
|
|
21
|
+
status: 404,
|
|
22
|
+
message: "Not found"
|
|
23
|
+
};
|
|
24
|
+
if (!await isGranted(oParams, { [sModel]: [ACTIONS[oReq.method]] })) throw {
|
|
25
|
+
status: 403,
|
|
26
|
+
message: "Forbidden"
|
|
27
|
+
};
|
|
28
|
+
return oParams.db[sModel];
|
|
29
|
+
}
|
|
30
|
+
/** The `where` of the URL's id, converted to the type of the model's id */
|
|
31
|
+
function where(oReq, oModel) {
|
|
32
|
+
const sId = String(oReq.params.id);
|
|
33
|
+
return { id: ID_TYPES[oModel.fields.id.typeName]?.(sId) ?? sId };
|
|
34
|
+
}
|
|
35
|
+
/** A list of the model with its total, or one record by its id */
|
|
36
|
+
async function read(oReq, oParams) {
|
|
37
|
+
const { id } = oReq.params;
|
|
38
|
+
const oModel = await delegate(oReq, oParams);
|
|
39
|
+
const oArgs = Query.toPrisma(oReq.body ?? {});
|
|
40
|
+
if (id) {
|
|
41
|
+
const { select, include } = oArgs;
|
|
42
|
+
return { result: await oModel.findUniqueOrThrow({
|
|
43
|
+
where: where(oReq, oModel),
|
|
44
|
+
select,
|
|
45
|
+
include
|
|
46
|
+
}) };
|
|
47
|
+
}
|
|
48
|
+
const [aRecords, iCount] = await Promise.all([oModel.findMany(oArgs), oModel.count({ where: oArgs.where })]);
|
|
49
|
+
return {
|
|
50
|
+
result: aRecords,
|
|
51
|
+
count: iCount
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Creates a record with the body as its data */
|
|
55
|
+
async function create(oReq, oParams) {
|
|
56
|
+
return { result: await (await delegate(oReq, oParams)).create({ data: oReq.body }) };
|
|
57
|
+
}
|
|
58
|
+
/** Updates only the fields of the body */
|
|
59
|
+
async function update(oReq, oParams) {
|
|
60
|
+
const oModel = await delegate(oReq, oParams);
|
|
61
|
+
return { result: await oModel.update({
|
|
62
|
+
where: where(oReq, oModel),
|
|
63
|
+
data: oReq.body
|
|
64
|
+
}) };
|
|
65
|
+
}
|
|
66
|
+
/** Deletes a record and answers it */
|
|
67
|
+
async function remove(oReq, oParams) {
|
|
68
|
+
const oModel = await delegate(oReq, oParams);
|
|
69
|
+
return { result: await oModel.delete({ where: where(oReq, oModel) }) };
|
|
70
|
+
}
|
|
71
|
+
/** `GET` reads without parameters, `QUERY` with `QueryParams` as its body */
|
|
72
|
+
var index_route_default = createRoute({
|
|
73
|
+
expression: "/:model{/:id}",
|
|
74
|
+
GET: read,
|
|
75
|
+
QUERY: read,
|
|
76
|
+
POST: create,
|
|
77
|
+
PUT: update,
|
|
78
|
+
DELETE: remove
|
|
79
|
+
});
|
|
80
|
+
export { index_route_default as default };
|
package/build/health/index.js
CHANGED
|
@@ -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
|
+
routesDir: "",
|
|
12
|
+
stage: "before",
|
|
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
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Official plugins for @nano-app/backend",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,12 +40,14 @@
|
|
|
40
40
|
"@nano-app/backend": ">=0.9.0"
|
|
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
|
-
"glob": "^
|
|
49
|
+
"glob": "^14.0.3",
|
|
50
|
+
"next": "^16.4.3",
|
|
49
51
|
"pg": "^8.23.1",
|
|
50
52
|
"prisma": "^7.10.1",
|
|
51
53
|
"tsdown": "^0.23.4",
|