Authentication works correctly (user is found, password verified), but:
Set-Cookie header is set on a discarded HttpResulthx-redirect is set as an HTML attribute instead of an HTTP headersequenceDiagram
participant CE as ComponentEndpoint
participant C as Component
participant SS as SessionService
CE->>C: handle_action - login
C->>C: to_result - creates HttpResult A
C->>SS: set_session_cookie - Result A, token
SS->>SS: result.set_header - Set-Cookie on A
Note over C: Result A is discarded - async void!
C-->>CE: void return
CE->>C: to_result - creates NEW HttpResult B
C-->>CE: Result B - no cookie!
The problem: handle_action() is async void - it cannot return an HttpResult. The cookie is set on a temporary result that gets discarded.
// WRONG - This sets an HTML attribute
this[login-form].set_attribute(hx-redirect, redirect_url);
// CORRECT - This sets an HTTP response header
result.set_header(HX-Redirect, redirect_url);
HTMX requires HX-Redirect as an HTTP response header, not an HTML attribute.
Add a ResponseState class that components use to accumulate response modifications during action handling. The state is then applied when to_result() creates the final HttpResult.
sequenceDiagram
participant CE as ComponentEndpoint
participant C as Component
participant RS as ResponseState
participant SS as SessionService
CE->>C: handle_action - login
C->>RS: add_header - Set-Cookie, token
C->>RS: redirect - /dashboard
C-->>CE: void return - state accumulated
CE->>C: to_result
C->>C: create HttpResult
C->>RS: apply_to - result
RS->>C: result.set_header - Set-Cookie
RS->>C: result.set_header - HX-Redirect
C-->>CE: Result with headers!
A new class to hold response modifications:
namespace Spry {
/**
* Redirect type for component responses.
*/
public enum RedirectType {
/** Client-side redirect using HX-Redirect header - default for HTMX */
CLIENT_SIDE,
/** HTTP 302 temporary redirect using Location header */
TEMPORARY,
/** HTTP 301 permanent redirect using Location header */
PERMANENT
}
/**
* Holds response state that will be applied to the final HttpResult.
*
* Components can modify this during action handling to influence
* the HTTP response without needing to return an HttpResult directly.
*/
public class ResponseState : GLib.Object {
// Headers to add to the response
private HashTable<string, string> _headers;
// HTTP status code override
private StatusCode? _status_code = null;
// Redirect configuration
private string? _redirect_url = null;
private RedirectType _redirect_type = RedirectType.CLIENT_SIDE;
/**
* Adds a header to the response.
* If the header already exists, it will be replaced.
*/
public void set_header(string name, string value) { ... }
/**
* Adds a header to the response.
* Multiple headers with the same name can be added.
*/
public void add_header(string name, string value) { ... }
/**
* Sets the HTTP status code for the response.
*/
public void set_status(StatusCode status) { ... }
/**
* Sets up a redirect.
* Default type is CLIENT_SIDE which uses HX-Redirect header.
*/
public void redirect(string url, RedirectType type = RedirectType.CLIENT_SIDE) { ... }
/**
* Applies all accumulated state to an HttpResult.
* Called internally by Component.to_result().
*/
internal void apply_to(HttpResult result) { ... }
/**
* Clears all accumulated state.
* Called after to_result() to prepare for next request.
*/
internal void reset() { ... }
}
}
Add ResponseState property and convenience methods to the base Component class:
public abstract class Component : Object, Renderable {
// Response state for action handlers
private ResponseState _response_state = new ResponseState();
/**
* The response state for this component.
* Modify during handle_action to influence the HTTP response.
*/
public ResponseState response { get { return _response_state; } }
// Convenience methods that delegate to response_state:
/**
* Adds a header to the response.
*/
protected void set_header(string name, string value) {
_response_state.set_header(name, value);
}
/**
* Sets the HTTP status code.
*/
protected void set_status(StatusCode status) {
_response_state.set_status(status);
}
/**
* Sets up a redirect.
* Default type is CLIENT_SIDE which uses HX-Redirect header.
*/
protected void redirect(string url, RedirectType type = RedirectType.CLIENT_SIDE) {
_response_state.redirect(url, type);
}
// Modified to_result():
public async HttpResult to_result() throws Error {
var document = yield to_document();
var result = document.to_result(get_status());
// Apply accumulated response state
_response_state.apply_to(result);
// Reset state for next request
_response_state.reset();
return result;
}
}
public class LoginFormComponent : Component {
private SessionService _session_service = inject<SessionService>();
public override async void handle_action(string action) throws Error {
if (action == "login") {
yield handle_login();
}
}
private async void handle_login() throws Error {
// ... authenticate user ...
// Create session
var session = yield _session_service.create_session_async(user.id);
var token = _session_service.generate_session_token(session);
// Set cookie using convenience method
var cookie_value = @"spry_session=$token; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Strict";
set_header("Set-Cookie", cookie_value);
// Set up HTMX redirect
redirect("/dashboard");
}
}
public override async void handle_action(string action) throws Error {
if (!user_is_admin) {
set_status(StatusCode.UNAUTHORIZED);
error_message = "Admin access required";
return;
}
// ... handle action ...
}
public override async void handle_action(string action) throws Error {
if (action == "logout") {
// Clear session
yield _session_service.delete_session_async(session_id);
// HTTP redirect to login page
redirect("/login", RedirectType.TEMPORARY);
}
}
public override async void handle_action(string action) throws Error {
// Set custom response headers
set_header("X-Custom-Header", "value");
set_header("Cache-Control", "no-cache");
// Multiple Set-Cookie headers
add_header("Set-Cookie", "pref=dark; Path=/");
add_header("Set-Cookie", "lang=en; Path=/");
}
Headers use a HashTable<string, string> with the following behavior:
set_header(): Replaces existing header with same nameadd_header(): Appends to header list - for headers that can appear multiple times like Set-CookieThe apply_to() method handles redirects:
internal void apply_to(HttpResult result) {
// Apply headers first
foreach (var header in _headers) {
result.set_header(header.key, header.value);
}
// Handle redirect
if (_redirect_url != null) {
switch (_redirect_type) {
case RedirectType.CLIENT_SIDE:
result.set_header("HX-Redirect", _redirect_url);
break;
case RedirectType.TEMPORARY:
result.set_header("Location", _redirect_url);
result.set_status(StatusCode.FOUND); // 302
break;
case RedirectType.PERMANENT:
result.set_header("Location", _redirect_url);
result.set_status(StatusCode.MOVED_PERMANENTLY); // 301
break;
}
}
// Apply status code if set - redirects may override this
if (_status_code != null && _redirect_url == null) {
result.set_status(_status_code);
}
}
The existing get_status() virtual method continues to work for initial page renders. ResponseState status only applies when set during action handling.
Priority order:
get_status() return value| File | Changes |
|---|---|
src/ResponseState.vala |
NEW - ResponseState class |
src/Component.vala |
Add ResponseState property, convenience methods, modify to_result |
src/Users/Components/LoginFormComponent.vala |
Use new API for cookie and redirect |
src/Users/SessionService.vala |
May need minor updates for cookie helper |
src/meson.build |
Add ResponseState.vala to build |
Spry.ResponseState - Holds response modificationsSpry.RedirectType - Enum for redirect types| Method | Description |
|---|---|
response |
Property to access ResponseState |
set_header(name, value) |
Set a response header |
add_header(name, value) |
Add a response header |
set_status(status) |
Set HTTP status code |
redirect(url, type) |
Redirect with optional type - default is CLIENT_SIDE |
| Method | Description |
|---|---|
set_header(name, value) |
Set/replace a header |
add_header(name, value) |
Add a header |
set_status(status) |
Set status code |
redirect(url, type) |
Redirect with optional type - default is CLIENT_SIDE |
has_modifications() |
Check if any state is set |
Should headers be accumulated or replaced by default?
set_header replaces, add_header appendsreplace_header for explicit replacementShould there be a distinction between HTMX redirects and HTTP redirects?
RedirectType enumHow should this interact with prepare and to_result?
to_result(), then resetComponentEndpoint after to_result()Should there be convenience methods like set_cookie?
set_cookie(name, value, options) helperWhat happens if both redirect and status are set?
ResponseState.valaComponent.vala to integrate ResponseStateLoginFormComponent.vala to use new API