javascript-solid-server 0.0.108 → 0.0.109
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.
- package/README.md +32 -0
- package/bin/jss.js +6 -0
- package/package.json +1 -1
- package/src/config.js +6 -0
- package/src/server.js +14 -1
- package/src/tunnel/index.js +226 -0
- package/test/tunnel.test.js +202 -0
- package/test-webrtc-smoke.mjs +0 -90
package/README.md
CHANGED
|
@@ -164,6 +164,8 @@ jss --help # Show help
|
|
|
164
164
|
| `--mongo-database <name>` | MongoDB database name | solid |
|
|
165
165
|
| `--webrtc` | Enable WebRTC signaling server | false |
|
|
166
166
|
| `--webrtc-path <path>` | WebRTC signaling WebSocket path | /.webrtc |
|
|
167
|
+
| `--tunnel` | Enable tunnel proxy (decentralized ngrok) | false |
|
|
168
|
+
| `--tunnel-path <path>` | Tunnel WebSocket path | /.tunnel |
|
|
167
169
|
| `-q, --quiet` | Suppress logs | false |
|
|
168
170
|
|
|
169
171
|
### Environment Variables
|
|
@@ -862,6 +864,36 @@ Messages are JSON over WebSocket:
|
|
|
862
864
|
|
|
863
865
|
On connect, peers receive a list of online users and get notified when others join or leave.
|
|
864
866
|
|
|
867
|
+
## Tunnel Proxy (Decentralized ngrok)
|
|
868
|
+
|
|
869
|
+
Expose a local dev server to the internet through your JSS pod. A tunnel client connects via WebSocket, registers a name, and receives proxied HTTP requests.
|
|
870
|
+
|
|
871
|
+
```bash
|
|
872
|
+
jss start --tunnel
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### How It Works
|
|
876
|
+
|
|
877
|
+
1. Tunnel client connects to `wss://your.pod/.tunnel` (WebID auth required)
|
|
878
|
+
2. Client registers a name: `{ "type": "register", "name": "myapp" }`
|
|
879
|
+
3. Public URL becomes available at `https://your.pod/tunnel/myapp/`
|
|
880
|
+
4. HTTP requests to that URL are serialized and sent to the tunnel client over WebSocket
|
|
881
|
+
5. Tunnel client forwards to localhost, returns the response
|
|
882
|
+
|
|
883
|
+
### Tunnel Client Protocol
|
|
884
|
+
|
|
885
|
+
```js
|
|
886
|
+
// 1. Register a tunnel
|
|
887
|
+
→ { "type": "register", "name": "myapp" }
|
|
888
|
+
← { "type": "registered", "name": "myapp", "url": "/tunnel/myapp/" }
|
|
889
|
+
|
|
890
|
+
// 2. Receive proxied HTTP requests
|
|
891
|
+
← { "type": "request", "id": "uuid", "method": "GET", "path": "/api/hello", "headers": {...} }
|
|
892
|
+
|
|
893
|
+
// 3. Return the response
|
|
894
|
+
→ { "type": "response", "id": "uuid", "status": 200, "headers": {...}, "body": "..." }
|
|
895
|
+
```
|
|
896
|
+
|
|
865
897
|
## HTTP 402 Paid Access
|
|
866
898
|
|
|
867
899
|
Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance.
|
package/bin/jss.js
CHANGED
|
@@ -68,6 +68,9 @@ program
|
|
|
68
68
|
.option('--webrtc', 'Enable WebRTC signaling server')
|
|
69
69
|
.option('--no-webrtc', 'Disable WebRTC signaling server')
|
|
70
70
|
.option('--webrtc-path <path>', 'WebRTC signaling WebSocket path (default: /.webrtc)')
|
|
71
|
+
.option('--tunnel', 'Enable tunnel proxy (decentralized ngrok)')
|
|
72
|
+
.option('--no-tunnel', 'Disable tunnel proxy')
|
|
73
|
+
.option('--tunnel-path <path>', 'Tunnel WebSocket path (default: /.tunnel)')
|
|
71
74
|
.option('--activitypub', 'Enable ActivityPub federation')
|
|
72
75
|
.option('--no-activitypub', 'Disable ActivityPub federation')
|
|
73
76
|
.option('--ap-username <name>', 'ActivityPub username (default: me)')
|
|
@@ -147,6 +150,8 @@ program
|
|
|
147
150
|
nostrMaxEvents: config.nostrMaxEvents,
|
|
148
151
|
webrtc: config.webrtc,
|
|
149
152
|
webrtcPath: config.webrtcPath,
|
|
153
|
+
tunnel: config.tunnel,
|
|
154
|
+
tunnelPath: config.tunnelPath,
|
|
150
155
|
activitypub: config.activitypub,
|
|
151
156
|
apUsername: config.apUsername,
|
|
152
157
|
apDisplayName: config.apDisplayName,
|
|
@@ -192,6 +197,7 @@ program
|
|
|
192
197
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
193
198
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
194
199
|
if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`);
|
|
200
|
+
if (config.tunnel) console.log(` Tunnel: enabled (${config.tunnelPath || '/.tunnel'})`);
|
|
195
201
|
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
|
|
196
202
|
if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`);
|
|
197
203
|
else if (config.inviteOnly) console.log(' Registration: invite-only');
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -58,6 +58,10 @@ export const defaults = {
|
|
|
58
58
|
webrtc: false,
|
|
59
59
|
webrtcPath: '/.webrtc',
|
|
60
60
|
|
|
61
|
+
// Tunnel (decentralized ngrok)
|
|
62
|
+
tunnel: false,
|
|
63
|
+
tunnelPath: '/.tunnel',
|
|
64
|
+
|
|
61
65
|
// ActivityPub federation
|
|
62
66
|
activitypub: false,
|
|
63
67
|
apUsername: 'me',
|
|
@@ -140,6 +144,8 @@ const envMap = {
|
|
|
140
144
|
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
|
|
141
145
|
JSS_WEBRTC: 'webrtc',
|
|
142
146
|
JSS_WEBRTC_PATH: 'webrtcPath',
|
|
147
|
+
JSS_TUNNEL: 'tunnel',
|
|
148
|
+
JSS_TUNNEL_PATH: 'tunnelPath',
|
|
143
149
|
JSS_ACTIVITYPUB: 'activitypub',
|
|
144
150
|
JSS_AP_USERNAME: 'apUsername',
|
|
145
151
|
JSS_AP_DISPLAY_NAME: 'apDisplayName',
|
package/src/server.js
CHANGED
|
@@ -19,6 +19,7 @@ import { activityPubPlugin, getActorHandler } from './ap/index.js';
|
|
|
19
19
|
import { remoteStoragePlugin } from './remotestorage.js';
|
|
20
20
|
import { dbPlugin } from './db/index.js';
|
|
21
21
|
import { webrtcPlugin } from './webrtc/index.js';
|
|
22
|
+
import { tunnelPlugin } from './tunnel/index.js';
|
|
22
23
|
|
|
23
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
|
|
@@ -78,6 +79,9 @@ export function createServer(options = {}) {
|
|
|
78
79
|
// WebRTC signaling is OFF by default
|
|
79
80
|
const webrtcEnabled = options.webrtc ?? false;
|
|
80
81
|
const webrtcPath = options.webrtcPath ?? '/.webrtc';
|
|
82
|
+
// Tunnel proxy is OFF by default
|
|
83
|
+
const tunnelEnabled = options.tunnel ?? false;
|
|
84
|
+
const tunnelPath = options.tunnelPath ?? '/.tunnel';
|
|
81
85
|
// ActivityPub federation is OFF by default
|
|
82
86
|
const activitypubEnabled = options.activitypub ?? false;
|
|
83
87
|
const apUsername = options.apUsername ?? 'me';
|
|
@@ -249,6 +253,11 @@ export function createServer(options = {}) {
|
|
|
249
253
|
fastify.register(webrtcPlugin, { path: webrtcPath });
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
// Register tunnel proxy if enabled
|
|
257
|
+
if (tunnelEnabled) {
|
|
258
|
+
fastify.register(tunnelPlugin, { path: tunnelPath });
|
|
259
|
+
}
|
|
260
|
+
|
|
252
261
|
// Register ActivityPub plugin if enabled
|
|
253
262
|
if (activitypubEnabled) {
|
|
254
263
|
fastify.register(activityPubPlugin, {
|
|
@@ -340,8 +349,11 @@ export function createServer(options = {}) {
|
|
|
340
349
|
return;
|
|
341
350
|
}
|
|
342
351
|
|
|
343
|
-
// Allow WebRTC
|
|
352
|
+
// Allow WebRTC and tunnel endpoints through when enabled
|
|
344
353
|
const urlNoQuery = request.url.split('?')[0];
|
|
354
|
+
if (tunnelEnabled && (urlNoQuery === tunnelPath || urlNoQuery.startsWith('/tunnel/'))) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
345
357
|
if (webrtcEnabled && urlNoQuery === webrtcPath) {
|
|
346
358
|
return;
|
|
347
359
|
}
|
|
@@ -421,6 +433,7 @@ export function createServer(options = {}) {
|
|
|
421
433
|
(payEnabled && isPayRequest(request.url)) ||
|
|
422
434
|
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
|
|
423
435
|
(webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
|
|
436
|
+
(tunnelEnabled && (request.url === tunnelPath || request.url.startsWith(tunnelPath + '?') || request.url.startsWith('/tunnel/'))) ||
|
|
424
437
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
425
438
|
return;
|
|
426
439
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel Plugin — Decentralized ngrok
|
|
3
|
+
*
|
|
4
|
+
* Tunnels HTTP traffic to a local dev server through JSS via WebSocket.
|
|
5
|
+
* A tunnel client connects over WebSocket, registers a name, and receives
|
|
6
|
+
* proxied HTTP requests which it forwards to localhost.
|
|
7
|
+
*
|
|
8
|
+
* Usage: jss start --tunnel
|
|
9
|
+
* Tunnel client connects to: wss://your.pod/.tunnel
|
|
10
|
+
* Public URL: https://your.pod/tunnel/{name}/path
|
|
11
|
+
*
|
|
12
|
+
* Tunnel client protocol (JSON over WebSocket):
|
|
13
|
+
* → { type: "register", name: "myapp" }
|
|
14
|
+
* ← { type: "registered", name: "myapp", url: "/tunnel/myapp/" }
|
|
15
|
+
* ← { type: "request", id: "<uuid>", method: "GET", path: "/api/hello", headers: {...}, body: "..." }
|
|
16
|
+
* → { type: "response", id: "<uuid>", status: 200, headers: {...}, body: "..." }
|
|
17
|
+
* ← { type: "error", message: "..." }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import websocket from '@fastify/websocket';
|
|
21
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
22
|
+
import { randomUUID } from 'crypto';
|
|
23
|
+
|
|
24
|
+
const REQUEST_TIMEOUT = 30000; // 30s timeout for tunnel responses
|
|
25
|
+
const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} fastify - Fastify instance
|
|
29
|
+
* @param {object} options - Options
|
|
30
|
+
* @param {string} options.path - WebSocket path for tunnel clients (default: '/.tunnel')
|
|
31
|
+
*/
|
|
32
|
+
export async function tunnelPlugin(fastify, options = {}) {
|
|
33
|
+
const wsPath = options.path || '/.tunnel';
|
|
34
|
+
|
|
35
|
+
// Instance-scoped: tunnel name → { socket, webId }
|
|
36
|
+
const tunnels = new Map();
|
|
37
|
+
// Pending HTTP requests waiting for tunnel response: id → { resolve, timer }
|
|
38
|
+
const pending = new Map();
|
|
39
|
+
|
|
40
|
+
if (!fastify.websocketServer) {
|
|
41
|
+
await fastify.register(websocket);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fastify.addHook('onClose', async () => {
|
|
45
|
+
for (const [, tunnel] of tunnels) {
|
|
46
|
+
tunnel.socket.close();
|
|
47
|
+
}
|
|
48
|
+
tunnels.clear();
|
|
49
|
+
for (const [, p] of pending) {
|
|
50
|
+
clearTimeout(p.timer);
|
|
51
|
+
p.resolve({ status: 502, headers: {}, body: 'Tunnel shutting down' });
|
|
52
|
+
}
|
|
53
|
+
pending.clear();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// WebSocket endpoint for tunnel clients
|
|
57
|
+
fastify.get(wsPath, { websocket: true }, async (connection, request) => {
|
|
58
|
+
const socket = connection.socket;
|
|
59
|
+
|
|
60
|
+
// Authenticate
|
|
61
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
62
|
+
if (!webId) {
|
|
63
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
|
|
64
|
+
socket.close();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let tunnelName = null;
|
|
69
|
+
|
|
70
|
+
socket.on('message', (data) => {
|
|
71
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
72
|
+
if (raw.byteLength > MAX_MESSAGE_SIZE) {
|
|
73
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Message too large' }));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let msg;
|
|
78
|
+
try {
|
|
79
|
+
msg = JSON.parse(raw.toString());
|
|
80
|
+
} catch {
|
|
81
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msg.type === 'register') {
|
|
86
|
+
// Register a tunnel name
|
|
87
|
+
const name = (msg.name || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
88
|
+
if (!name) {
|
|
89
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Invalid tunnel name' }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const existing = tunnels.get(name);
|
|
94
|
+
if (existing && existing.webId !== webId) {
|
|
95
|
+
socket.send(JSON.stringify({ type: 'error', message: 'Tunnel name taken by another user' }));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Close old tunnel with same name from same user
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.socket.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
tunnelName = name;
|
|
105
|
+
tunnels.set(name, { socket, webId });
|
|
106
|
+
socket.send(JSON.stringify({ type: 'registered', name, url: `/tunnel/${name}/` }));
|
|
107
|
+
|
|
108
|
+
} else if (msg.type === 'response') {
|
|
109
|
+
// Tunnel client returning an HTTP response
|
|
110
|
+
if (!msg.id) return;
|
|
111
|
+
const p = pending.get(msg.id);
|
|
112
|
+
if (p) {
|
|
113
|
+
clearTimeout(p.timer);
|
|
114
|
+
pending.delete(msg.id);
|
|
115
|
+
p.resolve({
|
|
116
|
+
status: msg.status || 502,
|
|
117
|
+
headers: msg.headers || {},
|
|
118
|
+
body: msg.body || '',
|
|
119
|
+
bodyEncoding: msg.bodyEncoding
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
socket.on('close', () => {
|
|
126
|
+
if (tunnelName && tunnels.get(tunnelName)?.socket === socket) {
|
|
127
|
+
tunnels.delete(tunnelName);
|
|
128
|
+
// Resolve pending requests for this tunnel only with 502
|
|
129
|
+
for (const [id, p] of pending) {
|
|
130
|
+
if (p.tunnelName === tunnelName) {
|
|
131
|
+
clearTimeout(p.timer);
|
|
132
|
+
pending.delete(id);
|
|
133
|
+
p.resolve({ status: 502, headers: {}, body: 'Tunnel disconnected' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
socket.on('error', () => {});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// HTTP proxy: /tunnel/{name}/*
|
|
143
|
+
fastify.all('/tunnel/:name/*', async (request, reply) => {
|
|
144
|
+
const { name } = request.params;
|
|
145
|
+
const tunnel = tunnels.get(name);
|
|
146
|
+
|
|
147
|
+
if (!tunnel || tunnel.socket.readyState !== 1) {
|
|
148
|
+
return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Build the downstream path (strip /tunnel/{name} prefix)
|
|
152
|
+
const fullPath = request.url.replace(`/tunnel/${name}`, '') || '/';
|
|
153
|
+
const id = randomUUID();
|
|
154
|
+
|
|
155
|
+
// Serialize the HTTP request
|
|
156
|
+
const tunnelReq = Object.create(null);
|
|
157
|
+
tunnelReq.type = 'request';
|
|
158
|
+
tunnelReq.id = id;
|
|
159
|
+
tunnelReq.method = request.method;
|
|
160
|
+
tunnelReq.path = fullPath;
|
|
161
|
+
tunnelReq.headers = Object.create(null);
|
|
162
|
+
// Forward relevant headers (skip hop-by-hop)
|
|
163
|
+
const skipHeaders = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'cookie', 'authorization', 'proxy-authorization']);
|
|
164
|
+
for (const [k, v] of Object.entries(request.headers)) {
|
|
165
|
+
if (!skipHeaders.has(k.toLowerCase())) {
|
|
166
|
+
tunnelReq.headers[k] = v;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Forward body if present
|
|
170
|
+
if (request.body) {
|
|
171
|
+
tunnelReq.body = Buffer.isBuffer(request.body)
|
|
172
|
+
? request.body.toString('base64')
|
|
173
|
+
: typeof request.body === 'string' ? request.body : JSON.stringify(request.body);
|
|
174
|
+
tunnelReq.bodyEncoding = Buffer.isBuffer(request.body) ? 'base64' : 'utf8';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Send to tunnel client and wait for response
|
|
178
|
+
const responsePromise = new Promise((resolve) => {
|
|
179
|
+
const timer = setTimeout(() => {
|
|
180
|
+
pending.delete(id);
|
|
181
|
+
resolve({ status: 504, headers: {}, body: 'Gateway Timeout' });
|
|
182
|
+
}, REQUEST_TIMEOUT);
|
|
183
|
+
pending.set(id, { resolve, timer, tunnelName: name });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
tunnel.socket.send(JSON.stringify(tunnelReq));
|
|
188
|
+
} catch {
|
|
189
|
+
const p = pending.get(id);
|
|
190
|
+
if (p) { clearTimeout(p.timer); pending.delete(id); }
|
|
191
|
+
return reply.code(502).send({ error: 'Bad Gateway', message: 'Failed to reach tunnel client' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const res = await responsePromise;
|
|
195
|
+
|
|
196
|
+
// Set response headers
|
|
197
|
+
const hopHeaders = new Set(['connection', 'transfer-encoding', 'keep-alive', 'set-cookie']);
|
|
198
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
199
|
+
if (!hopHeaders.has(k.toLowerCase())) {
|
|
200
|
+
reply.header(k, v);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Decode body if base64
|
|
205
|
+
const body = res.bodyEncoding === 'base64' && res.body
|
|
206
|
+
? Buffer.from(res.body, 'base64')
|
|
207
|
+
: res.body || '';
|
|
208
|
+
|
|
209
|
+
return reply.code(res.status).send(body);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Also handle /tunnel/{name} without trailing path
|
|
213
|
+
fastify.all('/tunnel/:name', async (request, reply) => {
|
|
214
|
+
// Redirect to add trailing slash, or proxy as root
|
|
215
|
+
const { name } = request.params;
|
|
216
|
+
const tunnel = tunnels.get(name);
|
|
217
|
+
|
|
218
|
+
if (!tunnel || tunnel.socket.readyState !== 1) {
|
|
219
|
+
return reply.code(502).send({ error: 'Bad Gateway', message: 'Tunnel not connected' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return reply.redirect(308, `/tunnel/${name}/`);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default tunnelPlugin;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel Proxy Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import {
|
|
9
|
+
startTestServer,
|
|
10
|
+
stopTestServer,
|
|
11
|
+
createTestPod,
|
|
12
|
+
getBaseUrl,
|
|
13
|
+
getPodToken
|
|
14
|
+
} from './helpers.js';
|
|
15
|
+
|
|
16
|
+
describe('Tunnel Proxy', () => {
|
|
17
|
+
let wsUrl, baseUrl;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
await startTestServer({ tunnel: true });
|
|
21
|
+
await createTestPod('tunneler');
|
|
22
|
+
baseUrl = getBaseUrl();
|
|
23
|
+
wsUrl = baseUrl.replace('http', 'ws') + '/.tunnel';
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
after(async () => {
|
|
27
|
+
await stopTestServer();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function connectTunnel() {
|
|
31
|
+
const token = getPodToken('tunneler');
|
|
32
|
+
return new WebSocket(wsUrl, {
|
|
33
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function waitMsg(ws, type, timeout = 5000) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
function handler(data) {
|
|
40
|
+
const msg = JSON.parse(data.toString());
|
|
41
|
+
if (msg.type === type) {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
ws.removeListener('message', handler);
|
|
44
|
+
ws.removeListener('close', onClose);
|
|
45
|
+
resolve(msg);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function onClose() {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
ws.removeListener('message', handler);
|
|
51
|
+
reject(new Error(`WebSocket closed while waiting for "${type}"`));
|
|
52
|
+
}
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
ws.removeListener('message', handler);
|
|
55
|
+
ws.removeListener('close', onClose);
|
|
56
|
+
reject(new Error(`Timeout waiting for "${type}"`));
|
|
57
|
+
}, timeout);
|
|
58
|
+
ws.on('message', handler);
|
|
59
|
+
ws.on('close', onClose);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Registration', () => {
|
|
64
|
+
it('should reject unauthenticated connections', async () => {
|
|
65
|
+
const ws = new WebSocket(wsUrl);
|
|
66
|
+
const msg = await waitMsg(ws, 'error');
|
|
67
|
+
assert.ok(msg.message.includes('Authentication'));
|
|
68
|
+
ws.close();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should register a tunnel name', async () => {
|
|
72
|
+
const ws = connectTunnel();
|
|
73
|
+
await new Promise(r => ws.on('open', r));
|
|
74
|
+
|
|
75
|
+
ws.send(JSON.stringify({ type: 'register', name: 'myapp' }));
|
|
76
|
+
const msg = await waitMsg(ws, 'registered');
|
|
77
|
+
assert.strictEqual(msg.name, 'myapp');
|
|
78
|
+
assert.strictEqual(msg.url, '/tunnel/myapp/');
|
|
79
|
+
|
|
80
|
+
ws.close();
|
|
81
|
+
await new Promise(r => setTimeout(r, 50));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should reject invalid tunnel names', async () => {
|
|
85
|
+
const ws = connectTunnel();
|
|
86
|
+
await new Promise(r => ws.on('open', r));
|
|
87
|
+
|
|
88
|
+
ws.send(JSON.stringify({ type: 'register', name: '...' }));
|
|
89
|
+
const msg = await waitMsg(ws, 'error');
|
|
90
|
+
assert.ok(msg.message.includes('Invalid'));
|
|
91
|
+
|
|
92
|
+
ws.close();
|
|
93
|
+
await new Promise(r => setTimeout(r, 50));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('HTTP Proxying', () => {
|
|
98
|
+
it('should proxy GET requests through the tunnel', async () => {
|
|
99
|
+
const ws = connectTunnel();
|
|
100
|
+
await new Promise(r => ws.on('open', r));
|
|
101
|
+
|
|
102
|
+
// Register tunnel
|
|
103
|
+
ws.send(JSON.stringify({ type: 'register', name: 'testapp' }));
|
|
104
|
+
await waitMsg(ws, 'registered');
|
|
105
|
+
|
|
106
|
+
// Listen for tunnel requests and respond
|
|
107
|
+
ws.on('message', (data) => {
|
|
108
|
+
const msg = JSON.parse(data.toString());
|
|
109
|
+
if (msg.type === 'request') {
|
|
110
|
+
ws.send(JSON.stringify({
|
|
111
|
+
type: 'response',
|
|
112
|
+
id: msg.id,
|
|
113
|
+
status: 200,
|
|
114
|
+
headers: { 'content-type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({ hello: 'world', path: msg.path })
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Make HTTP request through the tunnel
|
|
121
|
+
const res = await fetch(`${baseUrl}/tunnel/testapp/api/hello`);
|
|
122
|
+
assert.strictEqual(res.status, 200);
|
|
123
|
+
const body = await res.json();
|
|
124
|
+
assert.strictEqual(body.hello, 'world');
|
|
125
|
+
assert.strictEqual(body.path, '/api/hello');
|
|
126
|
+
|
|
127
|
+
ws.close();
|
|
128
|
+
await new Promise(r => setTimeout(r, 50));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should proxy POST requests with body', async () => {
|
|
132
|
+
const ws = connectTunnel();
|
|
133
|
+
await new Promise(r => ws.on('open', r));
|
|
134
|
+
|
|
135
|
+
ws.send(JSON.stringify({ type: 'register', name: 'postapp' }));
|
|
136
|
+
await waitMsg(ws, 'registered');
|
|
137
|
+
|
|
138
|
+
ws.on('message', (data) => {
|
|
139
|
+
const msg = JSON.parse(data.toString());
|
|
140
|
+
if (msg.type === 'request') {
|
|
141
|
+
assert.strictEqual(msg.method, 'POST');
|
|
142
|
+
ws.send(JSON.stringify({
|
|
143
|
+
type: 'response',
|
|
144
|
+
id: msg.id,
|
|
145
|
+
status: 201,
|
|
146
|
+
headers: { 'content-type': 'text/plain' },
|
|
147
|
+
body: 'Created'
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const res = await fetch(`${baseUrl}/tunnel/postapp/items`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ name: 'test' })
|
|
156
|
+
});
|
|
157
|
+
assert.strictEqual(res.status, 201);
|
|
158
|
+
assert.strictEqual(await res.text(), 'Created');
|
|
159
|
+
|
|
160
|
+
ws.close();
|
|
161
|
+
await new Promise(r => setTimeout(r, 50));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should return 502 for unregistered tunnel', async () => {
|
|
165
|
+
const res = await fetch(`${baseUrl}/tunnel/nonexistent/path`);
|
|
166
|
+
assert.strictEqual(res.status, 502);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should forward custom headers', async () => {
|
|
170
|
+
const ws = connectTunnel();
|
|
171
|
+
await new Promise(r => ws.on('open', r));
|
|
172
|
+
|
|
173
|
+
ws.send(JSON.stringify({ type: 'register', name: 'headerapp' }));
|
|
174
|
+
await waitMsg(ws, 'registered');
|
|
175
|
+
|
|
176
|
+
let receivedHeaders;
|
|
177
|
+
ws.on('message', (data) => {
|
|
178
|
+
const msg = JSON.parse(data.toString());
|
|
179
|
+
if (msg.type === 'request') {
|
|
180
|
+
receivedHeaders = msg.headers;
|
|
181
|
+
ws.send(JSON.stringify({
|
|
182
|
+
type: 'response',
|
|
183
|
+
id: msg.id,
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: { 'x-custom-response': 'from-tunnel' },
|
|
186
|
+
body: 'ok'
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const res = await fetch(`${baseUrl}/tunnel/headerapp/`, {
|
|
192
|
+
headers: { 'X-Custom-Request': 'to-tunnel' }
|
|
193
|
+
});
|
|
194
|
+
assert.strictEqual(res.status, 200);
|
|
195
|
+
assert.strictEqual(res.headers.get('x-custom-response'), 'from-tunnel');
|
|
196
|
+
assert.strictEqual(receivedHeaders['x-custom-request'], 'to-tunnel');
|
|
197
|
+
|
|
198
|
+
ws.close();
|
|
199
|
+
await new Promise(r => setTimeout(r, 50));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
package/test-webrtc-smoke.mjs
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { createServer } from './src/server.js';
|
|
2
|
-
import { WebSocket } from 'ws';
|
|
3
|
-
|
|
4
|
-
const PORT = 9877;
|
|
5
|
-
const BASE = `http://127.0.0.1:${PORT}`;
|
|
6
|
-
|
|
7
|
-
// Start server
|
|
8
|
-
const server = createServer({ logger: false, webrtc: true, forceCloseConnections: true });
|
|
9
|
-
await server.listen({ port: PORT, host: '127.0.0.1' });
|
|
10
|
-
console.log(`Server running on port ${PORT}`);
|
|
11
|
-
|
|
12
|
-
// Create two pods and get tokens
|
|
13
|
-
const aliceRes = await (await fetch(`${BASE}/.pods`, {
|
|
14
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
15
|
-
body: JSON.stringify({ name: 'alice' })
|
|
16
|
-
})).json();
|
|
17
|
-
|
|
18
|
-
const bobRes = await (await fetch(`${BASE}/.pods`, {
|
|
19
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
20
|
-
body: JSON.stringify({ name: 'bob' })
|
|
21
|
-
})).json();
|
|
22
|
-
|
|
23
|
-
console.log(`Alice WebID: ${aliceRes.webId}`);
|
|
24
|
-
console.log(`Bob WebID: ${bobRes.webId}`);
|
|
25
|
-
|
|
26
|
-
function connectWs(token) {
|
|
27
|
-
return new WebSocket(`ws://127.0.0.1:${PORT}/.webrtc`, {
|
|
28
|
-
headers: { 'Authorization': `Bearer ${token}` }
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function waitMsg(ws, type) {
|
|
33
|
-
return new Promise((resolve, reject) => {
|
|
34
|
-
const timer = setTimeout(() => { ws.removeListener('message', h); reject(new Error(`Timeout: ${type}`)); }, 5000);
|
|
35
|
-
function h(data) {
|
|
36
|
-
const msg = JSON.parse(data.toString());
|
|
37
|
-
if (msg.type === type) { clearTimeout(timer); ws.removeListener('message', h); resolve(msg); }
|
|
38
|
-
}
|
|
39
|
-
ws.on('message', h);
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// 1. Alice connects
|
|
44
|
-
const alice = connectWs(aliceRes.token);
|
|
45
|
-
const alicePeers = await waitMsg(alice, 'peers');
|
|
46
|
-
console.log(`\n1. Alice connected — you: ${alicePeers.you}, peers: [${alicePeers.peers}]`);
|
|
47
|
-
|
|
48
|
-
// 2. Bob connects — alice should get peer-joined
|
|
49
|
-
const joinPromise = waitMsg(alice, 'peer-joined');
|
|
50
|
-
const bob = connectWs(bobRes.token);
|
|
51
|
-
const bobPeers = await waitMsg(bob, 'peers');
|
|
52
|
-
console.log(`2. Bob connected — you: ${bobPeers.you}, peers: [${bobPeers.peers}]`);
|
|
53
|
-
|
|
54
|
-
const joined = await joinPromise;
|
|
55
|
-
console.log(` Alice got peer-joined: ${joined.webId}`);
|
|
56
|
-
|
|
57
|
-
// 3. Alice sends offer to Bob
|
|
58
|
-
const offerPromise = waitMsg(bob, 'offer');
|
|
59
|
-
alice.send(JSON.stringify({ type: 'offer', to: bobPeers.you, sdp: 'v=0\r\nfake-sdp-offer' }));
|
|
60
|
-
const offer = await offerPromise;
|
|
61
|
-
console.log(`3. Bob received offer from ${offer.from} — sdp: "${offer.sdp}"`);
|
|
62
|
-
|
|
63
|
-
// 4. Bob sends answer to Alice
|
|
64
|
-
const answerPromise = waitMsg(alice, 'answer');
|
|
65
|
-
bob.send(JSON.stringify({ type: 'answer', to: alicePeers.you, sdp: 'v=0\r\nfake-sdp-answer' }));
|
|
66
|
-
const answer = await answerPromise;
|
|
67
|
-
console.log(`4. Alice received answer from ${answer.from} — sdp: "${answer.sdp}"`);
|
|
68
|
-
|
|
69
|
-
// 5. Alice sends ICE candidate to Bob
|
|
70
|
-
const candPromise = waitMsg(bob, 'candidate');
|
|
71
|
-
alice.send(JSON.stringify({ type: 'candidate', to: bobPeers.you, candidate: { candidate: 'candidate:1 1 UDP 12345 192.168.1.1 9999 typ host' } }));
|
|
72
|
-
const cand = await candPromise;
|
|
73
|
-
console.log(`5. Bob received ICE candidate from ${cand.from}`);
|
|
74
|
-
|
|
75
|
-
// 6. Alice sends hangup
|
|
76
|
-
const hangPromise = waitMsg(bob, 'hangup');
|
|
77
|
-
alice.send(JSON.stringify({ type: 'hangup', to: bobPeers.you }));
|
|
78
|
-
const hang = await hangPromise;
|
|
79
|
-
console.log(`6. Bob received hangup from ${hang.from}`);
|
|
80
|
-
|
|
81
|
-
// 7. Bob disconnects — alice gets peer-left
|
|
82
|
-
const leftPromise = waitMsg(alice, 'peer-left');
|
|
83
|
-
bob.close();
|
|
84
|
-
const left = await leftPromise;
|
|
85
|
-
console.log(`7. Alice got peer-left: ${left.webId}`);
|
|
86
|
-
|
|
87
|
-
alice.close();
|
|
88
|
-
await server.close();
|
|
89
|
-
console.log(`\n✅ All signaling steps passed!`);
|
|
90
|
-
process.exit(0);
|