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/README.md CHANGED
@@ -1,3 +1,1407 @@
1
- # Temporary Holding Version
1
+ # nixo
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.