Pular para o conteúdo
← Voltar para projetos

Mercure Stream Delegation

Caddy stream delegation to Mercure hubs

#Mercure stream delegation prototype

PHP returns a topic and a subscriber token, then exits. Caddy subscribes to Mercure and serves that SSE stream as the original HTTP response. The client needs no Mercure adapter, redirect handling, or cookie jar. The handler does not parse JSON or know about MCP.

The demonstration includes an ordinary progress stream at POST /events and an MCP tool at POST /mcp. PHP-FPM runs with one worker. The SDK configuration uses the real PHP SDK and its proposed MercureStreamResponseFactory; a separate Node worker publishes progress and the SDK's prepared completion reply. This repository is the Caddy prototype, separate from the Symfony/API Platform application.

#Run with the PHP SDK

Keep the SDK worktree at ../php-sdk-caddy on branch caddy-stream-delegation, with its Composer dependencies installed. From this repository, on ARM64:

composer --working-dir=../php-sdk-caddy install
npm ci
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o caddy-linux ./cmd/caddy
DEMO_PORT=3086 docker compose -p mercure-stream-sdk \
  -f demo/compose.yaml -f demo/compose.sdk.yaml up -d
DEMO_URL=http://127.0.0.1:3086 node demo/test-sdk.mjs

With the stack up, curl -N -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3086/events opens a progress stream. While it is open, request http://127.0.0.1:3086/health: its PID matches the stream's X-Demo-PHP-Pid header, so the same PHP process serves another request while Caddy holds the first connection. http://127.0.0.1:3086/mcp is a normal Streamable HTTP endpoint exposing the stream_demo tool. The SDK handles initialization, discovery, sessions, validation, and modern per-request envelopes. The demo delegates delivery of this one tool's prepared reply; it does not move arbitrary SDK callbacks into a background process.

demo/index.sdk.php runs StreamableHttpTransport, persists the SDK's JSON reply for the worker, and hands the response to MercureStreamResponseFactory. The worker publishes the stored reply without changing its request ID or protocol metadata. A fresh topic is used for every call. The fixed completion message is prepared before the simulated work; real result-dependent work needs its own worker-side result construction and error handling.

The SDK suite exercises the unmodified TypeScript client, a 2026-07-28 request, origin rejection, cancellation, and the generic stream concurrency tests. The simpler configurations below use demo/index.php, a synthetic endpoint useful for testing the module independently of the SDK.

#Internal response contract

The application returns an empty body with these response headers:

Mercure-Stream: urn:stream-demo:request-id
Mercure-Stream-Token: <short-lived JWT authorizing this topic>

Caddy's existing intercept handler waits for PHP to return, then invokes mercure_stream. It consumes and strips both internal headers, drops the discarded entity's headers (Content-Length, Content-Type, Content-Encoding, Content-Range, Content-Disposition, digests, ETag, Last-Modified, Transfer-Encoding, Trailer) and replaces the response with 200 text/event-stream. Session and other application headers pass through. Only response headers from the configured application trigger delegation. Client-supplied headers do not.

The application body must be empty. Caddy's intercept appends the buffered original body when the replacement handler writes nothing, so a stray PHP warning would reach the client if the hub ended the stream before any event or heartbeat.

The hub URL is fixed in Caddy configuration. The handler creates a new GET request with the topic encoded as a match query parameter. It sends the supplied subscriber token to that hub, never the client's Authorization header, cookies, or request body. Upstream redirects are refused, and HTTPS uses normal certificate validation. The current adapter targets Mercure 1.0; a Cloud deployment must support that protocol and use matching token configuration. A Cloud account was not tested.

The publisher uses private Mercure updates. Normal SSE events, IDs and data reach the client. Comments flush immediately. The publisher finishes by sending an event of type mercure-stream-end, with any data. Caddy consumes that control event and closes the response, without exposing it to the client. Configure another name with end_event. This allows job progress, streamed generation, and other finite SSE responses to share the same mechanism without MCP-specific parsing.

The handler normalizes CR, LF, and CRLF line endings to LF and accepts a leading UTF-8 BOM. It caps frames at 1 MiB. timeout bounds the whole connection (default five minutes); idle_timeout abandons a hub that sends no bytes, heartbeats included, for that long (disabled by default). Hub connection errors and rejections are logged with their status code, never with the token. A client disconnect is not reported as a handler error. A hub that closes without the terminal event is logged as a warning and reported to the worker as cancelled. There is no automatic resubscription or replay.

#Worker coordination

Optional lifecycle_url and lifecycle_token configuration, set together, enable a fixed internal callback endpoint. Caddy sends authenticated POST JSON such as:

{"topic":"urn:stream-demo:request-id","state":"ready"}

