@nano-app/frontend 0.5.0 → 0.6.1
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 +108 -24
- package/build/core/const.js +11 -1
- package/build/core/loader/FileReader.js +1 -0
- package/build/core/loader/Validator.js +7 -17
- package/build/core/types/api.d.ts +10 -0
- package/build/core/utils/Formatter.js +2 -1
- package/build/creators/createStores.js +10 -5
- package/build/index.d.ts +2 -1
- package/build/proxy.js +2 -2
- package/build/stores/ApiStore.js +83 -48
- package/build/stores/AuthStore.js +60 -0
- package/build/stores/storeFiles.js +5 -0
- package/build/types/store.d.ts +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# @nano-app/frontend
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@nano-app/frontend)
|
|
4
|
+
|
|
5
|
+
Frontend of [nano-app](https://github.com/Nano-Nathan/nano-app) for Next.js apps: UI state in stores, and controllers whose methods the views use.
|
|
6
|
+
|
|
7
|
+
- Node.js 22.23.4, 24.22.1 or 26.12.1 and newer. ESM only.
|
|
8
|
+
- Next.js 16.3 and React 19 or newer, as peer dependencies.
|
|
4
9
|
|
|
5
10
|
## Quick start
|
|
6
11
|
|
|
@@ -78,6 +83,8 @@ export default counterController.extend(({ stores: { app } }) => ({
|
|
|
78
83
|
}));
|
|
79
84
|
```
|
|
80
85
|
|
|
86
|
+
The types of a controller and its views come from `@nano-app/frontend`: `Controller`, `View`, `ComponentContext` (`props` and `stores`) and `ComponentProps`.
|
|
87
|
+
|
|
81
88
|
## Backend
|
|
82
89
|
|
|
83
90
|
A store of type `api` connects the app to a backend. The browser never talks to the backend: it calls `/api/<store>/...` on the Next server, and the proxy sends the request there.
|
|
@@ -99,48 +106,61 @@ The backend URL is read at runtime from `API_<STORE>_URL` (`backend` → `API_BA
|
|
|
99
106
|
|
|
100
107
|
### Reading and writing
|
|
101
108
|
|
|
102
|
-
An `api` store reads and writes its backend
|
|
109
|
+
An `api` store reads and writes its backend. The controllers read and write; the views get what a read kept. It also has its own state like any store (`get`, `set`, and `data` in its file): UI state about that backend (a page, an order) can live next to it.
|
|
103
110
|
|
|
104
111
|
```ts
|
|
105
112
|
// src/controllers/users.controller.ts
|
|
106
113
|
export default createController(({ stores: { backend } }) => ({
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
load: () => backend.read('/users'),
|
|
115
|
+
add: async (sName: string) => {
|
|
116
|
+
await backend.create('/users', { name: sName });
|
|
117
|
+
return backend.read('/users');
|
|
118
|
+
},
|
|
110
119
|
}));
|
|
111
120
|
```
|
|
112
121
|
|
|
113
122
|
```tsx
|
|
114
123
|
// src/components/Users.tsx
|
|
115
|
-
export default usersController.bindView(({ stores: { backend },
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
))
|
|
124
|
+
export default usersController.bindView(({ stores: { backend }, load, add }) => {
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
load();
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<ul onClick={() => add('Bruno')}>
|
|
131
|
+
{(backend.get('/users')?.result ?? []).map(oUser => (
|
|
132
|
+
<li key={oUser.id}>{oUser.name}</li>
|
|
133
|
+
))}
|
|
134
|
+
</ul>
|
|
135
|
+
);
|
|
136
|
+
});
|
|
122
137
|
```
|
|
123
138
|
|
|
124
|
-
- `read(path, query?)` returns the `
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
- `
|
|
128
|
-
- A read
|
|
129
|
-
-
|
|
130
|
-
-
|
|
139
|
+
- `read(path, query?)` returns a promise of the `ApiResponse` and keeps it in the store for that path and query, over what the store had there (a field the new answer does not bring stays from the last one). The view gets it with `get(path, query?)`, and renders again when a read keeps a new one.
|
|
140
|
+
- Each path has a version that its writes raise. A read answers from the store while it was kept at the current version of its path; otherwise it asks the backend (`GET`, or `QUERY` with the query as JSON body). An answer that comes back after a write on its path is returned but not kept.
|
|
141
|
+
- `create` (`POST <path>`), `update` (`PUT <path>`) and `delete` (`DELETE <path>`) return a promise of the `ApiResponse` and raise the version of their path and its parents (`update('/users/7')` raises `/users/7` and `/users`), so their next reads ask the backend, whatever their query. Other paths keep what they have.
|
|
142
|
+
- A successful `update` also keeps its `ApiResponse` at its path, over what the store had there, with the new version of the path: its next read answers from the store. `create` and `delete` keep nothing.
|
|
143
|
+
- A read or a write always resolves with an `ApiResponse`. When the backend fails it is the backend's (`success: false`, `message`); an answer that is not JSON becomes `{ success: false, message: 'Request failed', result: <the text> }`, and no answer `{ success: false, message: 'Request failed' }`.
|
|
144
|
+
- Each failed request adds its `ApiResponse` to the store's `/error/list` as an `ApiError`, with `message` set to `'Request failed'` when the backend brings none and `timestamp` (`Date.now()`) of when it failed. The views get it with `get('/error/list')`, or through the `error` component of `@nano-app/components`.
|
|
145
|
+
- On the server (the first HTML that Next renders) `read` asks nothing and resolves with an empty `ApiResponse` (`{}`).
|
|
146
|
+
- `/busy` is `true` while a request of the store is out (`backend.get('/busy') ? 'Loading…' : …`).
|
|
147
|
+
- What another user changes is seen after a write on the same path.
|
|
131
148
|
|
|
132
|
-
To ask only when the user stops typing, keep the text in the `app` store and
|
|
149
|
+
To ask only when the user stops typing, keep the text in the `app` store and read after a pause:
|
|
133
150
|
|
|
134
151
|
```ts
|
|
135
152
|
let oTimer: ReturnType<typeof setTimeout>;
|
|
136
|
-
export default createController(({ stores: { app } }) => ({
|
|
153
|
+
export default createController(({ stores: { app, backend } }) => ({
|
|
137
154
|
type: (sText: string) => {
|
|
138
155
|
app.set('/search/text', sText);
|
|
139
156
|
clearTimeout(oTimer);
|
|
140
|
-
oTimer = setTimeout(() =>
|
|
157
|
+
oTimer = setTimeout(() => {
|
|
158
|
+
app.set('/search/applied', sText);
|
|
159
|
+
backend.read('/users', { filter: { name: { contains: sText } } });
|
|
160
|
+
}, 300);
|
|
141
161
|
},
|
|
142
162
|
}));
|
|
143
|
-
// The view
|
|
163
|
+
// The view gets backend.get('/users', { filter: { name: { contains: app.get('/search/applied') } } })
|
|
144
164
|
```
|
|
145
165
|
|
|
146
166
|
### Headers per backend
|
|
@@ -155,9 +175,63 @@ export default { type: 'api', headers: { 'x-api-key': 'STRIPE_KEY' } };
|
|
|
155
175
|
The proxy sends `x-api-key` with the value of `API_STRIPE_KEY_HEADER` (the name in upper case) on every request to that store; a missing variable answers 500 with its name.
|
|
156
176
|
|
|
157
177
|
- A store header wins over a browser header with the same name: the browser cannot replace or guess the key.
|
|
178
|
+
- The proxy adds the key to every request to that store, from any visitor, with or without a session: anyone who reaches the app can call any path and method of that backend with it. Declare a key only for a backend whose every endpoint may be called by your visitors, and keep the rest protected there.
|
|
179
|
+
- The proxy sends every header of the browser to the backend, cookies included: point a store only at a backend you trust with your users' cookies.
|
|
158
180
|
- The user's own credentials (the session cookie, `Authorization`) still go as the browser sends them. Do not declare a header the user needs in the store, or every user goes with the store's value.
|
|
159
181
|
- Without a `matcher`, Next runs the proxy on every request; to skip the rest, add `export const config = { matcher: '/api/:path*' };` to `proxy.ts` (Next reads it only when it is written there).
|
|
160
182
|
|
|
183
|
+
### Sessions
|
|
184
|
+
|
|
185
|
+
With [Better Auth](../backend/README.md#better-auth) on in the backend, its store declares `auth`, the `basePath` of Better Auth there. Without it the store stays as is.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
// src/stores/backend.store.ts
|
|
189
|
+
export default { type: 'api', auth: '/api/auth' };
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// src/controllers/session.controller.ts
|
|
194
|
+
export default createController(({ stores: { backend } }) => ({
|
|
195
|
+
load: () => backend.readSession(),
|
|
196
|
+
signIn: (sEmail: string, sPassword: string) =>
|
|
197
|
+
backend.signIn('email', { email: sEmail, password: sPassword }),
|
|
198
|
+
signOut: () => backend.signOut(),
|
|
199
|
+
}));
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- `signIn(kind, body)` and `signUp(kind, body)` send `POST <auth>/sign-in/<kind>` and `POST <auth>/sign-up/<kind>`: `kind` is the method of Better Auth (`email`, ...), and `body` what it asks. `signOut()` sends `POST <auth>/sign-out`.
|
|
203
|
+
- They return Better Auth's answer inside an `ApiResponse`: `{ success, result, message }`, with `success` when the backend answered 2xx and its `message` when it failed.
|
|
204
|
+
- `readSession()` asks `GET <auth>/get-session` and keeps `{ user, session }` at `/session`, or `null` without a session. It answers from the store until a `signIn`, `signUp` or `signOut` succeeds, which reads it again. On the server it asks nothing and resolves with `{}`.
|
|
205
|
+
- Any 401 of the store's backend sets `/session` to `null`, and the next `readSession()` asks again.
|
|
206
|
+
- The session lives in Better Auth's cookie, which the proxy passes as any other: a refresh reads it again with `readSession()`.
|
|
207
|
+
- Better Auth checks the `Origin` of every `POST`: add the URL of the frontend to its `trustedOrigins` in the backend.
|
|
208
|
+
- With two backends with Better Auth, their cookies get the same name on the frontend's domain: give one of them another `advanced.cookiePrefix`.
|
|
209
|
+
|
|
210
|
+
A page that needs a session gets a layout bound to the session controller: it renders nothing until the session is read, and goes to the login without one, also after a 401.
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
// src/components/Private.tsx
|
|
214
|
+
'use client';
|
|
215
|
+
|
|
216
|
+
export default sessionController.bindView(({ props, stores: { backend }, load }) => {
|
|
217
|
+
const oRouter = useRouter();
|
|
218
|
+
const oSession = backend.get('/session');
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
load();
|
|
222
|
+
}, []);
|
|
223
|
+
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (oSession === null) oRouter.replace('/login');
|
|
226
|
+
}, [oSession]);
|
|
227
|
+
|
|
228
|
+
return oSession ? props.children : null;
|
|
229
|
+
});
|
|
230
|
+
// app/private/layout.tsx renders <Private>{children}</Private>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The data stays protected by the backend: the layout only keeps the page from showing.
|
|
234
|
+
|
|
161
235
|
### With a proxy of your own
|
|
162
236
|
|
|
163
237
|
Next takes one `proxy.ts`. To keep yours, call nano-app's from it for `/api` and include `/api/:path*` in your `matcher`:
|
|
@@ -188,8 +262,18 @@ pnpm --filter nano-frontend-test-app build
|
|
|
188
262
|
pnpm --filter nano-frontend-test-app start # http://localhost:3100
|
|
189
263
|
```
|
|
190
264
|
|
|
191
|
-
The `Users` component reads and writes `/users` of the `backend` store: point it at any backend that answers `GET`, `POST`, `PUT` and `DELETE` on `/users` with an `ApiResponse`, and give it the key its store declares:
|
|
265
|
+
The `Users` component reads and writes `/users` of the `backend` store, and `UsersList` pages and sorts it with `list` of [`@nano-app/components`](../components): point it at any backend that answers `GET`, `QUERY` (`skip`, `top`, `orderby`), `POST`, `PUT` and `DELETE` on `/users` with an `ApiResponse`, and give it the key its store declares:
|
|
192
266
|
|
|
193
267
|
```bash
|
|
194
268
|
API_BACKEND_URL=http://localhost:3000 API_BACKEND_KEY_HEADER=dev pnpm --filter nano-frontend-test-app start
|
|
195
269
|
```
|
|
270
|
+
|
|
271
|
+
The `backend` store declares `auth: '/api/auth'`: the backend has Better Auth with `emailAndPassword`, and the URL of the app (`http://localhost:3100`) in its `trustedOrigins`. `/login` signs in, `/` shows the session with a button to sign out, and `/private` shows only with a session.
|
|
272
|
+
|
|
273
|
+
## Design
|
|
274
|
+
|
|
275
|
+
- The browser never talks to the backend: only the Next server does, and the backend URL never reaches the client bundle.
|
|
276
|
+
- The frontend has no credential of its own and does not depend on any backend plugin. What the backend exposes and how it is protected is decided in the backend.
|
|
277
|
+
- No `glob` or `jiti` in the client bundle, and no generated files in `node_modules`: build data goes through Next's `env`.
|
|
278
|
+
- Rejected, not coming back: `useStore`, `createComponent`, `StoreManager`, controllers as classes, a `set` guard during render, a static (server) mode of the creator.
|
|
279
|
+
- Not yet, until real controllers ask for it: `onInit`/`onExit` in the controller, inheritance between controllers, a debounce of the framework, dropping old reads, reading what other users change, `PATCH`, the React Compiler.
|
package/build/core/const.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
//#region ../core/const.ts
|
|
2
|
+
/** HTTP methods a route can handle */
|
|
3
|
+
const HTTP_METHODS = [
|
|
4
|
+
"GET",
|
|
5
|
+
"POST",
|
|
6
|
+
"PUT",
|
|
7
|
+
"DELETE",
|
|
8
|
+
"QUERY"
|
|
9
|
+
];
|
|
2
10
|
/** Whether the code runs on the server, where Next renders each component once for the HTML */
|
|
3
11
|
const IS_SERVER = typeof window === "undefined";
|
|
4
12
|
/** Env var that carries the stores to the bundle. Read as `process.env.NANO_APP_STORES`, literal */
|
|
5
13
|
const STORES_ENV = "NANO_APP_STORES";
|
|
14
|
+
/** Message of a failed request whose answer brings none */
|
|
15
|
+
const REQUEST_FAILED = "Request failed";
|
|
6
16
|
/** Error of a component outside the provider */
|
|
7
17
|
const MISSING_PROVIDER = "[nano-app] [ERROR] A component has no stores. Wrap the root layout with NanoAppProvider.";
|
|
8
18
|
//#endregion
|
|
9
|
-
export { IS_SERVER, MISSING_PROVIDER, STORES_ENV };
|
|
19
|
+
export { HTTP_METHODS, IS_SERVER, MISSING_PROVIDER, REQUEST_FAILED, STORES_ENV };
|
|
@@ -4,6 +4,7 @@ import { createJiti } from "jiti";
|
|
|
4
4
|
//#region ../core/loader/FileReader.ts
|
|
5
5
|
/** Reads and loads files from disk, resolved from `cwd` */
|
|
6
6
|
var FileReader = class {
|
|
7
|
+
/** Loader of the consumer's modules; the Prisma runtime is loaded natively */
|
|
7
8
|
static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
|
|
8
9
|
/** Loads a module via jiti */
|
|
9
10
|
static async loadModule(sFilePath, oOptions = {}) {
|
|
@@ -1,11 +1,5 @@
|
|
|
1
|
+
import { HTTP_METHODS } from "../const.js";
|
|
1
2
|
//#region ../core/loader/Validator.ts
|
|
2
|
-
const HTTP_METHODS = [
|
|
3
|
-
"GET",
|
|
4
|
-
"POST",
|
|
5
|
-
"PUT",
|
|
6
|
-
"DELETE",
|
|
7
|
-
"QUERY"
|
|
8
|
-
];
|
|
9
3
|
const STORE_TYPES = ["app", "api"];
|
|
10
4
|
/** Checks the consumer's files: invalid ones are warned about and discarded */
|
|
11
5
|
var Validator = class Validator {
|
|
@@ -24,15 +18,12 @@ var Validator = class Validator {
|
|
|
24
18
|
if (oEntry && typeof oEntry === "object") return true;
|
|
25
19
|
return Validator.warn(sFilePath, "the default export is not an object.");
|
|
26
20
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Type guard for a `*.route.ts` entry.
|
|
29
|
-
*/
|
|
21
|
+
/** Type guard for a `*.route.ts` entry */
|
|
30
22
|
static isRoute(oEntry, sFilePath) {
|
|
31
23
|
if (!Validator.isObject(oEntry, sFilePath)) return false;
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
if (!HTTP_METHODS.some((sMethod) => oRoute[sMethod])) return Validator.warn(sFilePath, "defines no HTTP method.");
|
|
24
|
+
if (oEntry.expression && typeof oEntry.expression !== "string") return Validator.warn(sFilePath, "\"expression\" must be a string.");
|
|
25
|
+
for (const sMethod of HTTP_METHODS) if (oEntry[sMethod] && typeof oEntry[sMethod] !== "function") return Validator.warn(sFilePath, `"${sMethod}" must be a function.`);
|
|
26
|
+
if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) return Validator.warn(sFilePath, "defines no HTTP method.");
|
|
36
27
|
return true;
|
|
37
28
|
}
|
|
38
29
|
/** Type guard for a `*.store.ts` entry */
|
|
@@ -41,11 +32,10 @@ var Validator = class Validator {
|
|
|
41
32
|
if (!STORE_TYPES.includes(oEntry.type)) return Validator.warn(sFilePath, `its type must be "app" or "api".`);
|
|
42
33
|
if (oEntry.data !== void 0 && (!oEntry.data || typeof oEntry.data !== "object")) return Validator.warn(sFilePath, "\"data\" must be an object.");
|
|
43
34
|
if (oEntry.headers !== void 0 && (!Validator.isObject(oEntry.headers, sFilePath) || Object.values(oEntry.headers).some((sValue) => typeof sValue !== "string"))) return Validator.warn(sFilePath, "\"headers\" must be an object of strings.");
|
|
35
|
+
if (oEntry.auth !== void 0 && typeof oEntry.auth !== "string") return Validator.warn(sFilePath, "\"auth\" must be a string: the base path of Better Auth.");
|
|
44
36
|
return true;
|
|
45
37
|
}
|
|
46
|
-
/**
|
|
47
|
-
* Type guard for a `.prisma` entry: the contents of the file, without comments.
|
|
48
|
-
*/
|
|
38
|
+
/** Type guard for a `.prisma` entry: the contents of the file, without comments */
|
|
49
39
|
static isPrisma(oEntry, sFilePath) {
|
|
50
40
|
if (typeof oEntry !== "string") return Validator.warn(sFilePath, "the contents are not a string.");
|
|
51
41
|
const sProvider = oEntry.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*["']([^"']+)["']/s)?.[1];
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
var Formatter = class {
|
|
4
4
|
/** Message of anything thrown: its `message` when it has one, else the value itself */
|
|
5
5
|
static error(oError) {
|
|
6
|
-
return String(oError
|
|
6
|
+
if (oError && typeof oError === "object" && "message" in oError) return String(oError.message ?? oError);
|
|
7
|
+
return String(oError);
|
|
7
8
|
}
|
|
8
9
|
/** Formats a date for the request log, in UTC: `2026-10-19 12:03:10` */
|
|
9
10
|
static logDate(oDate = /* @__PURE__ */ new Date()) {
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import Store from "../stores/Store.js";
|
|
2
2
|
import ApiStore from "../stores/ApiStore.js";
|
|
3
|
+
import AuthStore from "../stores/AuthStore.js";
|
|
4
|
+
import STORE_FILES from "../stores/storeFiles.js";
|
|
3
5
|
//#region src/creators/createStores.ts
|
|
4
6
|
/** Stores of one React tree, from the ones `withNanoApp` inlined at build time */
|
|
5
7
|
function createStores() {
|
|
6
|
-
const oFiles = JSON.parse(process.env.NANO_APP_STORES ?? "{}");
|
|
7
8
|
return {
|
|
8
|
-
...Object.fromEntries(Object.entries(
|
|
9
|
-
|
|
10
|
-
})),
|
|
11
|
-
["app"]: new Store(oFiles["app"]?.data)
|
|
9
|
+
...Object.fromEntries(Object.entries(STORE_FILES).map(([sName, oFile]) => [sName, createStore(sName, oFile)])),
|
|
10
|
+
["app"]: new Store(STORE_FILES["app"]?.data)
|
|
12
11
|
};
|
|
13
12
|
}
|
|
13
|
+
/** Store of one file: an api store reads its backend, with Better Auth when it declares `auth` */
|
|
14
|
+
function createStore(sName, oFile) {
|
|
15
|
+
if (oFile.type !== "api") return new Store(oFile.data);
|
|
16
|
+
if (oFile.auth) return new AuthStore(sName, oFile.auth, oFile.data);
|
|
17
|
+
return new ApiStore(sName, oFile.data);
|
|
18
|
+
}
|
|
14
19
|
//#endregion
|
|
15
20
|
export { createStores as default };
|
package/build/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import { ApiError } from "./types/store.js";
|
|
1
2
|
import { NanoAppProvider } from "./react/NanoAppProvider.js";
|
|
2
3
|
import { ComponentContext, ComponentProps, Controller, View } from "./types/component.js";
|
|
3
4
|
import createController from "./creators/createController.js";
|
|
4
|
-
export { type ComponentContext, type ComponentProps, type Controller, NanoAppProvider, type View, createController };
|
|
5
|
+
export { type ApiError, type ComponentContext, type ComponentProps, type Controller, NanoAppProvider, type View, createController };
|
package/build/proxy.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import STORE_FILES from "./stores/storeFiles.js";
|
|
1
2
|
import { NextResponse } from "next/server";
|
|
2
3
|
//#region src/proxy.ts
|
|
3
4
|
/** Sends `/api/<store>/<path>` to the backend of `<store>`, in `API_<STORE>_URL`, with its headers */
|
|
4
5
|
async function proxy(oReq) {
|
|
5
6
|
const [, sPrefix, sName, ...aPath] = oReq.nextUrl.pathname.split("/");
|
|
6
7
|
if (sPrefix !== "api" || !sName) return NextResponse.next();
|
|
7
|
-
const oFiles = JSON.parse(process.env.NANO_APP_STORES ?? "{}");
|
|
8
8
|
const sUrlEnv = `API_${sName.toUpperCase()}_URL`;
|
|
9
|
-
const aHeaders = Object.entries(
|
|
9
|
+
const aHeaders = Object.entries(STORE_FILES[sName]?.headers ?? {}).map(([sHeader, sValue]) => [sHeader, `API_${sValue.toUpperCase()}_HEADER`]);
|
|
10
10
|
const sMissing = [sUrlEnv, ...aHeaders.map(([, sEnvName]) => sEnvName)].find((sEnvName) => !process.env[sEnvName]);
|
|
11
11
|
if (sMissing) return NextResponse.json({
|
|
12
12
|
success: false,
|
package/build/stores/ApiStore.js
CHANGED
|
@@ -1,80 +1,115 @@
|
|
|
1
|
-
import { IS_SERVER } from "../core/const.js";
|
|
1
|
+
import { IS_SERVER, REQUEST_FAILED } from "../core/const.js";
|
|
2
2
|
import Store from "./Store.js";
|
|
3
|
-
import { observable, runInAction } from "mobx";
|
|
4
3
|
//#region src/stores/ApiStore.ts
|
|
5
|
-
/**
|
|
6
|
-
* Backend data of an `api` store, through its proxy (`/api/<name>/...`), by path and query.
|
|
7
|
-
* Its own state (`get`/`set`) is the one of a `Store`; what it read stays apart.
|
|
8
|
-
*/
|
|
4
|
+
/** Backend of an `api` store through its proxy; each read keeps its `ApiResponse` in the store */
|
|
9
5
|
var ApiStore = class ApiStore extends Store {
|
|
10
6
|
sName;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
oVersions: {}
|
|
15
|
-
});
|
|
16
|
-
oRead = {};
|
|
7
|
+
oVersions = {};
|
|
8
|
+
oKept = {};
|
|
9
|
+
iCount = 0;
|
|
17
10
|
/** Store of the backend `API_<NAME>_URL`, with the initial data of its file */
|
|
18
11
|
constructor(sName, oData) {
|
|
19
12
|
super(oData);
|
|
20
13
|
this.sName = sName;
|
|
21
14
|
}
|
|
22
|
-
/**
|
|
23
|
-
get
|
|
24
|
-
return
|
|
15
|
+
/** Value at a path, with the query of the read that kept it */
|
|
16
|
+
get(sPath, oQuery) {
|
|
17
|
+
return super.get(ApiStore.key(sPath, oQuery));
|
|
25
18
|
}
|
|
26
|
-
/**
|
|
27
|
-
read(sPath, oQuery) {
|
|
28
|
-
if (IS_SERVER) return
|
|
29
|
-
const sKey =
|
|
30
|
-
const iVersion = this.
|
|
31
|
-
if (this.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
19
|
+
/** `ApiResponse` of a path and query, from the store while it was kept at its path's version */
|
|
20
|
+
async read(sPath, oQuery) {
|
|
21
|
+
if (IS_SERVER) return {};
|
|
22
|
+
const sKey = ApiStore.key(sPath, oQuery);
|
|
23
|
+
const iVersion = this.oVersions[sPath] ?? 0;
|
|
24
|
+
if (this.oKept[sKey] === iVersion) return this.get(sPath, oQuery);
|
|
25
|
+
const oInit = oQuery ? {
|
|
26
|
+
method: "QUERY",
|
|
27
|
+
body: JSON.stringify(oQuery)
|
|
28
|
+
} : { method: "GET" };
|
|
29
|
+
const oResponse = await this.request(sPath, oInit);
|
|
30
|
+
if ((this.oVersions[sPath] ?? 0) !== iVersion) return oResponse;
|
|
31
|
+
this.keep(oResponse, sPath, oQuery);
|
|
32
|
+
return oResponse;
|
|
40
33
|
}
|
|
41
34
|
/** `POST <path>` */
|
|
42
35
|
create(sPath, oBody) {
|
|
43
36
|
return this.write("POST", sPath, oBody);
|
|
44
37
|
}
|
|
45
|
-
/** `PUT <path
|
|
46
|
-
update(sPath, oBody) {
|
|
47
|
-
|
|
38
|
+
/** `PUT <path>`, keeping a successful answer at its path with its new version */
|
|
39
|
+
async update(sPath, oBody) {
|
|
40
|
+
const oResponse = await this.write("PUT", sPath, oBody);
|
|
41
|
+
if (!oResponse.success) return oResponse;
|
|
42
|
+
this.keep(oResponse, sPath);
|
|
43
|
+
return oResponse;
|
|
48
44
|
}
|
|
49
45
|
/** `DELETE <path>` */
|
|
50
46
|
delete(sPath) {
|
|
51
47
|
return this.write("DELETE", sPath);
|
|
52
48
|
}
|
|
53
|
-
/** Sends a write and
|
|
54
|
-
write(sMethod, sPath, oBody) {
|
|
55
|
-
|
|
49
|
+
/** Sends a write and raises the version of its path and its parents */
|
|
50
|
+
async write(sMethod, sPath, oBody) {
|
|
51
|
+
const oResponse = await this.request(sPath, {
|
|
56
52
|
method: sMethod,
|
|
57
53
|
body: JSON.stringify(oBody)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}));
|
|
54
|
+
});
|
|
55
|
+
for (const sParent of ApiStore.parents(sPath)) this.oVersions[sParent] = (this.oVersions[sParent] ?? 0) + 1;
|
|
56
|
+
return oResponse;
|
|
62
57
|
}
|
|
63
|
-
/** Sends a request
|
|
58
|
+
/** Sends a request, keeping `/busy` while it is out and a failed answer in `/error/list` */
|
|
64
59
|
async request(sPath, oInit) {
|
|
65
|
-
|
|
60
|
+
this.iCount++;
|
|
61
|
+
this.set("/busy", true);
|
|
62
|
+
let sText;
|
|
63
|
+
let oResponse = {
|
|
64
|
+
success: false,
|
|
65
|
+
message: REQUEST_FAILED
|
|
66
|
+
};
|
|
66
67
|
try {
|
|
67
|
-
|
|
68
|
+
const oRes = await fetch(`/api/${this.sName}${sPath}`, {
|
|
68
69
|
...oInit,
|
|
69
70
|
headers: { "content-type": "application/json" }
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
});
|
|
72
|
+
sText = await oRes.text();
|
|
73
|
+
oResponse = this.receive(sPath, oRes, JSON.parse(sText));
|
|
74
|
+
} catch {
|
|
75
|
+
if (sText !== void 0) oResponse = {
|
|
76
|
+
...oResponse,
|
|
77
|
+
result: sText
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!oResponse.success) {
|
|
81
|
+
const oError = {
|
|
82
|
+
...oResponse,
|
|
83
|
+
message: oResponse.message || "Request failed",
|
|
84
|
+
timestamp: Date.now()
|
|
85
|
+
};
|
|
86
|
+
this.set("/error/list", [...this.get("/error/list") ?? [], oError]);
|
|
73
87
|
}
|
|
88
|
+
this.iCount--;
|
|
89
|
+
this.set("/busy", this.iCount > 0);
|
|
90
|
+
return oResponse;
|
|
91
|
+
}
|
|
92
|
+
/** `ApiResponse` of an answer of the proxy, from its JSON */
|
|
93
|
+
receive(_sPath, _oRes, oResponse) {
|
|
94
|
+
return oResponse;
|
|
95
|
+
}
|
|
96
|
+
/** Keeps an answer at its path and query, over what was there, with its path's version */
|
|
97
|
+
keep(oResponse, sPath, oQuery) {
|
|
98
|
+
const sKey = ApiStore.key(sPath, oQuery);
|
|
99
|
+
this.set(sKey, {
|
|
100
|
+
...this.get(sPath, oQuery),
|
|
101
|
+
...oResponse
|
|
102
|
+
});
|
|
103
|
+
this.oKept[sKey] = this.oVersions[sPath] ?? 0;
|
|
104
|
+
}
|
|
105
|
+
/** A path and each of its parents */
|
|
106
|
+
static parents(sPath) {
|
|
107
|
+
const aKeys = sPath.split("/").filter(Boolean);
|
|
108
|
+
return aKeys.map((_sKey, iIndex) => `/${aKeys.slice(0, iIndex + 1).join("/")}`);
|
|
74
109
|
}
|
|
75
|
-
/**
|
|
76
|
-
static
|
|
77
|
-
return
|
|
110
|
+
/** Key of a read: its path, with its query when it has one */
|
|
111
|
+
static key(sPath, oQuery) {
|
|
112
|
+
return oQuery ? `${sPath}?${JSON.stringify(oQuery)}` : sPath;
|
|
78
113
|
}
|
|
79
114
|
};
|
|
80
115
|
//#endregion
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { IS_SERVER } from "../core/const.js";
|
|
2
|
+
import ApiStore from "./ApiStore.js";
|
|
3
|
+
//#region src/stores/AuthStore.ts
|
|
4
|
+
/** Api store of a backend with Better Auth: signs in and out, and keeps the session */
|
|
5
|
+
var AuthStore = class extends ApiStore {
|
|
6
|
+
sBasePath;
|
|
7
|
+
oSession;
|
|
8
|
+
/** Store of the backend `API_<NAME>_URL`, with Better Auth at its base path */
|
|
9
|
+
constructor(sName, sBasePath, oData) {
|
|
10
|
+
super(sName, oData);
|
|
11
|
+
this.sBasePath = sBasePath;
|
|
12
|
+
}
|
|
13
|
+
/** `POST <basePath>/sign-in/<kind>` (`email`...), reading the session again on success */
|
|
14
|
+
signIn(sKind, oBody) {
|
|
15
|
+
return this.post(`/sign-in/${sKind}`, oBody);
|
|
16
|
+
}
|
|
17
|
+
/** `POST <basePath>/sign-up/<kind>`, reading the session again on success */
|
|
18
|
+
signUp(sKind, oBody) {
|
|
19
|
+
return this.post(`/sign-up/${sKind}`, oBody);
|
|
20
|
+
}
|
|
21
|
+
/** `POST <basePath>/sign-out`, reading the session again on success */
|
|
22
|
+
signOut() {
|
|
23
|
+
return this.post("/sign-out", {});
|
|
24
|
+
}
|
|
25
|
+
/** `GET <basePath>/get-session` into `/session`, kept until the next sign-in, -up or -out */
|
|
26
|
+
async readSession() {
|
|
27
|
+
if (IS_SERVER) return {};
|
|
28
|
+
if (this.oSession) return this.oSession;
|
|
29
|
+
const oResponse = await this.request(`${this.sBasePath}/get-session`, { method: "GET" });
|
|
30
|
+
this.oSession = oResponse;
|
|
31
|
+
this.set("/session", oResponse.result ?? null);
|
|
32
|
+
return oResponse;
|
|
33
|
+
}
|
|
34
|
+
/** Better Auth's answer inside an `ApiResponse`; a 401 of the backend clears the session */
|
|
35
|
+
receive(sPath, oRes, oBody) {
|
|
36
|
+
if (oRes.status === 401) {
|
|
37
|
+
this.oSession = void 0;
|
|
38
|
+
this.set("/session", null);
|
|
39
|
+
}
|
|
40
|
+
if (!sPath.startsWith(`${this.sBasePath}/`)) return oBody;
|
|
41
|
+
return {
|
|
42
|
+
success: oRes.ok,
|
|
43
|
+
result: oBody,
|
|
44
|
+
message: oBody?.message
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Sends a `POST` to Better Auth and, on success, reads the session again */
|
|
48
|
+
async post(sPath, oBody) {
|
|
49
|
+
const oResponse = await this.request(`${this.sBasePath}${sPath}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: JSON.stringify(oBody)
|
|
52
|
+
});
|
|
53
|
+
if (!oResponse.success) return oResponse;
|
|
54
|
+
this.oSession = void 0;
|
|
55
|
+
await this.readSession();
|
|
56
|
+
return oResponse;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
export { AuthStore as default };
|
package/build/types/store.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { APP_NAME } from "../core/const.js";
|
|
2
2
|
import { NanoObject } from "../core/types/objects.js";
|
|
3
|
+
import { ApiResponse } from "../core/types/api.js";
|
|
3
4
|
import Store from "../stores/Store.js";
|
|
4
5
|
//#region src/types/store.d.ts
|
|
6
|
+
/** A failed request in the `/error/list` of its api store: its `ApiResponse` and when it failed */
|
|
7
|
+
export type ApiError = ApiResponse & {
|
|
8
|
+
/** Milliseconds since the epoch, from `Date.now()` */
|
|
9
|
+
timestamp: number;
|
|
10
|
+
};
|
|
5
11
|
/** Stores of a React tree by name: a `Store` per `app` file, an `ApiStore` per `api` file */
|
|
6
12
|
export type Stores = NanoObject<any> & {
|
|
7
13
|
[APP_NAME]: Store;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "React provider for Next.js apps on top of @nano-app/backend",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
|
-
"node": ">=
|
|
47
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"next": ">=16.3.0",
|