setti 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1406 -2
- package/bin/nixo.mjs +8 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3060 -0
- package/dist/client/index.d.ts +180 -0
- package/dist/client/index.js +958 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +816 -0
- package/dist/not-found-fz-2RDV6.d.ts +340 -0
- package/dist/prefetch-Bp9dMSTK.d.ts +766 -0
- package/dist/server.d.ts +635 -0
- package/dist/server.js +2813 -0
- package/package.json +85 -3
package/README.md
CHANGED
|
@@ -1,3 +1,1407 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nixo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fullstack-фреймворк на **React 19**, **Fastify** и **Vite**.
|
|
4
|
+
Устанавливается как зависимость, предоставляет SSR и SPA «из коробки».
|
|
5
|
+
|
|
6
|
+
- **Реактивность** — собственный store слайсов (`Slice` / `SliceRegistry`).
|
|
7
|
+
- **Роутинг** — конфигурируемый (`router.ts` → `defineRoute([...])`).
|
|
8
|
+
- **Данные** — двухуровневый prefetch: глобальные loaders + page-scoped фабрики.
|
|
9
|
+
- **SSR** — стриминг через `renderToPipeableStream` + `hydrateRoot`.
|
|
10
|
+
- **HMR** — Fast Refresh из `@vitejs/plugin-react`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 📦 Установка
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add setti
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`react` и `react-dom` — peer-зависимости:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add react react-dom
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 🚀 Быстрый старт
|
|
29
|
+
|
|
30
|
+
### 1. `nixo.config.ts` в корне проекта
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { defineNixoConfig } from 'setti'
|
|
34
|
+
|
|
35
|
+
export default defineNixoConfig({
|
|
36
|
+
ssr: true,
|
|
37
|
+
layouts: {
|
|
38
|
+
main: './layouts/Main.tsx',
|
|
39
|
+
},
|
|
40
|
+
css: {
|
|
41
|
+
main: 'styles/main.css',
|
|
42
|
+
},
|
|
43
|
+
server: { port: 3000, host: '0.0.0.0' },
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Клиентский каталог `client/`
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
client/
|
|
51
|
+
├── HomePage/
|
|
52
|
+
│ ├── router.ts # defineRoute([...])
|
|
53
|
+
│ ├── view.tsx # компонент страницы
|
|
54
|
+
│ └── Factories/
|
|
55
|
+
│ ├── Root.ts # getConfig(configParams, router) → FactoryMap
|
|
56
|
+
│ └── usersList.ts # UserListLoader + UserListSlice
|
|
57
|
+
├── User.loader.ts # defineGlobalLoader({...})
|
|
58
|
+
├── layouts/
|
|
59
|
+
│ └── Main.tsx
|
|
60
|
+
└── styles/
|
|
61
|
+
└── main.css
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 3. Маршрут (`client/HomePage/router.ts`)
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { defineRoute } from 'setti'
|
|
68
|
+
|
|
69
|
+
export default defineRoute([
|
|
70
|
+
{
|
|
71
|
+
id: 'home',
|
|
72
|
+
index: true,
|
|
73
|
+
title: 'Главная',
|
|
74
|
+
type: 'main',
|
|
75
|
+
contentConfig: {
|
|
76
|
+
workspaceConfig: { template: 'HomePage/view:View' },
|
|
77
|
+
prefetchConfig: {
|
|
78
|
+
configLoader: 'HomePage/Factories/Root',
|
|
79
|
+
configParams: {},
|
|
80
|
+
dependencies: ['userInfo'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 4. Prefetch (`client/HomePage/Factories/Root.ts`)
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import type { FactoryMap, NixoRouter } from 'setti'
|
|
91
|
+
|
|
92
|
+
export default function getConfig(
|
|
93
|
+
configParams: Record<string, unknown>,
|
|
94
|
+
_router: NixoRouter,
|
|
95
|
+
): FactoryMap {
|
|
96
|
+
return {
|
|
97
|
+
UserList: {
|
|
98
|
+
dataFactoryName: 'HomePage/Factories/usersList:UserListLoader',
|
|
99
|
+
dataFactoryArguments: { limit: 10 },
|
|
100
|
+
dependencies: [],
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 5. Фабрика (`client/HomePage/Factories/usersList.ts`)
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { Slice, type FactoryImplementation } from 'setti'
|
|
110
|
+
|
|
111
|
+
interface User { id: number }
|
|
112
|
+
|
|
113
|
+
export class UserListSlice extends Slice<User[]> {
|
|
114
|
+
setUsers(users: User[]): void {
|
|
115
|
+
this.setState(users)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const UserListLoader: FactoryImplementation<User[]> = {
|
|
120
|
+
async loadData(args) {
|
|
121
|
+
const limit = (args.limit as number) ?? 10
|
|
122
|
+
return Array.from({ length: limit }, (_, i) => ({ id: i + 1 }))
|
|
123
|
+
},
|
|
124
|
+
slice: UserListSlice,
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 6. Компонент (`client/HomePage/view.tsx`)
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { useSlice } from 'setti/client'
|
|
132
|
+
import type { UserListSlice } from './Factories/usersList'
|
|
133
|
+
|
|
134
|
+
export function View() {
|
|
135
|
+
const slice = useSlice<UserListSlice>('UserList')
|
|
136
|
+
const users = slice?.getState() ?? []
|
|
137
|
+
return <h1>Users: {users.length}</h1>
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 7. Глобальный загрузчик (`client/User.loader.ts`)
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { defineGlobalLoader } from 'setti'
|
|
145
|
+
|
|
146
|
+
export default defineGlobalLoader({
|
|
147
|
+
userInfo: {
|
|
148
|
+
dataFactoryName: 'User/loaders/userInfo:UserInfoLoader',
|
|
149
|
+
dependencies: [],
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 8. Запуск
|
|
155
|
+
|
|
156
|
+
## 🧪 Команды
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# Из корня
|
|
160
|
+
pnpm install
|
|
161
|
+
pnpm lint # 0 errors, 0 warnings
|
|
162
|
+
pnpm typecheck # 0 errors
|
|
163
|
+
|
|
164
|
+
# Dev
|
|
165
|
+
pnpm dev:basic # → http://localhost:3000
|
|
166
|
+
NIXO_DEBUG=1 pnpm dev:basic # + логи __NIXO_STATE__
|
|
167
|
+
|
|
168
|
+
# Prod
|
|
169
|
+
pnpm --filter examples-basic exec nixo build # → examples/basic/dist/
|
|
170
|
+
pnpm --filter examples-basic exec nixo start # → http://localhost:3000
|
|
171
|
+
|
|
172
|
+
# Инспекция
|
|
173
|
+
pnpm inspect:basic
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 🏗 Архитектура
|
|
179
|
+
|
|
180
|
+
### Точки входа
|
|
181
|
+
|
|
182
|
+
| Импорт | Что внутри | Когда использовать |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `nixo` | `defineNixoConfig`, `defineRoute`, `defineGlobalLoader`, `Slice`, `AbstractSlice`, `SliceRegistry`, `NixoError`, `computePath`, типы | **client-safe**: `nixo.config.ts`, `router.ts`, `*.loader.ts`, фабрики, компоненты |
|
|
185
|
+
| `nixo/server` | `loadConfig`, `scan`, `buildRouteTree`, `buildManifest`, `createNixoServer`, `createDevModuleLoader`, `createProdModuleLoader`, `runPrefetch`, `serializeState`, `nixoPlugin`, `buildProject`, `collectFactoryModules` | только серверный код (кастомный `server/`, CLI) |
|
|
186
|
+
| `nixo/client` | всё из `nixo` + `NixoProvider`, `useSlice`, `useStrictSlice`, `useSliceRef`, `useResourceContext` | React-компоненты, хуки |
|
|
187
|
+
|
|
188
|
+
**Критично:** `nixo` **не тянет** jiti/fastify/vite. Всё Node-only — в `nixo/server`. Если добавить Node-зависимость в файл, импортируемый из `./index.ts` или `./client/index.ts`, браузер упадёт с `Module "node:X" has been externalized for browser compatibility`.
|
|
189
|
+
|
|
190
|
+
`nixo` **не тянет** React в рантайм — поэтому `nixo inspect` грузит только pure TS.
|
|
191
|
+
|
|
192
|
+
### Структура пакета
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
packages/nixo/src/
|
|
196
|
+
├── index.ts # публичный API (server-safe)
|
|
197
|
+
├── cli.ts # команды dev / inspect / build / start
|
|
198
|
+
├── config.ts # defineNixoConfig, loadConfig
|
|
199
|
+
├── types.ts # NixoConfig, NixoRoute, FactoryMap, Manifest, ...
|
|
200
|
+
├── errors.ts # NixoError
|
|
201
|
+
├── define-route.ts # defineRoute
|
|
202
|
+
├── define-global-loader.ts # defineGlobalLoader
|
|
203
|
+
├── scanner.ts # обход clientDir, router.ts, *.loader.ts
|
|
204
|
+
├── route-tree.ts # построение + валидация дерева
|
|
205
|
+
├── url.ts # computePath (parentId → URL)
|
|
206
|
+
├── manifest.ts # генерация Manifest
|
|
207
|
+
├── resolver.ts # resolveModule
|
|
208
|
+
│
|
|
209
|
+
├── client/ # React-часть
|
|
210
|
+
│ ├── index.ts # nixo/client
|
|
211
|
+
│ ├── slices/
|
|
212
|
+
│ │ ├── AbstractSlice.ts # базовый store
|
|
213
|
+
│ │ ├── Slice.ts # identity _initState
|
|
214
|
+
│ │ └── SliceRegistry.ts # page + global registry
|
|
215
|
+
│ ├── context/
|
|
216
|
+
│ │ └── NixoProvider.tsx # Context (createElement — без JSX)
|
|
217
|
+
│ └── hooks/
|
|
218
|
+
│ ├── useSlice.ts
|
|
219
|
+
│ ├── useStrictSlice.ts
|
|
220
|
+
│ ├── useSliceRef.ts
|
|
221
|
+
│ └── useSliceInternal.ts
|
|
222
|
+
│
|
|
223
|
+
├── server/ # серверная часть
|
|
224
|
+
│ ├── create-server.ts # Fastify + Vite (dev) / static (prod)
|
|
225
|
+
│ ├── ssr-handler.ts # renderToPipeableStream + __NIXO_STATE__
|
|
226
|
+
│ ├── prefetch.ts # getConfig → топосорт → loadData → hydrate
|
|
227
|
+
│ ├── module-loader.ts # createDevModuleLoader + createProdModuleLoader
|
|
228
|
+
│ └── serialize.ts # SerializedState
|
|
229
|
+
├── build/
|
|
230
|
+
│ ├── build.ts # buildProject (client + server, собирает additionalModules)
|
|
231
|
+
│ ├── build-client.ts # Vite client build
|
|
232
|
+
│ └── build-server.ts # Vite SSR build (React externalize)
|
|
233
|
+
│
|
|
234
|
+
├── vite/
|
|
235
|
+
│ └── plugin.ts # virtual:nixo/routes + virtual:nixo/client-entry
|
|
236
|
+
│
|
|
237
|
+
└── utils/
|
|
238
|
+
├── fs.ts
|
|
239
|
+
├── interpolate.ts
|
|
240
|
+
└── css.ts # resolveCssMainPath
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## ✅ Что реализовано
|
|
246
|
+
|
|
247
|
+
### Phase 1a — Сканер и манифест
|
|
248
|
+
|
|
249
|
+
- [x] Монорепо на pnpm (`../../pnpm-workspace.yaml`).
|
|
250
|
+
- [x] `defineNixoConfig` + `loadConfig` (jiti, дефолты, `ResolvedNixoConfig`).
|
|
251
|
+
- [x] `defineRoute` и `defineGlobalLoader` как публичные обёртки.
|
|
252
|
+
- [x] Сканер `clientDir/*/router.ts` → массив маршрутов.
|
|
253
|
+
- [x] Сканер `clientDir/**/*.loader.ts` → `FactoryMap`.
|
|
254
|
+
- [x] Построение дерева маршрутов (`parentId`), валидация:
|
|
255
|
+
- уникальность `id`;
|
|
256
|
+
- нет циклов;
|
|
257
|
+
- ровно один `index: true`;
|
|
258
|
+
- `type` резолвится в `config.layouts`;
|
|
259
|
+
- A3-коллизии (global vs page) — ошибка.
|
|
260
|
+
- [x] URL-билдер (`computePath`).
|
|
261
|
+
- [x] Manifest generator (`kind` по использованию).
|
|
262
|
+
- [x] `resolveModule` (dev, поддержка `:export` и переменных).
|
|
263
|
+
- [x] CLI `nixo inspect`.
|
|
264
|
+
- [x] ESLint 9 (flat config) + JSDoc-warnings.
|
|
265
|
+
- [x] `@types/react`/`@types/react-dom` в dev.
|
|
266
|
+
|
|
267
|
+
### Phase 1b-1 — Fastify + Vite
|
|
268
|
+
|
|
269
|
+
- [x] `createNixoServer` (Fastify + Vite middleware в dev).
|
|
270
|
+
- [x] `reply.hijack()` — контроль над raw-ответом.
|
|
271
|
+
- [x] HMR-клиент Vite.
|
|
272
|
+
- [x] Раздача ассетов через `vite.middlewares` для не-HTML запросов.
|
|
273
|
+
- [x] `@fastify/static` заготовка для prod.
|
|
274
|
+
|
|
275
|
+
### Phase 1b-2 — SSR + гидратация
|
|
276
|
+
|
|
277
|
+
- [x] `renderToPipeableStream` + `Transform` для суффикса.
|
|
278
|
+
- [x] Поиск маршрута по URL, загрузка `template` и `layout` через `vite.ssrLoadModule`.
|
|
279
|
+
- [x] Virtual entry `virtual:nixo/client-entry` (hydrateRoot).
|
|
280
|
+
- [x] **Единый `NixoContext`** — `NixoProvider` и `SliceRegistry` загружаются через Vite (иначе два инстанса `createContext`).
|
|
281
|
+
- [x] 404 без React.
|
|
282
|
+
- [x] Fast Refresh preamble для `@vitejs/plugin-react` (обязательно при `appType: 'custom'`).
|
|
283
|
+
|
|
284
|
+
### Phase 1b-3a — Prefetch на SSR
|
|
285
|
+
|
|
286
|
+
- [x] `runPrefetch` — пайплайн:
|
|
287
|
+
1. `getConfig(configParams, router)` → `FactoryMap`.
|
|
288
|
+
2. A3-проверка `getConfig` vs `globalRegistry`.
|
|
289
|
+
3. Топосорт с обнаружением циклов.
|
|
290
|
+
4. `loadData(args, deps, { router })` для каждого узла.
|
|
291
|
+
5. `new SliceClass().hydrate(data)` → регистрация в `SliceRegistry`.
|
|
292
|
+
- [x] `PrefetchResult` с `slices: Map<string, PrefetchSliceInfo>`.
|
|
293
|
+
- [x] `router` = `{ id, path, params, query, request }`.
|
|
294
|
+
|
|
295
|
+
### Phase 1b-3b — `__NIXO_STATE__`
|
|
296
|
+
|
|
297
|
+
- [x] `serializeState` — резолвинг `dataFactoryName` → Vite-путь + export.
|
|
298
|
+
- [x] `stringifyState` — JSON с экранированием `<` → `\u003c`.
|
|
299
|
+
- [x] Инъекция `<script>window.__NIXO_STATE__={...}</script>` перед entry.
|
|
300
|
+
- [x] На клиенте `hydrateSlicesFromState(registry)` **до** `hydrateRoot` — identity `Slice` из `__NIXO_STATE__.slices`.
|
|
301
|
+
- [x] Hydration match: `Users: 10` на сервере = `Users: 10` на клиенте.
|
|
302
|
+
- [x] Прямые `import()` в `virtual:nixo/routes` (glob не работает в virtual modules).
|
|
303
|
+
|
|
304
|
+
### Phase 1b-4 — Prod build + CSS
|
|
305
|
+
|
|
306
|
+
**Build:**
|
|
307
|
+
- [x] `nixo build` — клиентский бандл (`dist/client`) + SSR-бандл (`dist/server`).
|
|
308
|
+
- [x] `nixo start` — prod-сервер из `dist/`.
|
|
309
|
+
- [x] `virtual:nixo/ssr-entry` — генерирует `modules` map (template, layout, config, factory, global loader) + реэкспорт `NixoProvider`/`SliceRegistry`.
|
|
310
|
+
- [x] `createProdModuleLoader` — резолвинг `<file>:<export>` из `dist/server/entry.js` через namespace-модули.
|
|
311
|
+
- [x] `buildProject` — собирает `additionalModules` (вызывает `getConfig` каждого маршрута через jiti + все `dataFactoryName` из `*.loader.ts`).
|
|
312
|
+
- [x] React externalize в SSR (`react`, `react-dom`, `react/jsx-runtime`), `nixo/client` **бандлится** — иначе два `createContext`.
|
|
313
|
+
- [x] `nixo start` использует `@fastify/static` для `dist/client`.
|
|
314
|
+
|
|
315
|
+
**CSS:**
|
|
316
|
+
- [x] `css.main` в конфиге импортируется в клиентский entry (`import '/client/styles/main.css'`).
|
|
317
|
+
- [x] Per-component `import './view.css'` — работает автоматически (dev: HMR, prod: отдельный файл).
|
|
318
|
+
- [x] В prod SSR `<head>` содержит `<link rel="stylesheet">` для entry-CSS + CSS текущего template/layout.
|
|
319
|
+
- [x] `clientManifest` (`dist/client/.vite/manifest.json`) читается в `create-server.ts` и передаётся в `ssr-handler.ts`.
|
|
320
|
+
- [x] `config.vite` мёржится в `createServer`, `buildClient`, `buildServer` — Tailwind через `@tailwindcss/vite` работает без правок фреймворка.
|
|
321
|
+
|
|
322
|
+
**Исправления:**
|
|
323
|
+
- [x] `resolveSpec` принимает только **спеки** (`HomePage/view:View`), не Vite-пути. Двойной резолв ломал `path.relative` на Windows.
|
|
324
|
+
- [x] `modules` в SSR-бандле ключуется по `<file>` без `:export` — `createProdModuleLoader` находит namespace и берёт export.
|
|
325
|
+
|
|
326
|
+
### Phase 1b-5 — SPA, noSSR, enriched `_initState`, client-safe entry
|
|
327
|
+
|
|
328
|
+
**SPA-режим:**
|
|
329
|
+
- [x] `ssr: false` в `nixo.config.ts` — глобально SPA.
|
|
330
|
+
- [x] `?noSSR=true` — per-request escape hatch при `ssr: true`.
|
|
331
|
+
- [x] В SPA сервер отдаёт пустой `<div id="root"></div>` + entry + `window.__NIXO_SSR__=false`.
|
|
332
|
+
- [x] Endpoint `GET /__nixo/prefetch/:routeId` — клиент делает fetch, восстанавливает слайсы, `createRoot`.
|
|
333
|
+
- [x] В SSR: `hydrateRoot` + `window.__NIXO_SSR__=true` + готовый `__NIXO_STATE__`.
|
|
334
|
+
- [x] `window.__NIXO_SSR__` — флаг режима для клиента.
|
|
335
|
+
|
|
336
|
+
**Enriched `_initState` на клиенте:**
|
|
337
|
+
- [x] `collectFactoryModules(projectDir, routeTree, scanResult, clientDir)` — собирает `dataFactoryName` из `getConfig` каждого маршрута + `*.loader.ts`.
|
|
338
|
+
- [x] `virtual:nixo/routes` экспортирует `factoryLoaders: { [vitePath]: () => import(...) }`.
|
|
339
|
+
- [x] `loadSliceClass(modulePath, exportName)` — грузит прикладной класс слайса из factory-модуля.
|
|
340
|
+
- [x] `hydrateSlicesFromState` на клиенте: пробует `loadSliceClass`, при ошибке — fallback на generic `Slice` с warning'ом.
|
|
341
|
+
- [x] `factoryModules` попадают и в client build, и в SSR build.
|
|
342
|
+
|
|
343
|
+
**Разделение entry points (client-safe):**
|
|
344
|
+
- [x] `nixo` — pure TS: `defineNixoConfig`, `defineRoute`, `defineGlobalLoader`, `Slice`, `AbstractSlice`, `SliceRegistry`, `NixoError`, `computePath`, типы. **Без jiti/fastify/vite.**
|
|
345
|
+
- [x] `nixo/server` — `loadConfig`, `scan`, `buildRouteTree`, `buildManifest`, `createNixoServer`, `runPrefetch`, `serializeState`, `nixoPlugin`, `buildProject`, `collectFactoryModules`, `createDevModuleLoader`, `createProdModuleLoader`, `resolveModule`.
|
|
346
|
+
- [x] `nixo/client` — всё из `nixo` + `NixoProvider`, `useSlice`, `useStrictSlice`, `useSliceRef`, `useResourceContext`.
|
|
347
|
+
- [x] `defineNixoConfig` вынесен в `define-config.ts` (pure, без jiti).
|
|
348
|
+
- [x] `package.json → exports` содержит `.`, `./server`, `./client`.
|
|
349
|
+
|
|
350
|
+
**Dev CSS preload (FOUC fix):**
|
|
351
|
+
- [x] В dev `<head>` содержит `<link rel="stylesheet">` для `css.main` + прямые `import '*.css'` из template/layout.
|
|
352
|
+
- [x] `collectDirectCssImports(sourceFiles, projectDir)` — читает файлы, извлекает `import './x.css'` regex'ом, возвращает Vite-пути.
|
|
353
|
+
- [x] `BuildCssLinksContext` — типизированный контекст `buildCssLinks` (8 полей).
|
|
354
|
+
- [x] Ограничение: CSS **вложенных** компонентов (`Button.tsx` со своим CSS) в dev не прелоадится — Phase 3.
|
|
355
|
+
|
|
356
|
+
### Инфраструктура
|
|
357
|
+
|
|
358
|
+
- [x] CI-совместимые `pnpm lint`, `pnpm typecheck` — 0 ошибок.
|
|
359
|
+
- [x] ESLint flat config с JSDoc-правилами (warn, не блокируют).
|
|
360
|
+
- [x] Весь код документирован JSDoc.
|
|
361
|
+
- [x] Пример `../../examples/basic` демонстрирует все фичи.
|
|
362
|
+
|
|
363
|
+
___
|
|
364
|
+
|
|
365
|
+
### Phase 2 — Сервер и роутинг
|
|
366
|
+
|
|
367
|
+
#### 2.1. `defineServerRoutes` + `defineServerPlugins` ✅
|
|
368
|
+
|
|
369
|
+
**Пути в `nixo.config.ts`:**
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
export default defineNixoConfig({
|
|
373
|
+
server: {
|
|
374
|
+
routes: 'server/api/routes.ts', // путь до файла с defineServerRoutes
|
|
375
|
+
plugins: 'server/plugins', // каталог Fastify-плагинов
|
|
376
|
+
},
|
|
377
|
+
})
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
- `defineServerRoutes(ServerRoute[])` — массив объектов (D1).
|
|
381
|
+
- `ServerRoute = { method, path, handler, params?, middleware? }`.
|
|
382
|
+
- `handler`: `async (params, ctx) => Promise<unknown>`, `ctx = { request, reply, params }`.
|
|
383
|
+
- `defineServerPlugins({ order? })` в `server/plugins/index.ts` — явный порядок.
|
|
384
|
+
- Регистрация в `create-server.ts`: `plugins → /__nixo/prefetch → api → SSR`.
|
|
385
|
+
|
|
386
|
+
#### 2.2. Middleware — три реестра ✅
|
|
387
|
+
|
|
388
|
+
**Три разных контекста, три реестра, не смешиваются:**
|
|
389
|
+
|
|
390
|
+
| Реестр | Путь | `ctx` | Возврат |
|
|
391
|
+
|---|---|---|---|
|
|
392
|
+
| Client page | `client/middleware/*.ts` | `{ to, from }` (`NavTarget = { path, params, query, route }`) | `void \| { redirect }` |
|
|
393
|
+
| Server page | `server/middleware/*.ts` | `{ router, route, globalSlices }` | `void \| { redirect }` |
|
|
394
|
+
| Server API | `server/api-middleware/*.ts` | `{ request, reply }` (Fastify) | `void \| FastifyReply` |
|
|
395
|
+
|
|
396
|
+
**`MiddlewareDef`:**
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
interface MiddlewareDef {
|
|
400
|
+
id: string
|
|
401
|
+
handler: string // путь от projectDir
|
|
402
|
+
params?: Record<string, unknown>
|
|
403
|
+
side?: 'client' | 'server' | 'both' // только для глобальных
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**`side: 'both'`** — файл экспортирует `client` и `server`, nixo берёт нужный по контексту.
|
|
408
|
+
|
|
409
|
+
**Глобальные** — в `nixo.config.ts`:
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
middleware: [
|
|
413
|
+
{ id: 'logger', handler: 'shared/middleware/logger', side: 'both' },
|
|
414
|
+
]
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**Порядок:** глобальные → наследованные (по `parentId`) → локальные. `middleware: []` у ребёнка — отключает наследование.
|
|
418
|
+
|
|
419
|
+
**Side-фильтрация:** `runServerMiddleware` пропускает `side='client'` и `handler.startsWith('client/')`. Client-entry пропускает `side='server'` и `handler.startsWith('server/')`.
|
|
420
|
+
|
|
421
|
+
**Пайплайн SSR (C2):**
|
|
422
|
+
|
|
423
|
+
```
|
|
424
|
+
runGlobalPrefetch → server middleware → runPagePrefetch → render
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Client middleware:** вызываются только при `push`/`replace`/`back`, **не** при гидратации.
|
|
428
|
+
|
|
429
|
+
#### 2.3. Router API + `<Link>` + `<Outlet>` ✅
|
|
430
|
+
|
|
431
|
+
**Хуки:**
|
|
432
|
+
|
|
433
|
+
- `useRouter()` → `{ push(path), replace(path), back(), prefetch(path) }`.
|
|
434
|
+
- `useParams()` → `Record<string, string>`.
|
|
435
|
+
- `useQuery()` → `Record<string, string>`.
|
|
436
|
+
- `usePathname()` → `string`.
|
|
437
|
+
|
|
438
|
+
**`<Link href prefetch className>`** — перехватывает клик, `prefetch` по hover.
|
|
439
|
+
|
|
440
|
+
**`<Outlet/>`** — пока no-op (Phase 2.6 заменит на реальный slot).
|
|
441
|
+
|
|
442
|
+
**`NixoProvider`** держит `router` в React state:
|
|
443
|
+
|
|
444
|
+
```tsx
|
|
445
|
+
<NixoProvider sliceRegistry={...} initialRouter={...}>
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
При навигации: `disposePage() → fetch('/__nixo/prefetch/:routeId') → hydrateSlicesFromState → setRouter(newRouter)` → React re-render (без `createRoot` заново).
|
|
449
|
+
|
|
450
|
+
**`window.__NIXO_MIDDLEWARE__`** — глобальные client middleware, сериализуются в HTML в `<head>`.
|
|
451
|
+
|
|
452
|
+
#### 2.4. Разбивка prefetch ✅
|
|
453
|
+
|
|
454
|
+
- `runGlobalPrefetch(ctx)` → `Map<name, state>` (только `prefetchConfig.dependencies` + транзитивные global).
|
|
455
|
+
- `runPagePrefetch(ctx, globalStates)` → `PrefetchResult` (page-фабрики + глобальные для `__NIXO_STATE__`).
|
|
456
|
+
- `runPrefetch(ctx)` — facade для `/__nixo/prefetch` и SPA-навигации.
|
|
457
|
+
|
|
458
|
+
#### 2.5. Паттерн сериализации функций
|
|
459
|
+
|
|
460
|
+
`loadData` возвращает **только JSON-safe** данные. Функции, методы, computed — в `_initState`:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
export class UserListSlice extends Slice<UserListState> {
|
|
464
|
+
protected override _initState(result: unknown): UserListState {
|
|
465
|
+
const { users } = result as { users: User[] }
|
|
466
|
+
return {
|
|
467
|
+
users,
|
|
468
|
+
total: users.length,
|
|
469
|
+
reload: () => { /* создаётся заново и на сервере, и на клиенте */ },
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Иначе — hydration mismatch (`reload: function` на сервере vs `reload: undefined` на клиенте).
|
|
476
|
+
|
|
477
|
+
#### 2.6. `config.fastify` — проброс Fastify-опций ✅
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
export default defineNixoConfig({
|
|
481
|
+
server: { port: 3000, host: '0.0.0.0' }, // nixo-специфика
|
|
482
|
+
fastify: { // прямо в Fastify(opts)
|
|
483
|
+
logger: false,
|
|
484
|
+
trustProxy: true,
|
|
485
|
+
bodyLimit: 1048576,
|
|
486
|
+
},
|
|
487
|
+
})
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Тип — `FastifyServerOptions<RawServerDefault>` (фиксация HTTP/1, иначе TS выведет `Http2SecureServer` и сломает `FastifyInstance`).
|
|
491
|
+
|
|
492
|
+
Дефолт nixo: `{ logger: { level: 'info' } }`.
|
|
493
|
+
|
|
494
|
+
#### 2.7. Что ещё НЕ сделано (Phase 2)
|
|
495
|
+
|
|
496
|
+
- **Динамические сегменты** (`path: '/blog/:slug'`) — Шаг 4a.
|
|
497
|
+
- **Вложенные layouts** по `parentId` — Шаг 4b.
|
|
498
|
+
- **Реальный `<Outlet/>`** (slot) — Шаг 4c.
|
|
499
|
+
- **Error boundaries / loading / notFound** через `config.components` — Шаг 5.
|
|
500
|
+
- **`runtimeConfig`** — отложен.
|
|
501
|
+
- **Per-route CSS в SPA** — пока все CSS из манифеста.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## 🎯 Ключевые решения
|
|
506
|
+
|
|
507
|
+
| Область | Решение |
|
|
508
|
+
|---|---|
|
|
509
|
+
| Точка входа | «Путь A»: встроенный Fastify + опциональный `server/` |
|
|
510
|
+
| Папки | `client/` и `server/` по умолчанию; переопределяются в конфиге |
|
|
511
|
+
| SSR-движок | `renderToPipeableStream` + `hydrateRoot` |
|
|
512
|
+
| Роутинг | Скан `client/*/router.ts` + `defineRoute([...])` |
|
|
513
|
+
| URL | Цепочка `parentId → id`; `index: true` — единственный глобально |
|
|
514
|
+
| Layouts | Реестр в `nixo.config.ts`; вложенность через `<Outlet/>` (Phase 2) |
|
|
515
|
+
| Состояние | Свой store: `AbstractSlice` → `Slice`; `useSlice` / `useStrictSlice` / `useSliceRef` |
|
|
516
|
+
| Lifecycle слайсов | Page-scoped dispose на навигации; global живут сессию |
|
|
517
|
+
| Реестры | Два: `page` + `global`, поиск сначала в `page` |
|
|
518
|
+
| Данные | `getConfig` → топосорт `dependencies` → `loadData` → `hydrate` |
|
|
519
|
+
| Зависимости | `prefetchConfig.dependencies` (глобальные) + `dependencies` фабрик |
|
|
520
|
+
| A3-политика | Коллизии имён global vs page → ошибка сборки |
|
|
521
|
+
| Manifest | `kind` по использованию: `routes`, `component`, `config`, `factory`, `layout`, `globalLoader` |
|
|
522
|
+
| React | Последняя стабильная 19.x (`^19.0.0`), дедуп через pnpm |
|
|
523
|
+
| CSS | `css.main` в конфиге; per-component `import './x.css'` |
|
|
524
|
+
| Расширяемость | `meta`, generic `defineRoute<T>`, module augmentation `NixoRoute` |
|
|
525
|
+
|
|
526
|
+
---
|
|
527
|
+
|
|
528
|
+
## 📜 Публичный API
|
|
529
|
+
|
|
530
|
+
### `nixo` (client-safe)
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
// Конфиг
|
|
534
|
+
defineNixoConfig(config)
|
|
535
|
+
|
|
536
|
+
// Маршруты, данные
|
|
537
|
+
defineRoute(routes)
|
|
538
|
+
defineGlobalLoader(map)
|
|
539
|
+
|
|
540
|
+
// Слайсы
|
|
541
|
+
AbstractSlice<TState>
|
|
542
|
+
Slice<TState>
|
|
543
|
+
SliceRegistry
|
|
544
|
+
|
|
545
|
+
// JSON-RPC клиент
|
|
546
|
+
NixoService
|
|
547
|
+
Remote
|
|
548
|
+
|
|
549
|
+
// Prefetch (изоморфный)
|
|
550
|
+
runGlobalPrefetch(ctx, opts?)
|
|
551
|
+
runPagePrefetch(ctx, globalStates)
|
|
552
|
+
runPrefetch(ctx)
|
|
553
|
+
|
|
554
|
+
// URL
|
|
555
|
+
computePath(route, byId)
|
|
556
|
+
|
|
557
|
+
// Ошибки
|
|
558
|
+
NixoError
|
|
559
|
+
NixoRpcError
|
|
560
|
+
|
|
561
|
+
// Типы
|
|
562
|
+
NixoConfig, ResolvedNixoConfig, NixoRoute, NixoRouter,
|
|
563
|
+
FactoryDef, FactoryMap, FactoryImplementation, RouteTree,
|
|
564
|
+
Manifest, ManifestEntry, ModuleKind, ScanResult,
|
|
565
|
+
RpcOptions, RpcContext, RpcObjectDefinition, RpcMethodDefinition,
|
|
566
|
+
RpcParamsSchema, AuthRequirement, RpcExecutor,
|
|
567
|
+
ServiceOptions, CrudOp, IBinding, JsonRpcErrorPayload,
|
|
568
|
+
StandardSchemaV1, NixoClientRouter,
|
|
569
|
+
PrefetchContext, PrefetchRegistry, PrefetchResult, PrefetchSliceInfo
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### `nixo/server`
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
// Конфиг
|
|
576
|
+
loadConfig(projectDir)
|
|
577
|
+
|
|
578
|
+
// Маршруты
|
|
579
|
+
defineServerRoutes(routes)
|
|
580
|
+
defineServerPlugins({ order? })
|
|
581
|
+
|
|
582
|
+
// RPC-объекты
|
|
583
|
+
defineObject(def)
|
|
584
|
+
scanRpcObjects(serverDir)
|
|
585
|
+
RpcRegistry
|
|
586
|
+
createRpcHandler(options)
|
|
587
|
+
createRpcExecutor(registry, config)
|
|
588
|
+
runWithRpcContext(ctx, fn)
|
|
589
|
+
getRpcContext()
|
|
590
|
+
parseCookieHeader(cookie)
|
|
591
|
+
|
|
592
|
+
// Middleware
|
|
593
|
+
loadMiddleware(def, projectDir, side)
|
|
594
|
+
runServerMiddleware(globalMiddleware, route, ctx, projectDir)
|
|
595
|
+
|
|
596
|
+
// Сканер, дерево, манифест
|
|
597
|
+
scan(clientDir)
|
|
598
|
+
buildRouteTree(modules, loaders, config)
|
|
599
|
+
buildManifest(scan, config, clientDir)
|
|
600
|
+
resolveModule(spec, vars?)
|
|
601
|
+
|
|
602
|
+
// Сервер
|
|
603
|
+
createNixoServer(options)
|
|
604
|
+
createDevModuleLoader(clientDir, vite)
|
|
605
|
+
createProdModuleLoader(serverEntryPath)
|
|
606
|
+
|
|
607
|
+
// Prefetch (серверный re-export)
|
|
608
|
+
runGlobalPrefetch, runPagePrefetch, runPrefetch
|
|
609
|
+
|
|
610
|
+
// Vite + build
|
|
611
|
+
nixoPlugin(options)
|
|
612
|
+
buildProject(options)
|
|
613
|
+
buildClient(options)
|
|
614
|
+
buildServer(options)
|
|
615
|
+
collectFactoryModules(projectDir, routeTree, scanResult, clientDir)
|
|
616
|
+
|
|
617
|
+
// API / plugins
|
|
618
|
+
loadServerRoutes(projectDir, routesPath)
|
|
619
|
+
loadServerPlugins(projectDir, pluginsDir)
|
|
620
|
+
registerServerRoutes(fastify, routes, projectDir)
|
|
621
|
+
registerServerPlugins(fastify, plugins)
|
|
622
|
+
|
|
623
|
+
// Сериализация
|
|
624
|
+
serializeState(slices, projectDir, clientDir)
|
|
625
|
+
stringifyState(state)
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### `nixo/client`
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
// Всё из nixo + React:
|
|
632
|
+
export * from 'setti'
|
|
633
|
+
|
|
634
|
+
NixoProvider
|
|
635
|
+
useResourceContext
|
|
636
|
+
|
|
637
|
+
useSlice<S>(key)
|
|
638
|
+
useStrictSlice<S>(key)
|
|
639
|
+
useSliceRef<S>(key)
|
|
640
|
+
|
|
641
|
+
useRouter(), useParams(), useQuery(), usePathname()
|
|
642
|
+
|
|
643
|
+
Link
|
|
644
|
+
Outlet
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## ⚠️ Известные ограничения (актуально на Phase 1b-5)
|
|
650
|
+
|
|
651
|
+
### Функциональность
|
|
652
|
+
|
|
653
|
+
1. **Клиентская навигация** (`<Link>`, `router.push`) — не реализована, только полная перезагрузка страницы. → Phase 2.
|
|
654
|
+
2. **Динамические сегменты** (`/blog/:slug`) — в `router.ts` пока не поддерживаются, только статические `id`. → Phase 2.
|
|
655
|
+
3. **Вложенные layouts** — `<Outlet/>` внутри layout'а отсутствует; один layout на маршрут. → Phase 2.
|
|
656
|
+
4. **`server/api/`** — авторегистрация Fastify-плагинов из `serverDir/api/` не реализована. → Phase 2.
|
|
657
|
+
5. **`server/plugins/`** — пользовательские Fastify-плагины из `serverDir/plugins/` не сканируются. → Phase 2.
|
|
658
|
+
6. **`runtimeConfig`** — `public`/`private` переменные не сериализуются в HTML и не доступны в `loadData`. → Phase 2.
|
|
659
|
+
7. **Error boundaries / `loading.tsx`** — глобальные дефолты, per-route нет. → Phase 2.
|
|
660
|
+
8. **Per-route `ssr: false`** — только глобально или через `?noSSR=true`. → Phase 3.
|
|
661
|
+
9. **Стриминг данных** (React 19 `use` + Suspense) — не реализован, prefetch синхронный. → Phase 3.
|
|
662
|
+
10. **Refetch API** для глобальных loaders — нет, только перезагрузка. → Phase 3.
|
|
663
|
+
11. **Cross-page кэш фабрик** — отсутствует, при каждой навигации — заново. → Phase 3.
|
|
664
|
+
|
|
665
|
+
### CSS
|
|
666
|
+
|
|
667
|
+
12. **Dev FOUC для вложенных компонентов** — `collectDirectCssImports` не рекурсирует; CSS `Button.tsx` в dev не прелоадится. → Phase 3.
|
|
668
|
+
13. **CSS в SPA (prod)** — отдаём **все** CSS из манифеста, без per-route оптимизации. → Phase 2.
|
|
669
|
+
|
|
670
|
+
### Данные
|
|
671
|
+
|
|
672
|
+
14. **Сериализация функций** — `JSON.stringify` теряет функции в `state`. Enriched `_initState` восстанавливает их на клиенте, но generic-slice — нет. → Phase 3 (`devalue`).
|
|
673
|
+
15. **`getConfig` вызывается дважды** — в `collectFactoryModules` (build/prepare) и в `runPrefetch` (request). Если `getConfig` нечистый (side effects, чтение env) — может ломаться. → Phase 3.
|
|
674
|
+
16. **`dataFactoryArguments`** — статические, без доступа к `router.params`/`query` на этапе `build`. → Phase 3.
|
|
675
|
+
|
|
676
|
+
### Совместимость
|
|
677
|
+
|
|
678
|
+
17. **jiti 2.4.2** — `jsx: true` в отдельных конфигурациях игнорируется (баг), поэтому framework-файлы `.tsx` используют `createElement`, не JSX. → в `bin/nixo.mjs` оставлен `jsx: true`, но framework не полагается на него.
|
|
679
|
+
18. **Windows + pnpm** — Ctrl+C иногда не убивает дочерние процессы `nixo dev` → `EADDRINUSE`. → `Get-Process node | Stop-Process -Force`.
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
### Phase 2 — Сервер, роутинг, JSON-RPC 2.0
|
|
683
|
+
|
|
684
|
+
#### 2.1. `defineServerRoutes` + `defineServerPlugins`
|
|
685
|
+
|
|
686
|
+
**Пути в `nixo.config.ts`:**
|
|
687
|
+
|
|
688
|
+
```ts
|
|
689
|
+
export default defineNixoConfig({
|
|
690
|
+
server: {
|
|
691
|
+
routes: 'server/api/routes.ts', // путь до файла с defineServerRoutes
|
|
692
|
+
plugins: 'server/plugins', // каталог Fastify-плагинов
|
|
693
|
+
},
|
|
694
|
+
})
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
- `defineServerRoutes(ServerRoute[])` — массив объектов.
|
|
698
|
+
- `ServerRoute = { method, path, handler, params?, middleware? }`.
|
|
699
|
+
- `handler`: `async (params, ctx) => Promise<unknown>`, `ctx = { request, reply, params }`.
|
|
700
|
+
- `defineServerPlugins({ order? })` в `server/plugins/index.ts` — явный порядок (опционально).
|
|
701
|
+
- Порядок регистрации: `plugins → RPC /service → API routes → SSR catch-all`.
|
|
702
|
+
|
|
703
|
+
#### 2.2. Middleware — три реестра
|
|
704
|
+
|
|
705
|
+
Три разных контекста, три реестра, **не смешиваются**:
|
|
706
|
+
|
|
707
|
+
| Реестр | Путь | `ctx` | Возврат |
|
|
708
|
+
|---|---|---|---|
|
|
709
|
+
| Client page | `client/middleware/*.ts` | `{ to, from }` (`NavTarget = { path, pathname, params, query, route }`) | `void \| { redirect }` |
|
|
710
|
+
| Server page | `server/middleware/*.ts` | `{ router, route, globalSlices }` | `void \| { redirect }` |
|
|
711
|
+
| Server API | `server/api-middleware/*.ts` | `{ request, reply }` (Fastify) | `void \| FastifyReply` |
|
|
712
|
+
|
|
713
|
+
**`MiddlewareDef = { id, handler, params?, side? }`.**
|
|
714
|
+
|
|
715
|
+
Глобальные — в `nixo.config.ts → middleware[]`:
|
|
716
|
+
```ts
|
|
717
|
+
middleware: [
|
|
718
|
+
{ id: 'logger', handler: 'shared/middleware/logger', side: 'both' },
|
|
719
|
+
]
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
`side: 'both'` — файл экспортирует `client` и `server`, nixo берёт нужный по контексту.
|
|
723
|
+
|
|
724
|
+
**Порядок:** глобальные → наследованные по `parentId` → локальные. `middleware: []` у ребёнка — отключает наследование.
|
|
725
|
+
|
|
726
|
+
**Side-фильтрация:**
|
|
727
|
+
- `runServerMiddleware`: пропускает `side='client'` и `handler.startsWith('client/')`.
|
|
728
|
+
- Client-entry: пропускает `side='server'` и `handler.startsWith('server/')`.
|
|
729
|
+
|
|
730
|
+
**Пайплайн SSR:** `runGlobalPrefetch → server middleware → runPagePrefetch → render`.
|
|
731
|
+
**Client middleware:** только при `push`/`replace`/`back`, **не** при гидратации.
|
|
732
|
+
|
|
733
|
+
#### 2.3. Router API + `<Link>` + `<Outlet>`
|
|
734
|
+
|
|
735
|
+
**Хуки:**
|
|
736
|
+
|
|
737
|
+
- `useRouter()` → `{ push(path), replace(path), back(), prefetch(path) }`.
|
|
738
|
+
- `useParams()` → `Record<string, string>`.
|
|
739
|
+
- `useQuery()` → `Record<string, string>`.
|
|
740
|
+
- `usePathname()` → `string`.
|
|
741
|
+
|
|
742
|
+
**`<Link href prefetch className>`** — перехватывает клик, `prefetch` по hover.
|
|
743
|
+
|
|
744
|
+
**`<Outlet/>`** — пока no-op (Phase 2.6 заменит на реальный slot).
|
|
745
|
+
|
|
746
|
+
**`NixoProvider` — controlled:**
|
|
747
|
+
|
|
748
|
+
```tsx
|
|
749
|
+
<NixoProvider sliceRegistry={...} router={currentRouter}>
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
`router` — обычный prop, не `useState`. При навигации client-entry вызывает `rootRef.render(<NixoProvider router={newRouter} ...>)` — React корректно обновляет контекст.
|
|
753
|
+
|
|
754
|
+
**`window.__NIXO_MIDDLEWARE__`** — глобальные client middleware, сериализуются в HTML в `<head>`.
|
|
755
|
+
|
|
756
|
+
**Навигация:**
|
|
757
|
+
- `router.push('/about?tab=features')` → `buildNavTarget` использует `URL.pathname` для матчинга, `URL.searchParams` для query. SPA-переход без перезагрузки.
|
|
758
|
+
- `history.pushState` после prefetch.
|
|
759
|
+
- `popstate` → `navigate(window.location.pathname, 'replace')`.
|
|
760
|
+
|
|
761
|
+
#### 2.4. Prefetch: SSR + SPA (изоморфный)
|
|
762
|
+
|
|
763
|
+
`shared/prefetch.ts` — pure TS, используется **и сервером, и клиентом**.
|
|
764
|
+
|
|
765
|
+
- `runGlobalPrefetch(ctx, { skipExisting? })` → `Map<name, state>` (только `prefetchConfig.dependencies` + транзитивные global).
|
|
766
|
+
- `runPagePrefetch(ctx, globalStates)` → `PrefetchResult` (page-фабрики + глобальные для `__NIXO_STATE__`).
|
|
767
|
+
- `runPrefetch(ctx)` — facade.
|
|
768
|
+
|
|
769
|
+
`PrefetchContext = { route, globalRegistry, loadModule, registry, router }` — без `config` и `clientDir`.
|
|
770
|
+
|
|
771
|
+
**SSR:**
|
|
772
|
+
```
|
|
773
|
+
runGlobalPrefetch → server middleware → runPagePrefetch → serializeState → __NIXO_STATE__ → hydrateRoot
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
**SPA (в client-entry):**
|
|
777
|
+
```
|
|
778
|
+
client middleware → disposePage → runGlobalPrefetch({skipExisting:true}) → runPagePrefetch → rootRef.render
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
**`loadModule` инжектится:**
|
|
782
|
+
- Сервер (dev): `vite.ssrLoadModule`.
|
|
783
|
+
- Сервер (prod): `createProdModuleLoader(dist/server/entry.js)`.
|
|
784
|
+
- Клиент: `resolveModule(spec)` из `virtual:nixo/routes` (`moduleLoaders`).
|
|
785
|
+
|
|
786
|
+
**`/__nixo/prefetch` — удалён.** SPA-навигация выполняет `loadData` на клиенте через `NixoService`.
|
|
787
|
+
|
|
788
|
+
#### 2.5. Паттерн сериализации
|
|
789
|
+
|
|
790
|
+
`loadData` возвращает **только JSON-safe** данные. Функции/computed — в `_initState`:
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
export class UserListSlice extends Slice<UserListState> {
|
|
794
|
+
protected override _initState(result: unknown): UserListState {
|
|
795
|
+
const { users } = result as { users: User[] }
|
|
796
|
+
return {
|
|
797
|
+
users,
|
|
798
|
+
total: users.length,
|
|
799
|
+
reload: () => { /* создаётся заново и на сервере, и на клиенте */ },
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
Иначе — hydration mismatch.
|
|
806
|
+
|
|
807
|
+
#### 2.6. `config.fastify`
|
|
808
|
+
|
|
809
|
+
```ts
|
|
810
|
+
export default defineNixoConfig({
|
|
811
|
+
server: { port: 3000, host: '0.0.0.0' }, // nixo-специфика
|
|
812
|
+
fastify: { // прямо в Fastify(opts)
|
|
813
|
+
logger: false,
|
|
814
|
+
trustProxy: true,
|
|
815
|
+
bodyLimit: 1048576,
|
|
816
|
+
},
|
|
817
|
+
})
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Тип — `FastifyServerOptions<RawServerDefault>` (фиксация HTTP/1, иначе TS выведет `Http2SecureServer` и сломает `FastifyInstance`).
|
|
821
|
+
|
|
822
|
+
Дефолт nixo: `{ logger: { level: 'info' } }`.
|
|
823
|
+
|
|
824
|
+
#### 2.7. JSON-RPC 2.0 (`/service`)
|
|
825
|
+
|
|
826
|
+
**Опции:**
|
|
827
|
+
|
|
828
|
+
```ts
|
|
829
|
+
export default defineNixoConfig({
|
|
830
|
+
rpc: {
|
|
831
|
+
enabled: true, // по умолчанию true
|
|
832
|
+
url: '/service', // по умолчанию '/service'
|
|
833
|
+
extendContext: async (req) => ({
|
|
834
|
+
user: await getUser(req),
|
|
835
|
+
db: getDb(),
|
|
836
|
+
}),
|
|
837
|
+
},
|
|
838
|
+
})
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
**`defineObject` — один объект на файл `server/**/*.meta.ts`** (рекурсивный скан):
|
|
842
|
+
|
|
843
|
+
```ts
|
|
844
|
+
// server/users/Users.meta.ts
|
|
845
|
+
import { defineObject } from 'setti/server'
|
|
846
|
+
import type { RpcContext } from 'setti/server'
|
|
847
|
+
|
|
848
|
+
export default defineObject({
|
|
849
|
+
name: 'Users',
|
|
850
|
+
description: 'Пользователи',
|
|
851
|
+
methods: {
|
|
852
|
+
List: {
|
|
853
|
+
description: 'Список',
|
|
854
|
+
params: v.object({ limit: v.optional(v.number()) }), // Standard Schema
|
|
855
|
+
auth: true,
|
|
856
|
+
handler: async (params, ctx) => ctx.db.users.findMany({ take: params.limit }),
|
|
857
|
+
},
|
|
858
|
+
Count: {
|
|
859
|
+
internal: true, // доступен только через ctx.call
|
|
860
|
+
handler: async (params, ctx) => {
|
|
861
|
+
const users = await ctx.call<unknown[]>('Users.List', { limit: 5 })
|
|
862
|
+
return { count: users.length }
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
})
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
**`RpcContext`:**
|
|
870
|
+
|
|
871
|
+
```ts
|
|
872
|
+
interface RpcContext {
|
|
873
|
+
requestId: string // из X-Request-Id или UUID
|
|
874
|
+
call: <R>(method, params?) => Promise<R> // JSON-RPC через registry
|
|
875
|
+
[key: string]: unknown // из config.rpc.extendContext
|
|
876
|
+
}
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
**Endpoint `POST /service`:**
|
|
880
|
+
- JSON-RPC 2.0 (одиночный + batch).
|
|
881
|
+
- `id` из запроса, либо fallback: **1** для одиночного, **1,2,3...** для batch.
|
|
882
|
+
- Валидация `params` (Standard Schema) → `-32602`.
|
|
883
|
+
- `auth: true` → нужен `ctx.user`; `auth: { roles }` → проверка роли → `-32001`.
|
|
884
|
+
- `internal: true` → `-32601` снаружи.
|
|
885
|
+
- `requestId` → `X-Request-Id` в ответе.
|
|
886
|
+
- Коды: `-32600`, `-32601`, `-32602`, `-32603`, `-32001`.
|
|
887
|
+
|
|
888
|
+
#### 2.8. `NixoService` (изоморфный JSON-RPC клиент)
|
|
889
|
+
|
|
890
|
+
```ts
|
|
891
|
+
import { NixoService } from 'setti'
|
|
892
|
+
|
|
893
|
+
const users = await new NixoService({
|
|
894
|
+
endpoint: 'Users',
|
|
895
|
+
binding: { query: 'List' }, // опционально
|
|
896
|
+
}).query({ limit: 10 })
|
|
897
|
+
|
|
898
|
+
const one = await new NixoService({ endpoint: 'Users' }).read({ id: 1 })
|
|
899
|
+
const custom = await new NixoService({ endpoint: 'Users' }).call('Read', { id: 1 })
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
**`ServiceOptions = { url?, endpoint?, method?, binding?, timeout? }`.**
|
|
903
|
+
**`binding`: `{ read?, query?, create?, update?, destroy? }`.**
|
|
904
|
+
**Дефолт:** `read → Read`, `query → List`, `create → Create`, `update → Update`, `destroy → Delete`.
|
|
905
|
+
|
|
906
|
+
**Изоморфность:**
|
|
907
|
+
- Клиент: `fetch('/service', { method: 'POST', body })`.
|
|
908
|
+
- Сервер (SSR): executor из ALS — **прямой вызов handler'а из реестра, без HTTP**.
|
|
909
|
+
- Headers текущего запроса (cookies, auth) — из ALS на сервере, браузер сам на клиенте.
|
|
910
|
+
|
|
911
|
+
**`Service.call(method, params)` → `R`** (распаковка `result`, ошибки → `NixoRpcError`).
|
|
912
|
+
**`service.query/read/create/update/destroy` → `R`**.
|
|
913
|
+
**`service.callRaw(method, params)` → `Response`** (низкоуровневый).
|
|
914
|
+
|
|
915
|
+
#### 2.9. Два реестра объектов на клиенте
|
|
916
|
+
|
|
917
|
+
При навигации клиент сам выполняет prefetch через `NixoService` — запросы идут на **ваш** `/api/*` или `/service`, никогда на `/__nixo/prefetch`.
|
|
918
|
+
|
|
919
|
+
**Network при клике `/` → `/about`:**
|
|
920
|
+
- `POST /service` — **есть** (если `loadData` использует `NixoService`).
|
|
921
|
+
- `GET /__nixo/prefetch/*` — **нет** (endpoint удалён, 404).
|
|
922
|
+
|
|
923
|
+
#### 2.10. Динамические сегменты — `slug` и `path`
|
|
924
|
+
|
|
925
|
+
Два способа задать URL с параметрами. **Взаимоисключающие.**
|
|
926
|
+
|
|
927
|
+
**`slug: string[]`** — добавляется к цепочке `parentId → id`:
|
|
928
|
+
|
|
929
|
+
```ts
|
|
930
|
+
// /blog/<slug>, params.slug = '<slug>'
|
|
931
|
+
{
|
|
932
|
+
id: 'blog',
|
|
933
|
+
slug: ['/regex:[a-z0-9-]+'],
|
|
934
|
+
// ...
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// /user/123, params.id = '123'
|
|
938
|
+
{
|
|
939
|
+
id: 'user',
|
|
940
|
+
slug: [':id'],
|
|
941
|
+
// ...
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// /posts/list (литерал, без params)
|
|
945
|
+
{
|
|
946
|
+
id: 'posts',
|
|
947
|
+
slug: ['list'],
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// /catalog/42/foo, params.slug = '42', params.sub = 'foo'
|
|
951
|
+
{
|
|
952
|
+
id: 'catalog',
|
|
953
|
+
slug: ['/regex:[0-9]+', ':sub'],
|
|
954
|
+
}
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
Правила сегмента:
|
|
958
|
+
- `':param'` — именованный параметр, `params.param = value`.
|
|
959
|
+
- `'/regex:body'` — nixo **сам** оборачивает в `^(body)$`, имя параметра — `slug`, `slug1`, `slug2` (если несколько regex).
|
|
960
|
+
- `'literal'` — статический сегмент.
|
|
961
|
+
|
|
962
|
+
**`path: string`** — абсолютный паттерн с `:param`:
|
|
963
|
+
|
|
964
|
+
```ts
|
|
965
|
+
// /blog/:slug (полный контроль над URL)
|
|
966
|
+
{
|
|
967
|
+
id: 'blog-post',
|
|
968
|
+
path: '/blog/:slug',
|
|
969
|
+
// ...
|
|
970
|
+
}
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
**Правила:**
|
|
974
|
+
- Оба одновременно — `NixoError`.
|
|
975
|
+
- Regex-коллизии между маршрутами (например, `:slug` vs `:id` при одинаковой структуре) — `NixoError`.
|
|
976
|
+
- `matchRoute(url, routeTree.compiled)` — ищет по regex, возвращает `{ routeId, params }`.
|
|
977
|
+
|
|
978
|
+
**`useParams()`** в компоненте — `Record<string, string>` текущего маршрута.
|
|
979
|
+
|
|
980
|
+
**Title при SPA-навигации** обновляется автоматически (`document.title = route.title`).
|
|
981
|
+
|
|
982
|
+
#### 2.11. Вложенные layouts по `parentId` + `<Outlet/>`
|
|
983
|
+
|
|
984
|
+
Layout-цепочка строится по `parentId`: `type` каждого маршрута в цепочке применяется как слой.
|
|
985
|
+
|
|
986
|
+
```ts
|
|
987
|
+
// HomePage/router.ts — корень (index)
|
|
988
|
+
{ id: 'home', index: true, type: 'main', /* ... */ }
|
|
989
|
+
|
|
990
|
+
// AdminPage/router.ts — ребёнок home
|
|
991
|
+
{ id: 'admin', parentId: 'home', type: 'admin', /* ... */ }
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
→ URL `/admin`, layout-цепочка: `main → admin → template`.
|
|
995
|
+
|
|
996
|
+
**`RouteTree.layoutChains: Map<string, string[]>`** — вычисляется в `buildRouteTree` (обход `parentId` от корня к листу, сбор `type`).
|
|
997
|
+
|
|
998
|
+
**Рендер через `renderLayoutChain(page, layouts)`** из `nixo/client`:
|
|
999
|
+
|
|
1000
|
+
```tsx
|
|
1001
|
+
// Main.tsx (layout)
|
|
1002
|
+
import { Outlet } from 'setti/client'
|
|
1003
|
+
|
|
1004
|
+
export default function Main(): JSX.Element {
|
|
1005
|
+
return (
|
|
1006
|
+
<div>
|
|
1007
|
+
<Header />
|
|
1008
|
+
<main><Outlet /></main>
|
|
1009
|
+
<Footer />
|
|
1010
|
+
</div>
|
|
1011
|
+
)
|
|
1012
|
+
}
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
**Два способа получить дочерний контент в layout:**
|
|
1016
|
+
- `<Outlet />` — через React Context (`OutletContext`).
|
|
1017
|
+
- `props.children` — как обычный React-паттерн.
|
|
1018
|
+
|
|
1019
|
+
Оба работают одновременно (Layout получает `children` и контекст `<Outlet/>` с тем же узлом).
|
|
1020
|
+
|
|
1021
|
+
**Изоморфность:**
|
|
1022
|
+
- SSR: `ssr-handler.ts` загружает все `Layouts` цепочки, `renderLayoutChain(child, Layouts)`.
|
|
1023
|
+
- Клиент: `loadRouteModules` возвращает `{ Template, Layouts }`, entry делает `renderLayoutChain`.
|
|
1024
|
+
- `virtual:nixo/routes` содержит `layoutPaths: { [type]: { path, export } }`.
|
|
1025
|
+
- `virtual:nixo/ssr-entry` реэкспортирует `renderLayoutChain` из `nixo/client`.
|
|
1026
|
+
|
|
1027
|
+
#### 2.12. Per-route `ssr: false`
|
|
1028
|
+
|
|
1029
|
+
```ts
|
|
1030
|
+
// nixo.config.ts
|
|
1031
|
+
export default defineNixoConfig({ ssr: true })
|
|
1032
|
+
|
|
1033
|
+
// AdminPage/router.ts
|
|
1034
|
+
export default defineRoute([
|
|
1035
|
+
{
|
|
1036
|
+
id: 'admin',
|
|
1037
|
+
type: 'admin',
|
|
1038
|
+
ssr: false, // ← SPA: без SSR-рендера
|
|
1039
|
+
// ...
|
|
1040
|
+
},
|
|
1041
|
+
])
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
**Логика:**
|
|
1045
|
+
|
|
1046
|
+
```ts
|
|
1047
|
+
const globalSSR = config.ssr !== false
|
|
1048
|
+
const routeSSR = globalSSR ? route.ssr !== false : false
|
|
1049
|
+
const shouldSSR = routeSSR && query.noSSR !== 'true'
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
**Правила:**
|
|
1053
|
+
- `config.ssr: false` — весь проект SPA, `route.ssr: true` игнорируется.
|
|
1054
|
+
- `route.ssr: false` — только эта страница SPA, остальные SSR.
|
|
1055
|
+
- `index: true + ssr: false` — `NixoError` (индексная страница должна быть SSR).
|
|
1056
|
+
- `?noSSR=true` — escape hatch, работает независимо.
|
|
1057
|
+
|
|
1058
|
+
**Что происходит при `route.ssr: false`:**
|
|
1059
|
+
- Сервер отдаёт каркас: `<div id="root"></div>` + `__NIXO_SSR__=false` + пустой `__NIXO_STATE__`.
|
|
1060
|
+
- Server middleware **не вызывается** — auth на таких страницах через client middleware.
|
|
1061
|
+
- Клиент делает `clientPrefetch` (через `NixoService` / `fetch`) + `createRoot`.
|
|
1062
|
+
- `<Loading/>` показывается во время prefetch.
|
|
1063
|
+
|
|
1064
|
+
#### 2.13. Error boundaries / Loading / Not Found
|
|
1065
|
+
|
|
1066
|
+
**`nixo.config.ts`:**
|
|
1067
|
+
|
|
1068
|
+
```ts
|
|
1069
|
+
export default defineNixoConfig({
|
|
1070
|
+
components: {
|
|
1071
|
+
error: 'shared/ErrorBoundary:ErrorBoundary',
|
|
1072
|
+
loading: 'shared/Loading:Loading',
|
|
1073
|
+
notFound: 'shared/NotFound:NotFound',
|
|
1074
|
+
},
|
|
1075
|
+
})
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
Пути от `clientDir` в формате `<file>:<export>` или `<file>` (default).
|
|
1079
|
+
|
|
1080
|
+
**Дефолты** (если не задано): `NixoErrorBoundary`, `NixoLoading`, `NixoNotFound` из `nixo/client`.
|
|
1081
|
+
|
|
1082
|
+
**`error`** — React Error Boundary, оборачивает дерево страницы. Fallback получает `{ error }`.
|
|
1083
|
+
|
|
1084
|
+
```tsx
|
|
1085
|
+
// client/shared/ErrorBoundary.tsx
|
|
1086
|
+
export function ErrorBoundary(props: { error: Error }): JSX.Element {
|
|
1087
|
+
return <div className="...">Что-то пошло не так: {props.error.message}</div>
|
|
1088
|
+
}
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
Ловит:
|
|
1092
|
+
- ошибки в `View()` (SSR через `renderToPipeableStream`, клиент через `NixoErrorBoundary`).
|
|
1093
|
+
- **НЕ** ловит ошибки в `loadData` — они обрабатываются в `clientPrefetch` (`catch` в `navigate` / `main`), рендерится `ErrorComp` **напрямую**, не через boundary.
|
|
1094
|
+
|
|
1095
|
+
**`loading`** — показывается во время первого SPA-prefetch (`?noSSR` / `ssr: false`).
|
|
1096
|
+
|
|
1097
|
+
```tsx
|
|
1098
|
+
// client/shared/Loading.tsx
|
|
1099
|
+
export function Loading(): JSX.Element {
|
|
1100
|
+
return <div>Загружаем страницу…</div>
|
|
1101
|
+
}
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
**`notFound`** — кастомная 404.
|
|
1105
|
+
|
|
1106
|
+
```tsx
|
|
1107
|
+
// client/shared/NotFound.tsx
|
|
1108
|
+
export function NotFound(): JSX.Element {
|
|
1109
|
+
return <div>404 · {usePathname()}</div>
|
|
1110
|
+
}
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
**Как работает 404:**
|
|
1114
|
+
- SSR: `renderToString(<NixoProvider><NotFound/></NixoProvider>)` → HTML в `<div id="root">`.
|
|
1115
|
+
- Клиент: `hydrateRoot(<NixoProvider><NotFound/></NixoProvider>)` — **та же структура**, hydration без mismatch.
|
|
1116
|
+
- `__NIXO_SSR__=true`, `__NIXO_STATE__={"version":1,"slices":{}}`.
|
|
1117
|
+
|
|
1118
|
+
**Ключевой момент:** entry **не затирает** SSR-рендер дефолтным `rootEl.textContent = '404 Not Found'`. Вместо этого — `hydrateRoot` с тем же деревом.
|
|
1119
|
+
|
|
1120
|
+
**Loading в SPA:** при `?noSSR` / `ssr: false` entry сначала рендерит `<Loading/>`, потом `clientPrefetch`, потом финальный рендер. На localhost prefetch быстрый — Loading может не успеть отрисоваться (это норма, он для медленных сетей/API).
|
|
1121
|
+
|
|
1122
|
+
#### 2.14. `System.Describe` + рекурсивный CSS-scan (Phase 4.1)
|
|
1123
|
+
|
|
1124
|
+
**`System.Describe`** — встроенный JSON-RPC метод. Возвращает список объектов и методов (`Users.List`, `System.Ping`, ...) с описаниями, `hasParams`, `auth`, `internal`. Handler'ы **не** сериализуются.
|
|
1125
|
+
|
|
1126
|
+
```bash
|
|
1127
|
+
curl -X POST http://127.0.0.1:3000/service \
|
|
1128
|
+
-H "Content-Type: application/json" \
|
|
1129
|
+
-d '{"jsonrpc":"2.0","method":"System.Describe","id":1}'
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
**Ожидаемый ответ:**
|
|
1133
|
+
|
|
1134
|
+
```json
|
|
1135
|
+
{
|
|
1136
|
+
"jsonrpc": "2.0",
|
|
1137
|
+
"result": {
|
|
1138
|
+
"objects": [
|
|
1139
|
+
{
|
|
1140
|
+
"name": "Users",
|
|
1141
|
+
"description": "Пользователи",
|
|
1142
|
+
"methods": [
|
|
1143
|
+
{
|
|
1144
|
+
"name": "List",
|
|
1145
|
+
"fullName": "Users.List",
|
|
1146
|
+
"description": "Список пользователей",
|
|
1147
|
+
"hasParams": true,
|
|
1148
|
+
"auth": "public",
|
|
1149
|
+
"internal": false,
|
|
1150
|
+
"meta": {}
|
|
1151
|
+
}
|
|
1152
|
+
]
|
|
1153
|
+
}
|
|
1154
|
+
]
|
|
1155
|
+
},
|
|
1156
|
+
"id": 1
|
|
1157
|
+
}
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
Обрабатывается в `processOne` (в `server/rpc/process.ts`) **до** `registry.getMethod`, поэтому не требует `*.meta.ts`.
|
|
1161
|
+
|
|
1162
|
+
**Рекурсивный `collectDirectCssImports`** (dev): обходит граф `import` от `template` и `layout`, собирает `import './x.css'` со всех уровней. Защита от циклов (`visited`) + `MAX_DEPTH = 30`. Решает FOUC для **вложенных** компонентов (Button со своим CSS).
|
|
1163
|
+
|
|
1164
|
+
#### 2.15. `devalue` — богатые типы в `__NIXO_STATE__` (Phase 4.2)
|
|
1165
|
+
|
|
1166
|
+
`__NIXO_STATE__` → **`__NIXO_STATE_STR__`** (JSON-строка от `devalue.stringify`).
|
|
1167
|
+
|
|
1168
|
+
**Поддержка:** `Map`, `Set`, `Date`, `BigInt`, `undefined`, `NaN`, `Infinity`, `-0`, циклические ссылки. **Не** сериализуются: функции, классы (только plain object), `Symbol`.
|
|
1169
|
+
|
|
1170
|
+
**Установка:**
|
|
1171
|
+
|
|
1172
|
+
```bash
|
|
1173
|
+
pnpm --filter nixo add devalue
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
**Сервер** (`server/serialize.ts`):
|
|
1177
|
+
```ts
|
|
1178
|
+
import { stringify as devalueStringify } from 'devalue'
|
|
1179
|
+
|
|
1180
|
+
export function stringifyState(state: SerializedState): string {
|
|
1181
|
+
return devalueStringify(state).replace(/</g, '\\u003c')
|
|
1182
|
+
}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
**HTML** (`ssr-handler.ts`):
|
|
1186
|
+
```html
|
|
1187
|
+
<script>window.__NIXO_STATE_STR__="[{...devalue-encoded...}]"</script>
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
`JSON.stringify(serializedStateJson || '')` — оборачивает **строку** в JS-строковый литерал.
|
|
1191
|
+
|
|
1192
|
+
**Клиент** (`virtual:nixo/client-entry`):
|
|
1193
|
+
```js
|
|
1194
|
+
import { devalueParse } from 'setti/client'
|
|
1195
|
+
|
|
1196
|
+
const stateObj = window.__NIXO_STATE_STR__
|
|
1197
|
+
? devalueParse(window.__NIXO_STATE_STR__)
|
|
1198
|
+
: undefined
|
|
1199
|
+
await hydrateSlicesFromState(stateObj, sliceRegistry)
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
`devalueParse` **только** из `nixo/client` (реэкспорт в `packages/nixo/src/client/index.ts`):
|
|
1203
|
+
```ts
|
|
1204
|
+
export { parse as devalueParse, stringify as devalueStringify } from 'devalue'
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
**Правило для прикладника:** `loadData` возвращает JSON-safe + богатые типы (`Map`, `Date`, ...). **Не** возвращать функции/классы. Функции/computed — в `_initState` слайса.
|
|
1208
|
+
|
|
1209
|
+
**Пример:**
|
|
1210
|
+
```ts
|
|
1211
|
+
// loadData
|
|
1212
|
+
async loadData() {
|
|
1213
|
+
return {
|
|
1214
|
+
id: 1,
|
|
1215
|
+
permissions: new Map([['read', true], ['write', false]]),
|
|
1216
|
+
createdAt: new Date(),
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// На клиенте после hydrate:
|
|
1221
|
+
slice.getState().permissions instanceof Map // → true
|
|
1222
|
+
slice.getState().createdAt instanceof Date // → true
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
**Подводные камни:**
|
|
1226
|
+
- Если `loadData` вернул функцию — `devalue.stringify` бросит `Cannot stringify arbitrary non-POJOs (function)`.
|
|
1227
|
+
- Если `loadData` вернул инстанс класса — тоже ошибка. Сериализуйте `{ ...instance }` (plain object).
|
|
1228
|
+
- `devalueParse` должен быть **одной версии** на сервере и клиенте. Проверяйте `pnpm ls -r devalue` — одна версия.
|
|
1229
|
+
- В **entry** импорт `devalueParse` — **только** из `nixo/client`. Прямой `import from 'devalue'` в virtual module не резолвится (bare specifier).
|
|
1230
|
+
|
|
1231
|
+
#### 2.16. Cross-page кэш в `NixoService` (Phase 4.3)
|
|
1232
|
+
|
|
1233
|
+
Кэш **на уровне `NixoService`**, не `PrefetchResult`. Один вызов `service.query(...)` кэшируется для всех страниц — `userInfo` не перезапрашивается при навигации.
|
|
1234
|
+
|
|
1235
|
+
**Включение:**
|
|
1236
|
+
|
|
1237
|
+
```ts
|
|
1238
|
+
const usersService = new NixoService({
|
|
1239
|
+
endpoint: 'Users',
|
|
1240
|
+
binding: { query: 'List' },
|
|
1241
|
+
cache: 30000, // TTL 30 сек. Не задан / 0 — кэш выключен.
|
|
1242
|
+
})
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
**Per-call override:**
|
|
1246
|
+
|
|
1247
|
+
```ts
|
|
1248
|
+
await service.query({ limit: 10 }) // TTL из конструктора
|
|
1249
|
+
await service.call('List', { limit: 10 }, { cache: 60000 }) // 60 сек
|
|
1250
|
+
await service.call('List', { limit: 10 }, { cache: false }) // без кэша
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
**Инвалидация:**
|
|
1254
|
+
|
|
1255
|
+
```ts
|
|
1256
|
+
service.invalidate() // весь кэш endpoint
|
|
1257
|
+
service.invalidate('List') // все List
|
|
1258
|
+
service.invalidate('List', { limit: 10 }) // точечно
|
|
1259
|
+
NixoService.invalidateAll() // весь клиентский кэш (static)
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
**Ключ кэша:** `${endpoint}.${method}:${devalueStringify(params)}`.
|
|
1263
|
+
|
|
1264
|
+
**Изоморфность:**
|
|
1265
|
+
- **Клиент:** модульный `Map` — кросс-страничный, кросс-инстансный.
|
|
1266
|
+
- **Сервер (SSR):** per-request `Map` в ALS (`runWithRpcContext({ ..., cache: new Map() })`). Не утекает между пользователями.
|
|
1267
|
+
- **Сервер вне SSR** (build, скрипты): кэш выключен.
|
|
1268
|
+
|
|
1269
|
+
**`<Link prefetch>` — теперь реальный:** при hover запускает `clientPrefetch` (с `tempRegistry`), заполняя кэш `NixoService`. Клик → данные из кэша → мгновенный переход.
|
|
1270
|
+
|
|
1271
|
+
**Пример:**
|
|
1272
|
+
|
|
1273
|
+
```tsx
|
|
1274
|
+
const usersService = new NixoService({
|
|
1275
|
+
endpoint: 'Users',
|
|
1276
|
+
binding: { query: 'List' },
|
|
1277
|
+
cache: 30000,
|
|
1278
|
+
})
|
|
1279
|
+
|
|
1280
|
+
// loadData
|
|
1281
|
+
async loadData(args) {
|
|
1282
|
+
const users = await usersService.query<{ limit: number }, User[]>({
|
|
1283
|
+
limit: args.limit as number,
|
|
1284
|
+
})
|
|
1285
|
+
return { users }
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// В компоненте — кнопка invalidate
|
|
1289
|
+
<button onClick={() => usersService.invalidate()}>
|
|
1290
|
+
Invalidate cache
|
|
1291
|
+
</button>
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
**Ожидаемое поведение:**
|
|
1295
|
+
|
|
1296
|
+
| Действие | Network |
|
|
1297
|
+
|---|---|
|
|
1298
|
+
| Первый `/` | `POST /service` (Users.Read + Users.List) |
|
|
1299
|
+
| `/` → `/about` → `/` | **0 запросов** (кэш) |
|
|
1300
|
+
| Ждём 30 сек → `/about` → `/` | `POST /service` снова (TTL истёк) |
|
|
1301
|
+
| После `invalidate()` → `/about` → `/` | `POST /service` снова |
|
|
1302
|
+
| Hover на `<Link prefetch>` | `POST /service` **до** клика |
|
|
1303
|
+
|
|
1304
|
+
**Правила:**
|
|
1305
|
+
- `cache` задан в конструкторе — все вызовы кэшируются.
|
|
1306
|
+
- `cache: false` в `call` — этот вызов без кэша.
|
|
1307
|
+
- Кэш на сервере — **per-request** (ALS). Никогда не шарится между пользователями.
|
|
1308
|
+
- Auto-invalidate при `create`/`update`/`destroy` — **не делаем** (Phase 5, если понадобится).
|
|
1309
|
+
|
|
1310
|
+
---
|
|
1311
|
+
|
|
1312
|
+
## 🗺 Roadmap
|
|
1313
|
+
|
|
1314
|
+
### Phase 3 — Роутинг и layouts (ближайшее)
|
|
1315
|
+
|
|
1316
|
+
- [ ] Динамические сегменты: `NixoRoute.path: '/blog/:slug'`, `matchRoute(url) → { routeId, params }`.
|
|
1317
|
+
- [ ] Наследование layouts по `parentId`: `layoutChain: string[]`.
|
|
1318
|
+
- [ ] Реальный `<Outlet/>` (slot через Context).
|
|
1319
|
+
- [ ] `config.components: { error, loading, notFound }`.
|
|
1320
|
+
- [ ] Per-route `ssr: false`.
|
|
1321
|
+
|
|
1322
|
+
### Phase 4 — Данные
|
|
1323
|
+
|
|
1324
|
+
- [ ] Стриминг данных (React 19 `use` + Suspense).
|
|
1325
|
+
- [ ] `devalue` для сериализации функций.
|
|
1326
|
+
- [ ] Cross-page кэш фабрик.
|
|
1327
|
+
- [ ] Рекурсивный `collectDirectCssImports`.
|
|
1328
|
+
- [ ] `System.Describe` для JSON-RPC.
|
|
1329
|
+
|
|
1330
|
+
### Phase 5 — RSC
|
|
1331
|
+
|
|
1332
|
+
### Phase 6 — DX
|
|
1333
|
+
|
|
1334
|
+
---
|
|
1335
|
+
|
|
1336
|
+
## 🧪 Проверки
|
|
1337
|
+
|
|
1338
|
+
```bash
|
|
1339
|
+
# Установка (из корня монорепо)
|
|
1340
|
+
pnpm install
|
|
1341
|
+
|
|
1342
|
+
# Линт и типы
|
|
1343
|
+
pnpm lint
|
|
1344
|
+
pnpm typecheck
|
|
1345
|
+
|
|
1346
|
+
# Дамп маршрутов и манифеста
|
|
1347
|
+
pnpm inspect:basic
|
|
1348
|
+
|
|
1349
|
+
# Dev-сервер примера
|
|
1350
|
+
pnpm dev:basic
|
|
1351
|
+
# → http://localhost:3000
|
|
1352
|
+
|
|
1353
|
+
# С диагностикой __NIXO_STATE__
|
|
1354
|
+
NIXO_DEBUG=1 pnpm dev:basic
|
|
1355
|
+
# → [nixo] __NIXO_STATE__ length=234 slices=UserList
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
---
|
|
1359
|
+
|
|
1360
|
+
## ⚠️ Известные ограничения
|
|
1361
|
+
|
|
1362
|
+
### Функциональность
|
|
1363
|
+
|
|
1364
|
+
1. **Динамические сегменты** (`/blog/:slug`) — не реализованы. → Phase 3.
|
|
1365
|
+
2. **Вложенные layouts** — один layout на маршрут. → Phase 3.
|
|
1366
|
+
3. **`<Outlet/>`** — пока no-op (заглушка). → Phase 3.
|
|
1367
|
+
4. **Error boundaries / loading / notFound** через `config.components` — не реализованы. → Phase 3.
|
|
1368
|
+
5. **Per-route `ssr: false`** — только глобально или через `?noSSR=true`. → Phase 4.
|
|
1369
|
+
6. **Стриминг данных** (React 19 `use` + Suspense) — не реализован. → Phase 4.
|
|
1370
|
+
|
|
1371
|
+
### JSON-RPC
|
|
1372
|
+
|
|
1373
|
+
7. **Notifications** (без `id`, без ответа) — не поддержаны. Fallback: `id = 1..N`.
|
|
1374
|
+
8. **System.Describe** — не реализован.
|
|
1375
|
+
9. **`ctx.call` в `loadData`** — не доступен; `loadData` использует `NixoService` напрямую.
|
|
1376
|
+
10. **`RpcContext.call` на SSR** — прямой вызов handler'а (без HTTP), но `extendContext` получает только `{ headers }`, не FastifyRequest.
|
|
1377
|
+
|
|
1378
|
+
### CSS / Dev
|
|
1379
|
+
|
|
1380
|
+
11. **Dev FOUC для вложенных компонентов** — `collectDirectCssImports` не рекурсирует.
|
|
1381
|
+
12. **CSS в SPA (prod)** — все CSS из манифеста, без per-route оптимизации.
|
|
1382
|
+
|
|
1383
|
+
### Данные
|
|
1384
|
+
|
|
1385
|
+
13. **`getConfig` вызывается дважды** — при build (`collectFactoryModules`) и в рантайме.
|
|
1386
|
+
14. **Сериализация функций** — `JSON.stringify` теряет; восстанавливается через `_initState`.
|
|
1387
|
+
|
|
1388
|
+
### Инфраструктура
|
|
1389
|
+
|
|
1390
|
+
15. **Windows + pnpm:** Ctrl+C не убивает дочерние процессы. → `Get-Process node | Stop-Process -Force`.
|
|
1391
|
+
16. **`localhost` на Windows** резолвится в `::1`, а Fastify на `0.0.0.0` — IPv4. Используйте `127.0.0.1` или `host: '::'`.
|
|
1392
|
+
---
|
|
1393
|
+
|
|
1394
|
+
## 📚 Соглашения
|
|
1395
|
+
|
|
1396
|
+
- **Весь код документируется JSDoc** (`@param`, `@returns`, `@throws`).
|
|
1397
|
+
- **Не-тривиальная логика** — inline-комментарии (почему, а не что).
|
|
1398
|
+
- **Публичный API** — только через `src/index.ts` и `packages/nixo/src/client/index.ts`.
|
|
1399
|
+
- **Framework `.tsx`** — через `createElement`, не JSX (см. `NixoProvider.tsx`), чтобы не зависеть от JSX runtime загрузчика.
|
|
1400
|
+
- **Ошибки фреймворка** — через `NixoError`.
|
|
1401
|
+
- **Стилистические JSDoc-правила** (`tag-lines`, `no-undefined-types`) — отключены.
|
|
1402
|
+
|
|
1403
|
+
---
|
|
1404
|
+
|
|
1405
|
+
## 📄 Лицензия
|
|
1406
|
+
|
|
1407
|
+
TBD.
|