IR
irwinrodriguez.dev
Back to docs

Advanced Features

What sits above serving a JSON: real-time events, signed outgoing webhooks, the status endpoint, service mode, deploying into the installation and the test bench.

Server-Sent Events (SSE)

SSE lets the server push events to the client over an HTTP connection that stays open. The contract has three pieces, and all three are visible from the controller: the route has to end in /events and be declared, the client has to send Accept: text/event-stream or it gets a 406, and the backend declares which events it supports with res.events(). The framing, the retry:, the connected event, the pings and the replay through Last-Event-Id are the middleware’s job.

Enable in JSON:

"middleware": { "sse": { "enabled": true } }

In the controller:

SSE: declaring and emitting order events
* La ruta TIENE que terminar en /events. Aqui no se escribe el
* framing a mano ni se hace un bucle: eso ocuparia uno de los pocos
* hilos VFP del pool durante todo el rato. Lo unico que hace este
* endpoint es DECLARAR que eventos existen.
PROCEDURE pSuscripcion(req, res) HELP "GET: pedidos/events public"
    res.events("pedido.creado")
    res.events("pedido.enviado")
    RETURN THIS.Ok(.NULL.)
ENDPROC

* Y se emite desde cualquier otro endpoint, cuando pasa algo.
PROCEDURE pCrearPedido(req, res) HELP "POST: pedidos"
    LOCAL lcId, loRes

    lcId = THIS.CrearPedido(req.json)

    res.emit("pedido.creado", '{"id":"' + lcId + '"}')

    loRes    = THIS.newObject("id")
    loRes.id = lcId
    RETURN THIS.Created(loRes, "/pedidos/" + lcId)
ENDPROC

JavaScript client:

// EventSource manda Accept: text/event-stream por su cuenta.
// Sin esa cabecera, el middleware responde 406.
const es = new EventSource('/pedidos/events?events=pedido.creado');

// El middleware manda primero un evento 'connected' que dice que
// eventos hay, y despues los tuyos, cada uno con su id.
es.addEventListener('pedido.creado', (e) => {
  console.log(JSON.parse(e.data).id);
});

// Si se corta, el navegador reconecta solo mandando Last-Event-Id,
// y el servidor repone lo que tenga en cache: la entrega es
// at-least-once, asi que deduplica por e.lastEventId.
es.onerror = () => es.close();
Asking for an event the backend does not declare subscribes you to NOTHING Not to everything. The wildcard is only the default when you send no ?events= at all.

Webhooks System

FoxServer implements Standard Webhooks in both directions, audited and with tests. The key thing about the model: outgoing ones are not fired from code, they are declared in the manifest, and when that endpoint is served the response body goes out signed to the active subscribers.

  • Declared with foxserver webhook-add-out, and tuned with webhook-set --retries --timeout --backoff
  • HMAC-SHA256 signature in base64 over {id}.{timestamp}.{body}, prefixed v1,
  • The secret can be whsec_<base64> (the svix, Clerk or Resend format), and then the key is the decoded bytes
  • retries: N means N attempts in total, not N extra retries. And a 404 from the subscriber stops the retries at once
  • What runs out ends up in <host>\queue\webhooks\dead-letters\
  • Every attempt shares the same webhook-id, so the receiver can deduplicate, and each one is re-signed with its own timestamp
  • You test it without leaving the console, with foxserver webhook-test

Declaring an outgoing one:

REM Se declara con la CLI, no editando el manifiesto a mano.
foxserver webhook-add-out --project C:\proyectos\tienda ^
    --route pedidos --method POST ^
    --url https://socio.com/webhooks --secret whsec_...

REM Y se ajusta despues:
foxserver webhook-set --project C:\proyectos\tienda ^
    --route pedidos --retries 3 --timeout 30 --backoff 5,15,30

REM Para probarlo sin salir de la consola:
foxserver webhook-test --project C:\proyectos\tienda --route pedidos

And that is it: the endpoint is written like any other. Whatever it returns is what gets sent.

pCrearPedido: what it returns goes out signed
* Ni una linea de webhook aqui dentro. Lo que devuelve este endpoint
* es exactamente lo que sale firmado hacia los suscriptores.
PROCEDURE pCrearPedido(req, res) HELP "POST: pedidos"
    LOCAL lcId, loRes

    lcId = THIS.CrearPedido(req.json)

    loRes          = THIS.newObject("orderId,status")
    loRes.orderId  = lcId
    loRes.status   = "created"
    RETURN THIS.Created(loRes, "/pedidos/" + lcId)
ENDPROC

What reaches the subscriber:

POST https://socio.com/webhooks
webhook-id: 550e8400-e29b-41d4-a716-446655440000
webhook-timestamp: 1789476225
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

{"orderId":"ORD-001","status":"created"}

The webhook-timestamp is epoch in seconds, and the signature is v1, plus the base64 of the HMAC-SHA256 over {id}.{timestamp}.{body}. If your controller gets to run with an INCOMING webhook, the middleware has already checked the signature and the five-minute anti-replay window: the webhook is already authentic. One thing to watch: a provider with its own signature, such as Stripe or GitHub, must not be declared as incoming, because the middleware would reject it for not being Standard Webhooks. That route is served as a normal endpoint and you verify it yourself.

Reloading without restarting

There is no automatic reload, and it is worth saying plainly because the phrase "hot reload" suggests otherwise: FoxServer does not watch the disk. What there is is a switch, and it is enough: after a foxserver build, a POST to the status endpoint releases the server’s COM objects, and the next request creates them clean, with your new DLL.

