Skip to content

Commit 000ad95

Browse files
erdemgokselerdemgoksel
authored andcommitted
feat: add webhook support (blocking + notify) v0.2.6
1 parent fee00d5 commit 000ad95

File tree

7 files changed

+202
-3
lines changed

7 files changed

+202
-3
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## v0.2.6
4+
5+
Release date: 2026-03-26
6+
7+
### Added
8+
9+
- **Webhook support** (`webhooks` per `[server.NAME]`). Each server can now declare one or more webhooks that are called on every request **before** cache reads, ensuring access control is enforced even for cached responses.
10+
- **`type = "blocking"`** — phantom-frame POSTs the request metadata to the webhook URL and awaits the response. A `2xx` reply allows the request to proceed; any non-`2xx` reply causes the same status code to be returned to the client immediately (the request is never forwarded to the backend or served from cache). A timeout or network error returns `503 Service Unavailable`.
11+
- **`type = "notify"`** — the POST is dispatched as a fire-and-forget background task; the request always proceeds immediately regardless of the webhook outcome.
12+
- `url` — the endpoint to POST to.
13+
- `timeout_ms` — optional per-webhook timeout in milliseconds (default: `5000`). Only meaningful for `blocking` webhooks.
14+
- Multiple webhooks per server are supported via the `[[server.NAME.webhooks]]` TOML array syntax. Blocking webhooks run sequentially; the first denial short-circuits the chain.
15+
- Webhook POST body: `{ "method", "path", "query", "headers" }`. The request body is never consumed so latency overhead is minimal.
16+
- `serde_json` added as a dependency (used for webhook payload serialisation).
17+
318
## v0.2.5
419

520
Release date: 2026-03-26

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "phantom-frame"
3-
version = "0.2.5"
3+
version = "0.2.6"
44
edition = "2021"
55
authors = ["Erdem Göksel <erdem.goksel.dev@gmail.com>"]
66
description = "A high-performance prerendering proxy engine with caching support"
@@ -16,6 +16,7 @@ categories = ["web-programming", "caching", "network-programming"]
1616
tokio = { version = "1.40", features = ["full"] }
1717
axum = "0.8.6"
1818
serde = { version = "1.0", features = ["derive"] }
19+
serde_json = "1.0"
1920
toml = "0.9.8"
2021
reqwest = { version = "0.12", default-features = false, features = ["json"] }
2122
tower = "0.5"

examples/configs/basic.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,32 @@ enable_websocket = true
7979
# Optional: Override the directory used for filesystem-backed cache bodies
8080
# cache_directory = "./.phantom-frame-cache"
8181

82+
# ── Webhooks ──────────────────────────────────────────────────────────────────
83+
#
84+
# Each [[server.NAME.webhooks]] entry defines one webhook for that server.
85+
# Webhooks fire on every request, BEFORE cache reads are attempted, so access
86+
# control is enforced even for cached responses.
87+
#
88+
# type = "blocking" — phantom-frame POSTs request metadata to the URL and waits.
89+
# 2xx response → request is allowed to continue.
90+
# Non-2xx response → the webhook's status code is returned to
91+
# the client and the request is not forwarded.
92+
# Timeout or connection error → 503 is returned to the client.
93+
#
94+
# type = "notify" — phantom-frame fires a background POST and does not wait for
95+
# a response. The request always proceeds immediately.
96+
#
97+
# The POST body contains: method, path, query, headers (no request body).
98+
#
99+
# [[server.default.webhooks]]
100+
# url = "http://auth-service.internal/check"
101+
# type = "blocking"
102+
# timeout_ms = 3000 # optional, default 5000 ms
103+
#
104+
# [[server.default.webhooks]]
105+
# url = "http://logger.internal/log"
106+
# type = "notify"
107+
82108
# ── Example: multi-server config (SSG frontend + dynamic API backend) ─────────
83109
#
84110
# [server.frontend]

