diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index a58121e..45744b7 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -484,6 +484,9 @@ what a valid business email is. ### req: push/005 005 Push is one-way server-to-client delivery of EffectBatch. It does not define client mutation, optimistic queues, reconciliation, or conflict handling. +### req: push/006 +006 `data-slhx-sse` opens only non-empty same-origin SSE URLs by default. Empty static URLs are build errors; cross-origin streams belong to explicit integration code rather than the standard runtime convention. + --- ## sync diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 2e24ded..3385d0c 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -1041,6 +1041,14 @@ fn reject_invalid_slhx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i "expected a non-empty confirmation message", )); } + "data-slhx-sse" if value.trim().is_empty() => { + return Err(invalid_slhx_value( + path, + &attr.name, + value, + "expected a non-empty same-origin SSE URL", + )); + } "data-slhx-debounce" | "data-slhx-throttle" | "data-slhx-every" if !valid_duration(value) => { @@ -1578,6 +1586,12 @@ fn main() {{ "data-slhx-confirm", "non-empty", ), + ( + "empty-sse", + r#"
"#, + "data-slhx-sse", + "same-origin SSE URL", + ), ] { let base = test_dir(&format!("slhx-build-invalid-convention-{case}-test")); let templates = base.join("templates"); diff --git a/slhx-js/runtime/slhx.js b/slhx-js/runtime/slhx.js index ea684bf..f31bc19 100644 --- a/slhx-js/runtime/slhx.js +++ b/slhx-js/runtime/slhx.js @@ -637,7 +637,12 @@ function bindSse(root) { const url = root.getAttribute("data-slhx-sse"); if (!url || sseSources.has(root) || typeof EventSource === "undefined") return; - const source = new EventSource(new URL(url, location.href).href); + const href = new URL(url, location.href); + if (href.origin !== location.origin) { + emit(root, "slhx:sse-error", url); + return; + } + const source = new EventSource(href.href); source.addEventListener("slhx", (event) => applySseMessage(root, event)); source.addEventListener("message", (event) => applySseMessage(root, event)); source.addEventListener("error", () => emit(root, "slhx:sse-error", url)); diff --git a/slhx-js/tests/runtime.rs b/slhx-js/tests/runtime.rs index d7388cb..fa8aab8 100644 --- a/slhx-js/tests/runtime.rs +++ b/slhx-js/tests/runtime.rs @@ -218,12 +218,15 @@ fn runtime_refuses_partial_updates_on_fingerprint_mismatch() { #[test] fn runtime_applies_sse_effect_batches_inside_roots() { - // req: push/001 req: push/003 req: runtime/001 + // req: push/001 req: push/003 req: push/006 req: runtime/001 let source = slhx_js::RUNTIME_JS; assert!(source.contains("const sseSources = new WeakMap()")); assert!(source.contains("const url = root.getAttribute(\"data-slhx-sse\")")); - assert!(source.contains("new EventSource(new URL(url, location.href).href)")); + assert!(source.contains("const href = new URL(url, location.href)")); + assert!(source.contains("if (href.origin !== location.origin)")); + assert!(source.contains("emit(root, \"slhx:sse-error\", url)")); + assert!(source.contains("new EventSource(href.href)")); assert!(source.contains("source.addEventListener(\"slhx\", (event) => applySseMessage(root, event))")); assert!(source.contains("source.addEventListener(\"message\", (event) => applySseMessage(root, event))")); assert!(source.contains("applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root)"));