1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::EventType;
8use crate::state::app_state::AppState;
9use anyhow::{Context, Result};
10use serde_json::json;
11use std::sync::LazyLock;
12use tracing::debug;
13
14use crate::protocol::log_template::LogTemplate;
15
16pub static NPM_PACKAGE_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
23 EventType::new(
24 "NPM_PACKAGE_REQUEST",
25 "Triggered when a client requests package metadata (GET /{package})",
26 json!({
27 "type": "npm_package_metadata",
28 "metadata": {"name": "express", "version": "4.18.2", "description": "Fast web framework"}
29 }),
30 )
31 .with_actions(vec![package_metadata_action(), npm_error_action()])
32});
33
34pub static NPM_TARBALL_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
35 EventType::new(
36 "NPM_TARBALL_REQUEST",
37 "A client is downloading a package tarball (GET /{package}/-/{tarball}). Answer with \
38 npm_package_tarball only if the base64-encoded .tgz was given to you — it is binary \
39 and cannot be invented. Otherwise answer npm_error with 404.",
40 json!({
41 "type": "npm_package_tarball",
42 "tarball_data": "H4sIAI0lgmoC/+3NvQrCMBSG4cxehZxZYxJrB+8myKHUn7Q0tYt470YdBGcRwfdZ3sO3nD7uDrHRVf+s3ecumQ9zRV1VjxbvdW7jX/d9974Owcyd+YJzHuNQ3pv/dJEUTypbSTo2Oi6P7aSykEmH3Hap7N4Gu5brzAAAAAAAAAAAAAAAAAAAfskNxBOA2gAoAAA="
43 }),
44 )
45 .with_actions(vec![package_tarball_action(), npm_error_action()])
46});
47
48pub static NPM_LIST_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
49 EventType::new(
50 "NPM_LIST_REQUEST",
51 "Triggered when a client requests the package listing (GET /-/all)",
52 json!({"type": "npm_package_list", "packages": {"express": {"version": "4.18.2"}}}),
53 )
54 .with_actions(vec![package_list_action(), npm_error_action()])
55});
56
57pub static NPM_SEARCH_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
58 EventType::new(
59 "NPM_SEARCH_REQUEST",
60 "Triggered when a client searches for packages (GET /-/v1/search?text=...)",
61 json!({"type": "npm_package_search", "results": {"objects": [], "total": 0}}),
62 )
63 .with_actions(vec![package_search_action(), npm_error_action()])
64});
65
66pub struct NpmProtocol {}
68
69impl NpmProtocol {
70 pub fn new() -> Self {
71 Self {}
72 }
73}
74
75impl Protocol for NpmProtocol {
77 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
78 vec![]
79 }
80 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
81 vec![
82 package_metadata_action(),
83 package_tarball_action(),
84 package_list_action(),
85 package_search_action(),
86 npm_error_action(),
87 ]
88 }
89 fn protocol_name(&self) -> &'static str {
90 "NPM"
91 }
92 fn get_event_types(&self) -> Vec<EventType> {
93 get_npm_event_types()
94 }
95 fn stack_name(&self) -> &'static str {
96 "ETH>IP>TCP>HTTP>NPM"
97 }
98 fn keywords(&self) -> Vec<&'static str> {
99 vec!["npm"]
100 }
101 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
102 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
103
104 ProtocolMetadataV2::builder()
105 .state(DevelopmentState::Beta)
106 .implementation("hyper HTTP server with NPM registry endpoints")
107 .llm_control("LLM controls package metadata, tarballs, listings, and search results")
108 .e2e_testing("The real npm CLI, in tests/server/npm/e2e_test.rs::test_npm_with_real_cli, which is not #[ignore]d: `npm view --json` resolves netget-test-pkg@1.0.0 from the packument this server serves, and `npm install` then downloads the tarball, verifies its integrity and unpacks it into node_modules/. Both are asserted; the test also fails rather than skipping if the npm CLI is absent.")
109 .notes("Implements NPM registry protocol: package metadata (GET /{package}), tarballs (GET /{package}/-/{tarball}), listing (GET /-/all), and search (GET /-/v1/search). Beta because npm itself completes both a metadata resolution and a full install against it - not merely that an HTTP client got a 200.")
110 .request_only("The npm registry is HTTP request/response; a server cannot send a peer anything unprompted")
111 .build()
112 }
113 fn description(&self) -> &'static str {
114 "NPM registry server with LLM-controlled package responses"
115 }
116 fn example_prompt(&self) -> &'static str {
117 "Start an NPM registry on port 4873 that serves express package"
118 }
119 fn group_name(&self) -> &'static str {
120 "Package Management"
121 }
122
123 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
124 use crate::llm::actions::StartupExamples;
125
126 StartupExamples::new(
127 json!({
129 "type": "open_server",
130 "port": 4873,
131 "base_stack": "npm",
132 "instruction": "NPM registry server. Serve 'express' package with version 4.18.2. Return package metadata for GET /{package} and search results for /-/v1/search."
133 }),
134 json!({
136 "type": "open_server",
137 "port": 4873,
138 "base_stack": "npm",
139 "event_handlers": [{
140 "event_pattern": "NPM_PACKAGE_REQUEST",
141 "handler": {
142 "type": "script",
143 "language": "python",
144 "code": "return {type='npm_package_metadata', metadata={name='express', version='4.18.2', description='Fast web framework', versions={['4.18.2']={name='express', version='4.18.2'}}}}"
145 }
146 }, {
147 "event_pattern": "NPM_SEARCH_REQUEST",
148 "handler": {
149 "type": "script",
150 "language": "python",
151 "code": "return {type='npm_package_search', results={objects={{package={name='express', version='4.18.2'}}}, total=1}}"
152 }
153 }]
154 }),
155 json!({
157 "type": "open_server",
158 "port": 4873,
159 "base_stack": "npm",
160 "event_handlers": [{
161 "event_pattern": "NPM_PACKAGE_REQUEST",
162 "handler": {
163 "type": "static",
164 "actions": [{
165 "type": "npm_package_metadata",
166 "metadata": {
167 "name": "express",
168 "version": "4.18.2",
169 "description": "Fast, unopinionated, minimalist web framework"
170 }
171 }]
172 }
173 }]
174 }),
175 )
176 }
177}
178
179impl Server for NpmProtocol {
181 fn spawn(
182 &self,
183 ctx: crate::protocol::SpawnContext,
184 ) -> std::pin::Pin<
185 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
186 > {
187 Box::pin(async move {
188 use crate::server::npm::NpmServer;
189 NpmServer::spawn_with_llm_actions(
190 ctx.legacy_listen_addr(),
191 ctx.llm_client,
192 ctx.state,
193 ctx.status_tx,
194 ctx.server_id,
195 )
196 .await
197 })
198 }
199 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
200 let action_type = action
201 .get("type")
202 .and_then(|v| v.as_str())
203 .context("Missing 'type' field in action")?;
204
205 match action_type {
206 "npm_package_metadata" => self.execute_package_metadata(action),
207 "npm_package_tarball" => self.execute_package_tarball(action),
208 "npm_package_list" => self.execute_package_list(action),
209 "npm_package_search" => self.execute_package_search(action),
210 "npm_error" => self.execute_npm_error(action),
211 _ => Err(anyhow::anyhow!("Unknown NPM action: {}", action_type)),
212 }
213 }
214}
215
216impl NpmProtocol {
217 fn execute_package_metadata(&self, action: serde_json::Value) -> Result<ActionResult> {
218 let metadata = action.get("metadata").context("Missing 'metadata' field")?;
219
220 debug!("NPM package metadata response");
221
222 Ok(ActionResult::Custom {
223 name: "npm_package_metadata".to_string(),
224 data: json!({
225 "metadata": metadata
226 }),
227 })
228 }
229
230 fn execute_package_tarball(&self, action: serde_json::Value) -> Result<ActionResult> {
231 let tarball_data = action
232 .get("tarball_data")
233 .and_then(|v| v.as_str())
234 .context("Missing 'tarball_data' field")?;
235
236 debug!("NPM package tarball response");
237
238 Ok(ActionResult::Custom {
239 name: "npm_package_tarball".to_string(),
240 data: json!({
241 "tarball_data": tarball_data
242 }),
243 })
244 }
245
246 fn execute_package_list(&self, action: serde_json::Value) -> Result<ActionResult> {
247 let packages = action.get("packages").context("Missing 'packages' field")?;
248
249 debug!("NPM package list response");
250
251 Ok(ActionResult::Custom {
252 name: "npm_package_list".to_string(),
253 data: json!({
254 "packages": packages
255 }),
256 })
257 }
258
259 fn execute_package_search(&self, action: serde_json::Value) -> Result<ActionResult> {
260 let results = action.get("results").context("Missing 'results' field")?;
261
262 debug!("NPM package search response");
263
264 Ok(ActionResult::Custom {
265 name: "npm_package_search".to_string(),
266 data: json!({
267 "results": results
268 }),
269 })
270 }
271
272 fn execute_npm_error(&self, action: serde_json::Value) -> Result<ActionResult> {
273 let error_message = action
274 .get("error")
275 .and_then(|v| v.as_str())
276 .unwrap_or("Unknown error");
277
278 let status_code = match action.get("status_code") {
282 None => 500,
283 Some(v) if v.is_null() => 500,
284 Some(v) => {
285 let raw = v.as_u64().ok_or_else(|| {
286 anyhow::anyhow!("npm_error 'status_code' must be a number, got {v}")
287 })?;
288 if !(100..=599).contains(&raw) {
289 return Err(anyhow::anyhow!(
290 "npm_error 'status_code' {raw} is not an HTTP status code; use 100-599 \
291 (404 for an unknown package, 403 for a forbidden one, 500 for a \
292 registry fault)"
293 ));
294 }
295 raw as u16
296 }
297 };
298
299 debug!("NPM error response: {} ({})", error_message, status_code);
300
301 Ok(ActionResult::Custom {
302 name: "npm_error".to_string(),
303 data: json!({
304 "error": error_message,
305 "status_code": status_code
306 }),
307 })
308 }
309}
310
311fn package_metadata_action() -> ActionDefinition {
313 ActionDefinition {
314 name: "npm_package_metadata".to_string(),
315 description: "Return NPM package metadata (package.json manifest)".to_string(),
316 parameters: vec![Parameter {
317 name: "metadata".to_string(),
318 type_hint: "object".to_string(),
319 description:
320 "Package metadata JSON object with name, version, description, dependencies, etc."
321 .to_string(),
322 required: true,
323 }],
324 example: json!({
325 "type": "npm_package_metadata",
326 "metadata": {
327 "name": "express",
328 "version": "4.18.2",
329 "description": "Fast, unopinionated, minimalist web framework"
330 }
331 }),
332 log_template: Some(
333 LogTemplate::new()
334 .with_info("-> NPM package metadata")
335 .with_debug("NPM npm_package_metadata: returned package info"),
336 ),
337 }
338}
339
340fn package_tarball_action() -> ActionDefinition {
341 ActionDefinition {
342 name: "npm_package_tarball".to_string(),
343 description: "Return the package tarball (.tgz). A tarball is binary, so base64 is the \
344 only faithful form — but that also makes it the one npm response you \
345 cannot invent: send it only when the encoded bytes were given to you. \
346 With no tarball to serve, answer npm_error (404) instead."
347 .to_string(),
348 parameters: vec![Parameter {
349 name: "tarball_data".to_string(),
350 type_hint: "string".to_string(),
351 description: "The complete .tgz contents, base64-encoded. It is decoded before it \
352 reaches the client, so it must be valid base64 in full: an \
353 abbreviation ending in \"...\" is refused with a 500, not sent."
354 .to_string(),
355 required: true,
356 }],
357 example: json!({
362 "type": "npm_package_tarball",
363 "tarball_data": "H4sIAI0lgmoC/+3NvQrCMBSG4cxehZxZYxJrB+8myKHUn7Q0tYt470YdBGcRwfdZ3sO3nD7uDrHRVf+s3ecumQ9zRV1VjxbvdW7jX/d9974Owcyd+YJzHuNQ3pv/dJEUTypbSTo2Oi6P7aSykEmH3Hap7N4Gu5brzAAAAAAAAAAAAAAAAAAAfskNxBOA2gAoAAA="
364 }),
365 log_template: Some(
366 LogTemplate::new()
367 .with_info("-> NPM tarball ({tarball_data_len}B)")
368 .with_debug("NPM npm_package_tarball: sending tarball data"),
369 ),
370 }
371}
372
373fn package_list_action() -> ActionDefinition {
374 ActionDefinition {
375 name: "npm_package_list".to_string(),
376 description: "Return list of all available NPM packages".to_string(),
377 parameters: vec![Parameter {
378 name: "packages".to_string(),
379 type_hint: "object".to_string(),
380 description: "JSON object mapping package names to their metadata".to_string(),
381 required: true,
382 }],
383 example: json!({
384 "type": "npm_package_list",
385 "packages": {
386 "express": {"version": "4.18.2"},
387 "lodash": {"version": "4.17.21"}
388 }
389 }),
390 log_template: Some(
391 LogTemplate::new()
392 .with_info("-> NPM package list")
393 .with_debug("NPM npm_package_list: listing packages"),
394 ),
395 }
396}
397
398fn package_search_action() -> ActionDefinition {
399 ActionDefinition {
400 name: "npm_package_search".to_string(),
401 description: "Return NPM package search results".to_string(),
402 parameters: vec![Parameter {
403 name: "results".to_string(),
404 type_hint: "object".to_string(),
405 description: "Search results JSON object with objects array and total count"
406 .to_string(),
407 required: true,
408 }],
409 example: json!({
410 "type": "npm_package_search",
411 "results": {
412 "objects": [
413 {"package": {"name": "express", "version": "4.18.2"}}
414 ],
415 "total": 1
416 }
417 }),
418 log_template: Some(
419 LogTemplate::new()
420 .with_info("-> NPM search results")
421 .with_debug("NPM npm_package_search: returning search results"),
422 ),
423 }
424}
425
426fn npm_error_action() -> ActionDefinition {
427 ActionDefinition {
428 name: "npm_error".to_string(),
429 description: "Return an NPM error response".to_string(),
430 parameters: vec![
431 Parameter {
432 name: "error".to_string(),
433 type_hint: "string".to_string(),
434 description: "Error message".to_string(),
435 required: true,
436 },
437 Parameter {
438 name: "status_code".to_string(),
439 type_hint: "number".to_string(),
440 description: "HTTP status code (default: 500)".to_string(),
441 required: false,
442 },
443 ],
444 example: json!({
445 "type": "npm_error",
446 "error": "Package not found",
447 "status_code": 404
448 }),
449 log_template: Some(
450 LogTemplate::new()
451 .with_info("-> NPM error {status_code}: {error}")
452 .with_debug("NPM npm_error: status={status_code} error={error}"),
453 ),
454 }
455}
456
457fn get_npm_event_types() -> Vec<EventType> {
462 vec![
463 NPM_PACKAGE_REQUEST.clone(),
464 NPM_TARBALL_REQUEST.clone(),
465 NPM_LIST_REQUEST.clone(),
466 NPM_SEARCH_REQUEST.clone(),
467 ]
468}