@notrealstudio/backend-http
v0.9.1
Published
Транспортный шов nr-ui-protocol: serveBackend (node:http + SSE) и createHttpBackend (fetch)
Readme
@notrealstudio/backend-http
Транспортный шов nr-ui-protocol: обе стороны провода.
ProtocolBackend ── serveBackend(b) ── HTTP+SSE ── createHttpBackend(url) ── UI
(объект) (сервер §9) (тот же интерфейс)Бэкенд — объект, не процесс. Где он хостится (лаунчер, service.ts, постоянный
сервис) — дело потребителя; транспорт между UI и бэкендом всегда HTTP+SSE, даже
in-process. UI держит ProtocolBackend и не различает, локальный он или удалённый.
Спека: nr-ui-protocol.dev/specs/backends-spec.md §3.
Канон биндинга: nr-engine/docs/specs/nr-ui-protocol-spec.md §9.
Обе стороны живут в одном пакете намеренно: они обязаны эволюционировать синхронно и тестируются друг об друга (loopback).
Установка
npm i @notrealstudio/backend-httpЗависимости: node builtins + @notrealstudio/nr-ui-protocol. Ничего про pi / engine /
opencode здесь нет — это провод, а не бэкенд.
serveBackend
import { serveBackend } from '@notrealstudio/backend-http'
const srv = await serveBackend(myBackend, { port: 7331 })
console.log(srv.url) // http://127.0.0.1:7331
await srv.close() // backend.close() не трогает — жизнь бэкенда чужая| opt | default | что |
|---|---|---|
| port | 0 (свободный) | реальный порт — в srv.port |
| host | '127.0.0.1' | наружу сервер не торчит без спроса |
| cors | false | CORS-заголовки + preflight (dev) |
| basePath | '' | '/api' → /api/sessions; srv.url уже с ним |
| maxBodyBytes | 10 МБ | больше → 413 |
Поведение:
- Ошибки → Envelope §4:
NotSupportedError→{ok:false, error, code:'not_supported'}(501), прочее →{ok:false, error}(500). - Авария в стриме после старта → кадр
run_end{status:'error'}+ закрытие (заголовки уже ушли, Envelope-ошибку не отдать). До первого кадра → Envelope. - Обрыв SSE-коннекта ран НЕ отменяет (§5.4): итератор дочитывается до конца,
ран живёт на сервере, клиент ресинкается через
history. Отмена — только эндпоинтомcancel. - Один активный run на сессию (§10.1): второй
POST /runпри живом →409 {code:'busy'}.
createHttpBackend
import { createHttpBackend } from '@notrealstudio/backend-http'
const backend = createHttpBackend('http://127.0.0.1:7331')
for await (const ev of backend.run(sid, { userMessage: 'привет' })) {
if (ev.type === 'ask') await backend.respond!(sid, ev.askId, { allow: true })
}| opt | default | что |
|---|---|---|
| fetch | globalThis.fetch | своя реализация (тесты, авторизующая обёртка) |
| headers | — | заголовки транспорта; auth — вне протокола (§10.6) |
| capabilitiesTtl | ∞ | мс; по умолчанию кэш живёт до close() |
Поведение:
- Браузер и node: поверх
fetch, без EventSource — ран стартует POST'ом с телом. capabilities()кэшируется.- Обрыв стрима → синтетический
run_end{status:'error', error:{code:'connection_lost'}, version:''}, а не исключение: ран мог доехать на сервере, UI различает по коду и ресинкается черезhistory. Пустойversion= «токен неизвестен». - Неизвестные события едут вверх opaque, не фильтруются (§1.2).
close()рвёт живые запросы и запрещает новые.
Опциональные методы у клиента есть всегда
Правило протокола «метод есть ⇔ capability заявлена» держит бэкенд за
проводом: capabilities() асинхронны, а фабрика синхронна — узнать их к
моменту сборки объекта нельзя. Вызов незаявленной операции приезжает
NotSupportedError с кодом. UI и так гасит фичи по capabilities(), а не по
наличию метода.
Роуты (§9)
GET /capabilities
GET /sessions POST /sessions { title?, model?, profile?: id | ProfileDoc }
GET /sessions/:id DELETE /sessions/:id
PATCH /sessions/:id { title } → rename, { meta } → meta.patch
POST /sessions/:id/fork { atMessageId? }
GET /sessions/:id/history { messages, version }
POST /sessions/:id/run → SSE-стрим RunEvent
POST /sessions/:id/cancel { runId }
POST /sessions/:id/respond { askId, answer }
POST /sessions/:id/messages { parts, role?: user | assistant, swipeOf? } → messages.append
PATCH /sessions/:id/messages/:mid { parts | text, ifHash }
DELETE /sessions/:id/messages/:mid
POST /sessions/:id/messages/:mid/swipe { dir | index }
POST /sessions/:id/model { model, thinking? } → применённое
GET /models каталог ModelInfo[]
GET /workspace текущее пространство WorkspaceInfo
GET /workspaces известные пространства WorkspaceInfo[]
POST /workspace { id } → переключить (workspace.switch)
GET /workspace/files?path= FileEntry[] одного уровня (без path — корень) (workspace-files-spec §1)
GET /workspace/file?path=&maxBytes= FileContent
PUT /workspace/file { path, content, encoding?, revision? } → { revision }; revision: null — создать
DELETE /workspace/file?path= в корзину пространства
POST /workspace/file/rename { from, to }
GET /workspace/events → SSE workspace_update { paths } (capability workspace.files.watch)
GET /profiles каталог ProfileInfo[] (profiles-spec §4)
POST /sessions/:id/profile { id } | { doc } → профиль сессии (profiles.set; doc — встроенный, inline)
GET /profiles/:id ProfileDoc (profile-editor-spec §3)
PUT /profiles/:id ProfileDoc → сохранённый (profiles.save; id — из адреса)
DELETE /profiles/:id profiles.delete (default — bad_request)
POST /profiles/:id/duplicate { id } → копия (занятый id — conflict)
GET /catalogs/{tools,skills,extensions} каталоги конструктора профилей
GET /bots, /bots/:id, /bots/:id/assets/:file
GET /personas, /personas/:idДва роута §9 не даёт, а операции §4.2 требуют — добавлены по симметрии (обе стороны пакета их знают, спека — кандидат на правку):
GET /sessions/:id/meta meta.get (в §9 только PATCH)
POST /sessions/:id/messages/:mid/hide { hidden } messages.hide
GET /workspace/file/stat?path= workspace.files.stat (workspace-files-spec §1 роута не даёт)Файлы пространства (workspace-files-spec §1–2)
- Вход проверяется до бэкенда: нет
pathу read/delete,contentне строка, чужойencoding,revisionне строка/null, кривойmaxBytes— 400. Ошибки бэкенда — своими кодами:conflict409,not_found404,bad_request400. GET /workspace/events— отдельный SSE-канал, не поток рана: комментарий: workspace eventsна подключение, кадрыevent: workspace_update, keepalive: pingраз в 25 с. Живёт, пока клиент держит соединение.- Клиент:
workspace.files.watch(listener) → unsubscribeсинхронно, канал — фоном. Обрыв и 5xx — переподключение (500 мс, удвоение до 30 с), после переподключения подписчик получаетpaths: ['']— «перечитай всё». 4xx иnot_supported— без ретраев.close()гасит и watch. createStoreBackend({ files })— memory-FS для фикстур (createMemoryFiles, экспортирована): картаpath → content, ключ с/в конце — пустой каталог. Семантика backend-fs: корзина.nr-trash, revisionsize:mtimeMs,ignoredпо.gitignoreиз той же карты; watch — эмуляция на собственных мутациях. Без опции capabilityworkspaceостаётся{}.
code в Envelope §4 тоже нет — едет дополнительным полем, как требует
backends-spec §3.1. Клиент, не знающий про code, не ломается (§1.2:
неизвестные поля игнорируются).
Мета сессии (session-meta-spec §2)
meta.patch в createStoreBackend применяет правило патча протокола
(mergeSessionMeta: вложенный prompt, null удаляет ключ), а драйверу
уезжает готовый документ — правило одно на все хранилища, иначе «мета»
означала бы разное в фикстуре и в pi. Записывается через store.meta.set
(замена документа целиком); драйвер без set удаление ключа не переживёт —
вложенный merge при этом работает.
Фикстурный бэкенд для e2e фронта — createStoreBackend над
@notrealstudio/nr-chat-store/memory: sessionMeta там есть из коробки.
Тесты
Loopback (test/loopback.test.ts): fake-backend (in-memory, скриптованные
сценарии) → serveBackend → createHttpBackend. Все операции + полный ран
(multi-message, tool-parts, ask/respond, cancel, ошибка, обрыв). Приёмка —
события на клиенте равны событиям fake-бэкенда.
npm test # vitest
npm run build # tsc → dist
npx tsc --noEmit # только типы