Быстрый старт
Три действия: собрать хост → написать xs.json и исходники C-приложения → запустить.
1. Сборка хоста
Нужны GCC (переопределяется через --cc) и Python 3. Позиционные аргументы выбирают варианты расширений; Windows / Linux — единый процесс:
build.bat /* минимальный вариант */
build.bat sqlite xtp /* выбрать по имени: SQLite и XTP2 */
build.bat all /* все десять опциональных библиотек одним ключом */
build.sh all /* эквивалент для Linux */
xs --version /* версия и список вкомпилированных расширений */
Артефакт атомарно заменяется только после успешной линковки — неудачная сборка всегда оставляет последний рабочий xs. Результат — один исполняемый файл со встроенными компилятором TCC и полным набором заголовков SDK.
2. Минимальная конфигурация и приложение
/* xs.json — реальная конфигурация из demo-single */
{
"services": [
{
"enabled": true,
"class": "http",
"name": "demo",
"ip": "127.0.0.1",
"port": 9080,
"host_default": {
"enabled": true,
"name": "app",
"path": "wwwroot",
"devlang": "c",
"devfile": "main.c"
}
}
]
}
/* main.c — минимальный работающий скрипт */
#include <xsbase.h>
static void ServiceInit(XS_HostInfo* pHost)
{
printf("[%s] service init\n", pHost->Name);
}
static XS_RequestResult RequestProc(XS_HttpReq* pReq)
{
/* необработанные запросы падают в статический слой (wwwroot), остальное 404 */
return XS_FALLBACK;
}
3. Запуск
./xs /* по умолчанию читает xs.json из каталога приложения */
./xs my.json /* или указать конфиг явно */
Лог старта: [xs] config loaded → engine started → script loaded → по одному bound на порт → [xs] running. Откройте http://127.0.0.1:9080/ — получите статические страницы из wwwroot.
Структура приложения
Приложение XServer = exe хоста + один каталог исходников. Структуру определяете вы; ниже — проверенная в demo-single организация:
myapp/
├── xs.exe /* единственный бинарник (5,2 МБ, компилятор и SDK внутри) */
├── xs.json /* сервисы · порты · TLS · vhost */
├── main.c /* вход приложения: include-оркестровка + ServiceInit/Unit */
├── route.h /* реестр регистрации маршрутов */
├── modules/ /* переиспользуемые модули (http/db/template/resource…) */
├── route_http/ /* обработчики запросов */
├── page/ /* контролируемые страницы (вне wwwroot, без прямых ссылок) */
├── template/ /* шаблоны xrtTemplate */
├── db/ /* файлы данных SQLite */
└── wwwroot/ /* корень статики */
Все исходники сливаются в одну единицу трансляции (main.c подключает всё) и компилируются встроенным TCC в машинный код. Статические переменные изолированы по поколениям скрипта: перезагрузка создаёт новый набор статиков, а ServiceUnit старого поколения выполняется только после ухода последнего запроса — это и есть семантическая основа горячей перезагрузки.
dev_inc, сторонние библиотеки — в dev_lib (объявляются в xs.json), TCC ищет их автоматически; #pragma comment(lib, ...) ссылается на них напрямую.Справочник конфигурации (xs.json)
Загрузка конфигурации по принципу fail-fast: любое недопустимое поле прерывает старт с ошибкой, называющей конкретное поле, — процесс никогда не работает «с дефектом».
Поля корневого уровня
| Поле | Тип / ограничение | Описание |
|---|---|---|
engine.workers | uint, 0–1024 | Число рабочих потоков движка, 0 = значение xrt по умолчанию. Неизменяемо при горячей перезагрузке; изменения отклоняются |
services | массив, обязателен | Перечень сервисов; отсутствие = ошибка старта |
Поля уровня сервиса (services[i])
| Поле | Тип / ограничение | Описание |
|---|---|---|
enabled | bool, по умолчанию true | Включён ли сервис |
class | белый список | http / ws / tcp / udp / custom |
name | обязательно, глобально уникально | Ключ для xsServerFind |
ip | по умолчанию 0.0.0.0 | Адрес прослушивания |
port | 1–65535, обязательно кроме custom | Порт открытого текста |
tls | bool | Включить TLS-порт (для udp запрещено) |
port_tls | по умолчанию 443 | Старт отклоняется при конфликте с портом открытого текста на том же IP |
ip_tls | по умолчанию совпадает с ip | Адрес прослушивания TLS |
backlog | uint32 | Передаётся напрямую в конфигурацию слушателя xrt |
recv_limit | ≤SIZE_MAX; udp ≤65535 | Предел чтения потока |
path / devlang / devfile / dev_inc / dev_lib | — | Скриптовые значения по умолчанию уровня сервиса, сливаются в DefaultHost |
Поля уровня хоста (host_default / hosts[i])
| Поле | Тип / ограничение | Описание |
|---|---|---|
enabled | bool | Включён ли хост |
name | уникально внутри server, непустое | Имя хоста |
host | домены через точку с запятой | Обязательно и непусто при включённых vhost для http/ws; маршрутизация по заголовку Host / SNI |
path | путь | Корень статики / скриптов |
devlang | static / c / свой | static = чистая статика; c = скрипт TCC |
devfile | путь | Входной файл скрипта |
tls_ca / tls_cert / tls_key | пути PEM | Сертификаты уровня хоста → источник данных выбора по SNI; горячая замена |
dev_inc / dev_lib | каталоги через точку с запятой | Дополнительные пути поиска include / библиотек для скриптов (относительно appPath) |
Ручки приложения (поля Custom)
Предустановленной схемы нет — ключи зарезервированы для чтения приложением (через server->Custom / xsConfigRoot()). Соглашения хоста и демо:
| Поле | Уровень | Ограничение / умолчание |
|---|---|---|
header_limit | server | Предел заголовков запроса |
path_limit | server | Предел длины пути, по умолчанию 2048 |
body_limit | server | Предел тела запроса (413 при превышении) |
idle_timeout | server | Тайм-аут простоя (http/ws/tcp); сервер закрывает по истечении |
ws_message_limit | server | Предел сообщений WS; превышение закрывает с кодом 1009 |
ws_protocol | server | Обязательный подпротокол (валидируется при старте) |
static.headers | host | ≤8 полей; 8 зарезервированных заголовков запрещены (вкл. Transfer-Encoding) |
static.index | host | Массив индексов каталога, перебор по порядку (по умолчанию index.html) |
static.error_pages | host | Статусы 400–599 → относительный путь кастомной страницы ошибки |
static.deny_dotfiles | host | По умолчанию true: файлы с точкой в начале всегда отклоняются (вкл. обход путей) |
Все файловые пути переводятся в абсолютные относительно каталога файла конфигурации — каталог конфигурации самодостаточен и переносится целиком.
Контракт скриптов
Хост ищет колбэки по имени символа в скомпилированном результате; все необязательны и объявляются по мере надобности. Это и есть всё соглашение между XServer и приложением — без базовых классов и макросов регистрации.
| Символ | Протоколы | Сигнатура (xsbase.h) |
|---|---|---|
ServiceInit | все | void (XS_HostInfo*) инициализация после загрузки |
ServiceUnit | все | void (XS_HostInfo*) деструкция при утилизации, не более одного раза за жизнь |
ServiceSwap | все | bool (XS_HostInfo*, xvalue**) экспорт снимка состояния перед заменой |
RequestProc | http/https | XS_RequestResult (XS_HttpReq*) вход запроса |
WsOpen / WsText / WsBinary | ws/wss | (XS_HostInfo*, xwsstream*[, представление сообщения]) |
WsPing / WsPong / WsClose | ws/wss | то же; Close добавляет код состояния uint16 и представление причины |
EventOpen / EventData / EventClose | tcp/tcps | (XS_HostInfo*, XS_StreamConn*[, xnetbuf*/результат]) |
EventDgram | udp | (XS_HostInfo*, xnetudp*, const xnetudpmessage*) |
Трёхзначный возврат RequestProc
| Возврат | Семантика |
|---|---|
XS_OK | Полный ответ отправлен; драйвер вычитывает остаток тела и продолжает keep-alive |
XS_FALLBACK | Не обработано: GET/HEAD падают в статический слой, остальное 404 |
XS_TAKEOVER | Приложение перехватывает соединение: принимает/отправляет через pull/Future и в конце Close; терминальное состояние Close освобождает lease через XServer |
Разработка HTTP
XServer не навязывает объектную модель: XS_HttpReq — структура-представление над набором указателей xrt; вы напрямую используете весь API xrt.
| Поле | Описание |
|---|---|
tcp / tls | «Голые» указатели транспорта, ровно один не пуст — доступен весь потоковый/отправочный API xrt |
head | Разобранный заголовок запроса (вкл. MethodCode); представления полей действительны только внутри колбэка |
body | Автомат кадрирования: фиксированная длина / chunked / закрытие; инкрементальное чтение через xrtHttp1BodyRead |
host / server | Принадлежность (результат маршрутизации vhost) |
Построение ответов
Ответ приложение строит само — xrtHttp1ResponseWrite пишет заголовок, xrtHttp1ChunkWrite дробит на куски, xrtNetStreamSend / xrtTlsStreamSend отправляют. Ниже — реальная реализация демо (фрагмент):
/* modules/http.h — полный ответ: Content-Length + Content-Type генерируются
* вместе; HEAD сохраняет Content-Length, но не отправляет тело */
static bool ReplyRaw(XS_HttpReq* pReq, uint16 iStatus,
const char* sContentType, const void* pBody, size_t iBodySize)
{
char arrHead[1024], arrLength[32];
xhttpfield arrField[12];
size_t iFieldCount = 0, iHeadSize = 0;
snprintf(arrLength, sizeof(arrLength), "%llu",
(unsigned long long)iBodySize);
arrField[iFieldCount].Name = XRT_STR_LITERAL("Content-Length");
arrField[iFieldCount].Value = xrtStrView(arrLength);
iFieldCount++;
if ( !xrtHttp1ResponseWrite(XHTTP_VERSION_1_1, iStatus,
xrtHttpStatusText(iStatus), arrField, iFieldCount,
arrHead, sizeof(arrHead), &iHeadSize) ) return false;
if ( !ConnSend(pReq, arrHead, iHeadSize) ) return false;
return pReq->head->MethodCode == XHTTP_METHOD_HEAD ||
ConnSend(pReq, pBody, iBodySize);
}
Маршрутизация (шаблон уровня приложения)
Встроенного роутера нет — на каждый host один RequestProc, диспетчеризацию URL строит приложение. Проверенный в demo-single шаблон: статическая таблица (xrtMap) + динамические pattern (xrtPatternCompileMany) + слоты методов:
AddStaticRouteHTTP("/api/v1/items", XHTTP_METHOD_GET, Handle_API_List);
AddDynamicRouteHTTP("/api/v1/items/{id}",
XHTTP_METHOD_PUT | XHTTP_METHOD_PATCH, Handle_API_Item_Update);
/* после регистрации — однократная компиляция; стоимость сопоставления
не зависит от числа маршрутов */
(void)RouteHTTP_Compile();
- Маски методов свободно комбинируются;
XHTTP_METHOD_CRUD/XHTTP_METHOD_ANY— агрегированные маски - Порядок: статическое точное совпадение → динамический pattern → слот метода; промах возвращает
XS_FALLBACKв wwwroot - Замер на 10 000 маршрутов: 42,0 мкс @ 10 → 59,1 мкс @ 10 000 (детали бенчмарка)
vhost и TLS
Маршрутизация по трём источникам: заголовок Host / absolute-form / authority-form; HTTP/1.1 требует Host, несовпавшее уходит хосту по умолчанию. TLS выбирает сертификаты хоста по SNI; серверная cookie привязывает выбор из ClientHello к тому же поколению, что и Accept. Сертификаты заменяются на ходу (атомарная подмена таблицы под блокировкой).
Разработка WebSocket
XServer проверяет рукопожатие (400 при провале) и делает xrtWsStreamAttach; маскирование, пересборка фрагментов, управляющие кадры и permessage-deflate целиком живут в слое xrt — вы пишете только бизнес-колбэки:
static void WsOpen(XS_HostInfo* pHost, xwsstream* pWs)
{
(void)xrtWsStreamText(pWs, xrtStrView("connected"));
}
static void WsText(XS_HostInfo* pHost, xwsstream* pWs, xstrview tText)
{
(void)xrtWsStreamText(pWs, tText); /* echo */
}
- Отправка:
xrtWsStreamText / xrtWsStreamBinary(плюс варианты с передачей владения Ref / Take) - Пределы:
ws_message_limitзакрывает с кодом 1009;ws_protocolможет требовать подпротокол - Хост без экспортированных WsText/WsBinary отвечает 503; неудачная маршрутизация vhost — 404; превышение заголовков — 431
- Сканирование простоя использует межпотоково-безопасный Abort; аккумуляторы свыше 256 КБ освобождаются после конца сообщения
Потоковые сервисы TCP
class = tcp (переключатель tls уровня сервиса решает tcp или tcps). XS_StreamConn — структура-представление: ровно один из tcp / tls не пуст; используйте отправочный API ненулевой стороны (под tcps запрещено писать в tcp в обход tls).
static void EventOpen(XS_HostInfo* pHost, XS_StreamConn* pConn)
{
/* приветственный баннер при установке соединения */
if ( pConn->tcp != NULL )
(void)xrtNetStreamSend(pConn->tcp, "hello\n", 6);
}
static void EventData(XS_HostInfo* pHost, XS_StreamConn* pConn, xnetbuf* pBuffer)
{
/* pBuffer — цепочка буферов без копирования, заимствована на колбэк;
* размер через xrtNetBufSize(pBuffer); данные потребляются API буферов xrt */
}
static void EventClose(XS_HostInfo* pHost, XS_StreamConn* pConn,
xnetresult iResult, const xerror* pError)
{
/* жизненным циклом соединения владеет xs; приложение не вызывает Destroy */
}
- Жизненный цикл: перехват Accept → реестр → деструкция на Close — всем управляет и всем владеет XServer
idle_timeoutзакрывает соединения по простою;recv_limitменьше блока чтения по умолчанию автоматически сжимает блок- Сертификаты хостов строят таблицу SNI; каждому соединению сертификат по SNI
Сервисы UDP
class = udp, минимальный драйвер: bind → прозрачный EventDgram. Каждая дейтаграмма берёт lease текущего поколения через слот слушателя; состояния соединений нет.
static void EventDgram(XS_HostInfo* pHost, xnetudp* pUdp,
const xnetudpmessage* pMsg)
{
/* pMsg->Remote адрес отправителя · pMsg->Data/pMsg->Size нагрузка
* pMsg->Flags несёт флаг XNET_UDP_MESSAGE_TRUNCATED */
(void)xrtNetUdpSendTo(pUdp, &pMsg->Remote,
"ack", 3); /* ответ на адрес отправителя */
}
recv_limit ограничен 65535; отправка целиком на совести скрипта через xrtNetUdpSendTo, на любой адрес назначения.
Горячая перезагрузка
Перезагрузка — это задача координации целевого состояния, а не синхронная команда: Submit возвращает стабильный ID, итог опрашивается. И перезагрузка host, и server публикуются как полное поколение server.
API запуска
| Функция | Описание |
|---|---|
xsReloadHostSubmit(pHost) | Перезагрузить один host (публикация целым поколением его server) |
xsReloadServerSubmit(name) | Перезагрузить весь server по имени |
xsReloadAllSubmit() | Пакетная транзакция: все кандидаты прекампилируются, любая неудача откатывает в обратном порядке |
xsReloadQuery(id, &result) | Опрос итога и отпечатка Revision |
Терминальные состояния: ACCEPTED → PREPARING → SUCCEEDED / FAILED / SUPERSEDED / CANCELLED. Новое намерение на том же server вытесняет неисполненное старое (SUPERSEDED); предел pending — 64.
Передача состояния
/* Старое поколение: неразрушающий экспорт снимка (может продолжать обслуживать) */
static bool ServiceSwap(XS_HostInfo* pHost, xvalue** ppShared)
{
xvalue* pState = xrtValueObject();
xrtValueObjectSetNew(pState, XRT_STR_LITERAL("counter"),
xrtValueInt64(G_iCounter));
*ppShared = pState;
return true;
}
/* Новое поколение: забрать внутри ServiceInit и бесшовно продолжить */
static void ServiceInit(XS_HostInfo* pHost)
{
xvalue* pState = xsSwapTake(pHost);
if ( pState != NULL ) {
G_iCounter = xrtValueInt64(xrtValueObjectGet(pState, "counter"));
xrtValueRelease(pState);
}
}
Семантические гарантии
- Сначала компиляция, потом замена: всё множество кандидатов server обязано скомпилироваться до побочных эффектов — любая неудача отклоняет партию целиком, старое поколение продолжает обслуживать (откат — семантическая гарантия)
- Два поколения параллельно: старые соединения дорабатывают на старом коде; неизменённые эндпоинты передаются в слоте слушателя без мерцания
- Утилизация при нуле ссылок: после обнуления lease GC-поток в правильном порядке разрушает скрипт / драйвер / конфигурацию — soak-тесты: ноль утечек, ноль падений
- Таймеры умирают со своим поколением:
xsTimerAfterпривязан к поколению создания; замена отменяет его автоматически, в новое он не выстрелит - Неизменяемое: изменения
engine.workersотклоняются; класс custom без онлайн-перезагрузки (у «сырых» ресурсов нет терминального lease)
Публикация одним файлом (сайтовый VFS)
xs.json и весь сайт упаковываются в один LZMA-архив, дописываемый в конец бинарника xs, — получается развёртываемый одним файлом exe. Запуск без аргументов автоматически находит пакет приложения и читает конфигурацию и файлы из него.
xs.exe pack <каталог-сайта> -o app.exe # в каталоге должен лежать xs.json; база = сам запущенный xs
xs.exe pack --list app.exe # список элементов (путь/метод/размер/CRC)
xs.exe pack --extract app.exe -d out/ # распаковка для разбора
xs.exe pack --strip app.exe -o orig.exe # восстановить исходный бинарник (побайтово)
Приоритет диска
Положите одноимённый файл рядом с exe — он переопределяет версию из пакета (горячее исправление CSS / продление сертификатов без перепаковки); удалите — содержимое пакета вернётся. Пути сертификатов идут через единый выход чтения (сначала диск, потом пакет), поэтому после записи файла его копия в пакете естественно отменяется.
Формат архива
- Сжатие: LZMA по элементам (без выигрыша — STORE); демо-сайт 1,7 МБ → 542 КБ
- Обнаружение: чтение 24-байтового концевого маркера, нулевая цена; четыре уровня CRC (маркер/заголовок/индекс/элемент)
- Память: индекс загружается один раз при старте и неизменяем (потокобезопасно); содержимое распаковывается лениво при первом обращении и остаётся в кэше
--no-vfsигнорирует пакет приложения и принуждает режим каталогов (отладка)
Форма настольного приложения (class: "app")
Объявите "class": "app" у сервисного узла — получите оконную форму: локальный HTTP-сервис + окно WebView2 в одном процессе — окно загрузки при старте, автоматическое открытие интерфейса по готовности сервиса, корректная остановка при закрытии окна. Конфигурация http-стороны (host / static / TLS / горячая перезагрузка) значит то же самое; добавляется лишь дескриптор window (9 полей, см. справочник полей).
Когда выбирать форму app
- Доставка инструментального продукта конечным пользователям (панели управления, локальные дашборды, рабочие места агентов), где пользователь не должен «открывать браузер и вводить адрес»
- Один и тот же продукт должен работать и как серверное развёртывание — Linux / варианты без webview автоматически уходят в headless, конфигурация не меняется
- Нужен невидимый локальный порт: класс app по умолчанию
ip 127.0.0.1(без диалога брандмауэра) +port: 0автоназначение ОС (фактический порт — вXS_ServerInfo.PortBound)
xsw.exe
Релизный пакет для Windows содержит xsw.exe — вариант той же сборки с GUI-подсистемой (без консольного окна). Используйте его как базу упаковки для формы app: xs.exe pack сайт/ -o app.exe даёт продукт «по двойному щелчку»; с close: "stop" консоли нет ни на одном шаге.
Чек-лист выпуска
window.devtoolsвыключен (умолчание) — в релизных пакетах без F12install_fallbackпо аудитории:askдля обычных пользователей (запрос при отсутствии Runtime) /silentбез присмотра /headlessявно без окна- Внешние обратные вызовы или адреса в закладках → фиксированный порт (прилипание port:0 живёт только с процессом)
- Сценарии
close: "hide": подтвердите путь остановки (сигналы; в v1 без трея) - Та же xs.json без изменений развёртывается на серверы: среды без оконного бэкенда деградируют в headless и печатают адрес доступа в лог
Матрица деградации по платформам/вариантам и детали жизненного цикла — на странице справки webview; сквозной разбор — в главе 50 учебника.
Автопродление сертификатов (узел acme)
Требует расширения xacme. Корневой узел acme в xs.json включает встроенный демон ACME — жизненный цикл сертификата из «ручного продления» превращается в необслуживаемый: ежедневная проверка по расписанию, автоматическое продление DNS-01 при остатке менее 30 дней, горячая замена без разрыва соединений. Постатейные ограничения — в справочнике полей xs.json.
Правила продления
| Пункт | Поведение |
|---|---|
| Время проверки | check_time "03:00" — фиксированное ежедневное время (или check_interval_hours 12 — интервальный режим, взаимоисключающие); первая проверка через 5 минут после старта |
| Запуск продления | остаток жизни < 30 дней (renew_before_days, 1–89) или нет сертификата в store |
| Замыкание | при действующем сертификате ни один CA не опрашивается — чисто локальное решение |
| Горячая замена | продукт продления атомарно пишется на диск (раскладка acme.sh) → xsTlsRefresh для каждого server, ссылающегося на группу |
Цепочка отказоустойчивости CA
Упорядоченный массив directories[]: пробуем по порядку, побеждает первый успешный; сбой CA (сеть / лимиты / протокол) падает на следующий. Действующий локальный сертификат замыкает проверку; продление всегда retry с головы цепочки — автоматический возврат к основному CA после восстановления. Сосуществование нескольких CA без переделок: store xacme хранит аккаунты по каталогам на CA, один ключ аккаунта открывает аккаунты в нескольких CA.
EAB и импорт аккаунта
- CA с EAB (например litessl):
eab_kid_file / eab_hmac_fileпарой через ссылки (креденциалы никогда не попадают в xs.json) - Импорт существующего аккаунта acme.sh без EAB: предзаполнить ключ аккаунта (SEC1 или PKCS#8) в
acme-store/accounts/<первые 16 hex sha256(url)>/account.pem— демон идёт по пути «в store есть аккаунт», EAB не нужен вовсе
Шпаргалка эксплуатации
- Принуденная переподписка: удалить
acme-store/certs/<основной домен>/и перезапустить (решение читает store, а не каталог tls хоста) - Поток демона владеет своим сетевым движком (Workers=1) и снимает конфигурацию при старте — изменения acme требуют перезапуска
- Референс служебного развёртывания:
tools/xs.serviceв репозитории (systemd, 5-секундное восстановление после сбоя + AmbientCapabilities вместо setcap)
Развёртывание и эксплуатация
Форма поставки
Один файл xs / xs.exe (~5,2 МБ, встроенные через LZMA TCC + SDK + системные библиотеки) плюс каталог исходников приложения. Ноль установки на целевой машине: ни рантайма, ни DLL, ни зависимостей от заголовков.
Корректная остановка
Ctrl+C / SIGTERM запускает пять фаз: прекратить приём и утилизировать скрипты → дождаться терминальных состояний сети и остановить движок → утилизировать custom → вычистить деструкции на reaper → освободить конфигурацию. ServiceUnit на этом пути гарантированно выполняется не более одного раза.
Тестовые базлайны
func_test— 40 сценариев с утверждениями: 21 случай fail-fast конфигурации, 8 векторов обхода путей, штормы перезагрузкиreload_matrix— четыре протокола × TLS, 7 эндпоинтов: передача на тех же эндпоинтах и изоляция поколенийdrill— шестичасовой марафон: slowloris / бомбы пайплайнов / бомбы перезагрузок битых скриптов + замеры RSSroutebench— сравнение задержек на одной машине с Go / PHP / FastAPI (результаты)
Платформы
Windows и Linux (система сборки ветвится автоматически); macOS поддержан на уровне xrt бэкендом kqueue. Национальные пути на Windows преобразуются в UTF-8 на входе.