src/config.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{CacheStorageMode, CacheStrategy, CompressStrategy};
1+
use crate::{CacheStorageMode, CacheStrategy, CompressStrategy, WebhookConfig};
22
use anyhow::{bail, Result};
33
use serde::{
44
de::{self, Visitor},
@@ -224,6 +224,11 @@ pub struct ServerConfig {
224224
/// Example: `"./apps/client"`
225225
#[serde(default)]
226226
pub execute_dir: Option<String>,
227+
228+
/// Webhooks called for every request before cache reads.
229+
/// Blocking webhooks gate access; notify webhooks are fire-and-forget.
230+
#[serde(default)]
231+
pub webhooks: Vec<WebhookConfig>,
227232
}
228233

229234
// ── defaults ────────────────────────────────────────────────────────────────
@@ -368,6 +373,7 @@ impl Default for ServerConfig {
368373
pre_generate_fallthrough: false,
369374
execute: None,
370375
execute_dir: None,
376+
webhooks: vec![],
371377
}
372378
}
373379
}

src/lib.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,34 @@ impl std::fmt::Display for CacheStorageMode {
136136
}
137137
}
138138

139+
/// The type of a webhook — controls whether the webhook gates access or just receives a notification.
140+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
141+
#[serde(rename_all = "snake_case")]
142+
pub enum WebhookType {
143+
/// The webhook call must complete with a 2xx response before the request is forwarded.
144+
/// A non-2xx response (or a timeout / network error) causes the request to be denied.
145+
Blocking,
146+
/// The webhook call is dispatched in the background without blocking the request.
147+
#[default]
148+
Notify,
149+
}
150+
151+
/// Configuration for a single webhook attached to a server.
152+
#[derive(Clone, Debug, Serialize, Deserialize)]
153+
pub struct WebhookConfig {
154+
/// The URL to POST the request metadata to.
155+
pub url: String,
156+
157+
/// Whether this webhook is blocking (gates access) or notify (fire-and-forget).
158+
#[serde(rename = "type", default)]
159+
pub webhook_type: WebhookType,
160+
161+
/// Timeout in milliseconds for the webhook call (default: 5000 ms).
162+
/// On timeout, a blocking webhook denies the request with 503.
163+
#[serde(default)]
164+
pub timeout_ms: Option<u64>,
165+
}
166+
139167
/// Controls the operating mode of the proxy.
140168
#[derive(Clone, Debug, Default)]
141169
pub enum ProxyMode {
@@ -225,6 +253,10 @@ pub struct CreateProxyConfig {
225253

226254
/// Controls the operating mode of the proxy (Dynamic vs PreGenerate/SSG).
227255
pub proxy_mode: ProxyMode,
256+
257+
/// Webhooks called for every request before cache reads.
258+
/// Blocking webhooks gate access; notify webhooks are fire-and-forget.
259+
pub webhooks: Vec<WebhookConfig>,
228260
}
229261

230262
impl CreateProxyConfig {
@@ -250,6 +282,7 @@ impl CreateProxyConfig {
250282
cache_storage_mode: CacheStorageMode::Memory,
251283
cache_directory: None,
252284
proxy_mode: ProxyMode::Dynamic,
285+
webhooks: vec![],
253286
}
254287
}
255288

@@ -338,6 +371,13 @@ impl CreateProxyConfig {
338371
self.proxy_mode = mode;
339372
self
340373
}
374+
375+
/// Set the webhooks for this server.
376+
/// Blocking webhooks gate access; notify webhooks are fire-and-forget.
377+
pub fn with_webhooks(mut self, webhooks: Vec<WebhookConfig>) -> Self {
378+
self.webhooks = webhooks;
379+
self
380+
}
341381
}
342382

343383
/// The main library interface for using phantom-frame as a library

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ async fn main() -> anyhow::Result<()> {
8181
};
8282
proxy_config = proxy_config.with_proxy_mode(proxy_mode);
8383

84+
proxy_config = proxy_config.with_webhooks(server_cfg.webhooks.clone());
85+
8486
let (router, handle) = phantom_frame::create_proxy(proxy_config);
8587

