Quick start
Three steps: build the host → write an xs.json and your C application source → run.
1. Build the host
Requires GCC (override with --cc) and Python 3. Positional arguments pick extension variants; Windows / Linux share one flow:
build.bat /* minimal variant */
build.bat sqlite xtp /* pick by name: SQLite and XTP2 */
build.bat all /* all ten optional libraries in one switch */
build.sh all /* Linux equivalent */
xs --version /* version and the built-in extension list */
The artifact is replaced atomically only after a successful link — a failed build always leaves the last working xs intact. The result is a single executable with the TCC compiler and the full SDK headers embedded.
2. Minimal config and app
/* xs.json — the real config from 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 — the smallest runnable script */
#include <xsbase.h>
static void ServiceInit(XS_HostInfo* pHost)
{
printf("[%s] service init\n", pHost->Name);
}
static XS_RequestResult RequestProc(XS_HttpReq* pReq)
{
/* unhandled requests fall back to the static layer (wwwroot), else 404 */
return XS_FALLBACK;
}
3. Run
./xs /* reads xs.json from the app directory by default */
./xs my.json /* or point at a specific config */
Boot log: [xs] config loaded → engine started → script loaded → one bound per port → [xs] running. Open http://127.0.0.1:9080/ and you get the static pages from wwwroot.
App structure
A XServer app = the host exe + one source directory. The layout is yours to define; below is the demo-single proven arrangement:
myapp/
├── xs.exe /* the only binary (5.2MB, compiler and SDK inside) */
├── xs.json /* services · ports · TLS · vhosts */
├── main.c /* app entry: include orchestration + ServiceInit/Unit */
├── route.h /* route registration manifest */
├── modules/ /* reusable modules (http/db/template/resource…) */
├── route_http/ /* request handlers */
├── page/ /* controlled pages (outside wwwroot, no direct links) */
├── template/ /* xrtTemplate templates */
├── db/ /* SQLite data files */
└── wwwroot/ /* static asset root */
All sources merge into one translation unit (main.c includes everything) compiled to native machine code by the embedded TCC. Statics are isolated per script generation: a reload creates a fresh set of static variables, and the old generation runs ServiceUnit only after its last request leaves — this is the semantic basis of hot reload.
dev_inc directory and third-party libraries in dev_lib (declared in xs.json) and TCC picks them up automatically; #pragma comment(lib, ...) references them directly.Configuration reference (xs.json)
Config loading is fail-fast: any invalid field aborts startup with an error naming the exact field — the process never runs half-configured.
Root fields
| Field | Type / constraint | Description |
|---|---|---|
engine.workers | uint, 0–1024 | Engine worker threads, 0 = xrt default. Immutable across hot reload; changes are rejected |
services | array, required | The service list; missing means startup failure |
Service fields (services[i])
| Field | Type / constraint | Description |
|---|---|---|
enabled | bool, default true | Whether the service is enabled |
class | whitelist | http / ws / tcp / udp / custom |
name | required, globally unique | The key for xsServerFind |
ip | default 0.0.0.0 | Listen address |
port | 1–65535, required unless custom | Plaintext port |
tls | bool | Enable the TLS port (forbidden on udp) |
port_tls | default 443 | Rejected at startup if it collides with the plaintext port on the same IP |
ip_tls | defaults to ip | TLS listen address |
backlog | uint32 | Passed through to xrt listener config |
recv_limit | ≤SIZE_MAX; udp ≤65535 | Stream read cap |
path / devlang / devfile / dev_inc / dev_lib | — | Service-level script defaults, merged into the DefaultHost |
Host fields (host_default / hosts[i])
| Field | Type / constraint | Description |
|---|---|---|
enabled | bool | Whether the host is enabled |
name | unique within server, non-empty | Host name |
host | semicolon-separated domains | Required and non-empty when vhosts are enabled for http/ws; routing matches the Host header / SNI |
path | path | Static asset / script root |
devlang | static / c / custom | static = pure static hosting; c = TCC script |
devfile | path | Script entry file |
tls_ca / tls_cert / tls_key | PEM paths | Host-level certs → the data source for SNI selection; hot-swappable |
dev_inc / dev_lib | semicolon-separated dirs | Extra include / library search paths for scripts (relative to appPath) |
Application knobs (Custom fields)
No preset schema — these keys are reserved for the application to read (via server->Custom / xsConfigRoot()). Host and demo conventions:
| Field | Scope | Constraint / default |
|---|---|---|
header_limit | server | Request header cap |
path_limit | server | Path length cap, default 2048 |
body_limit | server | Request body cap (413 when exceeded) |
idle_timeout | server | Idle timeout (http/ws/tcp); the server closes on expiry |
ws_message_limit | server | WS message cap; exceeded closes with close code 1009 |
ws_protocol | server | Required subprotocol (validated at startup) |
static.headers | host | ≤8 fields; 8 reserved headers forbidden (incl. Transfer-Encoding) |
static.index | host | Directory index array, tried in order (default index.html) |
static.error_pages | host | 400–599 status → custom error page relative path |
static.deny_dotfiles | host | Default true: dot-files always rejected (incl. traversal) |
All file paths resolve to absolute against the directory of the config file — a config directory is self-contained and can be moved as a whole.
Script contract
The host looks callbacks up by symbol name in the compiled output; all are optional, declared as needed. That is the entire agreement between XServer and your app — no base classes, no registration macros.
| Symbol | Protocols | Signature (xsbase.h) |
|---|---|---|
ServiceInit | all | void (XS_HostInfo*) post-load initialization |
ServiceUnit | all | void (XS_HostInfo*) retirement destructor, at most once per lifetime |
ServiceSwap | all | bool (XS_HostInfo*, xvalue**) export a state snapshot before the swap |
RequestProc | http/https | XS_RequestResult (XS_HttpReq*) request entry |
WsOpen / WsText / WsBinary | ws/wss | (XS_HostInfo*, xwsstream*[, message view]) |
WsPing / WsPong / WsClose | ws/wss | same shape; Close adds a uint16 status code and reason view |
EventOpen / EventData / EventClose | tcp/tcps | (XS_HostInfo*, XS_StreamConn*[, xnetbuf*/result]) |
EventDgram | udp | (XS_HostInfo*, xnetudp*, const xnetudpmessage*) |
RequestProc's three-way return
| Return | Meaning |
|---|---|
XS_OK | Full response written; the driver drains the remaining body and continues keep-alive |
XS_FALLBACK | Not handled: GET/HEAD fall to the static layer, everything else 404 |
XS_TAKEOVER | The app takes over the connection: send/receive with pull/Future and Close at the end; XServer releases the lease on the Close terminal state |
HTTP development
XServer wraps no object model: XS_HttpReq is a view struct over a set of xrt pointers; you use the full xrt API directly.
| Field | Description |
|---|---|
tcp / tls | Transport raw pointers, exactly one non-null — the full xrt stream/send API applies |
head | Parsed request head (incl. MethodCode); field views are valid only inside the callback |
body | Framing state machine: fixed-length / chunked / close; read incrementally with xrtHttp1BodyRead |
host / server | Ownership info (the vhost routing result) |
Building responses
The app builds responses itself — xrtHttp1ResponseWrite writes the head, xrtHttp1ChunkWrite chunks, xrtNetStreamSend / xrtTlsStreamSend transmit. Below is the demo's real implementation (excerpt):
/* modules/http.h — full response: Content-Length + Content-Type generated
* together; HEAD keeps Content-Length but sends no body */
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);
}
Routing (application-level pattern)
The host ships no router — each host gets one RequestProc, and URL dispatch is yours to build. The demo-single proven pattern: static table (xrtMap) + dynamic patterns (xrtPatternCompileMany) + method slots:
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);
/* compile once after registration; matching cost is independent of route count */
(void)RouteHTTP_Compile();
- Method masks combine freely;
XHTTP_METHOD_CRUD/XHTTP_METHOD_ANYare aggregate masks - Match order: static exact → dynamic pattern → method slot; a miss returns
XS_FALLBACKinto wwwroot - Measured with 10k routes: 42.0µs @ 10 routes → 59.1µs @ 10,000 (benchmark details)
vhosts and TLS
Routing reads the Host header / absolute-form / authority-form; HTTP/1.1 requires Host, unmatched traffic falls to the default host. TLS picks host certificates by SNI; the server-side cookie binds the ClientHello's selection to the same generation as the Accept. Certificates hot-swap (atomic table swap under lock).
WebSocket development
XServer validates the handshake (400 on failure) and calls xrtWsStreamAttach; masking, fragment reassembly, control frames and permessage-deflate all live in the xrt layer — you write only business callbacks:
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 */
}
- Sending:
xrtWsStreamText / xrtWsStreamBinary(plus Ref / Take ownership variants) - Limits:
ws_message_limitcloses with 1009 when exceeded;ws_protocolcan force a subprotocol - A host that exports neither WsText nor WsBinary answers 503; failed vhost routing 404; oversized headers 431
- Idle scanning uses a cross-thread-safe Abort; accumulators over 256KB are freed after the message ends
TCP stream services
class = tcp (the service-level tls switch decides tcp vs tcps). XS_StreamConn is a view struct: exactly one of tcp / tls is non-null; use the matching send API on the non-null side (never bypass tls to write tcp directly under tcps).
static void EventOpen(XS_HostInfo* pHost, XS_StreamConn* pConn)
{
/* send a welcome banner on connect */
if ( pConn->tcp != NULL )
(void)xrtNetStreamSend(pConn->tcp, "hello\n", 6);
}
static void EventData(XS_HostInfo* pHost, XS_StreamConn* pConn, xnetbuf* pBuffer)
{
/* pBuffer is a zero-copy buffer chain, borrowed for the callback only;
* size via xrtNetBufSize(pBuffer); consume data through the xrt buffer API */
}
static void EventClose(XS_HostInfo* pHost, XS_StreamConn* pConn,
xnetresult iResult, const xerror* pError)
{
/* connection lifetime is owned by xs; the app never calls Destroy */
}
- Connection lifecycle: Accept takeover → registry → destruct on Close, all driven and owned by XServer
idle_timeoutcloses idle connections server-side;recv_limitshrinks read blocks below the default- Host certificates build the SNI table; each connection gets its cert by SNI
UDP services
class = udp, minimal driver: bind → EventDgram pass-through. Every datagram takes the current generation lease through the listener slot; there is no connection state.
static void EventDgram(XS_HostInfo* pHost, xnetudp* pUdp,
const xnetudpmessage* pMsg)
{
/* pMsg->Remote source address · pMsg->Data/pMsg->Size payload
* pMsg->Flags carries the XNET_UDP_MESSAGE_TRUNCATED flag */
(void)xrtNetUdpSendTo(pUdp, &pMsg->Remote,
"ack", 3); /* reply to the source address */
}
recv_limit caps at 65535; sending is entirely up to the script via xrtNetUdpSendTo, to any destination address.
Hot reload
Reload is a desired-state coordination task, not a synchronous command: Submit returns a stable ID and the final state is queryable. Both host- and server-level reloads publish a complete server generation.
Trigger APIs
| Function | Description |
|---|---|
xsReloadHostSubmit(pHost) | Reload one host (published as its whole server generation) |
xsReloadServerSubmit(name) | Reload an entire server by name |
xsReloadAllSubmit() | Batch transaction: all candidates pre-compile, any failure rolls back in reverse |
xsReloadQuery(id, &result) | Query the final state and the Revision fingerprint |
Terminal states: ACCEPTED → PREPARING → SUCCEEDED / FAILED / SUPERSEDED / CANCELLED. A newer intent on the same server supersedes an unexecuted older one (SUPERSEDED); pending is capped at 64.
State handover
/* Old generation: non-destructive snapshot export (it can keep serving) */
static bool ServiceSwap(XS_HostInfo* pHost, xvalue** ppShared)
{
xvalue* pState = xrtValueObject();
xrtValueObjectSetNew(pState, XRT_STR_LITERAL("counter"),
xrtValueInt64(G_iCounter));
*ppShared = pState;
return true;
}
/* New generation: take it back inside ServiceInit and carry on */
static void ServiceInit(XS_HostInfo* pHost)
{
xvalue* pState = xsSwapTake(pHost);
if ( pState != NULL ) {
G_iCounter = xrtValueInt64(xrtValueObjectGet(pState, "counter"));
xrtValueRelease(pState);
}
}
Semantic guarantees
- Compile before swap: a server's entire candidate set must compile before side effects — any failure rejects the whole batch and the old generation serves on (rollback is a semantic guarantee)
- Two generations in parallel: old connections finish on old code; unchanged endpoints hand off in the listener slot without blinking
- Reclaim at zero references: once leases hit zero, the GC thread destructs script / driver / config in order — soak tests show zero leaks, zero crashes
- Timers die with their generation:
xsTimerAfterbinds to the generation that created it; the swap cancels it automatically, it never fires into the new generation - Immutable items:
engine.workerschanges are rejected; the custom class has no online reload (raw resources carry no terminal lease)
Single-file deployment (the site VFS)
Pack xs.json and the whole site into one LZMA archive appended to the xs binary, yielding a single deployable exe. A zero-argument start auto-detects the app pack and reads config and files from it.
xs.exe pack <site-dir> -o app.exe # the dir must contain xs.json; base = the running xs
xs.exe pack --list app.exe # list entries (path/method/size/CRC)
xs.exe pack --extract app.exe -d out/ # unpack for forensics
xs.exe pack --strip app.exe -o orig.exe # restore the pristine binary (byte-identical)
Disk priority
Drop a same-named file next to the exe to override the packed version (hot-fix CSS / renewed certificates without repacking); delete it and the packed content returns. Certificate paths go through the unified read exit — disk first, pack as fallback — so after the program writes a file the packed twin is naturally invalidated.
Archive format
- Compression: per-entry LZMA (auto-STORE with no gain); the demo site 1.7 MB → 542 KB
- Detection: reads the 24-byte trailer at its own tail, zero cost; four CRC levels (trailer / header / index / entry)
- Memory: the index loads once at startup and is immutable (thread-safe); content decompresses lazily on first access and stays cached
--no-vfsignores the app pack and forces directory mode (debugging)
The desktop-app form (class: "app")
Declare "class": "app" on a service node to get the desktop-window form: local HTTP service + WebView2 window in one process — a loading window at startup, the interface opens automatically once the service is ready, closing the window shuts down gracefully. The http-side config (host / static / TLS / hot reload) all means the same; only a window descriptor is added (9 fields, see the field reference).
When to choose the app form
- Shipping a tool-like product to end users (admin panels, local dashboards, agent workbenches) where users should never "open a browser and type an address"
- The same artifact must double as a server deployment — Linux / no-webview variants degrade to headless automatically, config unchanged
- You want the local port invisible: the app class defaults to
ip 127.0.0.1(no firewall popup) +port: 0OS auto-assign (actual port inXS_ServerInfo.PortBound)
xsw.exe
The Windows release package ships xsw.exe — the GUI-subsystem variant of the same build (no console window). Use it as the pack base for the app form: xs.exe pack site/ -o app.exe yields a double-click product; with close: "stop" there is no console at any point.
Release checklist
window.devtoolsoff (default) — no F12 in production packagesinstall_fallbackby audience:askfor normal users (prompt when Runtime is missing) /silentunattended /headlessexplicitly windowless- External callbacks or bookmarked addresses → fixed port (port:0 stickiness lives only while the process lives)
close: "hide"scenarios: confirm the shutdown path (signals; no tray in v1)- The same xs.json deploys to servers as-is: environments without a window backend degrade to headless and print the access URL in the log
Platform / variant degradation matrix and lifecycle details are on the webview reference page; the end-to-end walkthrough is tutorial chapter 50.
Automatic certificate renewal (the acme node)
Requires the xacme extension. A root-level acme node in xs.json enables the built-in ACME daemon — the certificate lifecycle goes from "manual renewal" to unattended: a scheduled daily check, automatic DNS-01 renewal when fewer than 30 days remain, hot swap with no dropped connections. Field-by-field constraints are in the xs.json field reference.
Renewal rules
| Item | Behavior |
|---|---|
| Check timing | check_time "03:00" a fixed daily time (or check_interval_hours 12 interval mode — mutually exclusive); first check 5 minutes after startup |
| Renewal trigger | remaining lifetime < 30 days (renew_before_days, 1–89), or no certificate in the store |
| Short-circuit | with a healthy certificate no CA is contacted — a purely local decision |
| Hot swap | renewal output lands atomically (acme.sh-compatible layout) → xsTlsRefresh runs for every server referencing the group |
The CA fallback chain
An ordered directories[] array: tried in order, first success wins; a failing CA (network / rate limit / protocol) falls through to the next. A valid local certificate short-circuits; renewal retries from the head of the chain — automatic return to the primary once it recovers. Multiple CAs coexist with zero rework: the xacme store keeps accounts in per-CA directories and one account key can open accounts at several CAs.
EAB and account import
- CAs requiring EAB (e.g. litessl):
eab_kid_file / eab_hmac_filereferenced as a pair (credentials never enter xs.json) - Importing an existing acme.sh account without EAB: pre-fill the account key (SEC1 or PKCS#8) at
acme-store/accounts/<first 16 hex of sha256(url)>/account.pem— the daemon takes the "store has an account" path and EAB is never needed
Ops quick reference
- Forced re-issue: delete
acme-store/certs/<primary domain>/and restart (decisions read the store, not the host tls directory) - The daemon thread owns its network engine (Workers=1) and snapshots the config at startup — acme changes need a restart
- Service deployment reference:
tools/xs.servicein the repo (systemd, 5-second crash recovery + AmbientCapabilities doing away with setcap)
Deployment & operations
Delivery shape
A single-file xs / xs.exe (~5.2MB, LZMA-embedded TCC + SDK + system libs) plus the application source directory. Zero install on the target: no runtime, no DLLs, no header dependencies.
Graceful shutdown
Ctrl+C / SIGTERM triggers five phases: stop accepting and retire scripts → wait for network terminal states and stop the engine → retire custom → drain reaper destructors → release config. ServiceUnit is guaranteed to run at most once on this path.
Test baselines
func_test— 40 assertion scenarios: 21 config fail-fast cases, 8 path-traversal vectors, reload stormsreload_matrix— four protocols × TLS, 7 endpoints: same-endpoint handoff and generation isolationdrill— 6-hour soak: slowloris / pipeline bombs / bad-script reload bombs + RSS samplingroutebench— same-machine latency against Go / PHP / FastAPI (results)
Platforms
Windows and Linux (the build system branches automatically); macOS is supported at the xrt layer via the kqueue backend. Non-ASCII paths are converted to UTF-8 at the Windows entry points.