@undefineds.co/xpod 0.3.56 → 0.3.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/api/container/index.js +3 -0
  2. package/dist/api/container/index.js.map +1 -1
  3. package/dist/api/container/local.js +53 -13
  4. package/dist/api/container/local.js.map +1 -1
  5. package/dist/api/container/types.d.ts +6 -0
  6. package/dist/api/container/types.js.map +1 -1
  7. package/dist/api/handlers/AdminHandler.d.ts +15 -0
  8. package/dist/api/handlers/AdminHandler.js +128 -7
  9. package/dist/api/handlers/AdminHandler.js.map +1 -1
  10. package/dist/cli/commands/obj.d.ts +36 -0
  11. package/dist/cli/commands/obj.js +235 -30
  12. package/dist/cli/commands/obj.js.map +1 -1
  13. package/dist/components/components.jsonld +1 -0
  14. package/dist/components/context.jsonld +36 -0
  15. package/dist/edge/DdnsManager.d.ts +1 -1
  16. package/dist/edge/DdnsManager.js.map +1 -1
  17. package/dist/edge/reachability/P2PRealnetAcceptance.js +4 -3
  18. package/dist/edge/reachability/P2PRealnetAcceptance.js.map +1 -1
  19. package/dist/edge/reachability/TcpP2PSignalingSession.js +26 -17
  20. package/dist/edge/reachability/TcpP2PSignalingSession.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +3 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/tunnel/NgrokTunnelProvider.d.ts +46 -0
  25. package/dist/tunnel/NgrokTunnelProvider.js +344 -0
  26. package/dist/tunnel/NgrokTunnelProvider.js.map +1 -0
  27. package/dist/tunnel/NgrokTunnelProvider.jsonld +215 -0
  28. package/dist/tunnel/TunnelProvider.d.ts +2 -2
  29. package/dist/tunnel/TunnelProvider.js.map +1 -1
  30. package/dist/tunnel/index.d.ts +1 -0
  31. package/dist/tunnel/index.js +3 -1
  32. package/dist/tunnel/index.js.map +1 -1
  33. package/node_modules/@undefineds.co/drizzle-solid/dist/core/pod-database.d.ts +62 -0
  34. package/node_modules/@undefineds.co/drizzle-solid/dist/core/pod-database.d.ts.map +1 -1
  35. package/node_modules/@undefineds.co/drizzle-solid/dist/core/pod-database.js +197 -0
  36. package/node_modules/@undefineds.co/drizzle-solid/dist/core/pod-database.js.map +1 -1
  37. package/node_modules/@undefineds.co/drizzle-solid/dist/esm/core/pod-database.d.ts +62 -0
  38. package/node_modules/@undefineds.co/drizzle-solid/dist/esm/core/pod-database.d.ts.map +1 -1
  39. package/node_modules/@undefineds.co/drizzle-solid/dist/esm/core/pod-database.js +197 -0
  40. package/node_modules/@undefineds.co/drizzle-solid/dist/esm/core/pod-database.js.map +1 -1
  41. package/node_modules/@undefineds.co/drizzle-solid/dist/esm/pod.d.ts.map +1 -1
  42. package/node_modules/@undefineds.co/drizzle-solid/dist/pod.d.ts.map +1 -1
  43. package/node_modules/@undefineds.co/drizzle-solid/package.json +1 -1
  44. package/package.json +10 -6
  45. package/static/app/assets/{index-AaQ1qxhy.js → index-C1yksyT4.js} +28 -28
  46. package/static/app/assets/inrupt-smoke.js +2 -2
  47. package/static/app/assets/main.css +1 -1
  48. package/static/app/assets/main.js +10 -50
  49. package/static/dashboard/assets/dashboard-BssaVKTn.css +1 -0
  50. package/static/dashboard/assets/dashboard-Ckuadl3C.js +54 -0
  51. package/static/dashboard/dashboard.html +2 -2
  52. package/static/dashboard/reachability.html +221 -0
  53. package/static/dashboard/reachability.svg +7 -0
  54. package/static/dashboard/reachability.webmanifest +18 -0
  55. package/static/dashboard/signal-pod.html +293 -0
  56. package/static/dashboard/assets/dashboard-CTk9cn1-.js +0 -52
  57. package/static/dashboard/assets/dashboard-VQRsvXx9.css +0 -1
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Xpod Dashboard</title>
7
- <script type="module" crossorigin src="/dashboard/assets/dashboard-CTk9cn1-.js"></script>
8
- <link rel="stylesheet" crossorigin href="/dashboard/assets/dashboard-VQRsvXx9.css">
7
+ <script type="module" crossorigin src="/dashboard/assets/dashboard-Ckuadl3C.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/dashboard/assets/dashboard-BssaVKTn.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -0,0 +1,221 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0f172a">
7
+ <link rel="manifest" href="/app/reachability.webmanifest">
8
+ <link rel="icon" href="/app/reachability.svg" type="image/svg+xml">
9
+ <title>Xpod Reachability Verifier</title>
10
+ <style>
11
+ :root {
12
+ color-scheme: light dark;
13
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ background: #0f172a;
15
+ color: #e2e8f0;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body {
19
+ margin: 0;
20
+ min-height: 100vh;
21
+ padding: 24px;
22
+ background: radial-gradient(circle at top, #1e3a8a 0, #0f172a 42%, #020617 100%);
23
+ }
24
+ main {
25
+ width: min(720px, 100%);
26
+ margin: 0 auto;
27
+ padding: 24px;
28
+ border: 1px solid rgba(148, 163, 184, 0.28);
29
+ border-radius: 20px;
30
+ background: rgba(15, 23, 42, 0.84);
31
+ box-shadow: 0 24px 80px rgba(2, 6, 23, 0.42);
32
+ }
33
+ h1 { margin: 0 0 8px; font-size: clamp(26px, 6vw, 40px); }
34
+ p { line-height: 1.65; color: #cbd5e1; }
35
+ label { display: block; margin: 20px 0 8px; font-weight: 700; }
36
+ input {
37
+ width: 100%;
38
+ min-height: 48px;
39
+ padding: 12px 14px;
40
+ border: 1px solid #475569;
41
+ border-radius: 12px;
42
+ background: #020617;
43
+ color: #f8fafc;
44
+ font: inherit;
45
+ }
46
+ .actions { display: flex; flex-wrap: wrap; gap: 12px; margin: 16px 0 22px; }
47
+ button, a.button {
48
+ min-height: 44px;
49
+ padding: 10px 16px;
50
+ border: 0;
51
+ border-radius: 999px;
52
+ background: #38bdf8;
53
+ color: #082f49;
54
+ font-weight: 800;
55
+ text-decoration: none;
56
+ cursor: pointer;
57
+ }
58
+ button.secondary, a.secondary { background: #334155; color: #e2e8f0; }
59
+ dl {
60
+ display: grid;
61
+ grid-template-columns: minmax(96px, auto) 1fr;
62
+ gap: 8px 14px;
63
+ padding: 14px;
64
+ border-radius: 14px;
65
+ background: rgba(15, 23, 42, 0.78);
66
+ }
67
+ dt { color: #94a3b8; }
68
+ dd { margin: 0; overflow-wrap: anywhere; }
69
+ pre {
70
+ min-height: 140px;
71
+ white-space: pre-wrap;
72
+ overflow-wrap: anywhere;
73
+ padding: 14px;
74
+ border-radius: 14px;
75
+ background: #020617;
76
+ color: #e2e8f0;
77
+ border: 1px solid rgba(148, 163, 184, 0.25);
78
+ }
79
+ .ok { color: #86efac; }
80
+ .fail { color: #fca5a5; }
81
+ .warn { color: #fde68a; }
82
+ .hint { font-size: 14px; color: #94a3b8; }
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <main>
87
+ <h1>Xpod Reachability Verifier</h1>
88
+ <p>
89
+ 这个页面用于手机或浏览器最小验证:从当前 SP 域名发起同源请求,默认读取
90
+ <code>/.well-known/openid-configuration</code> 这个公开 CSS discovery endpoint。
91
+ 它不使用 Cloud API relay 分享链接。
92
+ </p>
93
+ <p class="hint">
94
+ 浏览器可以验证普通 HTTP(S) 数据面;P2P 打洞、localhost/LAN 私有路由仍需要 Desktop / CLI / Native managed client。
95
+ </p>
96
+
97
+ <label for="resourcePath">资源路径或同源 URL</label>
98
+ <input id="resourcePath" autocomplete="off" inputmode="url" value="/.well-known/openid-configuration">
99
+
100
+ <div class="actions">
101
+ <button id="fetchButton" type="button">Fetch 验证</button>
102
+ <button id="openButton" class="secondary" type="button">直接打开资源</button>
103
+ <button id="copyButton" class="secondary" type="button">复制资源 URL</button>
104
+ </div>
105
+
106
+ <dl>
107
+ <dt>当前 Origin</dt><dd id="origin"></dd>
108
+ <dt>目标 URL</dt><dd id="target"></dd>
109
+ <dt>结果</dt><dd id="status">未验证</dd>
110
+ <dt>耗时</dt><dd id="duration">-</dd>
111
+ </dl>
112
+
113
+ <h2>响应预览</h2>
114
+ <pre id="output">点击 “Fetch 验证” 后显示状态、响应头和前 8 KiB 内容。</pre>
115
+ </main>
116
+
117
+ <script>
118
+ const DEFAULT_PATH = '/.well-known/openid-configuration';
119
+ const resourcePath = document.getElementById('resourcePath');
120
+ const originText = document.getElementById('origin');
121
+ const targetText = document.getElementById('target');
122
+ const statusText = document.getElementById('status');
123
+ const durationText = document.getElementById('duration');
124
+ const output = document.getElementById('output');
125
+
126
+ function setStatus(message, ok) {
127
+ statusText.textContent = message;
128
+ statusText.className = ok === true ? 'ok' : ok === false ? 'fail' : ok === 'warn' ? 'warn' : '';
129
+ }
130
+
131
+ function resolveTargetUrl() {
132
+ const raw = resourcePath.value.trim() || DEFAULT_PATH;
133
+ const targetUrl = new URL(raw, `${window.location.origin}/`);
134
+ if (targetUrl.origin !== window.location.origin) {
135
+ throw new Error('浏览器 fetch 验证只支持同源 URL;跨域测试请点击“直接打开资源”。');
136
+ }
137
+ return targetUrl;
138
+ }
139
+
140
+ function syncTarget() {
141
+ try {
142
+ const targetUrl = resolveTargetUrl();
143
+ targetText.textContent = targetUrl.href;
144
+ return targetUrl;
145
+ } catch (error) {
146
+ targetText.textContent = error.message;
147
+ return null;
148
+ }
149
+ }
150
+
151
+ async function verifyFetch() {
152
+ const targetUrl = syncTarget();
153
+ if (!targetUrl) {
154
+ setStatus('URL 无效', false);
155
+ return;
156
+ }
157
+ const startedAt = performance.now();
158
+ setStatus('请求中...', null);
159
+ durationText.textContent = '-';
160
+ output.textContent = '';
161
+ try {
162
+ const response = await fetch(targetUrl.href, { method: 'GET', cache: 'no-store', credentials: 'include' });
163
+ const elapsed = Math.round(performance.now() - startedAt);
164
+ const text = await response.text();
165
+ const needsLogin = response.status === 401 || response.status === 403;
166
+ const headers = Array.from(response.headers.entries())
167
+ .map(([name, value]) => `${name}: ${value}`)
168
+ .join('\n');
169
+ setStatus(
170
+ needsLogin
171
+ ? `${response.status} ${response.statusText || ''}(CSS 可达,但目标资源需要登录)`.trim()
172
+ : `${response.status} ${response.statusText || ''}`.trim(),
173
+ needsLogin ? 'warn' : response.ok,
174
+ );
175
+ durationText.textContent = `${elapsed} ms`;
176
+ output.textContent = [
177
+ `URL: ${targetUrl.href}`,
178
+ `Status: ${response.status} ${response.statusText}`,
179
+ '',
180
+ 'Headers:',
181
+ headers || '(none)',
182
+ '',
183
+ 'Body preview:',
184
+ text.slice(0, 8192) || '(empty body)',
185
+ ].join('\n');
186
+ } catch (error) {
187
+ const elapsed = Math.round(performance.now() - startedAt);
188
+ setStatus('网络错误', false);
189
+ durationText.textContent = `${elapsed} ms`;
190
+ output.textContent = [
191
+ String(error && error.message ? error.message : error),
192
+ '',
193
+ '排查:确认手机能解析并访问当前 SP 域名;确认 gateway 已按 Host 路由到 node tunnel entrypoint;确认资源公开或当前浏览器已有登录态。',
194
+ ].join('\n');
195
+ }
196
+ }
197
+
198
+ function openDirect() {
199
+ const targetUrl = syncTarget();
200
+ if (targetUrl) window.location.assign(targetUrl.href);
201
+ }
202
+
203
+ async function copyUrl() {
204
+ const targetUrl = syncTarget();
205
+ if (!targetUrl) return;
206
+ await navigator.clipboard.writeText(targetUrl.href);
207
+ setStatus('已复制 URL', true);
208
+ }
209
+
210
+ const requestedPath = new URLSearchParams(window.location.search).get('path');
211
+ if (requestedPath) resourcePath.value = requestedPath;
212
+ originText.textContent = window.location.origin;
213
+ syncTarget();
214
+
215
+ resourcePath.addEventListener('input', syncTarget);
216
+ document.getElementById('fetchButton').addEventListener('click', verifyFetch);
217
+ document.getElementById('openButton').addEventListener('click', openDirect);
218
+ document.getElementById('copyButton').addEventListener('click', copyUrl);
219
+ </script>
220
+ </body>
221
+ </html>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
2
+ <rect width="128" height="128" rx="28" fill="#0f172a"/>
3
+ <path d="M34 69c0-16.6 13.4-30 30-30 8.7 0 16.5 3.7 22 9.6" fill="none" stroke="#38bdf8" stroke-width="10" stroke-linecap="round"/>
4
+ <path d="M94 59c0 16.6-13.4 30-30 30-8.7 0-16.5-3.7-22-9.6" fill="none" stroke="#86efac" stroke-width="10" stroke-linecap="round"/>
5
+ <path d="M85 27v23h23" fill="none" stroke="#38bdf8" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
6
+ <path d="M43 101V78H20" fill="none" stroke="#86efac" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
7
+ </svg>
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "Xpod Reachability Verifier",
3
+ "short_name": "Xpod Verify",
4
+ "description": "Minimal same-origin browser verifier for Xpod SP domain reachability.",
5
+ "start_url": "/app/reachability.html",
6
+ "scope": "/app/",
7
+ "display": "standalone",
8
+ "background_color": "#0f172a",
9
+ "theme_color": "#0f172a",
10
+ "icons": [
11
+ {
12
+ "src": "/app/reachability.svg",
13
+ "sizes": "any",
14
+ "type": "image/svg+xml",
15
+ "purpose": "any maskable"
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,293 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#111827">
7
+ <link rel="icon" href="/app/reachability.svg" type="image/svg+xml">
8
+ <title>Xpod Signal Pod Verifier</title>
9
+ <style>
10
+ :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #020617; color: #e5e7eb; }
11
+ * { box-sizing: border-box; }
12
+ body { margin: 0; min-height: 100vh; padding: 20px; background: radial-gradient(circle at top, rgba(14,165,233,.32), transparent 38%), #020617; }
13
+ main { width: min(860px, 100%); margin: 0 auto; }
14
+ header, section { margin: 0 0 16px; padding: 18px; border: 1px solid rgba(148,163,184,.25); border-radius: 18px; background: rgba(15,23,42,.88); box-shadow: 0 18px 60px rgba(0,0,0,.28); }
15
+ h1 { margin: 0 0 8px; font-size: clamp(26px, 6vw, 42px); }
16
+ h2 { margin: 0 0 12px; font-size: 20px; }
17
+ p, li { color: #cbd5e1; line-height: 1.62; }
18
+ code { color: #93c5fd; }
19
+ label { display: block; margin: 12px 0 7px; font-weight: 700; }
20
+ input, select, textarea { width: 100%; min-height: 42px; padding: 10px 12px; border: 1px solid #475569; border-radius: 12px; background: #020617; color: #f8fafc; font: inherit; }
21
+ textarea { min-height: 180px; font: 12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; resize: vertical; }
22
+ button { min-height: 42px; margin: 7px 7px 7px 0; padding: 10px 14px; border: 0; border-radius: 999px; background: #38bdf8; color: #082f49; font-weight: 800; cursor: pointer; }
23
+ button.secondary { background: #334155; color: #e2e8f0; }
24
+ button:disabled { opacity: .48; cursor: not-allowed; }
25
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
26
+ dl { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 8px 12px; padding: 12px; border-radius: 14px; background: rgba(2,6,23,.62); }
27
+ dt { color: #94a3b8; } dd { margin: 0; overflow-wrap: anywhere; }
28
+ pre { min-height: 160px; white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; border-radius: 14px; background: #020617; border: 1px solid rgba(148,163,184,.22); color: #d1d5db; font-size: 12px; }
29
+ .ok { color: #86efac; } .fail { color: #fca5a5; } .warn { color: #fde68a; } .small { font-size: 13px; color: #94a3b8; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <main>
34
+ <header>
35
+ <h1>Xpod Signal Pod Verifier</h1>
36
+ <p>
37
+ 这个页面用我们的 <code>/v1/signal</code> 做入口验证基本 Pod 能力:先读取 route set,按需创建
38
+ <code>sessions</code>,再用 signaling 返回的 route 去请求 Pod 资源。
39
+ </p>
40
+ <p class="small">这不是手动 WebRTC demo;它遥测的是 Xpod signaling API 本身。P2P session 当前验证到“session 创建、候选返回和落库”,真正候选交换/连接需要后续 worker 端实现。</p>
41
+ </header>
42
+
43
+ <section>
44
+ <h2>配置</h2>
45
+ <div class="grid">
46
+ <div>
47
+ <label for="apiBase">Signal API Base</label>
48
+ <input id="apiBase" inputmode="url" autocomplete="url">
49
+ </div>
50
+ <div>
51
+ <label for="nodeId">Node ID</label>
52
+ <input id="nodeId" autocomplete="off" placeholder="node-0000">
53
+ </div>
54
+ <div>
55
+ <label for="nodeToken">Node Token(可选;创建 session 需要)</label>
56
+ <input id="nodeToken" autocomplete="off" placeholder="node token">
57
+ </div>
58
+ <div>
59
+ <label for="clientId">Client ID</label>
60
+ <input id="clientId" autocomplete="off">
61
+ </div>
62
+ <div>
63
+ <label for="resourcePath">Pod 资源路径</label>
64
+ <input id="resourcePath" autocomplete="off" value="/alice/a.txt">
65
+ </div>
66
+ <div>
67
+ <label for="routeKind">优先 route kind</label>
68
+ <select id="routeKind">
69
+ <option value="">自动选择</option>
70
+ <option value="public-direct">public-direct</option>
71
+ <option value="user-tunnel">user-tunnel</option>
72
+ <option value="xpod-relay">xpod-relay/session canonical</option>
73
+ <option value="loopback">loopback</option>
74
+ <option value="lan">lan</option>
75
+ </select>
76
+ </div>
77
+ </div>
78
+ <button id="routesButton" type="button">1. 读取 Routes</button>
79
+ <button id="sessionButton" type="button">2. 创建 Session</button>
80
+ <button id="verifyButton" type="button">3. 按返回 Route 验 Pod</button>
81
+ <button class="secondary" id="copyButton" type="button">复制报告</button>
82
+ <dl>
83
+ <dt>状态</dt><dd id="status">未开始</dd>
84
+ <dt>Signal URL</dt><dd id="signalUrl">-</dd>
85
+ <dt>Resource URL</dt><dd id="resourceUrl">-</dd>
86
+ <dt>Route</dt><dd id="selectedRoute">-</dd>
87
+ </dl>
88
+ </section>
89
+
90
+ <section>
91
+ <h2>报告</h2>
92
+ <textarea id="report" readonly></textarea>
93
+ </section>
94
+ </main>
95
+
96
+ <script>
97
+ const $ = (id) => document.getElementById(id);
98
+ const state = { routes: null, session: null, selectedRoute: null, resourceResult: null };
99
+
100
+ function initialClientId() {
101
+ const existing = localStorage.getItem('xpod.signalPod.clientId');
102
+ if (existing) return existing;
103
+ const value = `browser_${Math.random().toString(36).slice(2, 10)}`;
104
+ localStorage.setItem('xpod.signalPod.clientId', value);
105
+ return value;
106
+ }
107
+
108
+ function setStatus(message, kind = '') {
109
+ $('status').textContent = message;
110
+ $('status').className = kind;
111
+ }
112
+
113
+ function normalizeBase(value) {
114
+ return new URL(value || window.location.origin, `${window.location.origin}/`);
115
+ }
116
+
117
+ function signalUrl(suffix) {
118
+ const nodeId = $('nodeId').value.trim();
119
+ if (!nodeId) throw new Error('Node ID 必填');
120
+ const base = normalizeBase($('apiBase').value.trim());
121
+ return new URL(`/v1/signal/nodes/${encodeURIComponent(nodeId)}${suffix}`, base);
122
+ }
123
+
124
+ function authHeaders(includeJson = false) {
125
+ const headers = new Headers();
126
+ if (includeJson) headers.set('content-type', 'application/json');
127
+ const nodeId = $('nodeId').value.trim();
128
+ const token = $('nodeToken').value.trim();
129
+ if (token) {
130
+ headers.set('authorization', `Bearer ${token}`);
131
+ headers.set('x-node-id', nodeId);
132
+ }
133
+ return headers;
134
+ }
135
+
136
+ async function fetchJson(signalUrl, init = {}) {
137
+ const response = await fetch(signalUrl.href, init);
138
+ const text = await response.text();
139
+ let body;
140
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
141
+ if (!response.ok) {
142
+ const error = new Error(`HTTP ${response.status}: ${text || response.statusText}`);
143
+ error.response = { status: response.status, body };
144
+ throw error;
145
+ }
146
+ return { status: response.status, body };
147
+ }
148
+
149
+ async function loadRoutes() {
150
+ try {
151
+ setStatus('读取 routes...', 'warn');
152
+ const url = signalUrl('/routes');
153
+ $('signalUrl').textContent = url.href;
154
+ const result = await fetchJson(url, { method: 'GET', headers: authHeaders() });
155
+ state.routes = result.body;
156
+ state.selectedRoute = selectRoute(result.body.routes || []);
157
+ updateSelectedRoute();
158
+ setStatus('routes ok', 'ok');
159
+ writeReport();
160
+ } catch (error) {
161
+ fail(error);
162
+ }
163
+ }
164
+
165
+ async function createSession() {
166
+ try {
167
+ setStatus('创建 p2p session...', 'warn');
168
+ const url = signalUrl('/sessions');
169
+ $('signalUrl').textContent = url.href;
170
+ const candidates = [{ type: 'browser', origin: window.location.origin, userAgent: navigator.userAgent }];
171
+ const result = await fetchJson(url, {
172
+ method: 'POST',
173
+ headers: authHeaders(true),
174
+ body: JSON.stringify({
175
+ kind: 'p2p',
176
+ clientId: $('clientId').value.trim() || initialClientId(),
177
+ capabilities: ['browser-signal-pod-smoke'],
178
+ candidates,
179
+ }),
180
+ });
181
+ state.session = result.body;
182
+ const nodeCandidates = Array.isArray(result.body.nodeCandidates) ? result.body.nodeCandidates : [];
183
+ state.selectedRoute = selectRoute(nodeCandidates.length ? nodeCandidates : (state.routes?.routes || []));
184
+ updateSelectedRoute();
185
+ setStatus('p2p session ok', 'ok');
186
+ writeReport();
187
+ } catch (error) {
188
+ fail(error);
189
+ }
190
+ }
191
+
192
+ function selectRoute(routes) {
193
+ const preferred = $('routeKind').value;
194
+ const candidates = routes.filter((route) => route && typeof route.targetUrl === 'string');
195
+ if (preferred) {
196
+ const exact = candidates.find((route) => route.kind === preferred);
197
+ if (exact) return exact;
198
+ }
199
+ return candidates.find((route) => route.visibility === 'public' && !route.requiresManagedClient)
200
+ || candidates.find((route) => !route.requiresManagedClient)
201
+ || candidates[0]
202
+ || null;
203
+ }
204
+
205
+ function buildResourceUrl(route) {
206
+ if (!route) throw new Error('没有可用 route;先读取 routes 或创建 session');
207
+ const resourcePath = $('resourcePath').value.trim() || '/alice/a.txt';
208
+ return new URL(resourcePath.startsWith('/') ? resourcePath : `/${resourcePath}`, route.targetUrl);
209
+ }
210
+
211
+ function updateSelectedRoute() {
212
+ const route = state.selectedRoute;
213
+ $('selectedRoute').textContent = route ? `${route.kind} -> ${route.targetUrl}` : '-';
214
+ try {
215
+ $('resourceUrl').textContent = route ? buildResourceUrl(route).href : '-';
216
+ } catch (error) {
217
+ $('resourceUrl').textContent = error.message;
218
+ }
219
+ }
220
+
221
+ async function verifyPodResource() {
222
+ try {
223
+ if (!state.selectedRoute) {
224
+ if (!state.routes) await loadRoutes();
225
+ state.selectedRoute = selectRoute(state.routes?.routes || []);
226
+ }
227
+ const resourceUrl = buildResourceUrl(state.selectedRoute);
228
+ $('resourceUrl').textContent = resourceUrl.href;
229
+ setStatus('请求 Pod 资源...', 'warn');
230
+ const startedAt = performance.now();
231
+ const response = await fetch(resourceUrl.href, { method: 'GET', cache: 'no-store', credentials: 'include' });
232
+ const text = await response.text();
233
+ state.resourceResult = {
234
+ url: resourceUrl.href,
235
+ status: response.status,
236
+ ok: response.ok,
237
+ elapsedMs: Math.round(performance.now() - startedAt),
238
+ headers: Array.from(response.headers.entries()),
239
+ bodyPreview: text.slice(0, 8192),
240
+ };
241
+ setStatus(response.ok ? 'Pod resource ok' : `Pod resource HTTP ${response.status}`, response.ok ? 'ok' : 'fail');
242
+ writeReport();
243
+ } catch (error) {
244
+ fail(error);
245
+ }
246
+ }
247
+
248
+ function fail(error) {
249
+ setStatus(error.message || String(error), 'fail');
250
+ state.lastError = { message: error.message || String(error), response: error.response };
251
+ writeReport();
252
+ }
253
+
254
+ function writeReport() {
255
+ $('report').value = JSON.stringify({
256
+ generatedAt: new Date().toISOString(),
257
+ apiBase: $('apiBase').value,
258
+ nodeId: $('nodeId').value,
259
+ clientId: $('clientId').value,
260
+ routes: state.routes,
261
+ p2pSession: state.session,
262
+ nodeCandidates: state.session?.nodeCandidates,
263
+ selectedRoute: state.selectedRoute,
264
+ resourceResult: state.resourceResult,
265
+ lastError: state.lastError,
266
+ }, null, 2);
267
+ }
268
+
269
+ async function copyReport() {
270
+ await navigator.clipboard.writeText($('report').value);
271
+ setStatus('报告已复制', 'ok');
272
+ }
273
+
274
+ const params = new URLSearchParams(window.location.search);
275
+ $('apiBase').value = params.get('api') || window.location.origin;
276
+ $('nodeId').value = params.get('nodeId') || '';
277
+ $('resourcePath').value = params.get('path') || '/alice/a.txt';
278
+ $('clientId').value = params.get('clientId') || initialClientId();
279
+ updateSelectedRoute();
280
+ writeReport();
281
+
282
+ $('routesButton').addEventListener('click', loadRoutes);
283
+ $('sessionButton').addEventListener('click', createSession);
284
+ $('verifyButton').addEventListener('click', verifyPodResource);
285
+ $('copyButton').addEventListener('click', copyReport);
286
+ $('routeKind').addEventListener('change', () => {
287
+ state.selectedRoute = selectRoute(state.session?.nodeCandidates || state.routes?.routes || []);
288
+ updateSelectedRoute();
289
+ writeReport();
290
+ });
291
+ </script>
292
+ </body>
293
+ </html>