@nano-app/frontend 0.3.6 → 0.5.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/build/core/const.d.ts +4 -0
  4. package/build/core/const.js +9 -0
  5. package/build/core/loader/FileLoader.js +38 -0
  6. package/build/core/loader/FileReader.js +30 -0
  7. package/build/core/loader/Validator.js +70 -0
  8. package/build/core/types/objects.d.ts +4 -0
  9. package/build/core/utils/Formatter.js +14 -0
  10. package/build/creators/createController.d.ts +6 -0
  11. package/build/creators/createController.js +27 -0
  12. package/build/creators/createStores.js +15 -0
  13. package/build/index.d.ts +4 -0
  14. package/build/index.js +3 -0
  15. package/build/next.d.ts +8 -0
  16. package/build/next.js +21 -0
  17. package/build/proxy.d.ts +5 -0
  18. package/build/proxy.js +25 -0
  19. package/build/react/NanoAppProvider.d.ts +7 -0
  20. package/build/react/NanoAppProvider.js +25 -0
  21. package/build/stores/ApiStore.js +81 -0
  22. package/build/stores/Store.d.ts +15 -0
  23. package/build/stores/Store.js +33 -0
  24. package/build/types/component.d.ts +21 -0
  25. package/build/types/store.d.ts +9 -0
  26. package/package.json +54 -33
  27. package/bin/generate-stores.mjs +0 -128
  28. package/dist/auto-register.mjs +0 -327
  29. package/dist/index.d.ts +0 -10
  30. package/dist/index.js +0 -34
  31. package/dist/store/ApiStore.d.ts +0 -13
  32. package/dist/store/ApiStore.js +0 -51
  33. package/dist/store/AppStore.d.ts +0 -25
  34. package/dist/store/AppStore.js +0 -59
  35. package/dist/store/RenderManager.d.ts +0 -47
  36. package/dist/store/RenderManager.js +0 -78
  37. package/dist/store/StoreManager.d.ts +0 -15
  38. package/dist/store/StoreManager.js +0 -30
  39. package/dist/ui/Controller.d.ts +0 -20
  40. package/dist/ui/Controller.js +0 -49
  41. package/dist/ui/createComponent.d.ts +0 -17
  42. package/dist/ui/createComponent.js +0 -32
  43. package/dist/utils/QueryBuilder.d.ts +0 -19
  44. package/dist/utils/QueryBuilder.js +0 -31
  45. package/dist/utils/Toast.d.ts +0 -12
  46. package/dist/utils/Toast.js +0 -28
  47. package/dist/utils/TokenHelper.d.ts +0 -5
  48. package/dist/utils/TokenHelper.js +0 -17
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cristian Serrano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # @nano-app/frontend
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.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ pnpm add @nano-app/frontend
9
+ ```
10
+
11
+ ```ts
12
+ // next.config.ts
13
+ import { withNanoApp } from '@nano-app/frontend/next';
14
+
15
+ export default withNanoApp({});
16
+ ```
17
+
18
+ ```tsx
19
+ // app/layout.tsx
20
+ import { NanoAppProvider } from '@nano-app/frontend';
21
+ import type { ReactNode } from 'react';
22
+
23
+ export default function RootLayout({ children }: { children: ReactNode }): ReactNode {
24
+ return (
25
+ <html lang="en">
26
+ <body>
27
+ <NanoAppProvider>{children}</NanoAppProvider>
28
+ </body>
29
+ </html>
30
+ );
31
+ }
32
+ ```
33
+
34
+ ## Stores
35
+
36
+ A `src/stores/**/*.store.ts` is a store of UI state, named after its path (`src/stores/admin/users.store.ts` → `admin_users`). `withNanoApp` loads them at build time; `NanoAppProvider` gives each React tree its own copy. The `app` store is always there, empty when the app has no `app.store.ts`.
37
+
38
+ ```ts
39
+ // src/stores/ui.store.ts
40
+ export default { type: 'app', data: { isOpen: false } };
41
+ ```
42
+
43
+ Each store reads and writes by path: `app.get('/drawer/isOpen')`, `app.set('/drawer/isOpen', true)`. A missing level reads as `undefined`, and `set` creates it.
44
+
45
+ ## Controllers and views
46
+
47
+ A controller holds the methods; a view binds to one controller and gets its methods, the props and the stores. Several views can bind to the same controller.
48
+
49
+ ```ts
50
+ // src/controllers/counter.controller.ts
51
+ import { createController } from '@nano-app/frontend';
52
+
53
+ export default createController(({ props, stores: { app } }) => ({
54
+ increment: () => app.set('/iCount', app.get('/iCount') + 1),
55
+ }));
56
+ ```
57
+
58
+ ```tsx
59
+ // src/components/Counter.tsx
60
+ 'use client';
61
+
62
+ import counterController from '../controllers/counter.controller';
63
+
64
+ export default counterController.bindView(({ stores: { app }, increment }) => (
65
+ <button onClick={increment}>{app.get('/iCount')}</button>
66
+ ));
67
+ ```
68
+
69
+ - A file that calls `bindView` needs `'use client'`. A component without stores or methods is a plain React function and can stay a server component.
70
+ - The view re-renders only when a store path it read changes.
71
+ - A method named `props` or `stores` is hidden by the context of the same name.
72
+
73
+ `extend` returns a new controller with your methods merged over its own: a method with the same name replaces the original, the others stay, and the original controller does not change. Its methods receive the same context (`props`, `stores`) as the controller's.
74
+
75
+ ```ts
76
+ export default counterController.extend(({ stores: { app } }) => ({
77
+ reset: () => app.set('/iCount', 0),
78
+ }));
79
+ ```
80
+
81
+ ## Backend
82
+
83
+ 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.
84
+
85
+ ```ts
86
+ // src/stores/backend.store.ts
87
+ export default { type: 'api' };
88
+ ```
89
+
90
+ ```ts
91
+ // proxy.ts, next to app/
92
+ export { proxy } from '@nano-app/frontend/proxy';
93
+ ```
94
+
95
+ The backend URL is read at runtime from `API_<STORE>_URL` (`backend` → `API_BACKEND_URL`, `admin/users.store.ts` → `API_ADMIN_USERS_URL`), on the server only: it never reaches the browser. The prefix goes away (`/api/backend/users/7?top=1` → `$API_BACKEND_URL/users/7?top=1`); a backend with a `basePath` puts it in the URL (`API_BACKEND_URL=http://backend:3000/api`).
96
+
97
+ - The request keeps its method, headers, cookies and body, and the backend's answer comes back as is, `Set-Cookie` and redirects included.
98
+ - Every `/api/<name>/...` goes to `API_<NAME>_URL`, and one without its variable answers 500 with the name of the variable to define. A request outside `/api/<name>` goes on to the app's own routes.
99
+
100
+ ### Reading and writing
101
+
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.
103
+
104
+ ```ts
105
+ // src/controllers/users.controller.ts
106
+ 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}`),
110
+ }));
111
+ ```
112
+
113
+ ```tsx
114
+ // 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
+ ));
122
+ ```
123
+
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.
131
+
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:
133
+
134
+ ```ts
135
+ let oTimer: ReturnType<typeof setTimeout>;
136
+ export default createController(({ stores: { app } }) => ({
137
+ type: (sText: string) => {
138
+ app.set('/search/text', sText);
139
+ clearTimeout(oTimer);
140
+ oTimer = setTimeout(() => app.set('/search/applied', sText), 300);
141
+ },
142
+ }));
143
+ // The view reads backend.read('/users', { filter: { name: { contains: app.get('/search/applied') } } })
144
+ ```
145
+
146
+ ### Headers per backend
147
+
148
+ A backend that asks for a key (`x-api-key`) gets it from the proxy. The store names the header and the variable that holds its value, never the value itself, since store files reach the browser:
149
+
150
+ ```ts
151
+ // src/stores/payments.store.ts
152
+ export default { type: 'api', headers: { 'x-api-key': 'STRIPE_KEY' } };
153
+ ```
154
+
155
+ 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
+
157
+ - A store header wins over a browser header with the same name: the browser cannot replace or guess the key.
158
+ - 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
+ - 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
+
161
+ ### With a proxy of your own
162
+
163
+ Next takes one `proxy.ts`. To keep yours, call nano-app's from it for `/api` and include `/api/:path*` in your `matcher`:
164
+
165
+ ```ts
166
+ // proxy.ts
167
+ import { NextResponse } from 'next/server';
168
+ import { proxy as nanoProxy } from '@nano-app/frontend/proxy';
169
+ import type { NextRequest } from 'next/server';
170
+
171
+ export function proxy(oReq: NextRequest) {
172
+ if (oReq.nextUrl.pathname.startsWith('/api/')) return nanoProxy(oReq);
173
+
174
+ // Your own logic
175
+ return NextResponse.next();
176
+ }
177
+
178
+ export const config = { matcher: ['/api/:path*', '/dashboard/:path*'] };
179
+ ```
180
+
181
+ ## Test app
182
+
183
+ `tests/app` is a Next app that uses the package from the workspace:
184
+
185
+ ```bash
186
+ pnpm --filter @nano-app/frontend build
187
+ pnpm --filter nano-frontend-test-app build
188
+ pnpm --filter nano-frontend-test-app start # http://localhost:3100
189
+ ```
190
+
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:
192
+
193
+ ```bash
194
+ API_BACKEND_URL=http://localhost:3000 API_BACKEND_KEY_HEADER=dev pnpm --filter nano-frontend-test-app start
195
+ ```
@@ -0,0 +1,4 @@
1
+ //#region ../core/const.d.ts
2
+ /** Name of the app: the backend's main plugin and the frontend's store of UI state, always present */
3
+ export declare const APP_NAME = "app";
4
+ //#endregion
@@ -0,0 +1,9 @@
1
+ //#region ../core/const.ts
2
+ /** Whether the code runs on the server, where Next renders each component once for the HTML */
3
+ const IS_SERVER = typeof window === "undefined";
4
+ /** Env var that carries the stores to the bundle. Read as `process.env.NANO_APP_STORES`, literal */
5
+ const STORES_ENV = "NANO_APP_STORES";
6
+ /** Error of a component outside the provider */
7
+ const MISSING_PROVIDER = "[nano-app] [ERROR] A component has no stores. Wrap the root layout with NanoAppProvider.";
8
+ //#endregion
9
+ export { IS_SERVER, MISSING_PROVIDER, STORES_ENV };
@@ -0,0 +1,38 @@
1
+ import Validator from "./Validator.js";
2
+ import FileReader from "./FileReader.js";
3
+ import Formatter from "../utils/Formatter.js";
4
+ import { glob } from "glob";
5
+ //#region ../core/loader/FileLoader.ts
6
+ /** Finds, loads and validates the consumer's files */
7
+ var FileLoader = class {
8
+ /**
9
+ * 1. Validates a `.prisma` file.
10
+ * 2. Loads a generated Prisma client module (`client.ts`, or `client.js`)
11
+ */
12
+ static async findPrismaClient(sClientPath, sSchemaPath) {
13
+ const sContent = FileReader.loadSchema(sSchemaPath);
14
+ if (!Validator.validateUnique(sContent, "prisma", sSchemaPath)) throw new Error(`[nano-app] [ERROR] ${sSchemaPath} is not a valid Prisma schema.`);
15
+ try {
16
+ return (await FileReader.loadModule(sClientPath))?.PrismaClient;
17
+ } catch (oError) {
18
+ throw new Error(`[nano-app] [ERROR] The Prisma client ${sClientPath} could not be loaded: ${Formatter.error(oError)}`);
19
+ }
20
+ }
21
+ /** Finds `*.route.ts` (or built `.js`) files and returns `{ [subPath]: defaultExport }` */
22
+ static async findMany({ extension, sourceDir }) {
23
+ const sSrcDir = (sourceDir ?? `src/${extension}s`).replace(/^\.\//, "").replace(/\/$/, "");
24
+ const aFilePaths = await glob(`${sSrcDir}/**/*.${extension}.{ts,js}`, {
25
+ posix: true,
26
+ ignore: "**/*.d.ts"
27
+ });
28
+ const oEntries = {};
29
+ for (const sFilePath of aFilePaths) {
30
+ const sFileSubPath = sFilePath.slice(sSrcDir.length).replace(new RegExp(`\\.${extension}\\.(ts|js)$`), "");
31
+ const sKey = extension === "store" ? sFileSubPath.slice(1).replaceAll("/", "_") : sFileSubPath;
32
+ oEntries[sKey] = await FileReader.loadModule(sFilePath, { default: true });
33
+ }
34
+ return Validator.validate(oEntries, extension);
35
+ }
36
+ };
37
+ //#endregion
38
+ export { FileLoader as default };
@@ -0,0 +1,30 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { createJiti } from "jiti";
4
+ //#region ../core/loader/FileReader.ts
5
+ /** Reads and loads files from disk, resolved from `cwd` */
6
+ var FileReader = class {
7
+ static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
8
+ /** Loads a module via jiti */
9
+ static async loadModule(sFilePath, oOptions = {}) {
10
+ return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
11
+ }
12
+ /** Reads the contents of a file */
13
+ static loadFile(sFilePath) {
14
+ const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
15
+ if (!sContent) throw new Error(`[nano-app] [ERROR] The file ${sFilePath} does not have valid content.`);
16
+ return sContent;
17
+ }
18
+ /** Reads a Prisma schema without its `//` comments */
19
+ static loadSchema(sFilePath) {
20
+ return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
21
+ }
22
+ /** Resolves the absolute path of a file */
23
+ static getAbsolutePath(sFilePath) {
24
+ const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
25
+ if (!existsSync(sAbsolutePath)) throw new Error(`[nano-app] [ERROR] ${sFilePath} not found.`);
26
+ return sAbsolutePath;
27
+ }
28
+ };
29
+ //#endregion
30
+ export { FileReader as default };
@@ -0,0 +1,70 @@
1
+ //#region ../core/loader/Validator.ts
2
+ const HTTP_METHODS = [
3
+ "GET",
4
+ "POST",
5
+ "PUT",
6
+ "DELETE",
7
+ "QUERY"
8
+ ];
9
+ const STORE_TYPES = ["app", "api"];
10
+ /** Checks the consumer's files: invalid ones are warned about and discarded */
11
+ var Validator = class Validator {
12
+ static oValidators = {
13
+ route: () => this.isRoute,
14
+ prisma: () => this.isPrisma,
15
+ store: () => this.isStore
16
+ };
17
+ /** Warns about an invalid file and returns `false`, to discard it */
18
+ static warn(sFilePath, sMessage) {
19
+ console.warn(`[nano-app] [WARNING] ${sFilePath}: ${sMessage}`);
20
+ return false;
21
+ }
22
+ /** Checks that the default export is an object */
23
+ static isObject(oEntry, sFilePath) {
24
+ if (oEntry && typeof oEntry === "object") return true;
25
+ return Validator.warn(sFilePath, "the default export is not an object.");
26
+ }
27
+ /**
28
+ * Type guard for a `*.route.ts` entry.
29
+ */
30
+ static isRoute(oEntry, sFilePath) {
31
+ 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.");
36
+ return true;
37
+ }
38
+ /** Type guard for a `*.store.ts` entry */
39
+ static isStore(oEntry, sFilePath) {
40
+ if (!Validator.isObject(oEntry, sFilePath)) return false;
41
+ if (!STORE_TYPES.includes(oEntry.type)) return Validator.warn(sFilePath, `its type must be "app" or "api".`);
42
+ if (oEntry.data !== void 0 && (!oEntry.data || typeof oEntry.data !== "object")) return Validator.warn(sFilePath, "\"data\" must be an object.");
43
+ 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.");
44
+ return true;
45
+ }
46
+ /**
47
+ * Type guard for a `.prisma` entry: the contents of the file, without comments.
48
+ */
49
+ static isPrisma(oEntry, sFilePath) {
50
+ if (typeof oEntry !== "string") return Validator.warn(sFilePath, "the contents are not a string.");
51
+ const sProvider = oEntry.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*["']([^"']+)["']/s)?.[1];
52
+ if (sProvider !== "postgresql") return Validator.warn(sFilePath, `the datasource provider must be "postgresql"${sProvider ? `, not "${sProvider}"` : ""}.`);
53
+ return true;
54
+ }
55
+ /** Validates a full map of entries and returns only the valid ones */
56
+ static validate(oEntries, sExtension) {
57
+ const oValid = {};
58
+ for (const [sFilePath, oEntry] of Object.entries(oEntries)) {
59
+ const oValidated = this.validateUnique(oEntry, sExtension, sFilePath);
60
+ if (oValidated !== void 0) oValid[sFilePath] = oValidated;
61
+ }
62
+ return oValid;
63
+ }
64
+ /** Validates one entry: returns it when valid, `undefined` otherwise */
65
+ static validateUnique(oEntry, sExtension, sFilePath = "") {
66
+ return this.oValidators[sExtension]?.()(oEntry, sFilePath) ? oEntry : void 0;
67
+ }
68
+ };
69
+ //#endregion
70
+ export { Validator as default };
@@ -0,0 +1,4 @@
1
+ //#region ../core/types/objects.d.ts
2
+ /** Generic object */
3
+ export type NanoObject<T = any> = Record<string, T>;
4
+ //#endregion
@@ -0,0 +1,14 @@
1
+ //#region ../core/utils/Formatter.ts
2
+ /** Formats values for logs and messages */
3
+ var Formatter = class {
4
+ /** Message of anything thrown: its `message` when it has one, else the value itself */
5
+ static error(oError) {
6
+ return String(oError?.message ?? oError);
7
+ }
8
+ /** Formats a date for the request log, in UTC: `2026-10-19 12:03:10` */
9
+ static logDate(oDate = /* @__PURE__ */ new Date()) {
10
+ return oDate.toISOString().slice(0, 19).replace("T", " ");
11
+ }
12
+ };
13
+ //#endregion
14
+ export { Formatter as default };
@@ -0,0 +1,6 @@
1
+ import { NanoObject } from "../core/types/objects.js";
2
+ import { ComponentContext, Controller } from "../types/component.js";
3
+ //#region src/creators/createController.d.ts
4
+ /** Creates a controller: methods on the stores and the props, for the views bound to it */
5
+ export default function createController<M extends NanoObject>(fnMethods: (context: ComponentContext) => M): Controller<M>;
6
+ //#endregion
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { IS_SERVER } from "../core/const.js";
3
+ import { useStores } from "../react/NanoAppProvider.js";
4
+ import { enableStaticRendering, observer } from "mobx-react-lite";
5
+ //#region src/creators/createController.ts
6
+ enableStaticRendering(IS_SERVER);
7
+ /** Creates a controller: methods on the stores and the props, for the views bound to it */
8
+ function createController(fnMethods) {
9
+ return {
10
+ bindView: (fnView) => observer((oProps) => {
11
+ const oContext = {
12
+ props: oProps,
13
+ stores: useStores()
14
+ };
15
+ return fnView({
16
+ ...fnMethods(oContext),
17
+ ...oContext
18
+ });
19
+ }),
20
+ extend: (fnExtension) => createController((oContext) => ({
21
+ ...fnMethods(oContext),
22
+ ...fnExtension(oContext)
23
+ }))
24
+ };
25
+ }
26
+ //#endregion
27
+ export { createController as default };
@@ -0,0 +1,15 @@
1
+ import Store from "../stores/Store.js";
2
+ import ApiStore from "../stores/ApiStore.js";
3
+ //#region src/creators/createStores.ts
4
+ /** Stores of one React tree, from the ones `withNanoApp` inlined at build time */
5
+ function createStores() {
6
+ const oFiles = JSON.parse(process.env.NANO_APP_STORES ?? "{}");
7
+ 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)
12
+ };
13
+ }
14
+ //#endregion
15
+ export { createStores as default };
@@ -0,0 +1,4 @@
1
+ import { NanoAppProvider } from "./react/NanoAppProvider.js";
2
+ import { ComponentContext, ComponentProps, Controller, View } from "./types/component.js";
3
+ import createController from "./creators/createController.js";
4
+ export { type ComponentContext, type ComponentProps, type Controller, NanoAppProvider, type View, createController };
package/build/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { NanoAppProvider } from "./react/NanoAppProvider.js";
2
+ import createController from "./creators/createController.js";
3
+ export { NanoAppProvider, createController };
@@ -0,0 +1,8 @@
1
+ import { NextConfig } from "next";
2
+ //#region src/next.d.ts
3
+ /**
4
+ * Wraps the app's Next config: loads its stores while Next reads `next.config.ts`, before compiling.
5
+ * Node only: never import it from the app's components.
6
+ */
7
+ export declare function withNanoApp(oNextConfig?: NextConfig): () => Promise<NextConfig>;
8
+ //#endregion
package/build/next.js ADDED
@@ -0,0 +1,21 @@
1
+ import { STORES_ENV } from "./core/const.js";
2
+ import FileLoader from "./core/loader/FileLoader.js";
3
+ //#region src/next.ts
4
+ /**
5
+ * Wraps the app's Next config: loads its stores while Next reads `next.config.ts`, before compiling.
6
+ * Node only: never import it from the app's components.
7
+ */
8
+ function withNanoApp(oNextConfig = {}) {
9
+ return async () => {
10
+ const oStores = await FileLoader.findMany({ extension: "store" });
11
+ return {
12
+ ...oNextConfig,
13
+ env: {
14
+ ...oNextConfig.env,
15
+ [STORES_ENV]: JSON.stringify(oStores)
16
+ }
17
+ };
18
+ };
19
+ }
20
+ //#endregion
21
+ export { withNanoApp };
@@ -0,0 +1,5 @@
1
+ import { NextRequest } from "next/server";
2
+ //#region src/proxy.d.ts
3
+ /** Sends `/api/<store>/<path>` to the backend of `<store>`, in `API_<STORE>_URL`, with its headers */
4
+ export declare function proxy(oReq: NextRequest): Promise<Response>;
5
+ //#endregion
package/build/proxy.js ADDED
@@ -0,0 +1,25 @@
1
+ import { NextResponse } from "next/server";
2
+ //#region src/proxy.ts
3
+ /** Sends `/api/<store>/<path>` to the backend of `<store>`, in `API_<STORE>_URL`, with its headers */
4
+ async function proxy(oReq) {
5
+ const [, sPrefix, sName, ...aPath] = oReq.nextUrl.pathname.split("/");
6
+ if (sPrefix !== "api" || !sName) return NextResponse.next();
7
+ const oFiles = JSON.parse(process.env.NANO_APP_STORES ?? "{}");
8
+ const sUrlEnv = `API_${sName.toUpperCase()}_URL`;
9
+ const aHeaders = Object.entries(oFiles[sName]?.headers ?? {}).map(([sHeader, sValue]) => [sHeader, `API_${sValue.toUpperCase()}_HEADER`]);
10
+ const sMissing = [sUrlEnv, ...aHeaders.map(([, sEnvName]) => sEnvName)].find((sEnvName) => !process.env[sEnvName]);
11
+ if (sMissing) return NextResponse.json({
12
+ success: false,
13
+ message: `[nano-app] [ERROR] The store "${sName}" needs the ${sMissing} environment variable. Define it.`
14
+ }, { status: 500 });
15
+ const oHeaders = new Headers(oReq.headers);
16
+ for (const [sHeader, sEnvName] of aHeaders) oHeaders.set(sHeader, process.env[sEnvName] ?? "");
17
+ const sTarget = `${process.env[sUrlEnv]}/${aPath.join("/")}${oReq.nextUrl.search}`;
18
+ const oRes = await fetch(new Request(sTarget, oReq), {
19
+ headers: oHeaders,
20
+ redirect: "manual"
21
+ });
22
+ return new Response(oRes.body, oRes);
23
+ }
24
+ //#endregion
25
+ export { proxy };
@@ -0,0 +1,7 @@
1
+ import { ReactNode } from "react";
2
+ //#region src/react/NanoAppProvider.d.ts
3
+ /** Root of the app's React tree: wraps the root layout and gives the tree its own stores */
4
+ export declare function NanoAppProvider({ children }: {
5
+ children: ReactNode;
6
+ }): ReactNode;
7
+ //#endregion
@@ -0,0 +1,25 @@
1
+ "use client";
2
+ import { MISSING_PROVIDER } from "../core/const.js";
3
+ import createStores from "../creators/createStores.js";
4
+ import { createContext, use, useRef } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/react/NanoAppProvider.tsx
7
+ /** Stores of the tree, read by the components */
8
+ const StoreContext = createContext(null);
9
+ /** Root of the app's React tree: wraps the root layout and gives the tree its own stores */
10
+ function NanoAppProvider({ children }) {
11
+ const oStoresRef = useRef(null);
12
+ oStoresRef.current ??= createStores();
13
+ return /* @__PURE__ */ jsx(StoreContext, {
14
+ value: oStoresRef.current,
15
+ children
16
+ });
17
+ }
18
+ /** Stores of the tree; fails outside the provider */
19
+ function useStores() {
20
+ const oStores = use(StoreContext);
21
+ if (!oStores) throw new Error(MISSING_PROVIDER);
22
+ return oStores;
23
+ }
24
+ //#endregion
25
+ export { NanoAppProvider, useStores };
@@ -0,0 +1,81 @@
1
+ import { IS_SERVER } from "../core/const.js";
2
+ import Store from "./Store.js";
3
+ import { observable, runInAction } from "mobx";
4
+ //#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
+ */
9
+ var ApiStore = class ApiStore extends Store {
10
+ sName;
11
+ oCache = observable({
12
+ iBusy: 0,
13
+ oResults: {},
14
+ oVersions: {}
15
+ });
16
+ oRead = {};
17
+ /** Store of the backend `API_<NAME>_URL`, with the initial data of its file */
18
+ constructor(sName, oData) {
19
+ super(oData);
20
+ this.sName = sName;
21
+ }
22
+ /** Requests in progress */
23
+ get busy() {
24
+ return this.oCache.iBusy;
25
+ }
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];
40
+ }
41
+ /** `POST <path>` */
42
+ create(sPath, oBody) {
43
+ return this.write("POST", sPath, oBody);
44
+ }
45
+ /** `PUT <path>` */
46
+ update(sPath, oBody) {
47
+ return this.write("PUT", sPath, oBody);
48
+ }
49
+ /** `DELETE <path>` */
50
+ delete(sPath) {
51
+ return this.write("DELETE", sPath);
52
+ }
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, {
56
+ method: sMethod,
57
+ 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
+ }));
62
+ }
63
+ /** Sends a request to the proxy and returns its JSON answer, counting it as busy */
64
+ async request(sPath, oInit) {
65
+ runInAction(() => this.oCache.iBusy++);
66
+ try {
67
+ return await (await fetch(`/api/${this.sName}${sPath}`, {
68
+ ...oInit,
69
+ headers: { "content-type": "application/json" }
70
+ })).json();
71
+ } finally {
72
+ runInAction(() => this.oCache.iBusy--);
73
+ }
74
+ }
75
+ /** Entity of a path: its first segment (`/users/7` → `/users`) */
76
+ static entity(sPath) {
77
+ return `/${sPath.split("/").filter(Boolean)[0] ?? ""}`;
78
+ }
79
+ };
80
+ //#endregion
81
+ export { ApiStore as default };
@@ -0,0 +1,15 @@
1
+ import { NanoObject } from "../core/types/objects.js";
2
+ //#region src/stores/Store.d.ts
3
+ /** State of one store, read and written by path (`/drawer/isOpen`) */
4
+ export default class Store {
5
+ private oState;
6
+ /** Makes the store's initial data observable, so reads are tracked and writes notify them */
7
+ constructor(oData?: NanoObject);
8
+ /** Value at a path: an object comes back observable, so reading its fields stays tracked */
9
+ get(sPath: string): any;
10
+ /** Writes a value at a path, creating the levels that are missing */
11
+ set(sPath: string, oValue: unknown): void;
12
+ /** Keys of a path, with or without the leading slash */
13
+ private static keys;
14
+ }
15
+ //#endregion