The full cycle:

  1. You edit and save your .prg.
  2. You rebuild with foxserver build (or from FoxForge) and the project MTDLL is regenerated. Mind this: it needs an elevated console, because BUILD MTDLL registers the coclass when it finishes.
  3. POST <status-endpoint>/reload with the X-Status-Pin header.
  4. The server drops its COM references. The next request creates the controllers again, now with the new code.

The call:

curl -X POST http://127.0.0.1:8099/status/reload ^
     -H "X-Status-Pin: <tu PIN>"

The status endpoint

It is declared in the manifest and asks for a PIN: without the X-Status-Pin header, or with the wrong PIN, it answers 401; if the server has no PIN configured, 403. Any verb other than GET on that route gives a 404, not a 405.

"server": {
  "checkstatusenabled": true,
  "endpoint": "status",
  "statuspin": "<cifrado con middleware-set>"
}
curl http://127.0.0.1:8099/status -H "X-Status-Pin: <tu PIN>"

And what it returns:

{
  "uptime": {
    "raw": "...",
    "formatted": "5d 3h 22m"
  },
  "memory": {
    "raw": 257425408,
    "formatted": "245 MB"
  },
  "connections": {
    "current": 7
  },
  "config": {
    "host": "127.0.0.1",
    "port": 8099,
    "middleware": {
      "auth": true,
      "logging": true
    }
  },
  "license": {
    "kind": "Runtime",
    "holder": "Tu Empresa, S.L.",
    "seats": 1,
    "isMaintenanceActive": true,
    "limitedByMaintenance": false
  },
  "metrics": {
    "successRate": 99.97,
    "rejectedRequests": 0,
    "rejectionRate": 0
  }
}
license.kind is what decides who can call you Developer or Runtime. With Developer, the server answers 403 to every request that does not come from 127.0.0.1. It is the first thing to look at when everything from another machine returns 403.

Windows Service mode

FoxServer can be installed as a Windows Service for automatic startup and background execution without a console window.

Commands:

# Install as service
bin\FoxServer.Host.exe --install-service

# Uninstall service
bin\FoxServer.Host.exe --uninstall-service

# Manage via Windows Services
services.msc
In service mode the logs go to the usual files. The Windows Event Viewer does NOT receive them: an entry is written there only if writing the log itself fails. The installer registers the service; by hand, bin\FoxServer.Host.exe --install-service from an elevated console.

Deploying into the installation

foxserver serve starts the host pointing at your project's build\ folder: fine for testing, but verify.cmd does not see that server and the Windows service does not start it, because both look at config\servers\ in the installation root. When you want it to be part of it:

foxserver deploy --project C:\proyectos\almacen
Desplegado Almacen en la instalacion:
  C:\Program Files\FoxServer\config\servers\ALMACEN.json
  clave: 127.0.0.1:8099:
Before writing it checks that there is no other manifest with the same host:port:prefix key. If there is, it exits with 11 and says which one - and rightly so: the host would start one and skip the other with a warning only visible in the log, which is one of the nastiest failures to diagnose. --force skips the check.

Rebuilding your application's environment

Your business logic needs what your ERP's main.prg sets up - the configuration, the connection, the master tables - and a controller is born with none of it. You do not have to rewrite the logic: you have to rebuild the environment once. That is what the lifecycle is for.

MiErpLifecycle: setting up the ERP environment at startup
define class MiErpLifecycle as ApplicationLifecycle olepublic

    procedure OnApplicationStarting
        local loCfg
        * Lo que hace el main.prg de tu aplicacion: SET PATH, abrir las
        * maestras, leer la configuracion, conectar.
        loCfg = createobject("Empty")
        addproperty(loCfg, "empresa", "SbSoftware")
        this.SetResource("config", loCfg)
    endproc

    procedure OnApplicationStopping
        * Cerrar lo que se abrio.
    endproc

enddefine
"lifecycle": { "enabled": true, "classname": "MiErpLifecycleClass" }
olepublic is not optional. Without it the class does not reach the MTDLL type library and the engine cannot activate it: you will see "Cannot create ApplicationLifecycle instance" in the log. The manifest classname is filled in by the hook at compile time, with its suffix, so you do not write it by hand.

Debugging without compiling - FoxServerBench

The tool that saves the most time: it runs your controller with no MTDLL, no server and no http.sys, with the debugger open over your own source. You put SET STEP ON in your procedure, launch the bench, and stop wherever you want.

FoxServerBench: running an endpoint without a server
loBench = createobject("FoxServerBench")
loBench.SetProject("c:\misproyectos\ventas")
loBench.OnDeclare = "MiCarga"              && tu rutina: SET PROCEDURE, abrir tablas...
loBench.SetResource("config", loFalsa)     && simula lo que deja el lifecycle

loBench.Request("GET", "articulos/42")
loRes = loBench.Run()

? loRes.StatusCode
? loRes.Body
Three things before accusing the bench of lying. JSONFox.fxp must be COMPILED in your project's build\: without it, a POST with a JSON body arrives with req.json null and nobody warns you. The API does not chain: Request(...).Run() does not work, each call goes on its own. And it is a bench, not an emulator: it reproduces no middleware, no authentication, no CORS, no concurrency. A green here proves your logic is servable, not that the endpoint authenticates properly.

Next: API Reference →