xs3 · xrt 2.0 · contract header xsbase.h

Developer Guide

The complete path from build to hot reload. Every config field, contract signature and code example on this page is checked against the mainline sources (config.h / xsbase.h / demo-single).

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.

Put extra script headers in a 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

FieldType / constraintDescription
engine.workersuint, 0–1024Engine worker threads, 0 = xrt default. Immutable across hot reload; changes are rejected
servicesarray, requiredThe service list; missing means startup failure

Service fields (services[i])

FieldType / constraintDescription
enabledbool, default trueWhether the service is enabled
classwhitelisthttp / ws / tcp / udp / custom
namerequired, globally uniqueThe key for xsServerFind
ipdefault 0.0.0.0Listen address
port1–65535, required unless customPlaintext port
tlsboolEnable the TLS port (forbidden on udp)
port_tlsdefault 443Rejected at startup if it collides with the plaintext port on the same IP
ip_tlsdefaults to ipTLS listen address
backloguint32Passed through to xrt listener config
recv_limit≤SIZE_MAX; udp ≤65535Stream read cap
path / devlang / devfile / dev_inc / dev_lib—Service-level script defaults, merged into the DefaultHost

Host fields (host_default / hosts[i])

FieldType / constraintDescription
enabledboolWhether the host is enabled
nameunique within server, non-emptyHost name
hostsemicolon-separated domainsRequired and non-empty when vhosts are enabled for http/ws; routing matches the Host header / SNI
pathpathStatic asset / script root
devlangstatic / c / customstatic = pure static hosting; c = TCC script
devfilepathScript entry file
tls_ca / tls_cert / tls_keyPEM pathsHost-level certs → the data source for SNI selection; hot-swappable
dev_inc / dev_libsemicolon-separated dirsExtra 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:

FieldScopeConstraint / default
header_limitserverRequest header cap
path_limitserverPath length cap, default 2048
body_limitserverRequest body cap (413 when exceeded)
idle_timeoutserverIdle timeout (http/ws/tcp); the server closes on expiry
ws_message_limitserverWS message cap; exceeded closes with close code 1009
ws_protocolserverRequired subprotocol (validated at startup)
static.headershost≤8 fields; 8 reserved headers forbidden (incl. Transfer-Encoding)
static.indexhostDirectory index array, tried in order (default index.html)
static.error_pageshost400–599 status → custom error page relative path
static.deny_dotfileshostDefault 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.

SymbolProtocolsSignature (xsbase.h)
ServiceInitallvoid (XS_HostInfo*) post-load initialization
ServiceUnitallvoid (XS_HostInfo*) retirement destructor, at most once per lifetime
ServiceSwapallbool (XS_HostInfo*, xvalue**) export a state snapshot before the swap
RequestProchttp/httpsXS_RequestResult (XS_HttpReq*) request entry
WsOpen / WsText / WsBinaryws/wss(XS_HostInfo*, xwsstream*[, message view])
WsPing / WsPong / WsClosews/wsssame shape; Close adds a uint16 status code and reason view
EventOpen / EventData / EventClosetcp/tcps(XS_HostInfo*, XS_StreamConn*[, xnetbuf*/result])
EventDgramudp(XS_HostInfo*, xnetudp*, const xnetudpmessage*)

RequestProc's three-way return

ReturnMeaning
XS_OKFull response written; the driver drains the remaining body and continues keep-alive
XS_FALLBACKNot handled: GET/HEAD fall to the static layer, everything else 404
XS_TAKEOVERThe app takes over the connection: send/receive with pull/Future and Close at the end; XServer releases the lease on the Close terminal state
Trust model: scripts are compiled native code sharing the host's process and privileges (no sandbox). The devfile must come from a trusted operator — this is a deliberate "no limits" design, not an oversight.

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.

FieldDescription
tcp / tlsTransport raw pointers, exactly one non-null — the full xrt stream/send API applies
headParsed request head (incl. MethodCode); field views are valid only inside the callback
bodyFraming state machine: fixed-length / chunked / close; read incrementally with xrtHttp1BodyRead
host / serverOwnership 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_ANY are aggregate masks
  • Match order: static exact → dynamic pattern → method slot; a miss returns XS_FALLBACK into 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_limit closes with 1009 when exceeded; ws_protocol can 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_timeout closes idle connections server-side; recv_limit shrinks 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

FunctionDescription
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: xsTimerAfter binds to the generation that created it; the swap cancels it automatically, it never fires into the new generation
  • Immutable items: engine.workers changes 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.

pack commands
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-vfs ignores 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: 0 OS auto-assign (actual port in XS_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.devtools off (default) — no F12 in production packages
  • install_fallback by audience: ask for normal users (prompt when Runtime is missing) / silent unattended / headless explicitly 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

ItemBehavior
Check timingcheck_time "03:00" a fixed daily time (or check_interval_hours 12 interval mode — mutually exclusive); first check 5 minutes after startup
Renewal triggerremaining lifetime < 30 days (renew_before_days, 1–89), or no certificate in the store
Short-circuitwith a healthy certificate no CA is contacted — a purely local decision
Hot swaprenewal 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_file referenced 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.service in 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 storms
  • reload_matrix — four protocols × TLS, 7 endpoints: same-endpoint handoff and generation isolation
  • drill — 6-hour soak: slowloris / pipeline bombs / bad-script reload bombs + RSS sampling
  • routebench — 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.