netget/server/npm/mod.rs
1//! NPM registry server implementation
2//!
3//! NPM registry runs over HTTP. The LLM controls package metadata, tarballs,
4//! listings, and search results.
5
6pub mod actions;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use base64::Engine;
13use bytes::Bytes;
14use http_body_util::Full;
15use hyper::body::Incoming;
16use hyper::server::conn::http1;
17use hyper::service::service_fn;
18use hyper::{Method, Request, Response, StatusCode};
19use hyper_util::rt::TokioIo;
20use serde_json::json;
21use tokio::sync::mpsc;
22use tracing::{error, trace};
23
24use crate::llm::action_helper::call_llm;
25use crate::llm::ollama_client::OllamaClient;
26use crate::logging::emit::Log;
27use crate::protocol::EventType;
28use crate::server::accept_bounded::{
29 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
30};
31use crate::server::connection::ConnectionId;
32use crate::server::npm::actions::NpmProtocol;
33use crate::state::app_state::AppState;
34
35/// How long a peer that has produced nothing at all may hold this connection.
36///
37/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
38/// registry client sends it inside its dial path, so a peer that has connected and sent no byte
39/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
40/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
41///
42/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
43/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
44/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
45/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
46/// without consuming it, so the request line is still there for hyper afterwards.
47const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
48
49/// How long an established connection may sit **silent** between requests.
50///
51/// This bounds the silence and never a transfer. A request being answered holds
52/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
53/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
54/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
55/// ever runs on a connection with nothing in flight.
56///
57/// Five minutes, because that is npm's own `fetch-timeout` default (300000 ms, confirmed with
58/// `npm config get fetch-timeout`): the client itself gives up on a server that has said nothing
59/// for this long, so closing a connection that has been silent for as long can never be the side
60/// that breaks a working `npm install`. Closing an idle persistent connection is ordinary HTTP -
61/// RFC 9112 requires a client to cope with it, which is why nginx ships `keepalive_timeout 75s`,
62/// a quarter of this. The tarball fetch itself is unaffected: `npm install` downloads, then
63/// unpacks locally, and only the gap between two requests is measured here.
64const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
65
66/// Concurrent connections this server admits.
67///
68/// Each admitted connection may hold one whole in-memory response body - a packument or a
69/// tarball - so the cap is what turns that per-connection bound into a total one. 256 is an
70/// order of magnitude above the most parallel client this server faces (npm's own `maxsockets`
71/// default is 15) and far below what socket exhaustion needs.
72const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
73
74/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
75///
76/// `503 Service Unavailable` with `Retry-After`, in NPM's own vocabulary:
77/// npm retries a 503 with backoff (`fetch-retries`, 2 by default)
78/// rather than reporting a hard failure, and a human with `curl` reads it directly.
79/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
80/// reason, under `decision=fail_closed_connection_cap`.
81const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
82 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
83
84/// NPM registry server that delegates to LLM
85pub struct NpmServer;
86
87impl NpmServer {
88 /// Spawn the NPM registry server with integrated LLM actions
89 pub async fn spawn_with_llm_actions(
90 listen_addr: SocketAddr,
91 llm_client: OllamaClient,
92 app_state: Arc<AppState>,
93 status_tx: mpsc::UnboundedSender<String>,
94 server_id: crate::state::ServerId,
95 ) -> anyhow::Result<SocketAddr> {
96 let listener =
97 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
98 let local_addr = listener.local_addr()?;
99 Log::new(Some(&status_tx)).info(format!("NPM registry server listening on {}", local_addr));
100
101 let protocol = Arc::new(NpmProtocol::new());
102
103 // Spawn server loop
104 let task_registrar = app_state.clone();
105 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
106 let accept_handle = tokio::spawn(async move {
107 loop {
108 match accept_bounded(
109 &listener,
110 &limiter,
111 CONNECTION_CAP_REFUSAL,
112 "NPM",
113 Some(&status_tx),
114 )
115 .await
116 {
117 Ok((stream, remote_addr, permit)) => {
118 let connection_id =
119 ConnectionId::new(app_state.get_next_unified_id().await);
120 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
121 Log::new(Some(&status_tx)).info(format!(
122 "NPM connection {} from {}",
123 connection_id, remote_addr
124 ));
125
126 // Add connection to ServerInstance
127 use crate::state::server::{
128 ConnectionState as ServerConnectionState, ConnectionStatus,
129 ProtocolConnectionInfo,
130 };
131 let now = crate::utils::clock::Instant::now();
132 let conn_state = ServerConnectionState {
133 id: connection_id,
134 remote_addr,
135 local_addr: local_addr_conn,
136 bytes_sent: 0,
137 bytes_received: 0,
138 packets_sent: 0,
139 packets_received: 0,
140 last_activity: now,
141 status: ConnectionStatus::Active,
142 status_changed_at: now,
143 protocol_info: ProtocolConnectionInfo::empty(),
144 };
145 app_state
146 .add_connection_to_server(server_id, conn_state)
147 .await;
148 let _ = status_tx.send("__UPDATE_UI__".to_string());
149
150 let llm_client_clone = llm_client.clone();
151 let app_state_clone = app_state.clone();
152 let status_tx_clone = status_tx.clone();
153 let protocol_clone = protocol.clone();
154
155 // Spawn a task to handle this connection
156 // Tracked, not detached: stop_server must abort this task too.
157 let task_owner = app_state.clone();
158 task_owner
159 .spawn_server_task(server_id, async move {
160 // Held for the life of the connection, so the
161 // cap counts live clients, not accepts.
162 let _permit = permit;
163
164 // The first-byte bound, before hyper sees the
165 // socket. `peek` waits for data without
166 // consuming it, so the request line is still
167 // there for hyper afterwards, and it bounds
168 // exactly the case that needs bounding: a peer
169 // that connected and said nothing at all.
170 let spoke = matches!(
171 tokio::time::timeout(
172 FIRST_BYTE_READ_TIMEOUT,
173 stream.peek(&mut [0u8; 1]),
174 )
175 .await,
176 Ok(Ok(n)) if n > 0
177 );
178
179 if spoke {
180 let io = TokioIo::new(stream);
181
182 // Clone for service closure
183 let status_for_service = status_tx_clone.clone();
184 let app_state_for_service = app_state_clone.clone();
185
186 // Whether this connection is answering
187 // anything. The watchdog below reads it, so
188 // only genuine silence - never work in
189 // flight - can close a connection.
190 let activity = Arc::new(ConnectionActivity::new());
191 let activity_for_service = Arc::clone(&activity);
192 // Create a service that handles NPM registry requests with LLM
193 let service = service_fn(move |req: Request<Incoming>| {
194 let llm_clone = llm_client_clone.clone();
195 let state_clone = app_state_for_service.clone();
196 let status_clone = status_for_service.clone();
197 let protocol_clone = protocol_clone.clone();
198 // Busy for the whole of this request - the
199 // model round-trip included, and a `manual`
200 // rule parked for a human - so the idle
201 // watchdog can never close the connection an
202 // answer belongs to.
203 let activity = Arc::clone(&activity_for_service);
204 async move {
205 let _busy = activity.busy();
206 handle_npm_request(
207 req,
208 connection_id,
209 llm_clone,
210 state_clone,
211 status_clone,
212 protocol_clone,
213 server_id,
214 )
215 .await
216 }
217 });
218
219 // Serve HTTP/1 on this connection
220 let conn = http1::Builder::new().serve_connection(io, service);
221 tokio::pin!(conn);
222 tokio::select! {
223 result = &mut conn => {
224 if let Err(err) = result {
225 error!("Error serving NPM connection: {:?}", err);
226 }
227 }
228 // The idle bound, over `ConnectionActivity`
229 // rather than over a read: hyper owns every
230 // read once `serve_connection` starts, and
231 // keeps polling for frames while a request is
232 // being answered, so a deadline on those reads
233 // would fire mid-answer. A connection with work
234 // in flight is not idle at all.
235 _ = watch_idle(
236 Arc::clone(&activity),
237 IDLE_BETWEEN_REQUESTS_TIMEOUT,
238 ) => {
239 Log::new(Some(&status_tx_clone)).debug(format!(
240 "NPM connection {} idle for {}s; closing",
241 connection_id,
242 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
243 ));
244 }
245 }
246 } else {
247 Log::new(Some(&status_tx_clone)).debug(format!(
248 "NPM peer {} sent no request within {}s; closing \
249 decision=fail_closed_first_byte_timeout",
250 remote_addr,
251 FIRST_BYTE_READ_TIMEOUT.as_secs()
252 ));
253 }
254
255 // Mark connection as closed
256 app_state_clone
257 .close_connection_on_server(server_id, connection_id)
258 .await;
259 Log::new(Some(&status_tx_clone))
260 .info(format!("NPM connection {} closed", connection_id));
261 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
262 })
263 .await;
264 }
265 Err(e) => {
266 Log::new(Some(&status_tx))
267 .error(format!("Failed to accept NPM connection: {}", e));
268 break;
269 }
270 }
271 }
272 });
273
274 // Register the accept loop so stop_server can abort it and release the port.
275 task_registrar
276 .register_server_task(server_id, accept_handle)
277 .await;
278
279 Ok(local_addr)
280 }
281}
282
283/// Approximate the bytes this request cost on the wire.
284///
285/// hyper hands us a parsed `Request`, so the original head is gone; this
286/// reconstructs its size from the parts that survive. It is an estimate, and
287/// deliberately so — the alternative is a `↓` counter frozen at 0, which reads as
288/// "this peer sent nothing" rather than "we did not measure".
289fn approximate_request_bytes(req: &Request<Incoming>) -> u64 {
290 use hyper::body::Body;
291 let head: usize = req.method().as_str().len()
292 + req.uri().to_string().len()
293 + 12 // " HTTP/1.1\r\n" plus the blank line terminating the head
294 + req
295 .headers()
296 .iter()
297 .map(|(name, value)| name.as_str().len() + value.len() + 4)
298 .sum::<usize>();
299 head as u64 + req.body().size_hint().lower()
300}
301
302/// Record one request/response exchange against the connection's counters, then
303/// return the response unchanged.
304///
305/// `update_connection_stats` is what the dashboard rail's `↓ ↑` columns and the
306/// connection-scoped task prompts read, and it is what keeps `last_activity`
307/// moving. Without it a busy NPM server draws every peer as idle with zero
308/// bytes in both directions — which is exactly what the rail is for.
309#[allow(clippy::too_many_arguments)]
310async fn handle_npm_request(
311 req: Request<Incoming>,
312 connection_id: ConnectionId,
313 llm_client: OllamaClient,
314 app_state: Arc<AppState>,
315 status_tx: mpsc::UnboundedSender<String>,
316 protocol: Arc<NpmProtocol>,
317 server_id: crate::state::ServerId,
318) -> Result<Response<Full<Bytes>>, Infallible> {
319 let bytes_received = approximate_request_bytes(&req);
320 let response = handle_npm_request_inner(
321 req,
322 connection_id,
323 llm_client,
324 app_state.clone(),
325 status_tx,
326 protocol,
327 server_id,
328 )
329 .await;
330
331 let bytes_sent = response
332 .as_ref()
333 .ok()
334 .and_then(|resp| {
335 use hyper::body::Body;
336 resp.body().size_hint().exact()
337 })
338 .unwrap_or(0);
339 app_state
340 .update_connection_stats(
341 server_id,
342 connection_id,
343 Some(bytes_received),
344 Some(bytes_sent),
345 Some(1),
346 Some(1),
347 )
348 .await;
349
350 response
351}
352
353/// Handle a single NPM registry request
354async fn handle_npm_request_inner(
355 req: Request<Incoming>,
356 _connection_id: ConnectionId,
357 llm_client: OllamaClient,
358 app_state: Arc<AppState>,
359 status_tx: mpsc::UnboundedSender<String>,
360 protocol: Arc<NpmProtocol>,
361 server_id: crate::state::ServerId,
362) -> Result<Response<Full<Bytes>>, Infallible> {
363 let method = req.method().clone();
364 let uri = req.uri().clone();
365 let path = uri.path();
366 let query = uri.query().unwrap_or("");
367
368 Log::new(Some(&status_tx)).debug(format!("NPM request: {} {}", method, path));
369
370 // Only handle GET requests
371 if method != Method::GET {
372 // Refused by this server before any model call: no event is raised, so this is
373 // neither the model's answer nor a backend failure and carries its own token.
374 Log::new(Some(&status_tx)).warn(format!(
375 "NPM {} {} decision=protocol_error (only GET is served)",
376 method, path
377 ));
378 let response = json!({
379 "error": "Method not allowed"
380 });
381 return Ok(Response::builder()
382 .status(StatusCode::METHOD_NOT_ALLOWED)
383 .header("Content-Type", "application/json")
384 .body(Full::new(Bytes::from(response.to_string())))
385 .unwrap());
386 }
387
388 // Route the request
389 let (event_type, description) = if path == "/-/all" {
390 ("NPM_LIST_REQUEST", "NPM package list request".to_string())
391 } else if path.starts_with("/-/v1/search") {
392 (
393 "NPM_SEARCH_REQUEST",
394 format!("NPM package search: {}", query),
395 )
396 } else if path.contains("/-/") {
397 // Tarball request: /{package}/-/{tarball}.tgz
398 let parts: Vec<&str> = path.split("/-/").collect();
399 let package_name = parts.get(0).unwrap_or(&"").trim_start_matches('/');
400 let tarball_name = parts.get(1).unwrap_or(&"");
401 (
402 "NPM_TARBALL_REQUEST",
403 format!(
404 "NPM tarball request: package={}, tarball={}",
405 package_name, tarball_name
406 ),
407 )
408 } else {
409 // Package metadata request: /{package}
410 let package_name = path.trim_start_matches('/');
411 (
412 "NPM_PACKAGE_REQUEST",
413 format!("NPM package metadata request: {}", package_name),
414 )
415 };
416
417 trace!("NPM event: {}: {}", event_type, &description);
418
419 // Verify server exists
420 if app_state.get_instruction(server_id).await.is_none() {
421 // Nothing to ask: the server row is gone, so the request is failed rather than
422 // answered from a default. `fail_closed_server_missing` is npm-specific because it
423 // is not a model outcome at all — it is netget's own state having disappeared.
424 Log::new(Some(&status_tx)).error(format!(
425 "NPM {} {} decision=fail_closed_server_missing (server {} not found)",
426 method, path, server_id
427 ));
428 let response = json!({
429 "error": "Server not found"
430 });
431 return Ok(Response::builder()
432 .status(StatusCode::INTERNAL_SERVER_ERROR)
433 .header("Content-Type", "application/json")
434 .body(Full::new(Bytes::from(response.to_string())))
435 .unwrap());
436 }
437
438 // Build NPM event - use the static event type references
439 let event_type_static: &'static EventType = match &event_type[..] {
440 "NPM_PACKAGE_REQUEST" => &actions::NPM_PACKAGE_REQUEST,
441 "NPM_TARBALL_REQUEST" => &actions::NPM_TARBALL_REQUEST,
442 "NPM_LIST_REQUEST" => &actions::NPM_LIST_REQUEST,
443 "NPM_SEARCH_REQUEST" => &actions::NPM_SEARCH_REQUEST,
444 _ => {
445 error!(
446 "Unknown NPM event type: {} decision=protocol_error",
447 event_type
448 );
449 let error_response = json!({
450 "error": format!("Internal error: unknown event type '{}'", event_type)
451 });
452 return Ok(Response::builder()
453 .status(StatusCode::INTERNAL_SERVER_ERROR)
454 .header("Content-Type", "application/json")
455 .body(Full::new(Bytes::from(error_response.to_string())))
456 .unwrap());
457 }
458 };
459
460 let event = crate::protocol::Event::new(
461 event_type_static,
462 json!({
463 "method": method.as_str(),
464 "path": path,
465 "query": query,
466 "description": description,
467 }),
468 );
469
470 Log::new(Some(&status_tx)).debug(format!("Calling LLM for NPM request: {} {}", method, path));
471
472 // Call LLM
473 let llm_result = call_llm(
474 &llm_client,
475 &app_state,
476 server_id,
477 None,
478 &event,
479 protocol.as_ref(),
480 )
481 .await;
482
483 // Process LLM result
484 match llm_result {
485 Ok(execution_result) => {
486 // Scan for the first action that is actually an NPM response. This was a
487 // `for` loop with an unconditional `return` inside it, so it examined only
488 // the first result and returned a 500 if that happened to be something
489 // like `show_message`. It also tripped clippy's `never_loop`.
490 let failures = execution_result.failures.len();
491 for result in execution_result.protocol_results {
492 if let Some(response) = process_npm_action_result(result, &status_tx).await {
493 // `npm_error` is the model deliberately refusing this package — a real
494 // answer, and a different thing from netget having failed to get one.
495 // Anything 2xx/3xx is the model having answered.
496 let decision = if response.status().is_client_error()
497 || response.status().is_server_error()
498 {
499 "model_reject"
500 } else {
501 "model_answer"
502 };
503 Log::new(Some(&status_tx)).info(format!(
504 "NPM {} {} -> {} decision={}",
505 method,
506 path,
507 response.status().as_u16(),
508 decision
509 ));
510 return Ok(response);
511 }
512 }
513
514 // Nothing usable came back. npm acts on a 200 — it unpacks whatever body it is
515 // given — so a synthesised or empty packument here would be worse than an error:
516 // this path must never produce one. The model answering with nothing and the
517 // model answering with something the executor refused end in the same 500, so
518 // the token is the only place they differ.
519 let msg = format!(
520 "NPM {} {} -> 500 decision={} ({} failed action(s))",
521 method,
522 path,
523 if failures == 0 {
524 "model_silent"
525 } else {
526 "fail_closed_bad_action"
527 },
528 failures
529 );
530 if failures == 0 {
531 Log::new(Some(&status_tx)).warn(msg);
532 } else {
533 Log::new(Some(&status_tx)).error(msg);
534 }
535
536 // No NPM actions found, return error
537 let error_response = json!({
538 "error": "No NPM action returned"
539 });
540 Ok(Response::builder()
541 .status(StatusCode::INTERNAL_SERVER_ERROR)
542 .header("Content-Type", "application/json")
543 .body(Full::new(Bytes::from(error_response.to_string())))
544 .unwrap())
545 }
546 Err(e) => {
547 // Non-fatal: a wire fallback (JSON error response) is still delivered and the
548 // HTTP connection continues.
549 // A 500 and never a 200: npm treats a 200 as a real packument or tarball and
550 // acts on it, so a backend outage must not be able to synthesise one. The peer
551 // gets the category; the error goes only to the log, next to the token.
552 let failure = crate::utils::WireFailure::classify(&e);
553 Log::new(Some(&status_tx)).error(format!(
554 "NPM {} {} -> 500 decision={}: {}",
555 method,
556 path,
557 if failure.is_overloaded() {
558 "fail_closed_llm_overloaded"
559 } else {
560 "fail_closed_llm_error"
561 },
562 e
563 ));
564 let error_response = json!({
565 "error": failure.text()
566 });
567 Ok(Response::builder()
568 .status(StatusCode::INTERNAL_SERVER_ERROR)
569 .header("Content-Type", "application/json")
570 .body(Full::new(Bytes::from(error_response.to_string())))
571 .unwrap())
572 }
573 }
574}
575
576/// A 500 for an answer the model gave that this server cannot turn into a response.
577///
578/// Every branch below used to `.unwrap()` on the field it needed. A missing field is
579/// the model's mistake, and `.unwrap()` inside a per-connection tokio task is the
580/// worst possible response to it: the panic is swallowed, the server keeps reporting
581/// `Running`, and the client hangs until its own timeout with nothing in the log
582/// connecting the two. An explicit 500 tells the client *and* the operator.
583///
584/// `reason` names action fields and this server's own expectations of them, so it goes
585/// to the operator only — the peer gets a category. See `crate::utils::wire_failure`.
586fn npm_server_error(
587 status_tx: &mpsc::UnboundedSender<String>,
588 reason: &str,
589) -> Response<Full<Bytes>> {
590 Log::new(Some(status_tx)).error(format!(
591 "NPM -> 500 decision=fail_closed_bad_action: could not build a response — {}",
592 reason
593 ));
594 Response::builder()
595 .status(StatusCode::INTERNAL_SERVER_ERROR)
596 .header("Content-Type", "application/json")
597 .body(Full::new(Bytes::from(
598 json!({ "error": crate::utils::WireFailure::Unavailable.prefixed_text() }).to_string(),
599 )))
600 .unwrap()
601}
602
603/// Process LLM action result and build HTTP response
604/// Build an NPM response from one action result.
605///
606/// Returns `None` when the action is not an NPM response action, so the caller can
607/// keep scanning. It used to return a 500 for anything it did not recognise, and the
608/// caller returned unconditionally on the first result — so a model emitting the
609/// documented `show_message` + NPM-response pair got a 500 and lost the real answer.
610async fn process_npm_action_result(
611 action_result: crate::llm::ActionResult,
612 status_tx: &mpsc::UnboundedSender<String>,
613) -> Option<Response<Full<Bytes>>> {
614 use crate::llm::ActionResult;
615
616 match action_result {
617 ActionResult::Custom { name, data } => {
618 match name.as_str() {
619 "npm_package_metadata" => {
620 // `.unwrap()` here used to panic the connection task, which
621 // tokio::spawn swallows: the server kept reporting Running while
622 // the client hung forever. A missing field is the model's mistake,
623 // not ours — answer it.
624 let Some(metadata) = data.get("metadata") else {
625 return Some(npm_server_error(
626 status_tx,
627 "npm_package_metadata carried no 'metadata' field",
628 ));
629 };
630
631 // FileOnly: the npm_package_metadata action's own log_template already
632 // reports "-> NPM package metadata" to the TUI at INFO.
633 Log::new(Some(status_tx)).debug("NPM package metadata response");
634 Some(
635 Response::builder()
636 .status(StatusCode::OK)
637 .header("Content-Type", "application/json")
638 .body(Full::new(Bytes::from(metadata.to_string())))
639 .unwrap(),
640 )
641 }
642 "npm_package_tarball" => {
643 let Some(tarball_data) = data.get("tarball_data").and_then(|v| v.as_str())
644 else {
645 return Some(npm_server_error(
646 status_tx,
647 "npm_package_tarball carried no 'tarball_data' string",
648 ));
649 };
650
651 // Fail closed on undecodable base64. This was
652 // `.unwrap_or_default()`, which turned a malformed answer into
653 // **HTTP 200 with a zero-byte body** — `npm install` then failed
654 // deep inside tar extraction with nothing pointing back here, and
655 // an empty package was indistinguishable from a real one. The
656 // action's own example made it likely rather than theoretical: it
657 // showed an elided `"H4sIAAAAAAAAA..."`, which does not decode.
658 let decoded =
659 match base64::engine::general_purpose::STANDARD.decode(tarball_data) {
660 Ok(bytes) => bytes,
661 Err(e) => {
662 return Some(npm_server_error(
663 status_tx,
664 &format!(
665 "tarball_data is not valid base64 ({}). A tarball is \
666 binary, so base64 is the only faithful form; send \
667 the whole encoded string, never an abbreviation \
668 ending in \"...\"",
669 e
670 ),
671 ));
672 }
673 };
674
675 if decoded.is_empty() {
676 return Some(npm_server_error(
677 status_tx,
678 "tarball_data decoded to zero bytes; an empty .tgz is not a \
679 package npm can install",
680 ));
681 }
682
683 // FileOnly: the npm_package_tarball action's own log_template already
684 // reports "-> NPM tarball (...)" to the TUI at INFO.
685 Log::new(Some(status_tx)).debug(format!(
686 "NPM package tarball response: {} bytes",
687 decoded.len()
688 ));
689 Some(
690 Response::builder()
691 .status(StatusCode::OK)
692 .header("Content-Type", "application/octet-stream")
693 .body(Full::new(Bytes::from(decoded)))
694 .unwrap(),
695 )
696 }
697 "npm_package_list" => {
698 let Some(packages) = data.get("packages") else {
699 return Some(npm_server_error(
700 status_tx,
701 "npm_package_list carried no 'packages' field",
702 ));
703 };
704
705 // FileOnly: the npm_package_list action's own log_template already
706 // reports "-> NPM package list" to the TUI at INFO.
707 Log::new(Some(status_tx)).debug("NPM package list response");
708 Some(
709 Response::builder()
710 .status(StatusCode::OK)
711 .header("Content-Type", "application/json")
712 .body(Full::new(Bytes::from(packages.to_string())))
713 .unwrap(),
714 )
715 }
716 "npm_package_search" => {
717 let Some(results) = data.get("results") else {
718 return Some(npm_server_error(
719 status_tx,
720 "npm_package_search carried no 'results' field",
721 ));
722 };
723
724 // FileOnly: the npm_package_search action's own log_template already
725 // reports "-> NPM search results" to the TUI at INFO.
726 Log::new(Some(status_tx)).debug("NPM package search response");
727 Some(
728 Response::builder()
729 .status(StatusCode::OK)
730 .header("Content-Type", "application/json")
731 .body(Full::new(Bytes::from(results.to_string())))
732 .unwrap(),
733 )
734 }
735 "npm_error" => {
736 let error_message = data
737 .get("error")
738 .and_then(|v| v.as_str())
739 .unwrap_or("Unknown error");
740 let status_code = data
741 .get("status_code")
742 .and_then(|v| v.as_u64())
743 .unwrap_or(500) as u16;
744
745 // FileOnly: the npm_error action's own log_template already reports
746 // "-> NPM error {status_code}: {error}" to the TUI at INFO.
747 Log::new(Some(status_tx))
748 .debug(format!("NPM error: {} ({})", error_message, status_code));
749 let error_response = json!({
750 "error": error_message
751 });
752 Some(
753 Response::builder()
754 .status(
755 StatusCode::from_u16(status_code)
756 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
757 )
758 .header("Content-Type", "application/json")
759 .body(Full::new(Bytes::from(error_response.to_string())))
760 .unwrap(),
761 )
762 }
763 _ => {
764 error!("Unknown NPM action: {}", name);
765 // Not an NPM action: let the caller keep scanning.
766 None
767 }
768 }
769 }
770 _ => {
771 error!("Unexpected action result type for NPM request");
772 // Not an NPM action: let the caller keep scanning.
773 None
774 }
775 }
776}