8688
tracing::info!(

src/proxy.rs

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::compression::{
44
decompress_body, identity_acceptable,
55
};
66
use crate::path_matcher::should_cache_path;
7-
use crate::{CompressStrategy, CreateProxyConfig, ProxyMode};
7+
use crate::{CompressStrategy, CreateProxyConfig, ProxyMode, WebhookType};
88
use axum::{
99
body::Body,
1010
extract::Extension,
@@ -42,6 +42,60 @@ fn is_upgrade_request(headers: &HeaderMap) -> bool {
4242
|| headers.contains_key(axum::http::header::UPGRADE)
4343
}
4444

45+
/// Build the JSON payload sent to webhook endpoints.
46+
///
47+
/// Contains `method`, `path`, `query`, and `headers` (as a flat string-to-string
48+
/// map). The request body is intentionally excluded so that the caller never has
49+
/// to consume it before the payload is built.
50+
fn build_webhook_payload(
51+
method: &str,
52+
path: &str,
53+
query: &str,
54+
headers: &HeaderMap,
55+
) -> serde_json::Value {
56+
let headers_map: serde_json::Map<String, serde_json::Value> = headers
57+
.iter()
58+
.filter_map(|(name, value)| {
59+
value
60+
.to_str()
61+
.ok()
62+
.map(|v| (name.as_str().to_string(), serde_json::Value::String(v.to_string())))
63+
})
64+
.collect();
65+
66+
serde_json::json!({
67+
"method": method,
68+
"path": path,
69+
"query": query,
70+
"headers": headers_map,
71+
})
72+
}
73+
74+
/// POST `payload` to `url`.
75+
///
76+
/// Returns:
77+
/// - `Ok(StatusCode)` — the HTTP status returned by the webhook server.
78+
/// - `Err(())` — timeout, connection error, or other transport failure.
79+
async fn call_webhook(
80+
url: &str,
81+
payload: &serde_json::Value,
82+
timeout_ms: u64,
83+
) -> Result<StatusCode, ()> {
84+
let client = reqwest::Client::builder()
85+
.timeout(std::time::Duration::from_millis(timeout_ms))
86+
.build()
87+
.map_err(|_| ())?;
88+
89+
let response = client
90+
.post(url)
91+
.json(payload)
92+
.send()
93+
.await
94+
.map_err(|_| ())?;
95+
96+
StatusCode::from_u16(response.status().as_u16()).map_err(|_| ())
97+
}
98+
4599
/// Main proxy handler that serves prerendered content from cache
46100
/// or fetches from backend if not cached
47101
pub async fn proxy_handler(
@@ -101,6 +155,61 @@ pub async fn proxy_handler(
101155
return Err(StatusCode::METHOD_NOT_ALLOWED);
102156
}
103157

158+
// ── Webhook dispatch ────────────────────────────────────────────────────
159+
// Webhooks fire before cache reads so that access control is enforced even
160+
// for requests that would otherwise be served from the cache.
161+
if !state.config.webhooks.is_empty() {
162+
let payload = build_webhook_payload(method_str, path, query, &headers);
163+
164+
for webhook in &state.config.webhooks {
165+
match webhook.webhook_type {
166+
WebhookType::Notify => {
167+
// Fire-and-forget: spawn without awaiting.
168+
let url = webhook.url.clone();
169+
let payload_clone = payload.clone();
170+
let timeout_ms = webhook.timeout_ms.unwrap_or(5000);
171+
tokio::spawn(async move {
172+
if let Err(()) = call_webhook(&url, &payload_clone, timeout_ms).await {
173+
tracing::warn!("Notify webhook POST to '{}' failed", url);
174+
}
175+
});
176+
}
177+
WebhookType::Blocking => {
178+
let timeout_ms = webhook.timeout_ms.unwrap_or(5000);
179+
match call_webhook(&webhook.url, &payload, timeout_ms).await {
180+
Ok(status) if status.is_success() => {
181+
tracing::debug!(
182+
"Blocking webhook '{}' allowed {} {}",
183+
webhook.url,
184+
method_str,
185+
path
186+
);
187+
}
188+
Ok(status) => {
189+
tracing::warn!(
190+
"Blocking webhook '{}' denied {} {} with status {}",
191+
webhook.url,
192+
method_str,
193+
path,
194+
status
195+
);
196+
return Err(status);
197+
}
198+
Err(()) => {
199+
tracing::warn!(
200+
"Blocking webhook '{}' timed out or failed for {} {} — denying request",
201+
webhook.url,
202+
method_str,
203+
path
204+
);
205+
return Err(StatusCode::SERVICE_UNAVAILABLE);
206+
}
207+
}
208+
}
209+
}
210+
}
211+
}
212+
104213
// Check if this path should be cached based on include/exclude patterns
105214
let should_cache = should_cache_path(
106215
method_str,

0 commit comments

Comments
 (0)