Skip to main content

netget/server/http2/
actions.rs

1//! HTTP/2 protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// HTTP/2 protocol action handler
15pub struct Http2Protocol;
16
17impl Default for Http2Protocol {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl Http2Protocol {
24    pub fn new() -> Self {
25        Self
26    }
27}
28
29// Implement Protocol trait (common functionality)
30impl Protocol for Http2Protocol {
31    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
32        let mut params = crate::server::tls_cert_manager::get_tls_startup_parameters();
33        params.extend(crate::server::http_common::handler::request_handling_startup_parameters());
34        params.push(crate::llm::actions::ParameterDefinition {
35            name: "first_byte_timeout_secs".to_string(),
36            type_hint: "number".to_string(),
37            description: "Seconds a connected peer may send nothing at all (no TLS ClientHello, \
38                          no HTTP/2 preface) before the server closes it. Default 30: HTTP/2 is \
39                          client-speaks-first, and NetGet's own HTTP/2 client opens no socket \
40                          until it has a request to send."
41                .to_string(),
42            required: false,
43            example: json!(30),
44            default: Some(serde_json::json!(
45                super::h2_server::FIRST_BYTE_READ_TIMEOUT.as_secs()
46            )),
47        });
48        params.push(crate::llm::actions::ParameterDefinition {
49            name: "idle_timeout_secs".to_string(),
50            type_hint: "number".to_string(),
51            description: "Seconds an established connection may carry no request before the \
52                          server sends GOAWAY and closes it. Default 300, above the 90 seconds \
53                          reqwest keeps an idle pooled connection. A request still being \
54                          answered never counts as idle."
55                .to_string(),
56            required: false,
57            example: json!(300),
58            default: Some(serde_json::json!(
59                super::h2_server::IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
60            )),
61        });
62        params
63    }
64    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
65        // HTTP/2 has no async actions - it's purely request-response
66        Vec::new()
67    }
68    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
69        vec![send_http2_response_action(), push_resource_action()]
70    }
71    fn protocol_name(&self) -> &'static str {
72        "HTTP2"
73    }
74    fn get_event_types(&self) -> Vec<EventType> {
75        get_http2_event_types()
76    }
77    fn stack_name(&self) -> &'static str {
78        "ETH>IP>TCP>HTTP/2"
79    }
80    fn keywords(&self) -> Vec<&'static str> {
81        vec![
82            "http2",
83            "http/2",
84            "http 2",
85            "http2 server",
86            "http/2 server",
87            "via http2",
88            "via http/2",
89        ]
90    }
91    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
92        use crate::protocol::metadata::{
93            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
94        };
95
96        ProtocolMetadataV2::builder()
97            .state(DevelopmentState::Experimental)
98            // HTTP/2 normally runs on 443 (TLS); h2c is often 80. The preflight
99            // check only fires when the requested port is actually < 1024.
100            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(443))
101            .well_known_port(443)
102            .implementation("h2 crate directly (server push), optional TLS via rustls")
103            .llm_control("Response content (status, headers, text body) + server push")
104            .e2e_testing("h2/reqwest + mocked LLM, tests/server/http2/e2e_test.rs (3 scenarios)")
105            .notes(
106                "Text bodies only, no streaming; ALPN is not negotiated, so a browser will not \
107                 pick HTTP/2 over TLS on its own - clients must select h2 explicitly or use h2c",
108            )
109            .max_inbound_bytes(crate::server::http_common::MAX_REQUEST_BODY_BYTES)
110            .request_only("HTTP/2 only answers requests; every response and push rides a stream the peer opened")
111            .build()
112    }
113    fn description(&self) -> &'static str {
114        "Web server serving HTTP/2 traffic with multiplexing and header compression"
115    }
116    fn example_prompt(&self) -> &'static str {
117        "HTTP/2 server on port 8443 serving JSON API with fast multiplexed responses"
118    }
119    fn group_name(&self) -> &'static str {
120        "Core"
121    }
122
123    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
124        use crate::llm::actions::StartupExamples;
125
126        // Deterministic: answer every HTTP/2 request with a fixed JSON body, no
127        // LLM call.
128        let script = r#"import json, sys
129data = json.load(sys.stdin)
130event = data["event"]
131if data["event_type_id"] == "http2_request":
132    actions = [{"type": "send_http2_response", "status": 200,
133                "headers": {"Content-Type": "application/json"},
134                "body": '{"message": "Hello from HTTP/2!"}'}]
135else:
136    actions = []
137print(json.dumps({"actions": actions}))"#;
138
139        StartupExamples::new(
140            json!({
141                "type": "open_server",
142                "port": 8443,
143                "base_stack": "http2",
144                "instruction": "HTTP/2 server with multiplexing and fast responses"
145            }),
146            json!({
147                "type": "open_server",
148                "port": 8443,
149                "base_stack": "http2",
150                "event_handlers": [{
151                    "event_pattern": "http2_request",
152                    "handler": {
153                        "type": "script",
154                        "language": "python",
155                        "code": script
156                    }
157                }]
158            }),
159            json!({
160                "type": "open_server",
161                "port": 8443,
162                "base_stack": "http2",
163                "event_handlers": [{
164                    "event_pattern": "http2_request",
165                    "handler": {
166                        "type": "static",
167                        "actions": [{
168                            "type": "send_http2_response",
169                            "status": 200,
170                            "headers": {
171                                "Content-Type": "application/json"
172                            },
173                            "body": "{\"message\": \"Hello from HTTP/2!\"}"
174                        }]
175                    }
176                }]
177            }),
178        )
179    }
180}
181
182// Implement Server trait (server-specific functionality)
183impl Server for Http2Protocol {
184    fn spawn(
185        &self,
186        ctx: crate::protocol::SpawnContext,
187    ) -> std::pin::Pin<
188        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
189    > {
190        Box::pin(async move {
191            use crate::server::http2::H2Server;
192
193            // Parse TLS configuration from startup_params
194            let tls_config = if let Some(ref params) = ctx.startup_params {
195                match crate::server::tls_cert_manager::extract_tls_config_from_params(params) {
196                    Ok(config) => config,
197                    Err(e) => {
198                        return Err(anyhow::anyhow!("Failed to create TLS config: {}", e));
199                    }
200                }
201            } else {
202                None
203            };
204
205            // Both bounds are the operator's to tune; the defaults are argued beside
206            // FIRST_BYTE_READ_TIMEOUT and IDLE_BETWEEN_REQUESTS_TIMEOUT in h2_server.rs.
207            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
208                Ok(ctx
209                    .startup_params
210                    .as_ref()
211                    .map(|p| p.get_optional_u64(name))
212                    .transpose()?
213                    .flatten())
214            };
215            let bounds = crate::server::http2::h2_server::H2Bounds::from_secs(
216                secs("first_byte_timeout_secs")?,
217                secs("idle_timeout_secs")?,
218            );
219
220            // Use h2-based server for full server push support
221            H2Server::spawn_with_push_support(
222                ctx.legacy_listen_addr(),
223                ctx.llm_client,
224                ctx.state,
225                ctx.status_tx,
226                ctx.server_id,
227                tls_config,
228                bounds,
229            )
230            .await
231        })
232    }
233    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
234        let action_type = action
235            .get("type")
236            .and_then(|v| v.as_str())
237            .context("Missing 'type' field in action")?;
238
239        match action_type {
240            "send_http2_response" => self.execute_send_http2_response(action),
241            "push_resource" => self.execute_push_resource(action),
242            _ => Err(anyhow::anyhow!("Unknown HTTP/2 action: {action_type}")),
243        }
244    }
245}
246
247impl Http2Protocol {
248    /// Execute send_http2_response sync action
249    fn execute_send_http2_response(&self, action: serde_json::Value) -> Result<ActionResult> {
250        // Use shared action execution logic
251        crate::server::http_common::execute_http_response_action(action)
252    }
253
254    /// Execute push_resource sync action (server push)
255    fn execute_push_resource(&self, action: serde_json::Value) -> Result<ActionResult> {
256        use anyhow::Context;
257        use serde_json::json;
258
259        let path = action
260            .get("path")
261            .and_then(|v| v.as_str())
262            .context("Missing 'path' parameter")?;
263
264        // Range-checked rather than cast: `65736 as u16` is 200, so an out-of-range value
265        // would silently become a plausible status instead of being reported.
266        let status = match action.get("status") {
267            None | Some(serde_json::Value::Null) => 200u16,
268            Some(v) => v
269                .as_u64()
270                .filter(|s| (100..=599).contains(s))
271                .with_context(|| {
272                    format!(
273                        "Invalid 'status' parameter {}: expected an HTTP status code between \
274                         100 and 599",
275                        v
276                    )
277                })? as u16,
278        };
279
280        let headers = action
281            .get("headers")
282            .and_then(|v| v.as_object())
283            .cloned()
284            .unwrap_or_default();
285
286        let body = action.get("body").and_then(|v| v.as_str()).unwrap_or("");
287
288        let method = action
289            .get("method")
290            .and_then(|v| v.as_str())
291            .unwrap_or("GET");
292
293        // Return push data as structured JSON that h2_server will recognize
294        let push_data = json!({
295            "_push_directive": true,
296            "path": path,
297            "method": method,
298            "status": status,
299            "headers": headers,
300            "body": body
301        });
302
303        tracing::debug!("Queued server push for {}", path);
304
305        Ok(ActionResult::Output(
306            serde_json::to_vec(&push_data).context("Failed to serialize push data")?,
307        ))
308    }
309}
310
311/// Action definition for send_http2_response (sync)
312fn send_http2_response_action() -> ActionDefinition {
313    ActionDefinition {
314        name: "send_http2_response".to_string(),
315        description: "Respond to the HTTP/2 request that triggered this event. Emit it exactly \
316            once per request: the response is sent complete, in one piece. There is no way to \
317            stream or chunk it, and the body is sent as UTF-8 text, so binary payloads cannot be \
318            produced. ALWAYS emit exactly one send_http2_response for every request — if you \
319            emit none, the server has no answer to send and refuses the stream with 500 (or the \
320            server's configured default_response, if it has one). push_resource does not count: \
321            a PUSH_PROMISE is an extra resource offered alongside an answer, not the answer."
322            .to_string(),
323        parameters: vec![
324            Parameter {
325                name: "status".to_string(),
326                type_hint: "number".to_string(),
327                description:
328                    "HTTP status code as a number between 100 and 599 (e.g. 200, 404, 500)."
329                        .to_string(),
330                required: true,
331            },
332            Parameter {
333                name: "headers".to_string(),
334                type_hint: "object".to_string(),
335                description: "Optional response headers as a flat name->value object. Do not set \
336                    HTTP/2 pseudo-headers (:status, :path, ...) or content-length; they are \
337                    handled by the server. Illegal header names/values are dropped."
338                    .to_string(),
339                required: false,
340            },
341            Parameter {
342                name: "body".to_string(),
343                type_hint: "string".to_string(),
344                description: "Response body as text. Optional: omit for an empty body (204/304). \
345                    A JSON object or array is serialized to compact JSON text. Text only - bytes \
346                    cannot be sent."
347                    .to_string(),
348                required: false,
349            },
350        ],
351        example: json!({
352            "type": "send_http2_response",
353            "status": 200,
354            "headers": {
355                "Content-Type": "application/json"
356            },
357            "body": "{\"message\": \"Hello from HTTP/2!\"}"
358        }),
359        log_template: Some(
360            LogTemplate::new()
361                .with_info("-> HTTP/2 {status}")
362                .with_debug("HTTP/2 send_http2_response: status={status}"),
363        ),
364    }
365}
366
367/// Action definition for push_resource (sync) - HTTP/2 server push
368fn push_resource_action() -> ActionDefinition {
369    ActionDefinition {
370        name: "push_resource".to_string(),
371        description: "Push a resource to the client proactively (HTTP/2 server push). Emit it \
372            alongside send_http2_response in the same batch; pushes are sent before the main \
373            response. Clients may refuse pushes (most modern browsers have disabled them), in \
374            which case the push is dropped and only the main response is delivered. Text bodies \
375            only."
376            .to_string(),
377        parameters: vec![
378            Parameter {
379                name: "path".to_string(),
380                type_hint: "string".to_string(),
381                description: "Resource path to push (e.g., /style.css)".to_string(),
382                required: true,
383            },
384            // Read by the executor and forwarded in the push directive, but declared nowhere,
385            // so every push was a GET whatever the model intended.
386            Parameter {
387                name: "method".to_string(),
388                type_hint: "string".to_string(),
389                description: "Method for the pushed request (default: GET). RFC 7540 §8.2 \
390                    allows only safe, cacheable methods - GET or HEAD."
391                    .to_string(),
392                required: false,
393            },
394            Parameter {
395                name: "status".to_string(),
396                type_hint: "number".to_string(),
397                description: "HTTP status code (default: 200)".to_string(),
398                required: false,
399            },
400            Parameter {
401                name: "headers".to_string(),
402                type_hint: "object".to_string(),
403                description: "Response headers as key-value pairs".to_string(),
404                required: false,
405            },
406            Parameter {
407                name: "body".to_string(),
408                type_hint: "string".to_string(),
409                description: "Resource content to push".to_string(),
410                required: false,
411            },
412        ],
413        example: json!({
414            "type": "push_resource",
415            "path": "/style.css",
416            "status": 200,
417            "headers": {
418                "Content-Type": "text/css"
419            },
420            "body": "body { margin: 0; }"
421        }),
422        log_template: Some(
423            LogTemplate::new()
424                .with_info("-> HTTP/2 PUSH {path}")
425                .with_debug("HTTP/2 push_resource: path={path}, status={status}"),
426        ),
427    }
428}
429
430// ============================================================================
431// HTTP/2 Action Constants
432// ============================================================================
433
434pub static SEND_HTTP2_RESPONSE_ACTION: LazyLock<ActionDefinition> =
435    LazyLock::new(send_http2_response_action);
436pub static PUSH_RESOURCE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(push_resource_action);
437
438// ============================================================================
439// HTTP/2 Event Type Constants
440// ============================================================================
441
442/// HTTP/2 request event - triggered when client sends an HTTP/2 request
443pub static HTTP2_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
444    EventType::new(
445        "http2_request",
446        "HTTP/2 request received from client",
447        json!({"type": "placeholder", "event_id": "http2_request"}),
448    )
449    .with_parameters(vec![
450        Parameter {
451            name: "method".to_string(),
452            type_hint: "string".to_string(),
453            description: "HTTP method (GET, POST, etc.)".to_string(),
454            required: true,
455        },
456        Parameter {
457            name: "uri".to_string(),
458            type_hint: "string".to_string(),
459            description: "Request URI".to_string(),
460            required: true,
461        },
462        Parameter {
463            name: "version".to_string(),
464            type_hint: "string".to_string(),
465            description: "HTTP version (HTTP/2.0)".to_string(),
466            required: true,
467        },
468        Parameter {
469            name: "headers".to_string(),
470            type_hint: "object".to_string(),
471            description: "Request headers as key-value pairs".to_string(),
472            required: true,
473        },
474        Parameter {
475            name: "body".to_string(),
476            type_hint: "string".to_string(),
477            description:
478                "Request body decoded as UTF-8 text (empty string when there is no body). \
479                Bytes that are not valid UTF-8 are replaced with U+FFFD, so when body_is_binary \
480                is true this field is lossy and must not be treated as the exact request payload."
481                    .to_string(),
482            required: false,
483        },
484        Parameter {
485            name: "body_bytes".to_string(),
486            type_hint: "number".to_string(),
487            description: "Size of the request body in bytes, before UTF-8 decoding.".to_string(),
488            required: false,
489        },
490        Parameter {
491            name: "body_is_binary".to_string(),
492            type_hint: "boolean".to_string(),
493            description: "Present and true only when the request body is not valid UTF-8. The \
494                body field is then a lossy decoding; the raw bytes are not available to you."
495                .to_string(),
496            required: false,
497        },
498    ])
499    .with_log_template(
500        LogTemplate::new()
501            .with_info("{method} {uri}")
502            .with_debug("HTTP/2 {method} {uri} v{version}")
503            .with_trace("HTTP/2: {json_pretty(.)}"),
504    )
505    .with_actions(vec![
506        SEND_HTTP2_RESPONSE_ACTION.clone(),
507        PUSH_RESOURCE_ACTION.clone(),
508    ])
509});
510
511/// Get HTTP/2 event types
512pub fn get_http2_event_types() -> Vec<EventType> {
513    vec![HTTP2_REQUEST_EVENT.clone()]
514}