javascript-solid-server 0.0.108 → 0.0.110
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/patch/n3-patch.js +40 -23
- package/src/server.js +14 -1
- package/src/tunnel/index.js +226 -0
- package/test/patch.test.js +61 -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/patch/n3-patch.js
CHANGED
|
@@ -57,6 +57,7 @@ export function parseN3Patch(patchText, baseUri) {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Parse triples from N3 block content
|
|
60
|
+
* Handles Turtle semicolon shorthand (same subject, different predicate-object)
|
|
60
61
|
*/
|
|
61
62
|
function parseTriples(content, prefixes, baseUri) {
|
|
62
63
|
const triples = [];
|
|
@@ -68,11 +69,37 @@ function parseTriples(content, prefixes, baseUri) {
|
|
|
68
69
|
// Split by '.' but be careful with strings containing '.'
|
|
69
70
|
const statements = splitStatements(content);
|
|
70
71
|
|
|
72
|
+
let lastSubject = null;
|
|
71
73
|
for (const stmt of statements) {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
74
|
+
const trimmed = stmt.trim();
|
|
75
|
+
if (!trimmed) continue;
|
|
76
|
+
|
|
77
|
+
const tokens = tokenize(trimmed);
|
|
78
|
+
if (tokens.length < 2) continue;
|
|
79
|
+
|
|
80
|
+
let subject, predicate, object;
|
|
81
|
+
|
|
82
|
+
if (tokens.length >= 3) {
|
|
83
|
+
// Full triple: subject predicate object
|
|
84
|
+
subject = resolveValue(tokens[0], prefixes, baseUri);
|
|
85
|
+
predicate = resolveValue(tokens[1], prefixes, baseUri);
|
|
86
|
+
object = resolveValue(tokens.slice(2).join(' '), prefixes, baseUri);
|
|
87
|
+
lastSubject = subject;
|
|
88
|
+
} else if (tokens.length === 2 && lastSubject) {
|
|
89
|
+
// Semicolon continuation: predicate object (reuse last subject)
|
|
90
|
+
subject = lastSubject;
|
|
91
|
+
predicate = resolveValue(tokens[0], prefixes, baseUri);
|
|
92
|
+
object = resolveValue(tokens[1], prefixes, baseUri);
|
|
93
|
+
} else {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle 'a' as rdf:type
|
|
98
|
+
if (predicate === 'a') {
|
|
99
|
+
predicate = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
75
100
|
}
|
|
101
|
+
|
|
102
|
+
triples.push({ subject, predicate, object });
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
return triples;
|
|
@@ -86,11 +113,18 @@ function splitStatements(content) {
|
|
|
86
113
|
let current = '';
|
|
87
114
|
let inString = false;
|
|
88
115
|
let stringChar = null;
|
|
116
|
+
let inIri = false;
|
|
89
117
|
|
|
90
118
|
for (let i = 0; i < content.length; i++) {
|
|
91
119
|
const char = content[i];
|
|
92
120
|
|
|
93
|
-
if (!inString &&
|
|
121
|
+
if (!inString && !inIri && char === '<') {
|
|
122
|
+
inIri = true;
|
|
123
|
+
current += char;
|
|
124
|
+
} else if (inIri && char === '>') {
|
|
125
|
+
inIri = false;
|
|
126
|
+
current += char;
|
|
127
|
+
} else if (!inIri && !inString && (char === '"' || char === "'")) {
|
|
94
128
|
inString = true;
|
|
95
129
|
stringChar = char;
|
|
96
130
|
current += char;
|
|
@@ -98,12 +132,12 @@ function splitStatements(content) {
|
|
|
98
132
|
inString = false;
|
|
99
133
|
stringChar = null;
|
|
100
134
|
current += char;
|
|
101
|
-
} else if (!inString && char === '.') {
|
|
135
|
+
} else if (!inString && !inIri && char === '.') {
|
|
102
136
|
if (current.trim()) {
|
|
103
137
|
statements.push(current);
|
|
104
138
|
}
|
|
105
139
|
current = '';
|
|
106
|
-
} else if (!inString && char === ';') {
|
|
140
|
+
} else if (!inString && !inIri && char === ';') {
|
|
107
141
|
// Turtle shorthand - same subject, different predicate
|
|
108
142
|
if (current.trim()) {
|
|
109
143
|
statements.push(current);
|
|
@@ -121,23 +155,6 @@ function splitStatements(content) {
|
|
|
121
155
|
return statements;
|
|
122
156
|
}
|
|
123
157
|
|
|
124
|
-
/**
|
|
125
|
-
* Parse a single N3 statement into a triple
|
|
126
|
-
*/
|
|
127
|
-
function parseStatement(stmt, prefixes, baseUri) {
|
|
128
|
-
if (!stmt) return null;
|
|
129
|
-
|
|
130
|
-
// Tokenize - split by whitespace but respect quotes
|
|
131
|
-
const tokens = tokenize(stmt);
|
|
132
|
-
if (tokens.length < 3) return null;
|
|
133
|
-
|
|
134
|
-
const subject = resolveValue(tokens[0], prefixes, baseUri);
|
|
135
|
-
const predicate = resolveValue(tokens[1], prefixes, baseUri);
|
|
136
|
-
const object = resolveValue(tokens.slice(2).join(' '), prefixes, baseUri);
|
|
137
|
-
|
|
138
|
-
return { subject, predicate, object };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
158
|
/**
|
|
142
159
|
* Tokenize a statement respecting quoted strings
|
|
143
160
|
*/
|
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;
|
package/test/patch.test.js
CHANGED
|
@@ -185,6 +185,67 @@ describe('PATCH Operations', () => {
|
|
|
185
185
|
assert.ok(data['@graph'], 'Should have @graph');
|
|
186
186
|
assert.strictEqual(data['@graph'].length, 2, 'Should have 2 nodes');
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
it('should handle semicolon shorthand and rdf:type "a" keyword', async () => {
|
|
190
|
+
// Create initial resource with @graph
|
|
191
|
+
const initial = {
|
|
192
|
+
'@context': { 'solid': 'http://www.w3.org/ns/solid/terms#' },
|
|
193
|
+
'@graph': []
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
await request('/patchtest/public/patch-semicolon.json', {
|
|
197
|
+
method: 'PUT',
|
|
198
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
199
|
+
body: JSON.stringify(initial),
|
|
200
|
+
auth: 'patchtest'
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Use semicolons and 'a' keyword (Turtle shorthand)
|
|
204
|
+
const patch = `
|
|
205
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
206
|
+
@prefix wf: <http://www.w3.org/2005/01/wf/flow#>.
|
|
207
|
+
_:patch a solid:InsertDeletePatch;
|
|
208
|
+
solid:inserts {
|
|
209
|
+
<#reg1> a solid:TypeRegistration;
|
|
210
|
+
solid:forClass wf:Tracker;
|
|
211
|
+
solid:instance <https://example.com/todo/data.jsonld#this>.
|
|
212
|
+
}.
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
const res = await request('/patchtest/public/patch-semicolon.json', {
|
|
216
|
+
method: 'PATCH',
|
|
217
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
218
|
+
body: patch,
|
|
219
|
+
auth: 'patchtest'
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
assertStatus(res, 204);
|
|
223
|
+
|
|
224
|
+
// Verify all three triples were inserted
|
|
225
|
+
const verify = await request('/patchtest/public/patch-semicolon.json');
|
|
226
|
+
const data = await verify.json();
|
|
227
|
+
const node = data['@graph'].find(n => n['@id'] && n['@id'].includes('#reg1'));
|
|
228
|
+
assert.ok(node, 'Should have the reg1 node');
|
|
229
|
+
|
|
230
|
+
// Check rdf:type value (from 'a' keyword)
|
|
231
|
+
const rdfType = node['rdf:type'] || node['http://www.w3.org/1999/02/22-rdf-syntax-ns#type'];
|
|
232
|
+
assert.ok(rdfType, 'Should have rdf:type (from "a" keyword)');
|
|
233
|
+
const typeId = rdfType['@id'] || rdfType;
|
|
234
|
+
assert.ok(String(typeId).includes('TypeRegistration'), `rdf:type should be TypeRegistration, got ${typeId}`);
|
|
235
|
+
|
|
236
|
+
// Check solid:forClass value
|
|
237
|
+
const forClass = node['solid:forClass'];
|
|
238
|
+
assert.ok(forClass, 'Should have solid:forClass');
|
|
239
|
+
const forClassId = forClass['@id'] || forClass;
|
|
240
|
+
assert.ok(String(forClassId).includes('Tracker'), `solid:forClass should be Tracker, got ${forClassId}`);
|
|
241
|
+
|
|
242
|
+
// Check solid:instance value (contains a dot in the IRI - tests IRI splitting)
|
|
243
|
+
const instance = node['solid:instance'];
|
|
244
|
+
assert.ok(instance, 'Should have solid:instance');
|
|
245
|
+
const instanceId = instance['@id'] || instance;
|
|
246
|
+
assert.strictEqual(instanceId, 'https://example.com/todo/data.jsonld#this',
|
|
247
|
+
'solid:instance should have full IRI preserved');
|
|
248
|
+
});
|
|
188
249
|
});
|
|
189
250
|
|
|
190
251
|
describe('PATCH Error Handling', () => {
|
|
@@ -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);
|