Replace the ingest and storage monitor WebSocket streams with Server-Sent Events (SSE).
Enduro currently exposes two browser-facing monitor streams:
GET /ingest/monitorGET /storage/monitor
Both streams deliver server-to-browser updates for dashboard state: SIPs, batches, AIPs, locations, workflows, and tasks. They are modeled in Goa as streaming results and currently transported over WebSockets.
The dashboard does not authenticate these monitor streams with an existing
server-side session cookie. Normal API calls use Authorization: Bearer ....
Because browser-native WebSocket cannot set arbitrary headers, Enduro uses a
short-lived ticket-cookie flow:
- The dashboard calls
POST /ingest/monitororPOST /storage/monitorwith its bearer token. - The API validates the token and stores the user claims behind a short-lived ticket.
- The API sets an HTTP-only ticket cookie.
- The dashboard opens the monitor stream.
- The API reads the ticket cookie, restores the user claims, and filters events by user attributes.
SSE can use ordinary HTTP cookies. The ticket-cookie flow is not required
because of SSE itself; it is required because Enduro's API authentication is
bearer-token based and native EventSource also cannot set an Authorization
header. Unless we introduce a real API session cookie or use a custom
fetch-based SSE client, the ticket-cookie bridge should remain.
This proposal keeps the same high-level monitor model, event payloads, ticket handshake, and authorization filtering, but changes the streaming transport from WebSocket to SSE.
SSE is a better fit for these monitor streams because Enduro's dashboard only needs server-to-client notifications. There is no browser-to-server messaging on these connections.
Operationally, WebSockets are more demanding than this use case requires:
- They require HTTP upgrade support through every deployment layer.
- Reverse proxies and load balancers need WebSocket-specific configuration.
- Some managed ingress, CDN, corporate proxy, and VPN environments treat WebSockets differently from normal HTTP.
- The API server needs WebSocket origin checking and upgrade plumbing.
- Generated Goa code includes WebSocket-specific client and server stream implementations.
- Failure modes are less visible as ordinary HTTP responses once the connection has been upgraded.
SSE keeps the same real-time behavior while staying on plain HTTP:
- No protocol upgrade is required.
- Cookies, status codes, headers, tracing, logging, and proxy behavior remain in the normal HTTP path.
- NGINX and similar proxies need only long-lived HTTP streaming settings, such as disabled buffering and longer read timeouts.
- The stream is naturally one-way, matching the monitor event model.
- Goa now supports SSE for streaming results, so the API design can express the intended transport directly.
This mirrors the successful migration in the legacy Enduro project commit b023db6af7b5a877eea27dd890a4e9313df46d1e.
Legacy Enduro was simpler: its collection monitor stream did not use the newer ticket-cookie and ABAC filtering flow. This repository does, so the migration is not a blind copy. The transport change is similar; the authentication and authorization model must be preserved.
In scope:
- Change the ingest monitor stream to SSE.
- Change the storage monitor stream to SSE.
- Keep the current event envelope shape unless Goa generation requires a small adjustment.
- Keep the monitor ticket request flow.
- Rename monitor ticket cookies from
*-ws-ticketto*-sse-ticket. - Update dashboard connection code and tests.
- Update NGINX and Vite proxy config.
- Update generated Goa code, OpenAPI docs, dashboard generated client, and admin documentation.
- Remove WebSocket-only server wiring and dependencies if no longer used.
Out of scope:
- Changing event semantics or permission filtering.
- Replacing the monitor ticket mechanism with bearer-token query parameters.
- Introducing bidirectional monitor commands.
- Changing upload or download streaming endpoints.
The transport change is a breaking change for any external client that opens the
monitor endpoints as WebSockets. Those clients must switch from ws:// or
wss:// to a normal HTTP GET that accepts text/event-stream.
This proposal also renames the monitor ticket cookies from WebSocket terminology to SSE terminology.
This cookie rename is not technically required. We could keep the current cookie names and still run the monitor streams over SSE. Renaming them makes the public contract more accurate, but it is an additional break for clients that have copied the cookie names. The proposal adopts that break now rather than carrying WebSocket terminology forward.
The monitor ticket cookie names will change from:
enduro-ingest-ws-ticketenduro-storage-ws-ticket
to:
enduro-ingest-sse-ticketenduro-storage-sse-ticket
The monitor endpoints themselves remain:
POST /ingest/monitorGET /ingest/monitorPOST /storage/monitorGET /storage/monitor
Impact:
- The bundled dashboard will be updated in the same change.
- Any external client that has copied the old cookie names must update.
- Any external client that opens
ws://.../monitororwss://.../monitormust switch to a normal HTTPGETwithAccept: text/event-stream. - The generated OpenAPI and TypeScript client artifacts will reflect the new cookie names and SSE response type.
This should be called out in release notes.
Enduro's browser-facing API uses bearer-token authentication for normal API
calls. The dashboard's generated TypeScript client attaches the user's access
token as an Authorization header.
The monitor streams are different because browser-native streaming APIs cannot attach that header:
WebSocketdoes not allow arbitrary request headers.- Native
EventSourcealso does not allow arbitrary request headers.
The existing POST /monitor endpoints bridge that limitation. They validate the
bearer token using the normal API auth path, store user claims behind a
short-lived ticket, and set an HTTP-only ticket cookie. The subsequent
GET /monitor reads that ticket cookie and uses the restored claims to filter
events by user attributes.
SSE can use cookies already present in the browser, but Enduro does not currently have an API session cookie that represents the validated user claims. OIDC provider cookies or frontend state are not enough for the API to perform the current ABAC event filtering.
The current monitor methods use StreamingResult(...) with regular HTTP
Response(StatusOK). The descriptions explicitly say WebSocket.
internal/api/design/ingest.gointernal/api/design/storage.go
The event result types are:
IngestEventininternal/api/design/ingest_monitor.goStorageEventininternal/api/design/storage_monitor.go
These are OneOf envelopes and should remain unchanged.
internal/api/api.go creates a Gorilla WebSocket upgrader and passes it to the
generated ingest and storage HTTP servers.
The generated WebSocket server stream hijacks the HTTP connection, so the API server write timeout no longer applies after the upgrade.
With SSE, the monitor connection remains normal HTTP. Therefore each monitor handler must explicitly opt out of the API write timeout:
ingestServer.Monitor = middleware.WriteTimeout(0)(ingestServer.Monitor)
storageServer.Monitor = middleware.WriteTimeout(0)(storageServer.Monitor)The service-level operation timeout already skips both monitor methods in
internal/api/interceptors.go, so that part does not need a design change.
The core monitor service logic is mostly transport-neutral:
- Check ticket and recover user claims.
- Subscribe to event service.
- Send hello and ping events.
- Filter events by user attributes.
- Send matching events.
This exists in:
internal/ingest/monitor.gointernal/storage/monitor.go
The implementation should switch to SendWithContext(ctx, event) where
practical, but the filtering logic can stay as-is.
dashboard/src/monitor.ts currently:
- Calls the generated
monitorRequest()method to set the ticket cookie. - Converts
http/httpsURLs tows/wss. - Opens a native
WebSocket. - Parses each message as JSON.
- Reconnects using local exponential backoff.
For SSE:
- Keep the
monitorRequest()call before opening the stream. - Use
new EventSource(url)instead ofnew WebSocket(url). - Keep the JSON parser against
MessageEvent.data. - On
error, close theEventSource, mark disconnected, and call the existing reconnect path so a fresh short-lived ticket is requested.
Native EventSource has automatic reconnects, but relying on it alone is not
ideal here because the monitor ticket cookie currently has MaxAge(5). The
application should own reconnects so it can re-mint a ticket before each new
stream.
Keep the existing two-step authentication flow:
POST /ingest/monitorandPOST /storage/monitorremain normal authenticated API calls usingAuthorization: Bearer ....- Those methods issue short-lived HTTP-only ticket cookies.
GET /ingest/monitorandGET /storage/monitorremainNoSecurity()in Goa because they authenticate by checking the ticket cookie.- The monitor service restores user claims from the ticket and continues to filter events by attributes before sending them.
Keep the existing ticket cookie max age for now. It is not material to the transport migration and can be revisited later if reconnect behavior shows a real need.
This keeps the migration focused on transport. It avoids placing access tokens
in query strings and avoids replacing native EventSource with a custom
fetch-based SSE parser just to carry an Authorization header.
Longer-term alternatives are possible, but they are larger auth changes:
- Introduce a first-class API session cookie and authenticate
GET /monitordirectly from it. - Use a custom SSE client built on
fetch()so the dashboard can sendAuthorization: Bearer .... - Use a query-string token. This is not recommended because URLs are commonly logged by browsers, proxies, and servers.
For both monitor methods:
StreamingResult(IngestEvent)
HTTP(func() {
GET("/monitor")
ServerSentEvents()
Response("internal_error", StatusInternalServerError)
Cookie("ticket:enduro-ingest-sse-ticket")
})And:
StreamingResult(StorageEvent)
HTTP(func() {
GET("/monitor")
ServerSentEvents()
Response("internal_error", StatusInternalServerError)
Cookie("ticket:enduro-storage-sse-ticket")
})For the request methods, rename the Set-Cookie outputs:
Cookie("ticket:enduro-ingest-sse-ticket")
Cookie("ticket:enduro-storage-sse-ticket")Descriptions should say "SSE event stream" or "monitor event stream", not "WebSocket".
Use default SSE message events and keep the existing JSON envelope in the
event data.
Do not add custom SSE event names such as event: ingest or event: storage.
The stream URL already identifies the service (/ingest/monitor or
/storage/monitor), and the JSON envelope already identifies the domain event
type (sip_workflow_updated_event, aip_location_updated_event, and so on).
Keeping all messages as default SSE events avoids custom generated-code changes and keeps the dashboard parser close to the current WebSocket parser.
Remove:
github.com/gorilla/websocketimport frominternal/api/api.go.- WebSocket upgrader construction.
- Upgrader/configurer arguments to generated server constructors.
internal/api/origin.goandinternal/api/origin_test.goif no longer used.
Add:
ingestServer.Monitor = middleware.WriteTimeout(0)(ingestServer.Monitor)
storageServer.Monitor = middleware.WriteTimeout(0)(storageServer.Monitor)Keep existing upload/download timeout exemptions.
In internal/ingest/monitor.go and internal/storage/monitor.go:
- Replace
stream.Send(...)withstream.SendWithContext(ctx, ...)where the generated SSE interface supports it. - Keep
defer stream.Close()if Goa keepsClose()on SSE streams. It should be a no-op. - Keep existing permission filtering.
- Keep hello and ping events.
Refactor dashboard/src/monitor.ts:
- Rename
sockettosourceoreventSource. - Remove
getWebSocketURL. - Build the stream URL directly from the API base URL:
baseUrl + "/" + type + "/monitor". - Use
EventSource. - Parse
ev.dataas JSON. - On
open, setisConnected = true. - On
error, close the source, setisConnected = false, and start the retry path unless the connection was intentionally closed.
The existing parseMonitorEvent helper should still work if the JSON envelope
remains:
{
"value": {
"type": "sip_workflow_updated_event",
"value": {
"uuid": "..."
}
}
}Update comments in monitor-ingest.ts, monitor-storage.ts, and
WorkflowCollapse.vue from "websocket" to "monitor event" or "SSE".
Replace monitor WebSocket proxy config with streaming HTTP config.
For NGINX:
location /api/ingest/monitor {
proxy_pass http://backend/ingest/monitor;
proxy_buffering off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
proxy_set_header Host $http_host;
}
location /api/storage/monitor {
proxy_pass http://backend/storage/monitor;
proxy_buffering off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
proxy_set_header Host $http_host;
}Remove:
proxy_http_version 1.1;
proxy_set_header Connection "Upgrade";
proxy_set_header Upgrade $http_upgrade;For Vite dev proxy, remove WebSocket-specific ws: true monitor proxy entries
unless a dedicated route is still needed for ordering. The generic /api proxy
can handle normal SSE HTTP traffic.
After Goa regeneration, run:
go mod tidyExpected result:
github.com/gorilla/websocketshould be removed from direct dependencies.- It may remain indirect only if pulled by another dependency.
Dashboard:
- Remove
vitest-websocket-mockif no longer used. - Add no dependency if a small local
EventSourcetest fake is enough.
- Update Goa design for ingest and storage monitor methods.
- Run
make gen-goa. - Update
internal/api/api.gofor generated constructor signatures and monitor write-timeout exemptions. - Remove dead WebSocket origin-check code.
- Adjust monitor service sends if needed for the generated SSE stream interface.
- Run Go tests for affected packages.
- Update dashboard monitor connection code from WebSocket to EventSource.
- Replace WebSocket tests with an EventSource fake.
- Run dashboard tests, typecheck, and build.
- Run
make gen-dashboard-client. - Update dashboard Dockerfile, Vite proxy config, and documentation.
- Run
go mod tidy. - Add a release-note or changelog entry documenting the breaking cookie/client change.
Backend:
go test ./internal/api ./internal/ingest ./internal/storageDashboard:
cd dashboard
npm run type-check
npm run test
npm run build-onlyManual checks:
- Log into the dashboard.
- Confirm the dashboard calls
POST /ingest/monitorand receivesSet-Cookie: enduro-ingest-sse-ticket=.... - Confirm
GET /ingest/monitorreturnsContent-Type: text/event-stream. - Confirm ingest events update SIP, workflow, task, and batch views.
- Confirm the dashboard calls
POST /storage/monitorand receivesSet-Cookie: enduro-storage-sse-ticket=.... - Confirm storage events update AIP, location, workflow, and task views.
- Stop and restart the API while the dashboard is open and confirm reconnect.
- Confirm monitor connections stay open longer than the API server write timeout.
- Confirm NGINX-proxied deployment keeps monitor streams open.
Risk:
SSE remains normal HTTP, so the API server write timeout can close monitor connections if the handler is not exempted.
Mitigation:
Wrap both generated monitor handlers with middleware.WriteTimeout(0).
Risk:
NGINX or another proxy may buffer SSE responses and delay updates.
Mitigation:
Set proxy_buffering off for monitor routes and document equivalent settings
for deployments.
Risk:
Native EventSource reconnects may happen after the short-lived ticket cookie
expires.
Mitigation:
Close and recreate the EventSource through application retry code, calling
POST /monitor before each new connection.
Risk:
Native EventSource cannot send arbitrary authorization headers. Cross-origin
credentialed EventSource also needs correct cookie and CORS behavior.
Mitigation:
The bundled dashboard is same-origin through /api, so this is acceptable.
External cross-origin clients should use the ticket-cookie flow and may need
EventSource with credentials support depending on their environment.
Risk:
SSE can send normal cookies, so it is tempting to remove the ticket request and rely on cookies that are already present in the browser.
Mitigation:
Do not remove the ticket flow unless Enduro gains an API-authenticated session cookie that contains or can recover the user claims needed for ABAC filtering. OIDC provider cookies and frontend state are not a substitute for an API session.
Risk:
External clients using WebSocket URLs or old cookie names will stop working.
Mitigation:
Document the breaking change clearly in release notes and API docs.
- Keep the current ticket cookie max age for this migration.
- Use default SSE
messageevents with the existing JSON envelope. - Rename monitor ticket cookies from
*-ws-ticketto*-sse-ticket. - Do not support the old monitor ticket cookie names during a compatibility period.
Proceed with the SSE migration and keep the monitor ticket flow.
The backend service behavior is already well aligned with SSE. The main work is generation, dashboard transport replacement, proxy/docs cleanup, and tests.
Do not replace the ticket-cookie bridge with "regular cookies" unless the auth
model changes to include a real API session cookie. The ticket flow exists to
bridge bearer-token authentication into browser-native streaming transports that
cannot set Authorization headers.
Rename the ticket cookies to enduro-ingest-sse-ticket and
enduro-storage-sse-ticket in this same migration. Do not keep a compatibility
fallback for the old *-ws-ticket names.