The callback receives ready after the hub accepts the subscription, complete after the terminal event, or cancelled after disconnect, timeout, or an incomplete upstream stream. The demo worker starts publishing only after ready; it cancels pending work on cancelled. Callback URLs cannot be supplied by the client or by PHP response headers.

The ready callback has a three-second timeout and must succeed before Caddy starts the client response; if the client disconnects meanwhile, the failure is not an error and cancelled follows. Completion and cancellation callbacks are best effort, have a three-second timeout independent of the client connection, and log a warning on failure. A production worker needs durable job ownership, expiration and idempotency; it cannot rely on a successful callback after a Caddy process crash. The demo keeps job records on disk and active work in memory, without recovery or garbage collection. The worker publishes the terminal event even when an earlier publication fails, so a broken job releases the client instead of holding it until the timeout.

#Build and run

Requirements: Go 1.25.1 or later, PHP-FPM, Node.js, and Docker for the hub.

go build -o caddy-local ./cmd/caddy
npm ci
python3 demo/run-local.py

The launcher uses loopback ports 3080, 3081, 3082 and 9085. Stop any already-running instance before launching another. Ctrl-C stops its processes and hub container; it retains logs and job files. Subsequent launches reuse the stopped demo hub container.

The Caddyfile for FPM is demo/Caddyfile.local. demo/Caddyfile.frankenphp uses the same handler around php_server, with both num_threads and max_threads set to one. The module uses Caddy's ordinary HTTP interfaces and has no PHP runtime dependency.

To run the complete Docker stack, first build caddy-linux for your Docker architecture. On ARM64:

CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o caddy-linux ./cmd/caddy
DEMO_PORT=3084 docker compose -p mercure-stream-compose -f demo/compose.yaml up -d
DEMO_URL=http://127.0.0.1:3084 npm test

The optional FrankenPHP service builds this module into the official FrankenPHP image and shares the stack's hub and job worker:

DEMO_PORT=3084 docker compose -p mercure-stream-compose \
  -f demo/compose.yaml -f demo/compose.frankenphp.yaml up -d --build frankenphp
DEMO_URL=http://127.0.0.1:3083 npm test

Both endpoints bind to loopback. The FrankenPHP configuration is mounted at /etc/frankenphp/Caddyfile, the path used by the image's default command.

#Validation

golangci-lint run ./...
go test -race ./...
npm test

GitHub Actions runs the first two commands on every push and pull request.

The live tests verify five concurrent streams served by the same PHP worker, worker reuse while SSE is active, cancellation, internal-header stripping, client-header spoofing resistance, MCP progress and results using the unmodified TypeScript SDK, and ten immediate publishers with no missing initial updates. Unit tests cover terminal events, SSE line endings including byte-wise reads, heartbeat flushing, frame limits, Caddyfile parsing, configuration validation, credential isolation, entity header removal, idle hub abandonment, truncated stream logging, upstream failure and disconnect propagation. Security tests verify rejection of untrusted TLS certificates, all five common redirect statuses, malformed delegation headers, failed readiness and incomplete upstream streams.

Integration results on 2026-09-10, using Mercure 1.0.0-alpha.3 and the unmodified TypeScript SDK 1.30.0:

Runtime PHP concurrency limit Full integration suite Health response during SSE
Native Caddy + PHP-FPM 8.5.10 One process Pass 4.3 ms
Docker Caddy 2.11.4 + PHP-FPM 8.5.10 One process Pass 11.4 ms
Docker FrankenPHP + PHP 8.5.9 ZTS One thread, maximum one Pass 4.2 ms
Docker Caddy + real PHP SDK + PHP-FPM 8.5.10 One process Pass, including modern request 2.5 ms

FrankenPHP's startup log confirms num_threads: 1 and max_threads: 1; PID equality alone would not establish a single thread. The tested builder contains source revision a765b086f5cc56f6b7753117367d56e1b0da948d, compiled with Go 1.26.7 and Caddy 2.11.4. These timing samples verify PHP availability during streaming; they are not benchmarks.

On 2026-09-10, unmodified Claude Code 2.1.236 and Codex CLI 0.154.0 both successfully invoked stream_demo through the real private Mercure hub. The TypeScript SDK 1.30.0 received progress values 1, 2 and 3 and the final result. CLI models' visible output is not evidence of progress notification support. Claude Desktop's hosted connector and ChatGPT's hosted connector have not been tested against this prototype.

Mercure Cloud requires a selected test hub and credentials. Hosted Claude and ChatGPT testing requires access to their connector interfaces. These remain unverified, as do HTTP/2 and HTTP/3 delivery, the full five-minute connection timeout, and recovery after a process crash.

The module is suitable for experimentation only. The demo has no user authentication and uses public development signing keys and callback credentials; keep its services bound to loopback. Debug logging of intercepted response headers must not expose the internal subscriber token.

#License

MIT, see LICENSE.

Nova versão disponível.