Skip to content

Commit dd3065a

Browse files
committed
Add CORS support and web UI prototype
Add CORS headers to all server responses and handle OPTIONS preflight requests, enabling browser-based clients to connect directly to ldk-server. Include a single-file web UI (index.html) that demonstrates connecting to the JSON API and SSE event stream from the browser using @microsoft/fetch-event-source. The UI shows node info, lists peers with keysend buttons, supports BOLT11 payments, and displays the live event stream. AI tools were used in preparing this commit.
1 parent 38a3389 commit dd3065a

2 files changed

Lines changed: 294 additions & 0 deletions

File tree

index.html

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<title>LDK Server</title>
6+
<style>
7+
body { font-family: monospace; max-width: 800px; margin: 40px auto; padding: 0 20px; }
8+
input, button { font-family: monospace; font-size: 14px; padding: 6px; }
9+
input { width: 500px; }
10+
button { cursor: pointer; }
11+
pre { background: #f4f4f4; padding: 12px; overflow-x: auto; max-height: 400px; overflow-y: auto; }
12+
.error { color: red; }
13+
.section { margin-bottom: 30px; }
14+
h3 { margin-bottom: 8px; }
15+
label { display: block; margin-bottom: 4px; font-size: 13px; color: #666; }
16+
table { border-collapse: collapse; width: 100%; }
17+
td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid #ddd; font-family: monospace; font-size: 13px; }
18+
th { font-weight: bold; color: #666; }
19+
.status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
20+
.status.connected { background: #4a4; }
21+
.status.disconnected { background: #aaa; }
22+
.pay-btn { font-size: 12px; padding: 3px 8px; }
23+
.pay-btn:disabled { opacity: 0.5; cursor: default; }
24+
</style>
25+
</head>
26+
<body>
27+
<h2>LDK Server</h2>
28+
29+
<div class="section">
30+
<h3>Connection</h3>
31+
<label>Base URL</label>
32+
<input id="base-url" value="https://127.0.0.1:3032">
33+
<br><br>
34+
<label>API Key (hex)</label>
35+
<input id="api-key" type="password" placeholder="64-char hex API key">
36+
<br><br>
37+
<button id="connect-btn" onclick="connect()">Connect</button>
38+
<span id="connect-status"></span>
39+
<br><br>
40+
<label>Node ID</label>
41+
<span id="node-id" style="color:#333; word-break:break-all;"></span>
42+
</div>
43+
44+
<div class="section">
45+
<h3>Peers</h3>
46+
<button onclick="listPeers()">Refresh</button>
47+
<div id="peers-container"></div>
48+
<pre id="peers-status"></pre>
49+
</div>
50+
51+
<div class="section">
52+
<h3>Send Payment</h3>
53+
<input id="invoice" placeholder="BOLT11 invoice">
54+
<button onclick="pay()">Pay</button>
55+
<pre id="pay-result"></pre>
56+
</div>
57+
58+
<div class="section">
59+
<h3>Events</h3>
60+
<button onclick="clearEvents()">Clear</button>
61+
<pre id="events"></pre>
62+
</div>
63+
64+
<script type="module">
65+
import { fetchEventSource } from 'https://esm.sh/@microsoft/fetch-event-source@2.0.1';
66+
67+
let abortController = null;
68+
69+
function getBaseUrl() { return document.getElementById('base-url').value; }
70+
function getApiKey() { return document.getElementById('api-key').value; }
71+
72+
// Persist base URL and API key in sessionStorage (cleared when tab closes)
73+
function saveSettings() {
74+
sessionStorage.setItem('ldk-base-url', getBaseUrl());
75+
sessionStorage.setItem('ldk-api-key', getApiKey());
76+
}
77+
function loadSettings() {
78+
const url = sessionStorage.getItem('ldk-base-url');
79+
const key = sessionStorage.getItem('ldk-api-key');
80+
if (url) document.getElementById('base-url').value = url;
81+
if (key) document.getElementById('api-key').value = key;
82+
}
83+
84+
async function hmacAuth(body = new Uint8Array()) {
85+
const apiKey = getApiKey();
86+
const timestamp = Math.floor(Date.now() / 1000);
87+
const keyBytes = new TextEncoder().encode(apiKey);
88+
const key = await crypto.subtle.importKey(
89+
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
90+
);
91+
const tsBytes = new Uint8Array(8);
92+
new DataView(tsBytes.buffer).setBigUint64(0, BigInt(timestamp));
93+
const msg = new Uint8Array([...tsBytes, ...body]);
94+
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
95+
const hex = [...sig].map(b => b.toString(16).padStart(2, '0')).join('');
96+
return `HMAC ${timestamp}:${hex}`;
97+
}
98+
99+
async function apiPost(path, body = {}) {
100+
const bodyStr = JSON.stringify(body);
101+
const bodyBytes = new TextEncoder().encode(bodyStr);
102+
const res = await fetch(`${getBaseUrl()}/${path}`, {
103+
method: 'POST',
104+
headers: {
105+
'Content-Type': 'application/json',
106+
'X-Auth': await hmacAuth(bodyBytes),
107+
},
108+
body: bodyStr,
109+
});
110+
return res.json();
111+
}
112+
113+
function appendEvent(data) {
114+
const el = document.getElementById('events');
115+
const time = new Date().toLocaleTimeString();
116+
el.textContent = `[${time}] ${data}\n` + el.textContent;
117+
}
118+
119+
function setConnectStatus(msg, isError) {
120+
const el = document.getElementById('connect-status');
121+
el.textContent = msg;
122+
el.style.color = isError ? 'red' : '#4a4';
123+
}
124+
125+
function setConnected(connected) {
126+
const btn = document.getElementById('connect-btn');
127+
btn.disabled = connected;
128+
btn.style.opacity = connected ? '0.5' : '1';
129+
}
130+
131+
window.connect = async function() {
132+
if (abortController) {
133+
abortController.abort();
134+
}
135+
abortController = new AbortController();
136+
setConnectStatus('', false);
137+
setConnected(false);
138+
139+
try {
140+
const info = await apiPost('GetNodeInfo');
141+
document.getElementById('node-id').textContent = info.node_id || '';
142+
} catch (e) {
143+
setConnectStatus(`Connection failed: ${e.message}`, true);
144+
return;
145+
}
146+
147+
listPeers();
148+
setConnected(true);
149+
150+
const el = document.getElementById('events');
151+
el.textContent = '';
152+
153+
await fetchEventSource(`${getBaseUrl()}/Subscribe`, {
154+
headers: { 'X-Auth': await hmacAuth() },
155+
signal: abortController.signal,
156+
async onopen(res) {
157+
if (!res.ok) {
158+
const text = await res.text();
159+
setConnectStatus(text || `Error ${res.status}`, true);
160+
setConnected(false);
161+
}
162+
},
163+
onmessage(ev) {
164+
try {
165+
const parsed = JSON.parse(ev.data);
166+
appendEvent(JSON.stringify(parsed, null, 2));
167+
} catch {
168+
appendEvent(ev.data);
169+
}
170+
},
171+
onerror(err) {
172+
setConnectStatus('Disconnected', true);
173+
setConnected(false);
174+
throw err;
175+
},
176+
});
177+
};
178+
179+
window.listPeers = async function() {
180+
const container = document.getElementById('peers-container');
181+
const status = document.getElementById('peers-status');
182+
try {
183+
const result = await apiPost('ListPeers');
184+
if (!result.peers || result.peers.length === 0) {
185+
container.innerHTML = '';
186+
status.textContent = 'No peers';
187+
return;
188+
}
189+
status.textContent = '';
190+
let html = '<table><tr><th>Node ID</th><th>Address</th><th>Status</th><th></th></tr>';
191+
for (const peer of result.peers) {
192+
const shortId = peer.node_id.slice(0, 16) + '...';
193+
const statusClass = peer.is_connected ? 'connected' : 'disconnected';
194+
const statusLabel = peer.is_connected ? 'connected' : 'disconnected';
195+
html += `<tr>
196+
<td title="${peer.node_id}">${shortId}</td>
197+
<td>${peer.address}</td>
198+
<td><span class="status ${statusClass}"></span>${statusLabel}</td>
199+
<td><button class="pay-btn" onclick="keysend('${peer.node_id}', this)">Send 10 sat</button></td>
200+
</tr>`;
201+
}
202+
html += '</table>';
203+
container.innerHTML = html;
204+
} catch (e) {
205+
status.textContent = `Error: ${e.message}`;
206+
status.className = 'error';
207+
}
208+
};
209+
210+
window.keysend = async function(nodeId, btn) {
211+
btn.disabled = true;
212+
btn.textContent = 'Sending...';
213+
try {
214+
const result = await apiPost('SpontaneousSend', {
215+
node_id: nodeId,
216+
amount_msat: 10000,
217+
});
218+
if (result.payment_id) {
219+
btn.textContent = 'Sent!';
220+
setTimeout(() => { btn.textContent = 'Send 10 sat'; btn.disabled = false; }, 3000);
221+
} else {
222+
btn.textContent = 'Failed';
223+
btn.disabled = false;
224+
}
225+
} catch (e) {
226+
btn.textContent = 'Error';
227+
btn.disabled = false;
228+
}
229+
};
230+
231+
window.pay = async function() {
232+
const el = document.getElementById('pay-result');
233+
const invoice = document.getElementById('invoice').value;
234+
if (!invoice) { el.textContent = 'Enter an invoice'; return; }
235+
try {
236+
el.textContent = 'Sending...';
237+
const result = await apiPost('Bolt11Send', { invoice });
238+
el.textContent = JSON.stringify(result, null, 2);
239+
el.className = '';
240+
} catch (e) {
241+
el.textContent = `Error: ${e.message}`;
242+
el.className = 'error';
243+
}
244+
};
245+
246+
window.clearEvents = function() {
247+
document.getElementById('events').textContent = '';
248+
};
249+
250+
// Initialize
251+
loadSettings();
252+
function onSettingsChanged() {
253+
saveSettings();
254+
setConnected(false);
255+
setConnectStatus('', false);
256+
}
257+
document.getElementById('base-url').addEventListener('input', onSettingsChanged);
258+
document.getElementById('api-key').addEventListener('input', onSettingsChanged);
259+
if (getApiKey()) {
260+
connect();
261+
}
262+
</script>
263+
</body>
264+
</html>

ldk-server/src/service.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,24 @@ impl Service<Request<Incoming>> for NodeService {
180180
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
181181

182182
fn call(&self, req: Request<Incoming>) -> Self::Future {
183+
// Handle CORS preflight
184+
if req.method() == hyper::Method::OPTIONS {
185+
return Box::pin(async {
186+
Ok(with_cors_headers(
187+
Response::builder().status(StatusCode::NO_CONTENT).body(empty_body()).unwrap(),
188+
))
189+
});
190+
}
191+
192+
let inner = self.call_inner(req);
193+
Box::pin(async move { inner.await.map(with_cors_headers) })
194+
}
195+
}
196+
197+
impl NodeService {
198+
fn call_inner(
199+
&self, req: Request<Incoming>,
200+
) -> Pin<Box<dyn Future<Output = Result<ServiceResponse, hyper::Error>> + Send>> {
183201
// Extract auth params from headers (validation happens after body is read)
184202
let auth_params = match extract_auth_params(&req) {
185203
Ok(params) => params,
@@ -465,6 +483,18 @@ fn error_to_response(e: LdkServerError) -> ServiceResponse {
465483
.unwrap()
466484
}
467485

486+
fn empty_body() -> BoxBody {
487+
Full::new(Bytes::new()).boxed()
488+
}
489+
490+
fn with_cors_headers(mut response: ServiceResponse) -> ServiceResponse {
491+
let headers = response.headers_mut();
492+
headers.insert("Access-Control-Allow-Origin", "*".parse().unwrap());
493+
headers.insert("Access-Control-Allow-Methods", "GET, POST, OPTIONS".parse().unwrap());
494+
headers.insert("Access-Control-Allow-Headers", "Content-Type, X-Auth".parse().unwrap());
495+
response
496+
}
497+
468498
async fn handle_request<
469499
T: DeserializeOwned,
470500
R: Serialize,

0 commit comments

Comments
 (0)