@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 CHANGED
@@ -1,6 +1,11 @@
1
1
  # @nano-app/frontend
2
2
 
3
- 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. Work in progress.
3
+ [![frontend](https://img.shields.io/npm/v/@nano-app/frontend?label=frontend)](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, and keeps what it read. It also has its own state like any store (`get`, `set`, and `data` in its file), apart from what it read: UI state about that backend (a page, an order) can live next to it.
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
- add: (sName: string) => backend.create('/users', { name: sName }),
108
- rename: (iId: number, sName: string) => backend.update(`/users/${iId}`, { name: sName }),
109
- remove: (iId: number) => backend.delete(`/users/${iId}`),
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 }, rename }) => (
116
- <ul>
117
- {(backend.read('/users') ?? []).map(oUser => (
118
- <li key={oUser.id} onClick={() => rename(oUser.id, 'Bruno')}>{oUser.name}</li>
119
- ))}
120
- </ul>
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 `result` it keeps for that path and query, `undefined` until it arrives. It works in the render: the first read, or a read after its entity changed, asks the backend (`GET`, or `QUERY` with the query as JSON body), and the view renders again when the answer comes. Each path and query is asked once per change of its entity.
125
- - The entity is the first segment of the path (`/users/7` → `/users`). `create` (`POST <path>`), `update` (`PUT <path>`) and `delete` (`DELETE <path>`) return the `ApiResponse`, and when it comes back every read of the entity asks again the next time it renders, whatever its query. Other entities keep what they have.
126
- - On the server (the first HTML that Next renders) `read` asks nothing and returns `undefined`: the browser asks when it hydrates.
127
- - `busy` counts the requests in progress (`backend.busy ? 'Loading…' : …`).
128
- - A read keeps the `result` of its answer, `undefined` when it failed, and is not asked again until a write on its entity. A request that fails (no answer, or not JSON) rejects: the errors module will show it.
129
- - What another user changes is seen after a write on the same entity.
130
- - Every path and query read stays kept while the page is open.
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 copy it to the query after a pause:
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(() => app.set('/search/applied', sText), 300);
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 reads backend.read('/users', { filter: { name: { contains: app.get('/search/applied') } } })
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.
@@ -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
- const oRoute = oEntry;
33
- if (oRoute.expression && typeof oRoute.expression !== "string") return Validator.warn(sFilePath, "\"expression\" must be a string.");
34
- for (const sMethod of HTTP_METHODS) if (oRoute[sMethod] && typeof oRoute[sMethod] !== "function") return Validator.warn(sFilePath, `"${String(sMethod)}" must be a function.`);
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];
@@ -0,0 +1,10 @@
1
+ //#region ../core/types/api.d.ts
2
+ /** API response */
3
+ export type ApiResponse<T = any> = {
4
+ status?: number;
5
+ success?: boolean;
6
+ result?: T;
7
+ message?: string;
8
+ warning?: boolean;
9
+ };
10
+ //#endregion
@@ -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?.message ?? 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(oFiles).map(([sName, oFile]) => {
9
- return [sName, oFile.type === "api" ? new ApiStore(sName, oFile.data) : new Store(oFile.data)];
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(oFiles[sName]?.headers ?? {}).map(([sHeader, sValue]) => [sHeader, `API_${sValue.toUpperCase()}_HEADER`]);
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,
@@ -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
- oCache = observable({
12
- iBusy: 0,
13
- oResults: {},
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
- /** Requests in progress */
23
- get busy() {
24
- return this.oCache.iBusy;
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
- /** Result at a path: from local, asked to the backend when never read or its entity changed */
27
- read(sPath, oQuery) {
28
- if (IS_SERVER) return void 0;
29
- const sKey = oQuery ? `${sPath}?${JSON.stringify(oQuery)}` : sPath;
30
- const iVersion = this.oCache.oVersions[ApiStore.entity(sPath)] ?? 0;
31
- if (this.oRead[sKey] !== iVersion) {
32
- this.oRead[sKey] = iVersion;
33
- const oInit = oQuery ? {
34
- method: "QUERY",
35
- body: JSON.stringify(oQuery)
36
- } : { method: "GET" };
37
- queueMicrotask(() => this.request(sPath, oInit).then((oResponse) => runInAction(() => this.oCache.oResults[sKey] = oResponse.result)));
38
- }
39
- return this.oCache.oResults[sKey];
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
- return this.write("PUT", sPath, oBody);
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 changes the version of its entity when the answer comes back */
54
- write(sMethod, sPath, oBody) {
55
- return this.request(sPath, {
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
- }).finally(() => runInAction(() => {
59
- const sEntity = ApiStore.entity(sPath);
60
- this.oCache.oVersions[sEntity] = (this.oCache.oVersions[sEntity] ?? 0) + 1;
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 to the proxy and returns its JSON answer, counting it as busy */
58
+ /** Sends a request, keeping `/busy` while it is out and a failed answer in `/error/list` */
64
59
  async request(sPath, oInit) {
65
- runInAction(() => this.oCache.iBusy++);
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
- return await (await fetch(`/api/${this.sName}${sPath}`, {
68
+ const oRes = await fetch(`/api/${this.sName}${sPath}`, {
68
69
  ...oInit,
69
70
  headers: { "content-type": "application/json" }
70
- })).json();
71
- } finally {
72
- runInAction(() => this.oCache.iBusy--);
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
- /** Entity of a path: its first segment (`/users/7` → `/users`) */
76
- static entity(sPath) {
77
- return `/${sPath.split("/").filter(Boolean)[0] ?? ""}`;
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 };
@@ -0,0 +1,5 @@
1
+ //#region src/stores/storeFiles.ts
2
+ /** Store files that `withNanoApp` inlined at build time, read once when the app loads */
3
+ const STORE_FILES = JSON.parse(process.env.NANO_APP_STORES ?? "{}");
4
+ //#endregion
5
+ export { STORE_FILES as default };
@@ -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.5.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": ">=22"
47
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "next": ">=16.3.0",