feat(wfe-yaml): add HTTP ops, module loader, and npm support via esm.sh

Phase 4 — Permission-gated HTTP fetch op:
- op_fetch with net permission check on every request
- globalThis.fetch() wrapper with .json()/.text() methods
- Supports GET/POST/PUT/DELETE with headers and body

Phase 5 — Module loader:
- WfeModuleLoader resolving npm: → esm.sh, https://, file://, relative paths
- All resolution paths permission-checked
- Bare path resolution (/) for esm.sh sub-module redirects
- Dynamic import rejection unless permissions.dynamic_import: true
- esm.sh auto-added to net allowlist when modules declared

Mandatory npm integration test (is-number via esm.sh).
25 new tests. 133 total deno tests, 326 total workspace tests.
This commit is contained in:
2026-03-25 23:02:51 +00:00
parent 6fec7dbab5
commit 1a84da40bf
11 changed files with 1026 additions and 6 deletions

View File

@@ -0,0 +1,108 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use deno_core::op2;
use deno_core::OpState;
use deno_error::JsErrorBox;
use serde::{Deserialize, Serialize};
use crate::executors::deno::permissions::PermissionChecker;
/// Options for the fetch call (method, headers, body).
#[derive(Deserialize, Default)]
pub struct FetchOptions {
pub method: Option<String>,
pub headers: Option<HashMap<String, String>>,
pub body: Option<String>,
}
/// Response returned to JavaScript from fetch.
#[derive(Serialize)]
pub struct FetchResponse {
pub status: u16,
pub ok: bool,
pub headers: HashMap<String, String>,
pub body: String,
}
#[op2]
#[serde]
pub async fn op_fetch(
state: Rc<RefCell<OpState>>,
#[string] url: String,
#[serde] options: Option<FetchOptions>,
) -> Result<FetchResponse, JsErrorBox> {
// 1. Parse URL to extract host for permission check.
let parsed = url::Url::parse(&url)
.map_err(|e| JsErrorBox::generic(format!("Invalid URL '{url}': {e}")))?;
let host = parsed
.host_str()
.ok_or_else(|| JsErrorBox::generic(format!("URL '{url}' has no host")))?
.to_string();
// 2. Check net permission.
{
let state = state.borrow();
let checker = state.borrow::<PermissionChecker>();
checker
.check_net(&host)
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
}
// 3. Build the request.
let opts = options.unwrap_or_default();
let method = opts.method.as_deref().unwrap_or("GET");
let client = reqwest::Client::new();
let mut builder = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
"PATCH" => client.patch(&url),
"HEAD" => client.head(&url),
other => {
return Err(JsErrorBox::generic(format!(
"Unsupported HTTP method: {other}"
)));
}
};
if let Some(ref headers) = opts.headers {
for (key, value) in headers {
builder = builder.header(key.as_str(), value.as_str());
}
}
if let Some(ref body) = opts.body {
builder = builder.body(body.clone());
}
// 4. Execute request.
let response = builder
.send()
.await
.map_err(|e| JsErrorBox::generic(format!("Fetch failed for '{url}': {e}")))?;
// 5. Build response.
let status = response.status().as_u16();
let ok = response.status().is_success();
let headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = response
.text()
.await
.map_err(|e| JsErrorBox::generic(format!("Failed to read response body: {e}")))?;
Ok(FetchResponse {
status,
ok,
headers,
body,
})
}

View File

@@ -1,3 +1,4 @@
pub mod http;
pub mod workflow;
pub use workflow::{StepMeta, StepOutputs, WorkflowInputs};

View File

@@ -46,7 +46,7 @@ pub fn op_log(state: &mut OpState, #[string] msg: String) {
deno_core::extension!(
wfe_ops,
ops = [op_inputs, op_output, op_log],
ops = [op_inputs, op_output, op_log, super::http::op_fetch],
esm_entry_point = "ext:wfe/bootstrap.js",
esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"],
);