Repository navigation
Server-Side Sessions Design — v3 #51
Replies: 4 comments
Design decisions from further discussionThe v3 design above is already partially outdated. Working through the session design surfaced deeper questions about middleware and context that change the architecture. Documenting the decisions here. No
|
Handler composition replaces before-middlewareThe handler actor is the composition unit. Instead of middleware injecting data through a type-unsafe context map, the handler embeds its dependencies and calls them directly with full type safety. actor MyDashboardHandler is hobby.HandlerReceiver
embed _handler: hobby.RequestHandler
new create(ctx: hobby.HandlerContext iso,
auth: AuthService tag, db: Database tag)
=>
_handler = hobby.RequestHandler(consume ctx)
auth.verify(_handler.request().headers.get("authorization"), this)
be auth_verified(user: User val) =>
db.query(user.id, this)
be auth_failed() =>
_handler.respond(stallion.StatusUnauthorized, "Unauthorized")
be query_result(data: String val) =>
_handler.respond(stallion.StatusOK, data)The handler factory captures whatever services the handler needs: .>get("/dashboard", {(ctx)(auth, db) =>
MyDashboardHandler(consume ctx, auth, db)
} val)The trade-off is boilerplate for power. With middleware, the framework dictates the protocol — A wrapper actor that does auth and then delegates to a business-logic handler is possible but doesn't really save complexity — it's just an extra actor hop. The auth actor needs the What remains as framework concerns:
|
Response headers and cookiesHandlers need to set headers incrementally, not all-or-nothing. Currently handler.set_header("X-Request-Id", request_id)
handler.add_header("Set-Cookie", cookie.header_value())
handler.respond(status, body)With this, cookies aren't a special case. The handler builds cookies with Stallion's This also means session cookie management doesn't require framework plumbing for the response side — the handler sets the |
Middleware classification for in-filter / out-filter / handler compositionWent through every middleware implementation in the codebase (examples + tests) and classified them against the design from the comments above. Becomes in-filter (synchronous request rejection)
Becomes out-filter (synchronous response header modification)
Becomes handler composition
Goes away (tests the data map, which is being removed)
Test infrastructure (adapts to whatever replaces Middleware)
NoteThe private middlewares (
|
Uh oh!
There was an error while loading. Please reload this page.
Philosophy
The framework manages session identity — ID generation, cookie
signing/verification, the async load state machine, Set-Cookie injection.
The framework does NOT manage session data. What gets stored, in what shape,
is the user's domain. The store is a user-implemented trait.
Public Types
SessionStore(trait tag) —session_store.ponyThe callback lambda is created by
_Connectioninside the hobby package,so it can call
_session_loaded(package-private) on the connection actor.This keeps internal types hidden from store implementations.
SessionConfig(class val) —session_config.ponyApplication— addwith_sessionsserve()passes_session_configto_Listener._Listenerthreads itto each
_Connection.RequestHandler— session lifecycle methodsNew fields:
New constructor extraction (consume-first pattern):
New methods:
Modified response methods — all include
_build_session_outcome():Session outcome builder:
BeforeContext— read-only session accessHandlerContext— carry session dataInternal Types
_SessionOutcome(union type) —_session_outcome.pony4 variants. Each carries only the data it needs.
_SessionId(primitive) —_session_id.pony_CsprngFailure(primitive) —_csprng_failure.pony_SessionCookieWriter(primitive) —_session_cookie_writer.pony_ConnectionProtocolChangesDefault
= Nonemeans existing callers (ServeFiles,_ServeFilesHandler,test mocks) need zero changes.
_ConnectionChangesNew state and fields
Actor declaration:
No new traits — the store callback uses a lambda, not a trait on
_Connection.Session loading timer
Only used for the
_LoadingSessionphase. Covers the async wait for thestore callback. When
_session_loadedcalls_dispatch,_dispatchbumps_current_tokenagain (making this timer's token stale), then starts itsown timer for the handler phase. This means the loading timer is
automatically invalidated when dispatch begins.
_dispatchkeeps its existing inline token bump + timer start unchanged.No
_begin_request_lifecycle()extraction — that would start the timereven for before-middleware short-circuits (a behavioral regression).
Modified
_process_request_session_loadedbehaviorModified
_dispatchGains two parameters for session data. No longer bumps token or starts
timer (that's in
_begin_request_lifecycle).session_datais(Any val | None)— sendable into therecover isoblock.
session_idis(String val | None)— also sendable.Session persistence
Called by
_run_after_and_sendAFTER after-middleware, BEFORE wire send.Wait —
regenerate_session_id()onRequestHandlersets a flag, but thenew ID is generated here in
_persist_session. This means the handlerdoesn't know its new session ID. That's fine — the handler doesn't need it.
The framework generates the new ID at persistence time and sends the cookie.
Streaming + sessions
At
_handler_start_streaming: cookie injection AND store persistence.Since
_build_session_outcome()clears handler-side flags after building,the
finish()outcome only reflects changes made DURING streaming (e.g.,a
save_session()call betweenstart_streaming()andfinish()).regenerate_session_id()errors during streaming, sofinish()willnever see
_SessionSaveWithNewId.At
_handler_finish: store persistence via_persist_session(cookieinjection is no-op since
buf.is_streamingis true).Modified
_run_after_and_sendUpdated
_handler_respondand_handler_finishState machine edge cases
Updated
_handler_timeoutguard and new_LoadingSessionarm:Updated
on_closed:_inject_set_cookiehelperCapability Flow
All actor boundary crossings use
valtypes. Norefdata crossesboundaries. No
SessionEditor. No framework-prescribed data shape.Security Properties
SignedCookieSecure,HttpOnly,SameSite=Lax,__Host-prefix — all hardcoded
regenerate_session_id()rotates ID, deletes oldfrom store. Streaming guard prevents rotation when cookie can't be sent.
_CsprngFailure, connection sends 500save_session()arenever persisted — no cookie, no store entry
File Layout
New files:
Modified files:
Build Order
Phase 1: Pure types (independently testable):
_SessionId+ testsSessionStoretrait (no tests — it's a trait)_SessionOutcome+_CsprngFailure(no tests — primitives/classes)SessionConfig+ tests (validation)_SessionCookieWriter+ testsPhase 2: Integration (modifies existing code):
6.
_ConnectionProtocol(add session_outcome default params)7.
HandlerContext(add session fields)8.
RequestHandler(consume-first constructor, session methods)9.
BeforeContext(session_data read access)10.
_Connection+_LoadingSession(state machine, loading, persistence)11.
_Listener(thread config)12.
Application(with_sessions)13. Update test mocks for new protocol params
14. Session integration tests
Phase 3: Polish:
15. Example program
16. Package docstring, examples README
17. CLAUDE.md updates
Build/test:
make test ssl=3.0.x,make test-one t=TestName ssl=3.0.xCounterfactual testing: after each new test passes, temporarily break each
assertion to confirm it fires.
Example Program
Verify Empirically
_ConnectionProtocoltrait behaviorssession_data: (Any val | None)passing intorecover isoblock forHandlerContextconstruction_Connection tagin store load callback_inject_set_cookierecover block withHeadersiterationKnown Limitations
regenerate_session_id()errors during streaming__Host-prefix requires HTTPS; use a non-prefixed cookie name forlocal HTTP dev
All reactions