diff --git a/package.json b/package.json index 224abce..6ef61fa 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "default": "./src/index.js" }, "./cors": "./src/cors.js", + "./session": "./src/session.js", "./validate": "./src/validate.js", "./http-server.config": "./src/http-server.config.js" }, diff --git a/rust-native/Cargo.lock b/rust-native/Cargo.lock index 4148b55..70e1e32 100644 --- a/rust-native/Cargo.lock +++ b/rust-native/Cargo.lock @@ -128,6 +128,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -303,6 +304,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http_native_napi" version = "0.1.0" @@ -310,6 +320,8 @@ dependencies = [ "anyhow", "base64", "bytes", + "getrandom", + "hmac", "httparse", "itoa", "json5", @@ -318,8 +330,10 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "parking_lot", "serde", "serde_json", + "sha2", "socket2", "url", ] @@ -668,6 +682,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -756,6 +793,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -871,6 +917,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" diff --git a/rust-native/Cargo.toml b/rust-native/Cargo.toml index 9ff68a4..9f4f445 100644 --- a/rust-native/Cargo.toml +++ b/rust-native/Cargo.toml @@ -14,11 +14,15 @@ httparse = "1.9" itoa = "1.0" json5 = "0.4" memchr = "2.7" -monoio = { version = "0.2", features = ["sync"] } +getrandom = "0.2" +hmac = "0.12" +monoio = { version = "0.2", features = ["sync", "legacy"] } napi = { version = "3", default-features = false, features = ["napi8"] } napi-derive = "3" +parking_lot = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" socket2 = { version = "0.5", features = ["all"] } url = "2.5" diff --git a/rust-native/src/lib.rs b/rust-native/src/lib.rs index cc91864..6123b22 100644 --- a/rust-native/src/lib.rs +++ b/rust-native/src/lib.rs @@ -1,6 +1,7 @@ mod analyzer; mod manifest; mod router; +pub mod session; use anyhow::{anyhow, Context, Result}; use memchr::memmem; @@ -233,6 +234,101 @@ impl NativeServerHandle { } } +// ─── Global Session Store ────────────── +// +// Accessible from both the server threads and direct NAPI calls from JS. +// Initialized during start_server when session config is present. + +static GLOBAL_SESSION_STORE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// Get a session value by key. Returns JSON string or null. +#[napi] +pub fn session_get(session_id_hex: String, key: String) -> Option { + let store = GLOBAL_SESSION_STORE.get()?; + let id = session::hex_decode_id(&session_id_hex)?; + let entry = store.get(&id)?; + let value = entry.data.get(&key)?; + String::from_utf8(value.clone()).ok() +} + +/// Set a session value. Value should be a JSON string. +#[napi] +pub fn session_set(session_id_hex: String, key: String, value: String) -> bool { + let Some(store) = GLOBAL_SESSION_STORE.get() else { return false }; + let Some(id) = session::hex_decode_id(&session_id_hex) else { return false }; + let mut mutations = std::collections::HashMap::new(); + mutations.insert(key, value.into_bytes()); + store.upsert(&id, mutations, &[]); + true +} + +/// Delete a session key. +#[napi] +pub fn session_delete(session_id_hex: String, key: String) -> bool { + let Some(store) = GLOBAL_SESSION_STORE.get() else { return false }; + let Some(id) = session::hex_decode_id(&session_id_hex) else { return false }; + store.upsert(&id, std::collections::HashMap::new(), &[key]); + true +} + +/// Destroy an entire session. +#[napi] +pub fn session_destroy(session_id_hex: String) -> bool { + let Some(store) = GLOBAL_SESSION_STORE.get() else { return false }; + let Some(id) = session::hex_decode_id(&session_id_hex) else { return false }; + store.destroy(&id); + true +} + +/// Verify a signed session cookie. Returns the session ID hex if valid, null otherwise. +#[napi] +pub fn session_verify_cookie(cookie_value: String) -> Option { + let store = GLOBAL_SESSION_STORE.get()?; + let id = store.verify_cookie(&cookie_value)?; + Some(session::hex_encode_id(&id)) +} + +/// Generate a new signed session cookie value. Returns "hex_id.hex_hmac". +#[napi] +pub fn session_new_cookie() -> Option { + let store = GLOBAL_SESSION_STORE.get()?; + let id = store.generate_id(); + Some(store.build_cookie_value(&id)) +} + +/// Get all session data as a JSON object string. +#[napi] +pub fn session_get_all(session_id_hex: String) -> Option { + let store = GLOBAL_SESSION_STORE.get()?; + let id = session::hex_decode_id(&session_id_hex)?; + let entry = store.get(&id)?; + let mut map = serde_json::Map::new(); + for (key, value) in &entry.data { + if let Ok(json_val) = serde_json::from_slice(value) { + map.insert(key.clone(), json_val); + } else if let Ok(s) = std::str::from_utf8(value) { + map.insert(key.clone(), serde_json::Value::String(s.to_string())); + } + } + serde_json::to_string(&serde_json::Value::Object(map)).ok() +} + +/// Set multiple session values at once. Takes a JSON object string. +#[napi] +pub fn session_set_all(session_id_hex: String, data_json: String) -> bool { + let Some(store) = GLOBAL_SESSION_STORE.get() else { return false }; + let Some(id) = session::hex_decode_id(&session_id_hex) else { return false }; + let Ok(obj) = serde_json::from_str::(&data_json) else { return false }; + let Some(map) = obj.as_object() else { return false }; + let mut mutations = std::collections::HashMap::new(); + for (key, value) in map { + mutations.insert(key.clone(), value.to_string().into_bytes()); + } + store.upsert(&id, mutations, &[]); + true +} + #[napi] pub fn start_server( manifest_json: String, @@ -245,6 +341,24 @@ pub fn start_server( Arc::new(HttpServerConfig::from_manifest(&manifest).map_err(to_napi_error)?); let router = Arc::new(Router::from_manifest(&manifest).map_err(to_napi_error)?); + // Build session store if session config is present in manifest + let session_store: Option> = manifest.session.as_ref().map(|cfg| { + let store = Arc::new(session::SessionStore::new(session::SessionConfig { + secret: cfg.secret.as_bytes().to_vec(), + max_age_secs: cfg.max_age_secs, + cookie_name: cfg.cookie_name.clone(), + http_only: cfg.http_only, + secure: cfg.secure, + same_site: session::SameSite::from_str(&cfg.same_site), + path: cfg.path.clone(), + max_sessions: cfg.max_sessions, + max_data_size: cfg.max_data_size, + })); + // Register globally so NAPI functions can access it + let _ = GLOBAL_SESSION_STORE.set(Arc::clone(&store)); + store + }); + let callback: DispatchTsfn = dispatcher .build_threadsafe_function::() .build() @@ -264,6 +378,7 @@ pub fn start_server( let thread_dispatcher = Arc::clone(&dispatcher); let thread_config = Arc::clone(&server_config); let thread_shutdown = Arc::clone(&shutdown_flag); + let thread_session_store = session_store.clone(); let thread_options = NativeListenOptions { host: options.host.clone(), port: options.port, @@ -290,6 +405,7 @@ pub fn start_server( thread_dispatcher, thread_config, thread_shutdown, + thread_session_store, ) .await }) @@ -400,6 +516,7 @@ async fn run_server( dispatcher: Arc, server_config: Arc, shutdown_flag: Arc, + session_store: Option>, ) -> Result<()> { let active_connections: std::cell::Cell = std::cell::Cell::new(0); @@ -427,6 +544,7 @@ async fn run_server( let router = Arc::clone(&router); let dispatcher = Arc::clone(&dispatcher); let server_config = Arc::clone(&server_config); + let session_store = session_store.clone(); active_connections.set(active_connections.get() + 1); // Safety: monoio is single-threaded per worker, so Cell is fine here @@ -434,7 +552,7 @@ async fn run_server( monoio::spawn(async move { if let Err(error) = - handle_connection(stream, router, dispatcher, server_config).await + handle_connection(stream, router, dispatcher, server_config, session_store).await { eprintln!("[http-native] connection error: {error}"); } @@ -471,6 +589,8 @@ struct ParsedRequest<'a> { has_chunked_te: bool, /// Pre-parsed header pairs — stored once, used by both routing and bridge headers: Vec<(&'a str, &'a str)>, + /// Raw cookie header value for session extraction + cookie_header: Option<&'a str>, } use monoio::time::timeout; @@ -488,6 +608,7 @@ async fn handle_connection( router: Arc, dispatcher: Arc, server_config: Arc, + session_store: Option>, ) -> Result<()> { let mut buffer = acquire_buffer(); @@ -497,6 +618,7 @@ async fn handle_connection( &router, &dispatcher, &server_config, + session_store.as_deref(), ) .await; @@ -510,6 +632,7 @@ async fn handle_connection_inner( router: &Router, dispatcher: &JsDispatcher, server_config: &HttpServerConfig, + session_store: Option<&session::SessionStore>, ) -> Result<()> { let mut is_first_request = true; @@ -630,12 +753,16 @@ async fn handle_connection_inner( // String/Vec allocations for method, target, path, and headers. if !has_body { let dispatch_decision = build_dispatch_decision_zero_copy(router, &parsed, &[])?; + + // Extract session before dropping parsed + let (session_id, is_new_session) = resolve_session(session_store, parsed.cookie_header); + drop(parsed); drain_consumed_bytes(buffer, header_bytes); match dispatch_decision { DispatchDecision::BridgeRequest(request, cache_insertion, handler_id, url_bytes) => { - write_dynamic_dispatch_response(stream, dispatcher, request, keep_alive, cache_insertion, handler_id, &url_bytes) + write_dynamic_dispatch_response(stream, dispatcher, request, keep_alive, cache_insertion, handler_id, &url_bytes, session_store, session_id, is_new_session) .await?; } DispatchDecision::SpecializedResponse(response) => { @@ -668,6 +795,7 @@ async fn handle_connection_inner( .iter() .map(|(n, v)| (n.to_string(), v.to_string())) .collect(); + let (session_id_body, is_new_session_body) = resolve_session(session_store, parsed.cookie_header); drop(parsed); // ── Read request body @@ -744,7 +872,7 @@ async fn handle_connection_inner( match dispatch_decision_owned { DispatchDecision::BridgeRequest(request, cache_insertion, handler_id, url_bytes) => { - write_dynamic_dispatch_response(stream, dispatcher, request, keep_alive, cache_insertion, handler_id, &url_bytes).await?; + write_dynamic_dispatch_response(stream, dispatcher, request, keep_alive, cache_insertion, handler_id, &url_bytes, session_store, session_id_body, is_new_session_body).await?; } DispatchDecision::SpecializedResponse(response) => { let timeout_result = timeout(TIMEOUT_WRITE, stream.write_all(response)).await; @@ -799,6 +927,7 @@ fn parse_request_httparse(bytes: &[u8]) -> Option> { let mut has_body = false; let mut content_length: Option = None; let mut has_chunked_te = false; + let mut cookie_header: Option<&str> = None; let mut headers = Vec::with_capacity(req.headers.len()); for header in req.headers.iter() { @@ -847,6 +976,11 @@ fn parse_request_httparse(bytes: &[u8]) -> Option> { } } + // Session: capture cookie header for session extraction + if name.eq_ignore_ascii_case("cookie") { + cookie_header = Some(value); + } + headers.push((name, value)); } @@ -860,6 +994,7 @@ fn parse_request_httparse(bytes: &[u8]) -> Option> { content_length, has_chunked_te, headers, + cookie_header, }) } @@ -937,6 +1072,7 @@ fn parse_hot_root_request( content_length: None, has_chunked_te: false, headers: Vec::new(), + cookie_header: None, // Hot path doesn't parse cookies }) } @@ -1093,6 +1229,7 @@ fn build_dispatch_decision_owned( content_length: None, has_chunked_te: false, headers: header_refs.clone(), + cookie_header: None, }; let key = crate::router::interpolate_cache_key(cfg, &mock_parsed, url_str, matched_route.param_names, &matched_route.param_values); cache_insertion = Some((matched_route.handler_id, key, cfg.max_entries, cfg.ttl_secs)); @@ -1747,6 +1884,115 @@ fn compute_ncache_key(handler_id: u32, url_bytes: &[u8]) -> u64 { hasher.finish() } +// ─── Session Trailer Extraction ──────── +// +// Extracts session write instructions from the response envelope trailer. +// Magic: 0x5E 0x57 | action(1) | entry_count(2) | deleted_count(2) | entries... | deleted_keys... + +struct SessionWriteTrailer { + action: session::SessionAction, + mutations: std::collections::HashMap>, + deleted_keys: Vec, +} + +/// Find the body end offset in a response envelope (skip fixed header + all headers + body). +fn response_body_end_offset(dispatch_bytes: &[u8]) -> Option { + if dispatch_bytes.len() < 8 { + return None; + } + let mut offset = 0usize; + let _status = (dispatch_bytes[offset] as u16) | ((dispatch_bytes[offset + 1] as u16) << 8); + offset += 2; + let header_count = (dispatch_bytes[offset] as u16) | ((dispatch_bytes[offset + 1] as u16) << 8); + offset += 2; + let body_length = (dispatch_bytes[offset] as u32) + | ((dispatch_bytes[offset + 1] as u32) << 8) + | ((dispatch_bytes[offset + 2] as u32) << 16) + | ((dispatch_bytes[offset + 3] as u32) << 24); + offset += 4; + + // Skip headers + for _ in 0..header_count { + if offset + 3 > dispatch_bytes.len() { return None; } + let name_len = dispatch_bytes[offset] as usize; + offset += 1; + let value_len = (dispatch_bytes[offset] as u16) | ((dispatch_bytes[offset + 1] as u16) << 8); + offset += 2; + offset += name_len + value_len as usize; + } + + // Skip body + offset += body_length as usize; + Some(offset) +} + +/// Extract session write trailer from the response envelope. +/// Called after the ncache trailer position. Scans from `start_offset`. +fn extract_session_trailer(dispatch_bytes: &[u8], start_offset: usize) -> Option { + let mut offset = start_offset; + + // Check for session magic (0x5E 0x57) + if offset + 7 > dispatch_bytes.len() { + return None; + } + if dispatch_bytes[offset] != 0x5E || dispatch_bytes[offset + 1] != 0x57 { + return None; + } + offset += 2; + + let action_byte = dispatch_bytes[offset]; + offset += 1; + let action = session::SessionAction::from_byte(action_byte)?; + + let entry_count = (dispatch_bytes[offset] as u16) | ((dispatch_bytes[offset + 1] as u16) << 8); + offset += 2; + + let deleted_count = (dispatch_bytes[offset] as u16) | ((dispatch_bytes[offset + 1] as u16) << 8); + offset += 2; + + let mut mutations = std::collections::HashMap::new(); + for _ in 0..entry_count { + if offset + 2 > dispatch_bytes.len() { return None; } + let key_len = (dispatch_bytes[offset] as u16) | ((dispatch_bytes[offset + 1] as u16) << 8); + offset += 2; + let key_len = key_len as usize; + if offset + key_len > dispatch_bytes.len() { return None; } + let key = std::str::from_utf8(&dispatch_bytes[offset..offset + key_len]).ok()?; + offset += key_len; + + if offset + 4 > dispatch_bytes.len() { return None; } + let value_len = (dispatch_bytes[offset] as u32) + | ((dispatch_bytes[offset + 1] as u32) << 8) + | ((dispatch_bytes[offset + 2] as u32) << 16) + | ((dispatch_bytes[offset + 3] as u32) << 24); + offset += 4; + let value_len = value_len as usize; + if offset + value_len > dispatch_bytes.len() { return None; } + let value = dispatch_bytes[offset..offset + value_len].to_vec(); + offset += value_len; + + mutations.insert(key.to_string(), value); + } + + let mut deleted_keys = Vec::new(); + for _ in 0..deleted_count { + if offset + 2 > dispatch_bytes.len() { return None; } + let key_len = (dispatch_bytes[offset] as u16) | ((dispatch_bytes[offset + 1] as u16) << 8); + offset += 2; + let key_len = key_len as usize; + if offset + key_len > dispatch_bytes.len() { return None; } + let key = std::str::from_utf8(&dispatch_bytes[offset..offset + key_len]).ok()?; + offset += key_len; + deleted_keys.push(key.to_string()); + } + + Some(SessionWriteTrailer { + action, + mutations, + deleted_keys, + }) +} + async fn write_dynamic_dispatch_response( stream: &mut TcpStream, dispatcher: &JsDispatcher, @@ -1755,11 +2001,14 @@ async fn write_dynamic_dispatch_response( cache_insertion: Option<(u32, u64, usize, u64)>, handler_id: u32, url_bytes: &[u8], + session_store: Option<&session::SessionStore>, + session_id: Option<[u8; session::SESSION_ID_BYTES]>, + is_new_session: bool, ) -> Result<()> { match dispatcher.dispatch(request).await { Ok(response) => { match build_http_response_from_dispatch(response.as_ref(), keep_alive) { - Ok(http_response) => { + Ok(mut http_response) => { if let Some((handler_id, cache_key, max_entries, ttl_secs)) = cache_insertion { // Route-level cache insertion (takes precedence over ncache) let response_bytes_close: bytes::Bytes = if !keep_alive { @@ -1812,6 +2061,63 @@ async fn write_dynamic_dispatch_response( } } + // Process session trailer if session store is active + if let Some(store) = session_store { + if let Some(body_end) = response_body_end_offset(response.as_ref()) { + // Skip past ncache trailer (10 bytes) if present + let mut session_scan_offset = body_end; + if session_scan_offset + 10 <= response.as_ref().len() + && response.as_ref()[session_scan_offset] == 0xCA + && response.as_ref()[session_scan_offset + 1] == 0xCE + { + session_scan_offset += 10; + } + + if let Some(trailer) = extract_session_trailer(response.as_ref(), session_scan_offset) { + match trailer.action { + session::SessionAction::Update => { + if let Some(sid) = session_id { + store.upsert(&sid, trailer.mutations, &trailer.deleted_keys); + // Inject Set-Cookie for new sessions + if is_new_session { + let cookie = store.build_set_cookie(&sid); + inject_set_cookie_header(&mut http_response, &cookie); + } + } + } + session::SessionAction::Destroy => { + if let Some(sid) = session_id { + store.destroy(&sid); + let cookie = store.build_destroy_cookie(); + inject_set_cookie_header(&mut http_response, &cookie); + } + } + session::SessionAction::Regenerate => { + // Destroy old, create new + if let Some(old_sid) = session_id { + let old_data = store.get(&old_sid); + store.destroy(&old_sid); + let new_sid = store.generate_id(); + if let Some(entry) = old_data { + store.upsert(&new_sid, entry.data, &[]); + } + store.upsert(&new_sid, trailer.mutations, &trailer.deleted_keys); + let cookie = store.build_set_cookie(&new_sid); + inject_set_cookie_header(&mut http_response, &cookie); + } + } + } + } else if is_new_session { + // No session trailer but session was accessed — set cookie + if let Some(sid) = session_id { + store.upsert(&sid, std::collections::HashMap::new(), &[]); + let cookie = store.build_set_cookie(&sid); + inject_set_cookie_header(&mut http_response, &cookie); + } + } + } + } + let timeout_result = timeout(TIMEOUT_WRITE, stream.write_all(http_response)).await; if let Ok((write_result, _)) = timeout_result { write_result?; @@ -1921,6 +2227,45 @@ fn build_http_response_from_dispatch(dispatch_bytes: &[u8], keep_alive: bool) -> Ok(output) } +/// Resolve the session ID from the cookie header. Returns (session_id, is_new). +/// If no cookie is present or invalid, generates a new session ID. +fn resolve_session( + session_store: Option<&session::SessionStore>, + cookie_header: Option<&str>, +) -> (Option<[u8; session::SESSION_ID_BYTES]>, bool) { + let Some(store) = session_store else { + return (None, false); + }; + + if let Some(cookie_value) = cookie_header.and_then(|h| store.extract_cookie_value(h)) { + if let Some(id) = store.verify_cookie(cookie_value) { + // Valid existing session + return (Some(id), false); + } + } + + // No valid session cookie — generate a new session ID + let new_id = store.generate_id(); + (Some(new_id), true) +} + +/// Inject a Set-Cookie header into an already-built HTTP response. +/// Inserts the header just before the final \r\n\r\n (end of headers). +fn inject_set_cookie_header(response: &mut Vec, cookie_value: &str) { + // Find the \r\n\r\n boundary between headers and body + if let Some(pos) = memmem::find(response, b"\r\n\r\n") { + let header_line = format!("set-cookie: {}\r\n", cookie_value); + let header_bytes = header_line.as_bytes(); + + // Insert before the final \r\n\r\n + let insert_pos = pos + 2; // after the last header's \r\n, before the blank \r\n + response.splice(insert_pos..insert_pos, header_bytes.iter().copied()); + + // Update Content-Length — it shouldn't change since we're adding headers, not body. + // Content-Length only measures the body, which is unchanged. + } +} + /// Build a simple error response without going through the JS bridge fn build_error_response_bytes(status: u16, body: &[u8], keep_alive: bool) -> Vec { let reason = status_reason(status); diff --git a/rust-native/src/manifest.rs b/rust-native/src/manifest.rs index 9c01fe0..4056977 100644 --- a/rust-native/src/manifest.rs +++ b/rust-native/src/manifest.rs @@ -7,8 +7,40 @@ pub struct ManifestInput { pub server_config: Option, pub middlewares: Vec, pub routes: Vec, + #[serde(default)] + pub session: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfigInput { + pub secret: String, + #[serde(default = "default_max_age")] + pub max_age_secs: u64, + #[serde(default = "default_cookie_name")] + pub cookie_name: String, + #[serde(default = "default_true")] + pub http_only: bool, + #[serde(default)] + pub secure: bool, + #[serde(default = "default_same_site")] + pub same_site: String, + #[serde(default = "default_path")] + pub path: String, + #[serde(default = "default_max_sessions")] + pub max_sessions: usize, + #[serde(default = "default_max_data_size")] + pub max_data_size: usize, } +fn default_max_age() -> u64 { 3600 } +fn default_cookie_name() -> String { "sid".to_string() } +fn default_true() -> bool { true } +fn default_same_site() -> String { "lax".to_string() } +fn default_path() -> String { "/".to_string() } +fn default_max_sessions() -> usize { 100_000 } +fn default_max_data_size() -> usize { 4096 } + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HttpServerConfigInput { @@ -49,6 +81,9 @@ pub struct RouteInput { #[serde(default)] pub needs_query: bool, #[serde(default)] + #[allow(dead_code)] + pub needs_session: bool, + #[serde(default)] pub cache: Option, } diff --git a/rust-native/src/session.rs b/rust-native/src/session.rs new file mode 100644 index 0000000..c39353c --- /dev/null +++ b/rust-native/src/session.rs @@ -0,0 +1,415 @@ +use hmac::{Hmac, Mac}; +use parking_lot::RwLock; +use sha2::Sha256; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +type HmacSha256 = Hmac; + +/// Number of shards to reduce lock contention across workers. +const SHARD_COUNT: usize = 64; +/// Default max sessions across all shards. +const DEFAULT_MAX_SESSIONS: usize = 100_000; +/// Default max data size per session (4 KB). +const DEFAULT_MAX_DATA_SIZE: usize = 4096; +/// Session ID: 16 bytes = 128-bit random. +pub const SESSION_ID_BYTES: usize = 16; +/// Hex-encoded session ID length. +pub const SESSION_ID_HEX_LEN: usize = SESSION_ID_BYTES * 2; +/// HMAC signature length (SHA-256 = 32 bytes = 64 hex chars). +const HMAC_HEX_LEN: usize = 64; + +// ─── Session Configuration ──────────────── + +#[derive(Debug, Clone)] +pub struct SessionConfig { + pub secret: Vec, + pub max_age_secs: u64, + pub cookie_name: String, + pub http_only: bool, + pub secure: bool, + pub same_site: SameSite, + pub path: String, + pub max_sessions: usize, + pub max_data_size: usize, +} + +#[derive(Debug, Clone, Copy)] +pub enum SameSite { + Strict, + Lax, + None, +} + +impl SameSite { + pub fn as_str(&self) -> &'static str { + match self { + SameSite::Strict => "Strict", + SameSite::Lax => "Lax", + SameSite::None => "None", + } + } + + pub fn from_str(s: &str) -> Self { + match s.to_ascii_lowercase().as_str() { + "strict" => SameSite::Strict, + "none" => SameSite::None, + _ => SameSite::Lax, + } + } +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + secret: Vec::new(), + max_age_secs: 3600, + cookie_name: "sid".to_string(), + http_only: true, + secure: false, + same_site: SameSite::Lax, + path: "/".to_string(), + max_sessions: DEFAULT_MAX_SESSIONS, + max_data_size: DEFAULT_MAX_DATA_SIZE, + } + } +} + +// ─── Session Entry ──────────────────────── + +#[derive(Debug, Clone)] +pub struct SessionEntry { + pub data: HashMap>, + pub created_at: Instant, + pub last_accessed: Instant, + pub expires_at: Instant, +} + +impl SessionEntry { + fn new(max_age: Duration) -> Self { + let now = Instant::now(); + Self { + data: HashMap::new(), + created_at: now, + last_accessed: now, + expires_at: now + max_age, + } + } + + fn is_expired(&self) -> bool { + Instant::now() >= self.expires_at + } + + fn touch(&mut self, max_age: Duration) { + let now = Instant::now(); + self.last_accessed = now; + self.expires_at = now + max_age; + } + + /// Total size of all stored data in bytes. + fn data_size(&self) -> usize { + self.data.iter().map(|(k, v)| k.len() + v.len()).sum() + } +} + +// ─── Session Shard ──────────────────────── + +struct SessionShard { + map: HashMap<[u8; SESSION_ID_BYTES], SessionEntry>, +} + +impl SessionShard { + fn new() -> Self { + Self { + map: HashMap::new(), + } + } +} + +// ─── Session Store ──────────────────────── + +pub struct SessionStore { + shards: Box<[RwLock]>, + config: SessionConfig, +} + +impl SessionStore { + pub fn new(config: SessionConfig) -> Self { + let shards: Vec> = + (0..SHARD_COUNT).map(|_| RwLock::new(SessionShard::new())).collect(); + + Self { + shards: shards.into_boxed_slice(), + config, + } + } + + pub fn config(&self) -> &SessionConfig { + &self.config + } + + /// Shard index for a given session ID. + fn shard_index(&self, id: &[u8; SESSION_ID_BYTES]) -> usize { + // Use the first byte of the session ID as shard selector. + (id[0] as usize) % SHARD_COUNT + } + + /// Generate a new cryptographically random session ID. + pub fn generate_id(&self) -> [u8; SESSION_ID_BYTES] { + let mut id = [0u8; SESSION_ID_BYTES]; + getrandom::getrandom(&mut id).expect("failed to generate random session ID"); + id + } + + /// Sign a session ID with HMAC-SHA256. Returns hex-encoded signature. + pub fn sign(&self, id: &[u8; SESSION_ID_BYTES]) -> String { + let mut mac = + HmacSha256::new_from_slice(&self.config.secret).expect("HMAC key should be valid"); + mac.update(id); + let result = mac.finalize(); + hex_encode(result.into_bytes().as_slice()) + } + + /// Verify a signed cookie value. Returns the raw session ID if valid. + pub fn verify_cookie(&self, cookie_value: &str) -> Option<[u8; SESSION_ID_BYTES]> { + // Format: . + let dot = cookie_value.find('.')?; + let id_hex = &cookie_value[..dot]; + let sig_hex = &cookie_value[dot + 1..]; + + if id_hex.len() != SESSION_ID_HEX_LEN || sig_hex.len() != HMAC_HEX_LEN { + return None; + } + + let id_bytes = hex_decode(id_hex)?; + if id_bytes.len() != SESSION_ID_BYTES { + return None; + } + + let mut id = [0u8; SESSION_ID_BYTES]; + id.copy_from_slice(&id_bytes); + + // Verify HMAC + let mut mac = + HmacSha256::new_from_slice(&self.config.secret).expect("HMAC key should be valid"); + mac.update(&id); + let sig_bytes = hex_decode(sig_hex)?; + mac.verify_slice(&sig_bytes).ok()?; + + Some(id) + } + + /// Build the signed cookie value: . + pub fn build_cookie_value(&self, id: &[u8; SESSION_ID_BYTES]) -> String { + let id_hex = hex_encode(id); + let sig = self.sign(id); + format!("{id_hex}.{sig}") + } + + /// Build the full Set-Cookie header value. + pub fn build_set_cookie(&self, id: &[u8; SESSION_ID_BYTES]) -> String { + let value = self.build_cookie_value(id); + let cfg = &self.config; + let mut cookie = format!( + "{}={}; Path={}; Max-Age={}", + cfg.cookie_name, value, cfg.path, cfg.max_age_secs + ); + if cfg.http_only { + cookie.push_str("; HttpOnly"); + } + if cfg.secure { + cookie.push_str("; Secure"); + } + cookie.push_str("; SameSite="); + cookie.push_str(cfg.same_site.as_str()); + cookie + } + + /// Build a Set-Cookie header that destroys the session cookie. + pub fn build_destroy_cookie(&self) -> String { + let cfg = &self.config; + let mut cookie = format!( + "{}=; Path={}; Max-Age=0", + cfg.cookie_name, cfg.path + ); + if cfg.http_only { + cookie.push_str("; HttpOnly"); + } + if cfg.secure { + cookie.push_str("; Secure"); + } + cookie.push_str("; SameSite="); + cookie.push_str(cfg.same_site.as_str()); + cookie + } + + /// Look up a session. Returns cloned data if found and not expired. + pub fn get(&self, id: &[u8; SESSION_ID_BYTES]) -> Option { + let shard_idx = self.shard_index(id); + let mut shard = self.shards[shard_idx].write(); + + if let Some(entry) = shard.map.get_mut(id) { + if entry.is_expired() { + shard.map.remove(id); + return None; + } + entry.touch(Duration::from_secs(self.config.max_age_secs)); + return Some(entry.clone()); + } + + None + } + + /// Create or update a session with the given data mutations. + /// `mutations` contains only the changed keys. Existing keys not in + /// `mutations` are preserved. + pub fn upsert( + &self, + id: &[u8; SESSION_ID_BYTES], + mutations: HashMap>, + deleted_keys: &[String], + ) { + let shard_idx = self.shard_index(id); + let mut shard = self.shards[shard_idx].write(); + let max_age = Duration::from_secs(self.config.max_age_secs); + + let entry = shard + .map + .entry(*id) + .or_insert_with(|| SessionEntry::new(max_age)); + + // Apply deletions + for key in deleted_keys { + entry.data.remove(key); + } + + // Merge mutations (last-write-wins) + for (key, value) in mutations { + entry.data.insert(key, value); + } + + // Enforce per-session data size limit + if entry.data_size() > self.config.max_data_size { + // Truncate by removing oldest entries until under limit. + // Simple strategy: just clear if over limit. + entry.data.clear(); + } + + entry.touch(max_age); + } + + /// Destroy a session. + pub fn destroy(&self, id: &[u8; SESSION_ID_BYTES]) { + let shard_idx = self.shard_index(id); + let mut shard = self.shards[shard_idx].write(); + shard.map.remove(id); + } + + /// Parse the session cookie from a Cookie header value. + /// Scans for `cookie_name=` in the header. + pub fn extract_cookie_value<'a>(&self, cookie_header: &'a str) -> Option<&'a str> { + let name = &self.config.cookie_name; + let search = format!("{name}="); + + for part in cookie_header.split(';') { + let trimmed = part.trim(); + if trimmed.starts_with(&search) { + let value = &trimmed[search.len()..]; + // Trim whitespace and quotes + let value = value.trim().trim_matches('"'); + if !value.is_empty() { + return Some(value); + } + } + } + + None + } + + /// Total sessions across all shards (for diagnostics). + pub fn session_count(&self) -> usize { + self.shards.iter().map(|s| s.read().map.len()).sum() + } +} + +// ─── Session Action (from JS response trailer) ─── + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SessionAction { + /// Update session data with mutations. + Update = 1, + /// Destroy the session entirely. + Destroy = 2, + /// Regenerate session ID (destroy old, create new). + Regenerate = 3, +} + +impl SessionAction { + pub fn from_byte(b: u8) -> Option { + match b { + 1 => Some(Self::Update), + 2 => Some(Self::Destroy), + 3 => Some(Self::Regenerate), + _ => None, + } + } +} + +// ─── Hex Encoding Helpers ───────────────── + +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(HEX_CHARS[(b >> 4) as usize]); + s.push(HEX_CHARS[(b & 0xf) as usize]); + } + s +} + +const HEX_CHARS: [char; 16] = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', +]; + +fn hex_decode(hex: &str) -> Option> { + if hex.len() % 2 != 0 { + return None; + } + + let mut bytes = Vec::with_capacity(hex.len() / 2); + let chars: Vec = hex.bytes().collect(); + + for chunk in chars.chunks_exact(2) { + let high = hex_nibble(chunk[0])?; + let low = hex_nibble(chunk[1])?; + bytes.push((high << 4) | low); + } + + Some(bytes) +} + +fn hex_nibble(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +// ─── Public Hex Helpers for NAPI ────────── + +/// Decode a hex session ID string to raw bytes. +pub fn hex_decode_id(hex: &str) -> Option<[u8; SESSION_ID_BYTES]> { + if hex.len() != SESSION_ID_HEX_LEN { + return None; + } + let bytes = hex_decode(hex)?; + let mut id = [0u8; SESSION_ID_BYTES]; + id.copy_from_slice(&bytes); + Some(id) +} + +/// Encode raw session ID bytes to hex string. +pub fn hex_encode_id(id: &[u8; SESSION_ID_BYTES]) -> String { + hex_encode(id) +} diff --git a/src/index.d.ts b/src/index.d.ts index 3196d0e..bd11bce 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -38,6 +38,49 @@ export interface Request { /** Get the request body as an ArrayBuffer */ arrayBuffer(): ArrayBuffer; + + /** Session object (available when session middleware is used) */ + session: Session; + + /** Current session ID (available when session middleware is used) */ + readonly sessionId?: string; +} + +export interface Session { + /** Get a session value by key */ + get(key: string): T | undefined; + + /** Set a session value */ + set(key: string, value: unknown): void; + + /** Delete a session key */ + delete(key: string): void; + + /** Check if a key exists */ + has(key: string): boolean; + + /** Destroy the entire session */ + destroy(): void; + + /** Whether the session has been destroyed */ + readonly isDestroyed: boolean; +} + +export interface SessionOptions { + /** HMAC signing secret (required) */ + secret: string; + /** Session TTL in seconds (default 3600) */ + maxAge?: number; + /** Cookie name (default "sid") */ + cookieName?: string; + /** HttpOnly flag (default true) */ + httpOnly?: boolean; + /** Secure flag (default false) */ + secure?: boolean; + /** SameSite policy (default "lax") */ + sameSite?: "strict" | "lax" | "none"; + /** Cookie path (default "/") */ + path?: string; } export interface Response { diff --git a/src/index.js b/src/index.js index 0e2c254..32e0895 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import { mergeRequestAccessPlans, releaseRequestObject, } from "./bridge.js"; +import { encodeSessionTrailer } from "./session.js"; import { loadNativeModule } from "./native.js"; import defaultHttpServerConfig, { normalizeHttpServerConfig, @@ -571,7 +572,14 @@ function createDispatcher( ? performance.now() - dispatchStartMs : undefined; runtimeOptimizer?.recordDispatch(route, req, responseSnapshot, dispatchDurationMs); - const encoded = encodeResponseEnvelope(responseSnapshot); + let encoded = encodeResponseEnvelope(responseSnapshot); + + // Append session trailer if session mutations exist + const sessionTrailer = encodeSessionTrailer(res._sessionState); + if (sessionTrailer) { + encoded = Buffer.concat([encoded, sessionTrailer]); + } + maybePromoteRouteResponseCache( route, responseSnapshot, @@ -1053,9 +1061,27 @@ export function createApp() { }), } : null, + needsSession: /\breq\.session\b|\breq\.sessionId\b/.test(route.handlerSource), })), }; + // Detect session middleware and add config to manifest + const sessionMiddleware = this._middlewares.find((mw) => mw.handler._sessionConfig); + if (sessionMiddleware) { + const cfg = sessionMiddleware.handler._sessionConfig; + manifest.session = { + secret: cfg.secret, + maxAgeSecs: cfg.maxAge, + cookieName: cfg.cookieName, + httpOnly: cfg.httpOnly, + secure: cfg.secure, + sameSite: cfg.sameSite, + path: cfg.path, + maxSessions: cfg.maxSessions, + maxDataSize: cfg.maxDataSize, + }; + } + const runtimeOptimizer = createRuntimeOptimizer( compiledRoutes, compiledMiddlewares, diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..db4f8c3 --- /dev/null +++ b/src/session.js @@ -0,0 +1,369 @@ +/** + * http-native session middleware. + * + * Default store: Rust in-memory (sharded RwLock, cross-worker safe). + * Pluggable: pass any store with get/set/delete/destroy/getAll methods. + * + * Usage: + * import { session, MemoryStore, RedisStore } from "http-native/session"; + * + * // In-memory (default, Rust-backed) + * app.use(session({ secret: "my-key" })); + * + * // Redis + * import Redis from "ioredis"; + * app.use(session({ secret: "my-key", store: new RedisStore(new Redis()) })); + * + * // Custom store + * app.use(session({ secret: "my-key", store: myCustomStore })); + */ + +import { Buffer } from "node:buffer"; +import { loadNativeModule } from "./native.js"; + +const SESSION_DEFAULTS = { + secret: "", + maxAge: 3600, + cookieName: "sid", + httpOnly: true, + secure: false, + sameSite: "lax", + path: "/", + maxSessions: 100_000, + maxDataSize: 4096, +}; + +// ─── Store Interface ────────────────────── + +/** + * In-memory session store backed by Rust's native sharded RwLock. + * All operations are synchronous (direct NAPI calls into Rust). + */ +export class MemoryStore { + #native; + + constructor() { + this.#native = loadNativeModule(); + } + + get(sessionId, key) { + const raw = this.#native.sessionGet(sessionId, key); + if (raw === null || raw === undefined) return undefined; + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + + set(sessionId, key, value) { + this.#native.sessionSet(sessionId, key, JSON.stringify(value)); + } + + delete(sessionId, key) { + this.#native.sessionDelete(sessionId, key); + } + + destroy(sessionId) { + this.#native.sessionDestroy(sessionId); + } + + getAll(sessionId) { + const raw = this.#native.sessionGetAll(sessionId); + if (raw === null || raw === undefined) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + } + + setAll(sessionId, data) { + this.#native.sessionSetAll(sessionId, JSON.stringify(data)); + } +} + +/** + * Redis session store. Requires an ioredis (or compatible) client. + * + * Usage: + * import Redis from "ioredis"; + * const store = new RedisStore(new Redis()); + */ +export class RedisStore { + #client; + #prefix; + #maxAge; + + /** + * @param {import("ioredis").Redis} client - ioredis client instance + * @param {Object} [options] + * @param {string} [options.prefix] - Key prefix (default "sess:") + * @param {number} [options.maxAge] - TTL in seconds (default from session config) + */ + constructor(client, options = {}) { + if (!client) throw new TypeError("RedisStore requires a Redis client"); + this.#client = client; + this.#prefix = options.prefix || "sess:"; + this.#maxAge = options.maxAge || 3600; + } + + _key(sessionId) { + return `${this.#prefix}${sessionId}`; + } + + async get(sessionId, key) { + const raw = await this.#client.hget(this._key(sessionId), key); + if (raw === null) return undefined; + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + + async set(sessionId, key, value) { + const k = this._key(sessionId); + await this.#client.hset(k, key, JSON.stringify(value)); + await this.#client.expire(k, this.#maxAge); + } + + async delete(sessionId, key) { + await this.#client.hdel(this._key(sessionId), key); + } + + async destroy(sessionId) { + await this.#client.del(this._key(sessionId)); + } + + async getAll(sessionId) { + const data = await this.#client.hgetall(this._key(sessionId)); + if (!data || Object.keys(data).length === 0) return null; + const result = Object.create(null); + for (const [key, raw] of Object.entries(data)) { + try { + result[key] = JSON.parse(raw); + } catch { + result[key] = raw; + } + } + return result; + } + + async setAll(sessionId, data) { + const k = this._key(sessionId); + const flat = []; + for (const [key, value] of Object.entries(data)) { + flat.push(key, JSON.stringify(value)); + } + if (flat.length > 0) { + await this.#client.hset(k, ...flat); + await this.#client.expire(k, this.#maxAge); + } + } +} + +// ─── Session Middleware ─────────────────── + +/** + * Create a session middleware. + * + * @param {Object} options + * @param {string} options.secret - HMAC signing secret (required) + * @param {number} [options.maxAge] - Session TTL in seconds (default 3600) + * @param {string} [options.cookieName] - Cookie name (default "sid") + * @param {boolean} [options.httpOnly] - HttpOnly flag (default true) + * @param {boolean} [options.secure] - Secure flag (default false) + * @param {string} [options.sameSite] - SameSite policy (default "lax") + * @param {string} [options.path] - Cookie path (default "/") + * @param {Object} [options.store] - Session store (default: MemoryStore) + * @returns {Function} Middleware function + */ +export function session(options = {}) { + if (!options.secret || typeof options.secret !== "string") { + throw new TypeError("session({ secret }) is required and must be a non-empty string"); + } + + const config = { ...SESSION_DEFAULTS, ...options }; + const store = config.store || new MemoryStore(); + const native = loadNativeModule(); + const isAsync = !(store instanceof MemoryStore); + + /** + * Session middleware. + */ + const sessionMiddleware = isAsync + ? async function sessionMiddlewareAsync(req, res, next) { + const sessionId = resolveSessionId(req, res, config, native); + attachSession(req, res, sessionId, store, config); + await next(); + await flushSession(req, res, config); + } + : function sessionMiddlewareSync(req, res, next) { + const sessionId = resolveSessionId(req, res, config, native); + attachSession(req, res, sessionId, store, config); + return next(); + }; + + // Attach config for manifest serialization + sessionMiddleware._sessionConfig = config; + return sessionMiddleware; +} + +/** + * Resolve the session ID from cookies or create a new one. + */ +function resolveSessionId(req, res, config, native) { + const cookieHeader = req.header?.("cookie") || req.headers?.cookie || ""; + const cookieName = config.cookieName; + + // Extract cookie value + let cookieValue = null; + for (const part of cookieHeader.split(";")) { + const trimmed = part.trim(); + if (trimmed.startsWith(`${cookieName}=`)) { + cookieValue = trimmed.slice(cookieName.length + 1).trim().replace(/^"|"$/g, ""); + break; + } + } + + if (cookieValue) { + // Verify the signed cookie via Rust + const verifiedId = native.sessionVerifyCookie(cookieValue); + if (verifiedId) { + res._sessionId = verifiedId; + res._sessionIsNew = false; + return verifiedId; + } + } + + // Generate new session via Rust + const newCookie = native.sessionNewCookie(); + if (newCookie) { + const dotIndex = newCookie.indexOf("."); + const newId = dotIndex >= 0 ? newCookie.slice(0, dotIndex) : newCookie; + res._sessionId = newId; + res._sessionIsNew = true; + res._sessionCookie = newCookie; + return newId; + } + + return null; +} + +/** + * Attach the session proxy to req.session. + */ +function attachSession(req, res, sessionId, store, config) { + let destroyed = false; + let dirty = false; + const pendingAsync = []; + + req.sessionId = sessionId; + + req.session = { + get(key) { + if (destroyed || !sessionId) return undefined; + return store.get(sessionId, key); + }, + + set(key, value) { + if (destroyed || !sessionId) return; + dirty = true; + const result = store.set(sessionId, key, value); + if (result && typeof result.then === "function") { + pendingAsync.push(result); + } + }, + + delete(key) { + if (destroyed || !sessionId) return; + dirty = true; + const result = store.delete(sessionId, key); + if (result && typeof result.then === "function") { + pendingAsync.push(result); + } + }, + + has(key) { + if (destroyed || !sessionId) return false; + const val = store.get(sessionId, key); + // Handle async stores + if (val && typeof val.then === "function") { + return val.then((v) => v !== undefined); + } + return val !== undefined; + }, + + destroy() { + if (!sessionId) return; + destroyed = true; + dirty = true; + const result = store.destroy(sessionId); + if (result && typeof result.then === "function") { + pendingAsync.push(result); + } + }, + + get isDestroyed() { + return destroyed; + }, + }; + + // Store references for flush + res._sessionState = { dirty: () => dirty, destroyed: () => destroyed, pendingAsync }; +} + +/** + * Flush async session operations and set cookies (for async stores). + */ +async function flushSession(req, res, config) { + const state = res._sessionState; + if (!state) return; + + // Wait for any pending async operations + if (state.pendingAsync.length > 0) { + await Promise.all(state.pendingAsync); + } + + // Set-Cookie injection is handled by Rust for MemoryStore. + // For async stores, we need to set it from JS. + if (res._sessionIsNew && state.dirty() && !state.destroyed()) { + const cookie = buildSetCookie(res._sessionCookie, config); + res.set("set-cookie", cookie); + } else if (state.destroyed()) { + const cookie = buildDestroyCookie(config); + res.set("set-cookie", cookie); + } +} + +function buildSetCookie(cookieValue, config) { + let cookie = `${config.cookieName}=${cookieValue}; Path=${config.path}; Max-Age=${config.maxAge}`; + if (config.httpOnly) cookie += "; HttpOnly"; + if (config.secure) cookie += "; Secure"; + cookie += `; SameSite=${capitalize(config.sameSite)}`; + return cookie; +} + +function buildDestroyCookie(config) { + let cookie = `${config.cookieName}=; Path=${config.path}; Max-Age=0`; + if (config.httpOnly) cookie += "; HttpOnly"; + if (config.secure) cookie += "; Secure"; + cookie += `; SameSite=${capitalize(config.sameSite)}`; + return cookie; +} + +function capitalize(s) { + return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); +} + +// ─── Session Trailer (for backward compat) ── + +/** + * Encode session write trailer. Returns null since session ops now go + * directly through NAPI — no trailer needed for MemoryStore. + * Kept for API compatibility. + */ +export function encodeSessionTrailer(sessionState) { + return null; +}